Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -275,25 +275,56 @@ component extends="wheels.databaseAdapters.Base" output=false {
if (Left(local.sql, 11) == "INSERT INTO" && !StructKeyExists(arguments.result, $generatedKey())) {
local.startPar = Find("(", local.sql) + 1;
local.endPar = Find(")", local.sql);
local.columnList = ReplaceList(
Mid(local.sql, local.startPar, (local.endPar - local.startPar)),
"#Chr(10)#,#Chr(13)#, ",
",,"
);
local.columnList = "";
if (local.endPar) {
local.rawColumns = Mid(local.sql, local.startPar, (local.endPar - local.startPar));

// BoxLang compatibility fix - ReplaceList behaves differently
if (StructKeyExists(server, "boxlang")) {
// For BoxLang, use regex to properly parse column names
local.columnList = REReplace(local.rawColumns, "\s*,\s*", ",", "all");
local.columnList = REReplace(local.columnList, "[\r\n]", "", "all");
local.columnList = Trim(local.columnList);
} else {
// Original Lucee/ACF behavior
local.columnList = ReplaceList(
local.rawColumns,
"#Chr(10)#,#Chr(13)#, ",
",,"
);
}
}
// Strip identifier quotes from column list for comparison
local.columnList = $stripIdentifierQuotes(local.columnList);
if (!ListFindNoCase(local.columnList, ListFirst(arguments.primaryKey))) {
local.rv = {};

// Use @@IDENTITY instead of SCOPE_IDENTITY() for BoxLang compatibility
// SCOPE_IDENTITY() returns empty values in BoxLang with SQL Server
// Prefer the driver-supplied generated key: mssql-jdbc retrieves it in the
// insert's own batch, so it is both scope-safe and trigger-safe — a trigger
// that inserts into another identity table cannot leak its key in here.
// StructKeyExists is case-insensitive, so Lucee's lowercase `generatedkey`
// result key matches. ListFirst because multi-row inserts can return a list.
if (StructKeyExists(arguments.result, "generatedKey") && Len(arguments.result.generatedKey)) {
local.rv[$generatedKey()] = ListFirst(arguments.result.generatedKey);
return local.rv;
}

// Last resort — only runs when the driver supplied no generated key
// (currently BoxLang + SQL Server). @@IDENTITY is session-scoped and can
// return a trigger-generated identity instead of ours. The order is NOT
// flipped to prefer SCOPE_IDENTITY() because a standalone
// `SELECT SCOPE_IDENTITY()` executes in its own scope (a batch is a scope)
// and returns NULL — the documented BoxLang empty-value behavior — so the
// reorder would just add a wasted round-trip per insert and still resolve
// via @@IDENTITY. Same-batch retrieval for this fallback is tracked in a
// follow-up issue.
query = $query(sql = "SELECT @@IDENTITY AS lastId", argumentCollection = arguments.queryAttributes);
// Fallback to SCOPE_IDENTITY() if @@IDENTITY fails (for other CFML engines)
if (!len(query.lastId)) {

// Fallback to SCOPE_IDENTITY() if @@IDENTITY returned nothing (other CFML engines).
if (!Len(query.lastId)) {
query = $query(sql = "SELECT SCOPE_IDENTITY() AS lastId", argumentCollection = arguments.queryAttributes);
}

local.rv[$generatedKey()] = query.lastId;
return local.rv;
}
Expand Down
66 changes: 61 additions & 5 deletions vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -133,16 +133,72 @@ component extends="wheels.databaseAdapters.Base" output=false {
if (Left(local.sql, 11) == "INSERT INTO" && !StructKeyExists(arguments.result, $generatedKey())) {
local.startPar = Find("(", local.sql) + 1;
local.endPar = Find(")", local.sql);
local.columnList = ReplaceList(
Mid(local.sql, local.startPar, (local.endPar - local.startPar)),
"#Chr(10)#,#Chr(13)#, ",
",,"
);
local.columnList = "";
if (local.endPar) {
local.rawColumns = Mid(local.sql, local.startPar, (local.endPar - local.startPar));

// BoxLang compatibility fix - ReplaceList behaves differently
if (StructKeyExists(server, "boxlang")) {
// For BoxLang, use regex to properly parse column names
local.columnList = REReplace(local.rawColumns, "\s*,\s*", ",", "all");
local.columnList = REReplace(local.columnList, "[\r\n]", "", "all");
local.columnList = Trim(local.columnList);
} else {
// Original Lucee/ACF behavior
local.columnList = ReplaceList(
local.rawColumns,
"#Chr(10)#,#Chr(13)#, ",
",,"
);
}
}
// Strip identifier quotes from column list for comparison
local.columnList = $stripIdentifierQuotes(local.columnList);
if (!ListFindNoCase(local.columnList, ListFirst(arguments.primaryKey))) {
local.rv = {};
local.tbl = SpanExcluding(Right(local.sql, Len(local.sql) - 12), " ");

// Resolve a driver-supplied key first. CFML engines set
// Statement.RETURN_GENERATED_KEYS on INSERTs (see $bulkInsertSQL), so the
// Oracle JDBC driver returns the inserted row's ROWID. Lucee surfaces it
// as result.generatedKey (StructKeyExists is case-insensitive so the
// lowercase `generatedkey` key matches); ACF surfaces it as result.rowid.
// ListFirst because multi-row inserts can return a list.
local.generated = "";
if (StructKeyExists(arguments.result, "generatedKey") && Len(arguments.result.generatedKey)) {
local.generated = ListFirst(arguments.result.generatedKey);
} else if (StructKeyExists(arguments.result, "rowid") && Len(arguments.result.rowid)) {
local.generated = arguments.result.rowid;
}
if (Len(local.generated)) {
// Some driver/engine combos return the identity value itself.
if (IsNumeric(local.generated)) {
local.rv[$generatedKey()] = local.generated;
return local.rv;
}
// Standard extended ROWID: 18 base-64 chars. The value originates from
// the JDBC driver — not user input — but $query has no parameter
// binding, so gate strictly before interpolating; UROWIDs and anything
// unexpected fall through to the legacy query below. This exact-row
// lookup targets OUR insert, so it is race-free under concurrent
// inserts (unlike MAX(ROWID)).
if (REFind("^[A-Za-z0-9/+]{18}$", local.generated) == 1) {
query = $query(
sql = "SELECT #arguments.primaryKey# AS lastId FROM #local.tbl# WHERE ROWID = CHARTOROWID('#local.generated#')",
argumentCollection = arguments.queryAttributes
);
if (query.recordCount && Len(query.lastId)) {
local.rv[$generatedKey()] = query.lastId;
return local.rv;
}
}
}

// Legacy heuristic, kept only for engines that surface no generated key
// (e.g. current BoxLang). ROWID is physical location, not insertion order,
// so MAX(ROWID) races under concurrent inserts and can return another
// session's row. Replacing this with CURRVAL on the identity sequence is
// tracked in a follow-up issue.
query = $query(
sql = "SELECT #arguments.primaryKey# AS lastId FROM #local.tbl# WHERE ROWID = (SELECT MAX(ROWID) FROM #local.tbl#)",
argumentCollection = arguments.queryAttributes
Expand Down
96 changes: 96 additions & 0 deletions vendor/wheels/tests/specs/database/MicrosoftSQLServerUnitSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
component extends="wheels.WheelsTest" {

function beforeAll() {
adapter = CreateObject("component", "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerModel");
}

function run() {

describe("Microsoft SQL Server Adapter Unit Tests", () => {

describe("$generatedKey", () => {

it("returns identitycol", () => {
expect(adapter.$generatedKey()).toBe("identitycol");
});
});

describe("$identitySelect", () => {

it("returns identitycol from result.generatedKey", () => {
var result = {
sql = "INSERT INTO users (firstname) VALUES ('test')",
generatedKey = "42"
};
var rv = adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
);
expect(rv).toBeStruct();
expect(rv).toHaveKey("identitycol");
expect(rv.identitycol).toBe("42");
});

it("returns the first key when result.generatedKey is a list", () => {
var result = {
sql = "INSERT INTO users (firstname) VALUES ('test')",
generatedKey = "42,43"
};
var rv = adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
);
expect(rv).toBeStruct();
expect(rv).toHaveKey("identitycol");
expect(rv.identitycol).toBe("42");
});

it("returns void when result already contains identitycol", () => {
var result = {
sql = "INSERT INTO users (firstname) VALUES ('test')",
identitycol = "7"
};
// CFML void functions don't return null — the variable simply
// won't exist. Use IsNull() on the raw call to verify no return.
expect(IsNull(adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
))).toBeTrue();
});

it("returns void when the primary key is in the insert column list", () => {
var result = {
sql = "INSERT INTO users (id, firstname) VALUES (1, 'test')",
generatedKey = "42"
};
expect(IsNull(adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
))).toBeTrue();
});

it("returns void for non-INSERT statements", () => {
var result = {
sql = "SELECT * FROM users WHERE id = 1",
generatedKey = "42"
};
expect(IsNull(adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
))).toBeTrue();
});
});
});
}

}
112 changes: 112 additions & 0 deletions vendor/wheels/tests/specs/database/OracleUnitSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
component extends="wheels.WheelsTest" {

function beforeAll() {
adapter = CreateObject("component", "wheels.databaseAdapters.Oracle.OracleModel");
}

function run() {

describe("Oracle Adapter Unit Tests", () => {

describe("$generatedKey", () => {

it("returns lastId", () => {
expect(adapter.$generatedKey()).toBe("lastId");
});
});

describe("$identitySelect", () => {

it("uses a numeric result.generatedKey directly", () => {
var result = {
sql = "INSERT INTO users (firstname) VALUES ('test')",
generatedKey = "42"
};
var rv = adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
);
expect(rv).toBeStruct();
expect(rv).toHaveKey("lastId");
expect(rv.lastId).toBe("42");
});

it("uses a numeric result.rowid directly (ACF surface)", () => {
var result = {
sql = "INSERT INTO users (firstname) VALUES ('test')",
rowid = "42"
};
var rv = adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
);
expect(rv).toBeStruct();
expect(rv).toHaveKey("lastId");
expect(rv.lastId).toBe("42");
});

it("uses the first value when result.generatedKey is a numeric list", () => {
var result = {
sql = "INSERT INTO users (firstname) VALUES ('test')",
generatedKey = "42,43"
};
var rv = adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
);
expect(rv).toBeStruct();
expect(rv).toHaveKey("lastId");
expect(rv.lastId).toBe("42");
});

it("returns void when the primary key is in the insert column list", () => {
var result = {
sql = "INSERT INTO users (id, firstname) VALUES (1, 'test')",
generatedKey = "42"
};
// CFML void functions don't return null — the variable simply
// won't exist. Use IsNull() on the raw call to verify no return.
expect(IsNull(adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
))).toBeTrue();
});

it("returns void when result already has lastId key", () => {
var result = {
sql = "INSERT INTO users (firstname) VALUES ('test')",
lastId = 10
};
expect(IsNull(adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
))).toBeTrue();
});

it("returns void for non-INSERT statements", () => {
var result = {
sql = "SELECT * FROM users WHERE id = 1",
generatedKey = "42"
};
expect(IsNull(adapter.$identitySelect(
queryAttributes = {},
result = result,
primaryKey = "id",
returningIdentity = ""
))).toBeTrue();
});
});
});
}

}
Loading