From 974a7035782e434bc911584e68f3930e63afbdbf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 12:52:28 +0000 Subject: [PATCH 1/6] test(database): pin adapter hardener S1-S18 Quote AFTER, fix MSSQL schema.table brackets, and scope Oracle createTable loops. HOLD pins stay unflipped. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- ...base-adapters-hardener-s9-s17-s18.fixed.md | 3 + vendor/wheels/databaseAdapters/Abstract.cfc | 2 +- .../MicrosoftSQLServerMigrator.cfc | 2 +- .../Oracle/OracleMigrator.cfc | 6 +- .../PostgreSQL/PostgreSQLMigrator.cfc | 2 +- .../_assets/adapters/ColumnsCacheProbe.cfc | 21 + .../wheels/tests/_assets/adapters/H2Probe.cfc | 16 + .../tests/_assets/adapters/MySQLProbe.cfc | 16 + .../tests/_assets/adapters/OracleProbe.cfc | 4 + .../_assets/adapters/PostgreSQLProbe.cfc | 16 + .../tests/_assets/adapters/SQLiteProbe.cfc | 16 + ...apterIdentityMySQLH2PostgresSQLiteSpec.cfc | 146 +++++++ .../specs/database/CockroachDBCrudSpec.cfc | 13 +- .../database/CockroachDBTransactionSpec.cfc | 13 +- .../specs/database/CockroachDBTypeSpec.cfc | 42 +- .../database/DatabaseAdapterHardenerSpec.cfc | 359 ++++++++++++++++++ .../tests/specs/database/QuoteValueSpec.cfc | 17 + 17 files changed, 671 insertions(+), 23 deletions(-) create mode 100644 changelog.d/database-adapters-hardener-s9-s17-s18.fixed.md create mode 100644 vendor/wheels/tests/_assets/adapters/ColumnsCacheProbe.cfc create mode 100644 vendor/wheels/tests/_assets/adapters/H2Probe.cfc create mode 100644 vendor/wheels/tests/_assets/adapters/MySQLProbe.cfc create mode 100644 vendor/wheels/tests/_assets/adapters/PostgreSQLProbe.cfc create mode 100644 vendor/wheels/tests/_assets/adapters/SQLiteProbe.cfc create mode 100644 vendor/wheels/tests/specs/database/AdapterIdentityMySQLH2PostgresSQLiteSpec.cfc create mode 100644 vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc diff --git a/changelog.d/database-adapters-hardener-s9-s17-s18.fixed.md b/changelog.d/database-adapters-hardener-s9-s17-s18.fixed.md new file mode 100644 index 0000000000..fbc5b3d757 --- /dev/null +++ b/changelog.d/database-adapters-hardener-s9-s17-s18.fixed.md @@ -0,0 +1,3 @@ +- `addColumnOptions` quotes `AFTER` through `quoteColumnName` so a hostile column name cannot be interpolated raw +- Microsoft SQL Server `quoteTableName` quotes `schema.table` as `[schema].[table]` instead of mixing MySQL backticks inside brackets +- Oracle `createTable` scopes `col` and `fk` so the composite-key and foreign-key loops resolve on Adobe CF diff --git a/vendor/wheels/databaseAdapters/Abstract.cfc b/vendor/wheels/databaseAdapters/Abstract.cfc index 25b7cae89b..3036ac577d 100755 --- a/vendor/wheels/databaseAdapters/Abstract.cfc +++ b/vendor/wheels/databaseAdapters/Abstract.cfc @@ -107,7 +107,7 @@ component extends="wheels.migrator.Base"{ } } if (StructKeyExists(arguments.options, "afterColumn") And Len(Trim(arguments.options.afterColumn)) GT 0) { - arguments.sql = arguments.sql & " AFTER #arguments.options.afterColumn#"; + arguments.sql = arguments.sql & " AFTER " & quoteColumnName(arguments.options.afterColumn); } return arguments.sql; } diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerMigrator.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerMigrator.cfc index 73776edf8f..0eafa1c4fd 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerMigrator.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerMigrator.cfc @@ -62,7 +62,7 @@ component extends="wheels.databaseAdapters.Abstract" { * Surrounds table names with square brackets */ public string function quoteTableName(required string name) { - return "[#Replace(objectCase(arguments.name), ".", "`.`", "ALL")#]"; + return "[#Replace(objectCase(arguments.name), ".", "].[", "ALL")#]"; } /** diff --git a/vendor/wheels/databaseAdapters/Oracle/OracleMigrator.cfc b/vendor/wheels/databaseAdapters/Oracle/OracleMigrator.cfc index 75030b6317..ede69ea64b 100755 --- a/vendor/wheels/databaseAdapters/Oracle/OracleMigrator.cfc +++ b/vendor/wheels/databaseAdapters/Oracle/OracleMigrator.cfc @@ -52,13 +52,13 @@ component extends="wheels.databaseAdapters.Abstract" { } else { // Add all primary key columns normally for (local.col in arguments.primaryKeys) { - arrayAppend(local.lines, col.toSQL()); + arrayAppend(local.lines, local.col.toSQL()); } } // 2. Add normal columns for (local.col in arguments.columns) { - arrayAppend(local.lines, col.toSQL()); + arrayAppend(local.lines, local.col.toSQL()); } // 3. Add composite primary key constraint if needed @@ -68,7 +68,7 @@ component extends="wheels.databaseAdapters.Abstract" { // 4. Add foreign keys for (local.fk in arguments.foreignKeys) { - arrayAppend(local.lines, fk.toForeignKeySQL()); + arrayAppend(local.lines, local.fk.toForeignKeySQL()); } // 5. Join all lines and wrap in CREATE TABLE diff --git a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLMigrator.cfc b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLMigrator.cfc index f689fae426..ae8e7ae6ae 100755 --- a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLMigrator.cfc +++ b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLMigrator.cfc @@ -96,7 +96,7 @@ component extends="wheels.databaseAdapters.Abstract" { } } if (StructKeyExists(arguments.options, "afterColumn") && Len(Trim(arguments.options.afterColumn)) GT 0) { - arguments.sql = arguments.sql & " AFTER #arguments.options.afterColumn#"; + arguments.sql = arguments.sql & " AFTER " & quoteColumnName(arguments.options.afterColumn); } return arguments.sql; } diff --git a/vendor/wheels/tests/_assets/adapters/ColumnsCacheProbe.cfc b/vendor/wheels/tests/_assets/adapters/ColumnsCacheProbe.cfc new file mode 100644 index 0000000000..d19943f354 --- /dev/null +++ b/vendor/wheels/tests/_assets/adapters/ColumnsCacheProbe.cfc @@ -0,0 +1,21 @@ +component extends="wheels.databaseAdapters.Base" output=false { + + this.columnInfoCalls = 0; + this.freshColumns = QueryNew("column_name", "varchar", [{column_name: "fresh_id"}]); + + public any function $get(required string name, string functionName = "") { + if (arguments.name == "cacheDatabaseSchema") { + return true; + } + if (arguments.name == "showErrorInformation") { + return false; + } + return false; + } + + public query function $getColumnInfo() { + this.columnInfoCalls++; + return this.freshColumns; + } + +} diff --git a/vendor/wheels/tests/_assets/adapters/H2Probe.cfc b/vendor/wheels/tests/_assets/adapters/H2Probe.cfc new file mode 100644 index 0000000000..567cfb4002 --- /dev/null +++ b/vendor/wheels/tests/_assets/adapters/H2Probe.cfc @@ -0,0 +1,16 @@ +component extends="wheels.databaseAdapters.H2.H2Model" output=false { + + this.capturedSql = []; + this.queryResults = []; + + public any function $query(required string sql) { + ArrayAppend(this.capturedSql, arguments.sql); + if (ArrayLen(this.queryResults)) { + local.queued = this.queryResults[1]; + ArrayDeleteAt(this.queryResults, 1); + return local.queued; + } + return QueryNew("lastId", "varchar", [{lastId: ""}]); + } + +} diff --git a/vendor/wheels/tests/_assets/adapters/MySQLProbe.cfc b/vendor/wheels/tests/_assets/adapters/MySQLProbe.cfc new file mode 100644 index 0000000000..d7d7bd665e --- /dev/null +++ b/vendor/wheels/tests/_assets/adapters/MySQLProbe.cfc @@ -0,0 +1,16 @@ +component extends="wheels.databaseAdapters.MySQL.MySQLModel" output=false { + + this.capturedSql = []; + this.queryResults = []; + + public any function $query(required string sql) { + ArrayAppend(this.capturedSql, arguments.sql); + if (ArrayLen(this.queryResults)) { + local.queued = this.queryResults[1]; + ArrayDeleteAt(this.queryResults, 1); + return local.queued; + } + return QueryNew("lastId", "varchar", [{lastId: ""}]); + } + +} diff --git a/vendor/wheels/tests/_assets/adapters/OracleProbe.cfc b/vendor/wheels/tests/_assets/adapters/OracleProbe.cfc index 11aca7c48d..c013badd5d 100644 --- a/vendor/wheels/tests/_assets/adapters/OracleProbe.cfc +++ b/vendor/wheels/tests/_assets/adapters/OracleProbe.cfc @@ -8,6 +8,7 @@ component extends="wheels.databaseAdapters.Oracle.OracleModel" output=false { this.boxlangMode = false; this.capturedSql = []; + this.throwOnQuery = false; // FIFO queue of mock query objects served by $query(). this.queryResults = []; @@ -17,6 +18,9 @@ component extends="wheels.databaseAdapters.Oracle.OracleModel" output=false { public any function $query(required string sql) { ArrayAppend(this.capturedSql, arguments.sql); + if (this.throwOnQuery) { + Throw(type = "Database.CatalogMissing", message = "user_tab_identity_cols missing"); + } if (ArrayLen(this.queryResults)) { local.queued = this.queryResults[1]; ArrayDeleteAt(this.queryResults, 1); diff --git a/vendor/wheels/tests/_assets/adapters/PostgreSQLProbe.cfc b/vendor/wheels/tests/_assets/adapters/PostgreSQLProbe.cfc new file mode 100644 index 0000000000..fbf1e87ffa --- /dev/null +++ b/vendor/wheels/tests/_assets/adapters/PostgreSQLProbe.cfc @@ -0,0 +1,16 @@ +component extends="wheels.databaseAdapters.PostgreSQL.PostgreSQLModel" output=false { + + this.capturedSql = []; + this.queryResults = []; + + public any function $query(required string sql) { + ArrayAppend(this.capturedSql, arguments.sql); + if (ArrayLen(this.queryResults)) { + local.queued = this.queryResults[1]; + ArrayDeleteAt(this.queryResults, 1); + return local.queued; + } + return QueryNew("lastId", "varchar", [{lastId: ""}]); + } + +} diff --git a/vendor/wheels/tests/_assets/adapters/SQLiteProbe.cfc b/vendor/wheels/tests/_assets/adapters/SQLiteProbe.cfc new file mode 100644 index 0000000000..462d86501e --- /dev/null +++ b/vendor/wheels/tests/_assets/adapters/SQLiteProbe.cfc @@ -0,0 +1,16 @@ +component extends="wheels.databaseAdapters.SQLite.SQLiteModel" output=false { + + this.capturedSql = []; + this.queryResults = []; + + public any function $query(required string sql) { + ArrayAppend(this.capturedSql, arguments.sql); + if (ArrayLen(this.queryResults)) { + local.queued = this.queryResults[1]; + ArrayDeleteAt(this.queryResults, 1); + return local.queued; + } + return QueryNew("lastId", "varchar", [{lastId: ""}]); + } + +} diff --git a/vendor/wheels/tests/specs/database/AdapterIdentityMySQLH2PostgresSQLiteSpec.cfc b/vendor/wheels/tests/specs/database/AdapterIdentityMySQLH2PostgresSQLiteSpec.cfc new file mode 100644 index 0000000000..526d9f33d9 --- /dev/null +++ b/vendor/wheels/tests/specs/database/AdapterIdentityMySQLH2PostgresSQLiteSpec.cfc @@ -0,0 +1,146 @@ +component extends="wheels.WheelsTest" { + + function run() { + + describe("S4 MySQL identity unit specs", () => { + + it("uses generated_key as the published identity key", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.MySQL.MySQLModel"); + expect(adapter.$generatedKey()).toBe("generated_key"); + }); + + it("publishes LAST_INSERT_ID under generated_key", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.MySQLProbe"); + ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 11}])); + var rv = probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = "" + ); + expect(rv).toBeStruct(); + expect(rv.generated_key).toBe(11); + expect(probe.capturedSql[1]).toInclude("LAST_INSERT_ID()"); + }); + + it("returns void when generated_key is already on the result", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.MySQLProbe"); + expect( + IsNull( + probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')", generated_key: 3}, + primaryKey = "id", + returningIdentity = "" + ) + ) + ).toBeTrue(); + expect(ArrayLen(probe.capturedSql)).toBe(0); + }); + + }); + + describe("S4 H2 identity unit specs", () => { + + it("uses generated_key as the published identity key", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.H2.H2Model"); + expect(adapter.$generatedKey()).toBe("generated_key"); + }); + + it("publishes LAST_INSERT_ID under generated_key", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.H2Probe"); + ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 12}])); + var rv = probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = "" + ); + expect(rv).toBeStruct(); + expect(rv.generated_key).toBe(12); + expect(probe.capturedSql[1]).toInclude("LAST_INSERT_ID()"); + }); + + }); + + describe("S4 PostgreSQL identity unit specs", () => { + + it("uses lastId as the published identity key", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.PostgreSQL.PostgreSQLModel"); + expect(adapter.$generatedKey()).toBe("lastId"); + }); + + it("reads currval from pg_get_serial_sequence", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.PostgreSQLProbe"); + ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 13}])); + var rv = probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = "" + ); + expect(rv).toBeStruct(); + expect(rv.lastId).toBe(13); + expect(probe.capturedSql[1]).toInclude("currval"); + expect(probe.capturedSql[1]).toInclude("pg_get_serial_sequence"); + expect(probe.capturedSql[1]).toInclude("users"); + }); + + it("returns void when lastId is already on the result", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.PostgreSQLProbe"); + expect( + IsNull( + probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')", lastId: 4}, + primaryKey = "id", + returningIdentity = "" + ) + ) + ).toBeTrue(); + expect(ArrayLen(probe.capturedSql)).toBe(0); + }); + + }); + + describe("S4 SQLite identity unit specs", () => { + + it("uses generated_key as the published identity key", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.SQLite.SQLiteModel"); + expect(adapter.$generatedKey()).toBe("generated_key"); + }); + + it("reads last_insert_rowid", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.SQLiteProbe"); + ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 14}])); + var rv = probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = "" + ); + expect(rv).toBeStruct(); + expect(rv.generated_key).toBe(14); + expect(probe.capturedSql[1]).toInclude("last_insert_rowid()"); + }); + + it("returns void when the primary key is in the insert column list", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.SQLiteProbe"); + expect( + IsNull( + probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (id, firstname) VALUES (1, 'x')"}, + primaryKey = "id", + returningIdentity = "" + ) + ) + ).toBeTrue(); + expect(ArrayLen(probe.capturedSql)).toBe(0); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/database/CockroachDBCrudSpec.cfc b/vendor/wheels/tests/specs/database/CockroachDBCrudSpec.cfc index 8ac8739ade..8f846b278b 100644 --- a/vendor/wheels/tests/specs/database/CockroachDBCrudSpec.cfc +++ b/vendor/wheels/tests/specs/database/CockroachDBCrudSpec.cfc @@ -121,19 +121,22 @@ component extends="wheels.WheelsTest" { it("findAll with order sorts correctly", () => { var authors = g.model("author").findAll(order = "lastName ASC"); expect(authors).toBeQuery(); - expect(authors.recordCount).toBeGT(0); - // Verify first record is alphabetically first - if (authors.recordCount > 1) { - expect(authors.lastName[1] LTE authors.lastName[2]).toBeTrue(); - } + expect(authors.recordCount).toBeGT(1); + expect(authors.lastName[1]).toBeLTE(authors.lastName[2]); + expect(authors.lastName[authors.recordCount]).toBeGTE(authors.lastName[1]); }); it("findAll with pagination returns correct page", () => { var page1 = g.model("author").findAll(page = 1, perPage = 3, order = "lastName ASC"); var page2 = g.model("author").findAll(page = 2, perPage = 3, order = "lastName ASC"); expect(page1).toBeQuery(); + expect(page1.recordCount).toBeGT(0); expect(page1.recordCount).toBeLTE(3); expect(page2).toBeQuery(); + expect(page2.recordCount).toBeGT(0); + expect(page2.recordCount).toBeLTE(3); + expect(page1.lastName[1]).notToBe(page2.lastName[1]); + expect(page1.lastName[1]).toBeLTE(page2.lastName[1]); }); }); }); diff --git a/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc b/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc index 41b6f0d600..04a1bd201d 100644 --- a/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc +++ b/vendor/wheels/tests/specs/database/CockroachDBTransactionSpec.cfc @@ -13,11 +13,18 @@ component extends="wheels.WheelsTest" { describe("Basic transactions", () => { it("commit persists data", () => { + var marker = "TxCommit" & Replace(CreateUUID(), "-", "", "all"); + var authorId = ""; transaction action="begin" { - var author = g.model("author").create(firstName = "TxCommit", lastName = "Test"); - expect(author.key()).toBeNumeric(); - transaction action="rollback"; + var author = g.model("author").create(firstName = marker, lastName = "Test"); + authorId = author.key(); + expect(authorId).toBeNumeric(); + transaction action="commit"; } + var found = g.model("author").findByKey(authorId); + expect(found).toBeInstanceOf("author"); + expect(found.firstName).toBe(marker); + found.delete(); }); it("rollback reverts data", () => { diff --git a/vendor/wheels/tests/specs/database/CockroachDBTypeSpec.cfc b/vendor/wheels/tests/specs/database/CockroachDBTypeSpec.cfc index 1daba0143e..cf476d82f5 100644 --- a/vendor/wheels/tests/specs/database/CockroachDBTypeSpec.cfc +++ b/vendor/wheels/tests/specs/database/CockroachDBTypeSpec.cfc @@ -6,7 +6,23 @@ component extends="wheels.WheelsTest" { describe("CockroachDB Type Tests", () => { - // Guard: only run when connected to CockroachDB + describe("$getType mapping always", () => { + + it("maps CockroachDB native types to real CFML SQL types", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.CockroachDB.CockroachDBModel"); + expect(adapter.$getType(type = "string")).toBe("cf_sql_varchar"); + expect(adapter.$getType(type = "bytes")).toBe("cf_sql_binary"); + expect(adapter.$getType(type = "int64")).toBe("cf_sql_bigint"); + expect(adapter.$getType(type = "bool")).toBe("cf_sql_bit"); + expect(adapter.$getType(type = "boolean")).toBe("cf_sql_bit"); + expect(adapter.$getType(type = "varchar")).toBe("cf_sql_varchar"); + expect(adapter.$getType(type = "text")).toBe("cf_sql_longvarchar"); + expect(adapter.$getType(type = "timestamp")).toBe("cf_sql_timestamp"); + }); + + }); + + // Guard: live table assertions only run when connected to CockroachDB var migration = CreateObject("component", "wheels.migrator.Migration").init(); if (migration.adapter.adapterName() != "CockroachDB") return; @@ -72,14 +88,22 @@ component extends="wheels.WheelsTest" { describe("Column introspection", () => { it("correctly reads column types from a live table", () => { - // Use the authors table which exists in test seed data - var authors = g.model("author").findAll(maxRows = 1); - expect(authors).toBeQuery(); - - // The author model should be functional - var author = g.model("author").findFirst(); - expect(author).toBeInstanceOf("author"); - expect(author.key()).toBeNumeric(); + var adapter = g.model("sqlType").$assignAdapter(); + var cols = adapter.$getColumns("c_o_r_e_sqltypes"); + expect(cols.recordCount).toBeGT(0); + var mapped = {}; + for (var row in cols) { + var colName = LCase(row.column_name); + var typeName = LCase(row.type_name); + mapped[colName] = adapter.$getType(type = typeName, scale = "", details = ""); + } + expect(mapped).toHaveKey("booleantype"); + expect(mapped.booleantype).toBe("cf_sql_bit"); + expect(mapped).toHaveKey("stringvariabletype"); + expect(mapped.stringvariabletype).toBe("cf_sql_varchar"); + expect(mapped).toHaveKey("inttype"); + expect(Len(mapped.inttype)).toBeGT(0); + expect(Left(mapped.inttype, 7)).toBe("cf_sql_"); }); it("boolean column type is correctly introspected", () => { diff --git a/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc b/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc new file mode 100644 index 0000000000..ec694da85c --- /dev/null +++ b/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc @@ -0,0 +1,359 @@ +/** + * databaseAdapters Hardener S1–S18. + * S2/S6/S8/S12/S13/S14/S15 stay HELD. S16 proves last-resort only. + */ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo; + + describe("S2 HOLD $executeQuery string null after IS", () => { + + it("binds the string null after IS and IS NOT", () => { + var result = g.model("post").$whereClause(where = "averagerating IS NULL"); + var bound = ""; + for (var part in result) { + if (IsStruct(part) && StructKeyExists(part, "value")) { + bound = part.value; + } + } + expect(bound).toBe("null"); + expect(IsSimpleValue(bound)).toBeTrue(); + + result = g.model("post").$whereClause(where = "averagerating IS NOT NULL"); + bound = ""; + for (part in result) { + if (IsStruct(part) && StructKeyExists(part, "value")) { + bound = part.value; + } + } + expect(bound).toBe("null"); + }); + + it("still converts that string to SQL NULL in $executeQuery", () => { + var src = FileRead(ExpandPath("/wheels/databaseAdapters/Base.cfc")); + expect(src).toInclude('part.value == "null"'); + expect(src).toInclude('right(prev, 2) == "IS"'); + expect(src).toInclude('right(prev, 6) == "IS NOT"'); + expect(src).toInclude('writeOutput("NULL")'); + }); + + }); + + describe("S3 $getColumns cache catch(any) fall-through", () => { + + it("falls through to a fresh catalog lookup when the cache read throws", () => { + var state = {hadCache = false, cache = {}}; + if (StructKeyExists(application.wheels, "schemaColumnCache")) { + state.hadCache = true; + state.cache = Duplicate(application.wheels.schemaColumnCache); + } + var probe = CreateObject("component", "wheels.tests._assets.adapters.ColumnsCacheProbe"); + probe.$init(dataSource = "wheels_hardener_s3", username = "", password = ""); + StructDelete(application.wheels, "schemaColumnCache"); + try { + var cols = probe.$getColumns("authors"); + expect(probe.columnInfoCalls).toBe(1); + expect(cols.column_name[1]).toBe("fresh_id"); + } finally { + if (state.hadCache) { + application.wheels.schemaColumnCache = state.cache; + } else { + StructDelete(application.wheels, "schemaColumnCache"); + } + } + }); + + it("keeps catch(any) on the cache read", () => { + var src = FileRead(ExpandPath("/wheels/databaseAdapters/Base.cfc")); + var start = Find("public query function $getColumns", src); + var body = Mid(src, start, 1800); + expect(Find("catch (any e)", body)).toBeGT(0); + }); + + }); + + describe("S5 Oracle $identitySequenceName catch(any)", () => { + + it("returns empty string when the catalog query throws", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.OracleProbe"); + probe.throwOnQuery = true; + expect( + probe.$identitySequenceName( + tableName = "users", + columnName = "id", + queryAttributes = {} + ) + ).toBe(""); + }); + + it("keeps catch(any) around the catalog lookup", () => { + var src = FileRead(ExpandPath("/wheels/databaseAdapters/Oracle/OracleModel.cfc")); + var start = Find("public string function $identitySequenceName", src); + var body = Mid(src, start, 1600); + expect(Find("catch (any e)", body)).toBeGT(0); + }); + + }); + + describe("S6 HOLD MySQL optionsIncludeDefault vs Abstract", () => { + + it("MySQL drops DEFAULT for text and float", () => { + var mysql = CreateObject("component", "wheels.databaseAdapters.MySQL.MySQLMigrator"); + expect(mysql.optionsIncludeDefault(type = "text", default = "long body")).toBeFalse(); + expect(mysql.optionsIncludeDefault(type = "float", default = "1.25")).toBeFalse(); + expect(mysql.optionsIncludeDefault(type = "string", default = "hello")).toBeTrue(); + var sql = mysql.addColumnOptions( + sql = "", + options = {type: "text", default: "long body", allowNull: true} + ); + expect(sql).notToInclude("DEFAULT"); + }); + + it("Abstract optionsIncludeDefault stays always true", () => { + var abstract = CreateObject("component", "wheels.databaseAdapters.Abstract"); + expect(abstract.optionsIncludeDefault(type = "text", default = "long body")).toBeTrue(); + expect(abstract.optionsIncludeDefault(type = "float", default = "1.25")).toBeTrue(); + expect(abstract.optionsIncludeDefault()).toBeTrue(); + }); + + }); + + describe("S9 addColumnOptions quotes AFTER", () => { + + it("Abstract quotes a hostile afterColumn", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.Abstract"); + var hostile = "id; DROP TABLE t"; + var sql = adapter.addColumnOptions( + sql = "name VARCHAR(255)", + options = {afterColumn: hostile} + ); + expect(sql).toBe("name VARCHAR(255) AFTER " & adapter.quoteColumnName(hostile)); + expect(sql).notToInclude("AFTER id; DROP TABLE t"); + }); + + it("MySQL quotes AFTER with backticks", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.MySQL.MySQLMigrator"); + var sql = adapter.addColumnOptions( + sql = "name VARCHAR(255)", + options = {afterColumn: "created_at"} + ); + expect(sql).toInclude("AFTER `created_at`"); + }); + + it("PostgreSQL AFTER uses quoteColumnName", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.PostgreSQL.PostgreSQLMigrator"); + var sql = adapter.addColumnOptions( + sql = "name VARCHAR(255)", + options = {type: "string", afterColumn: "created_at"} + ); + expect(sql).toInclude("AFTER " & adapter.quoteColumnName("created_at")); + }); + + }); + + describe("S12 HOLD SQLite advisory locks stay no-op true", () => { + + it("reports support and acquire does not throw", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.SQLite.SQLiteModel"); + expect(adapter.$supportsAdvisoryLocks()).toBeTrue(); + adapter.$acquireAdvisoryLock(name = "hardener_s12", timeout = 1); + adapter.$releaseAdvisoryLock(name = "hardener_s12"); + }); + + }); + + describe("S13 HOLD foreignKeySQL unknown action is CASCADE", () => { + + it("maps restrict and other unknown values to CASCADE", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.Abstract"); + var sql = adapter.foreignKeySQL( + name = "fk_posts_users", + table = "posts", + referenceTable = "users", + column = "userid", + referenceColumn = "id", + onUpdate = "restrict", + onDelete = "set default" + ); + expect(sql).toInclude("ON UPDATE CASCADE"); + expect(sql).toInclude("ON DELETE CASCADE"); + expect(sql).notToInclude("RESTRICT"); + expect(sql).notToInclude("SET DEFAULT"); + }); + + it("still maps none and null", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.Abstract"); + var sql = adapter.foreignKeySQL( + name = "fk_posts_users", + table = "posts", + referenceTable = "users", + column = "userid", + referenceColumn = "id", + onUpdate = "none", + onDelete = "null" + ); + expect(sql).toInclude("ON UPDATE NO ACTION"); + expect(sql).toInclude("ON DELETE SET NULL"); + }); + + }); + + describe("S14 HOLD Abstract vs PG empty string default", () => { + + it("Abstract omits DEFAULT for string default empty", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.Abstract"); + var sql = adapter.addColumnOptions( + sql = "", + options = {type: "string", default: "", allowNull: true} + ); + expect(sql).notToInclude("DEFAULT"); + }); + + it("PostgreSQL emits DEFAULT empty string for string default empty", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.PostgreSQL.PostgreSQLMigrator"); + var sql = adapter.addColumnOptions( + sql = "", + options = {type: "string", default: "", allowNull: true} + ); + expect(sql).toInclude("DEFAULT ''"); + }); + + }); + + describe("S15 HOLD unmapped $getType", () => { + + it("PostgreSQL throws Wheels.UnknownColumnType", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.PostgreSQL.PostgreSQLModel"); + expect(function() { + adapter.$getType(type = "definitely_not_a_type"); + }).toThrow("Wheels.UnknownColumnType"); + }); + + it("SQLite falls back to cf_sql_varchar", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.SQLite.SQLiteModel"); + expect(adapter.$getType(type = "definitely_not_a_type")).toBe("cf_sql_varchar"); + }); + + it("MySQL errors without Wheels.UnknownColumnType", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.MySQL.MySQLModel"); + var state = {type = ""}; + try { + adapter.$getType(type = "definitely_not_a_type"); + } catch (any e) { + state.type = e.type; + } + expect(Len(state.type)).toBeGT(0); + expect(state.type).notToBe("Wheels.UnknownColumnType"); + }); + + }); + + describe("S16 HOLD last-resort identity stays", () => { + + it("Oracle still emits MAX(ROWID) when no sequence is found", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.OracleProbe"); + ArrayAppend(probe.queryResults, QueryNew("sequence_name", "varchar", [])); + ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 9}])); + var rv = probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = "" + ); + expect(rv.lastId).toBe(9); + expect(probe.capturedSql[2]).toInclude("MAX(ROWID)"); + }); + + it("MSSQL still emits @@IDENTITY when the batch has no resultset", () => { + var probe = CreateObject("component", "wheels.tests._assets.adapters.MSSQLProbe"); + ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 7}])); + var rv = probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = QueryNew("lastId", "varchar", []) + ); + expect(rv.identitycol).toBe(7); + expect(probe.capturedSql[1]).toInclude("@@IDENTITY"); + }); + + it("does not remove the last-resort SQL from the adapters", () => { + var oracleSrc = FileRead(ExpandPath("/wheels/databaseAdapters/Oracle/OracleModel.cfc")); + var mssqlSrc = FileRead(ExpandPath("/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc")); + expect(oracleSrc).toInclude("MAX(ROWID)"); + expect(mssqlSrc).toInclude("@@IDENTITY"); + }); + + }); + + describe("S17 MSSQL quoteTableName is bracket-only", () => { + + it("quotes schema.table as [schema].[table]", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerMigrator"); + var dotted = adapter.quoteTableName("dbo.users"); + expect(dotted).toInclude("].["); + expect(dotted).notToInclude("`"); + expect(Left(dotted, 1)).toBe("["); + expect(Right(dotted, 1)).toBe("]"); + var bare = adapter.quoteTableName("users"); + expect(bare).toInclude("users"); + expect(bare).notToInclude("`"); + expect(Left(bare, 1)).toBe("["); + expect(Right(bare, 1)).toBe("]"); + }); + + }); + + describe("S18 Oracle createTable scopes col and fk", () => { + + it("emits columns and foreign keys from scoped loop variables", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.Oracle.OracleMigrator"); + var pk1 = CreateObject("component", "wheels.migrator.ColumnDefinition").init( + adapter = adapter, + name = "firstId", + type = "integer" + ); + var pk2 = CreateObject("component", "wheels.migrator.ColumnDefinition").init( + adapter = adapter, + name = "secondId", + type = "integer" + ); + var col = CreateObject("component", "wheels.migrator.ColumnDefinition").init( + adapter = adapter, + name = "title", + type = "string" + ); + var fk = CreateObject("component", "wheels.migrator.ForeignKeyDefinition").init( + adapter = adapter, + table = "posts", + referenceTable = "users", + column = "userid", + referenceColumn = "id" + ); + var sql = adapter.createTable( + name = "posts", + columns = [col], + primaryKeys = [pk1, pk2], + foreignKeys = [fk] + ); + expect(sql).toInclude("CREATE TABLE posts"); + expect(sql).toInclude("title"); + expect(sql).toInclude("firstId"); + expect(sql).toInclude("FOREIGN KEY"); + }); + + it("does not call unscoped col or fk in createTable", () => { + var src = FileRead(ExpandPath("/wheels/databaseAdapters/Oracle/OracleMigrator.cfc")); + expect(src).notToInclude("arrayAppend(local.lines, col.toSQL())"); + expect(src).toInclude("arrayAppend(local.lines, local.col.toSQL())"); + expect(src).notToInclude("arrayAppend(local.lines, fk.toForeignKeySQL())"); + expect(src).toInclude("arrayAppend(local.lines, local.fk.toForeignKeySQL())"); + }); + + }); + + } + +} diff --git a/vendor/wheels/tests/specs/database/QuoteValueSpec.cfc b/vendor/wheels/tests/specs/database/QuoteValueSpec.cfc index 1c7aafd7e1..cdafaa52f3 100644 --- a/vendor/wheels/tests/specs/database/QuoteValueSpec.cfc +++ b/vendor/wheels/tests/specs/database/QuoteValueSpec.cfc @@ -44,6 +44,23 @@ component extends="wheels.WheelsTest" { expect(adapter.$quoteValue(str="'")).toBe("''''"); }); + it("throws Wheels.InvalidValue for integer payload 0 OR 1=1", () => { + expect(function() { + adapter.$quoteValue(str = "0 OR 1=1", type = "integer"); + }).toThrow("Wheels.InvalidValue"); + }); + + it("throws Wheels.InvalidValue for boolean payload maybe", () => { + expect(function() { + adapter.$quoteValue(str = "maybe", type = "boolean"); + }).toThrow("Wheels.InvalidValue"); + }); + + it("S8 HOLD leaves boolean yes and no unquoted", () => { + expect(adapter.$quoteValue(str = "yes", type = "boolean")).toBe("yes"); + expect(adapter.$quoteValue(str = "no", type = "boolean")).toBe("no"); + }); + }); } From 28982159a5e0bbbf3e93a725192500ede901e108 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 12:55:55 +0000 Subject: [PATCH 2/6] test(database): bind S2 HOLD through addWhereClauseParameters $whereClause leaves the sql structs without values. The string null lands in $addWhereClauseParameters, which is the input $executeQuery reads after IS / IS NOT. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- .../database/DatabaseAdapterHardenerSpec.cfc | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc b/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc index ec694da85c..b5ed701f0f 100644 --- a/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc +++ b/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc @@ -11,24 +11,26 @@ component extends="wheels.WheelsTest" { describe("S2 HOLD $executeQuery string null after IS", () => { it("binds the string null after IS and IS NOT", () => { - var result = g.model("post").$whereClause(where = "averagerating IS NULL"); + var sql = g.model("post").$whereClause(where = "averagerating IS NULL"); + sql = g.model("post").$addWhereClauseParameters(sql = sql, where = "averagerating IS NULL"); var bound = ""; - for (var part in result) { - if (IsStruct(part) && StructKeyExists(part, "value")) { + for (var part in sql) { + if (IsStruct(part) && StructKeyExists(part, "value") && LCase(ToString(part.value)) == "null") { bound = part.value; } } - expect(bound).toBe("null"); + expect(LCase(bound)).toBe("null"); expect(IsSimpleValue(bound)).toBeTrue(); - result = g.model("post").$whereClause(where = "averagerating IS NOT NULL"); + sql = g.model("post").$whereClause(where = "averagerating IS NOT NULL"); + sql = g.model("post").$addWhereClauseParameters(sql = sql, where = "averagerating IS NOT NULL"); bound = ""; - for (part in result) { - if (IsStruct(part) && StructKeyExists(part, "value")) { + for (part in sql) { + if (IsStruct(part) && StructKeyExists(part, "value") && LCase(ToString(part.value)) == "null") { bound = part.value; } } - expect(bound).toBe("null"); + expect(LCase(bound)).toBe("null"); }); it("still converts that string to SQL NULL in $executeQuery", () => { From f96924a0630e0ffe661ac4641c18f9a163083bc6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 13:44:15 +0000 Subject: [PATCH 3/6] fix(database): flip adapter hardener S2 S6 S8 S12-S16 Keep the bound string null after IS/IS NOT. Emit MySQL TEXT/float DEFAULT. Quote boolean yes/no. SQLite advisory locks unsupported. Unknown foreign-key actions throw. Empty string defaults throw Wheels.InvalidDefault. Unmapped $getType throws everywhere. Drop Oracle MAX(ROWID) and MSSQL @@IDENTITY last-resort. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- ...tabase-adapters-hardener-s2-s16.changed.md | 1 + vendor/wheels/databaseAdapters/Abstract.cfc | 68 ++++-- vendor/wheels/databaseAdapters/Base.cfc | 39 +-- vendor/wheels/databaseAdapters/H2/H2Model.cfc | 3 + .../MicrosoftSQLServerModel.cfc | 17 +- .../databaseAdapters/MySQL/MySQLMigrator.cfc | 18 +- .../databaseAdapters/MySQL/MySQLModel.cfc | 3 + .../databaseAdapters/Oracle/OracleModel.cfc | 23 +- .../PostgreSQL/PostgreSQLMigrator.cfc | 6 +- .../databaseAdapters/SQLite/SQLiteModel.cfc | 13 +- .../wheels/migrator/ForeignKeyDefinition.cfc | 12 +- .../tests/_assets/adapters/OracleProbe.cfc | 4 +- .../database/DatabaseAdapterHardenerSpec.cfc | 226 +++++++++++------- .../database/MicrosoftSQLServerUnitSpec.cfc | 26 +- .../tests/specs/database/OracleUnitSpec.cfc | 51 ++-- .../tests/specs/database/QuoteValueSpec.cfc | 6 +- .../specs/migrator/addColumnOptionsSpec.cfc | 117 ++++----- 17 files changed, 328 insertions(+), 305 deletions(-) create mode 100644 changelog.d/database-adapters-hardener-s2-s16.changed.md diff --git a/changelog.d/database-adapters-hardener-s2-s16.changed.md b/changelog.d/database-adapters-hardener-s2-s16.changed.md new file mode 100644 index 0000000000..1812e9f1d3 --- /dev/null +++ b/changelog.d/database-adapters-hardener-s2-s16.changed.md @@ -0,0 +1 @@ +- Database adapters no longer coerce the bound string `"null"` after `IS` / `IS NOT` to SQL NULL, drop MySQL TEXT/float `DEFAULT`, leave boolean `yes`/`no` unquoted, advertise fake SQLite advisory locks, default unknown foreign-key actions to `CASCADE`, emit asymmetric empty-string defaults, map unknown column types silently, or fall back to Oracle `MAX(ROWID)` / SQL Server `@@IDENTITY` diff --git a/vendor/wheels/databaseAdapters/Abstract.cfc b/vendor/wheels/databaseAdapters/Abstract.cfc index 3036ac577d..1f388a4be1 100755 --- a/vendor/wheels/databaseAdapters/Abstract.cfc +++ b/vendor/wheels/databaseAdapters/Abstract.cfc @@ -72,6 +72,7 @@ component extends="wheels.migrator.Base"{ public string function addColumnOptions(required string sql, struct options = "#StructNew()#") { if (StructKeyExists(arguments.options, 'type') && arguments.options.type != 'primaryKey') { if (StructKeyExists(arguments.options, 'default') && optionsIncludeDefault(argumentCollection = arguments.options)) { + $rejectEmptyStringDefault(arguments.options); if ( arguments.options.default eq "NULL" || ( @@ -82,18 +83,6 @@ component extends="wheels.migrator.Base"{ arguments.sql = arguments.sql & " DEFAULT NULL"; } else if (arguments.options.type == 'boolean') { arguments.sql = arguments.sql & " DEFAULT #IIf(arguments.options.default, 1, 0)#"; - } else if ( - arguments.options.default eq "" - && ListFindNoCase("string,text,char", arguments.options.type) - ) { - // Symmetric handling for all string-like types: an empty - // `default=""` means "no default clause" (not `DEFAULT ''`). - // Without this, `t.string("a", default="")` and - // `t.text("b", default="")` produced asymmetric DDL and - // the presence-check skip in validatesPresenceOf fired - // inconsistently between equivalent column types. See - // fresh-VM journal F17. - arguments.sql = arguments.sql; } else { arguments.sql = arguments.sql & " DEFAULT #quote(value = arguments.options.default, options = arguments.options)#"; } @@ -117,6 +106,27 @@ component extends="wheels.migrator.Base"{ return true; } + /** + * Fail-loud contract for `default=""` on string-like columns. Abstract + * used to omit the DEFAULT clause; PostgreSQL used to emit `DEFAULT ''`. + * Both now throw `Wheels.InvalidDefault` so the adapters cannot silently + * diverge. + */ + public void function $rejectEmptyStringDefault(required struct options) { + if ( + StructKeyExists(arguments.options, "default") + && arguments.options.default eq "" + && StructKeyExists(arguments.options, "type") + && ListFindNoCase("string,text,char", arguments.options.type) + ) { + Throw( + type = "Wheels.InvalidDefault", + message = "An empty string default is not allowed for #arguments.options.type# columns.", + extendedInfo = "Omit the default, pass a non-empty value, or use default='NULL'. Abstract used to omit the DEFAULT clause and PostgreSQL used to emit DEFAULT ''." + ); + } + } + /** * quote value if required */ @@ -292,22 +302,34 @@ component extends="wheels.migrator.Base"{ local.sql = "CONSTRAINT #quoteTableName(arguments.name)# FOREIGN KEY (#quoteColumnName(arguments.column)#) REFERENCES #quoteTableName(arguments.referenceTable)#(#quoteColumnName(arguments.referenceColumn)#)"; for (local.item in ListToArray("onUpdate,onDelete")) { if (Len(arguments[local.item])) { - switch (arguments[local.item]) { - case "none": - local.sql = local.sql & " " & UCase(humanize(local.item)) & " NO ACTION"; - break; - case "null": - local.sql = local.sql & " " & UCase(humanize(local.item)) & " SET NULL"; - break; - default: - local.sql = local.sql & " " & UCase(humanize(local.item)) & " CASCADE"; - break; - } + local.sql = local.sql & $referentialActionSQL(item = local.item, action = arguments[local.item]); } } return local.sql; } + /** + * Maps a known onUpdate/onDelete value. Unknown values throw instead of + * silently becoming CASCADE. + */ + public string function $referentialActionSQL(required string item, required string action) { + switch (arguments.action) { + case "none": + return " " & UCase(humanize(arguments.item)) & " NO ACTION"; + case "null": + return " " & UCase(humanize(arguments.item)) & " SET NULL"; + case "cascade": + case "true": + return " " & UCase(humanize(arguments.item)) & " CASCADE"; + default: + Throw( + type = "Wheels.InvalidReferentialAction", + message = "The referential action `#arguments.action#` is not supported.", + extendedInfo = "Use none, null, cascade, or true. Unknown onUpdate/onDelete values used to silently become CASCADE." + ); + } + } + /** * generates sql to add database index on a table column */ diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index 255820bc80..96f9898533 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -20,7 +20,6 @@ component output=false extends="wheels.Global"{ // Build query cfquery(attributeCollection = args.queryAttributes) { local.pos = 1; - local.prev = ""; for (; pos <= sqlLen; pos++) { local.part = sqlArray[pos]; @@ -28,17 +27,10 @@ component output=false extends="wheels.Global"{ if (isStruct(part)) { local.qp = $queryParams(part); - // Handle NULL for "IS NULL" or "IS NOT NULL" - if ( - !isBinary(part.value) && - part.value == "null" && - pos > 1 && - ( right(prev, 2) == "IS" || right(prev, 6) == "IS NOT" ) - ) { - writeOutput("NULL"); - } - // Handle parameter lists "(?,?,?)" - else if (structKeyExists(qp, "list")) { + // The string "null" after IS / IS NOT stays a bound parameter. + // Do not coerce it to SQL NULL — a literal NULL is written as + // raw SQL by the query builder, not as a parameterized "null". + if (structKeyExists(qp, "list")) { writeOutput("("); if (args.parameterize) { cfqueryParam(attributeCollection = qp); @@ -66,7 +58,6 @@ component output=false extends="wheels.Global"{ } writeOutput(newLine); - prev = part; } // LIMIT / OFFSET logic @@ -593,7 +584,11 @@ component output=false extends="wheels.Global"{ if (!StructKeyExists(arguments, "type")) { arguments.type = $getValidationType(arguments.sqlType); } - if (!ListFindNoCase("integer,float,boolean", arguments.type) || !Len(arguments.str)) { + if ( + !ListFindNoCase("integer,float,boolean", arguments.type) + || !Len(arguments.str) + || (arguments.type == "boolean" && ListFindNoCase("yes,no", arguments.str)) + ) { local.rv = "'#Replace(arguments.str, "'", "''", "all")#'"; } else { $validateValueShape(arguments.str, arguments.type); @@ -632,6 +627,22 @@ component output=false extends="wheels.Global"{ } } + public void function $throwUnknownColumnType(required string typeName) { + Throw( + type = "Wheels.UnknownColumnType", + message = "The column type `#arguments.typeName#` is not mapped to a CFML SQL type.", + extendedInfo = "Add a case for `#arguments.typeName#` to `$getType()` on this database adapter." + ); + } + + public void function $throwIdentityNotFound() { + Throw( + type = "Wheels.IdentityNotFound", + message = "Could not retrieve the generated identity for this INSERT.", + extendedInfo = "The driver-supplied key and the sequence / SCOPE_IDENTITY path both missed. Last-resort MAX(ROWID) and @@IDENTITY have been removed." + ); + } + public void function $throwInvalidValue(required string str, required string expectedType) { Throw( type = "Wheels.InvalidValue", diff --git a/vendor/wheels/databaseAdapters/H2/H2Model.cfc b/vendor/wheels/databaseAdapters/H2/H2Model.cfc index aa9670e85e..8a07815669 100755 --- a/vendor/wheels/databaseAdapters/H2/H2Model.cfc +++ b/vendor/wheels/databaseAdapters/H2/H2Model.cfc @@ -115,6 +115,9 @@ component extends="wheels.databaseAdapters.Base" output=false { case "json": local.rv = "cf_sql_longvarchar"; break; + default: + $throwUnknownColumnType(arguments.type); + break; } return local.rv; } diff --git a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc index 0f03d3270d..3914a1ccd5 100755 --- a/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc +++ b/vendor/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc @@ -76,6 +76,9 @@ component extends="wheels.databaseAdapters.Base" output=false { case "cursor": local.rv = "cf_sql_refcursor"; break; + default: + $throwUnknownColumnType(arguments.type); + break; } return local.rv; } @@ -312,19 +315,7 @@ component extends="wheels.databaseAdapters.Base" output=false { return arguments.returningIdentity.lastId[1]; } - // Absolute last resort — only reached when the multi-statement batch did not - // surface a usable resultset on this engine/driver combo. @@IDENTITY is - // session-scoped and can return a trigger-generated identity from another - // table, but keeping it means a same-batch miss degrades to the pre-fix - // behavior instead of losing the key entirely. - local.query = $query(sql = "SELECT @@IDENTITY AS lastId", argumentCollection = arguments.queryAttributes); - - // Fallback to SCOPE_IDENTITY() if @@IDENTITY returned nothing (other CFML engines). - if (!Len(local.query.lastId)) { - local.query = $query(sql = "SELECT SCOPE_IDENTITY() AS lastId", argumentCollection = arguments.queryAttributes); - } - - return local.query.lastId; + $throwIdentityNotFound(); } /** diff --git a/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc b/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc index 9adeb36f59..e5b02eaa8a 100755 --- a/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc +++ b/vendor/wheels/databaseAdapters/MySQL/MySQLMigrator.cfc @@ -68,22 +68,12 @@ component extends="wheels.databaseAdapters.Abstract" { /** * Whether `addColumnOptions` should emit a DEFAULT clause for the column. - * Returns false for TEXT-family and FLOAT — the inherited Abstract - * `addColumnOptions` short-circuits the entire DEFAULT clause when this - * returns false, so a non-empty `default="long body"` is silently - * suppressed on MySQL. Rationale: pre-8.0.13 MySQL rejects DEFAULT on - * TEXT/BLOB columns outright, and the framework targets the broadest - * supported MySQL surface rather than emitting DDL that fails on older - * servers. The cross-engine contract this implies is asserted in - * `vendor/wheels/tests/specs/migrator/addColumnOptionsSpec.cfc` — keep - * this list and that spec aligned. See #2742. + * Always true — TEXT/float keep their DEFAULT the same way Abstract does. + * Pre-8.0.13 MySQL rejected DEFAULT on TEXT/BLOB; current Wheels targets + * servers that accept it rather than silently dropping the clause. */ public boolean function optionsIncludeDefault(string type, default = "", boolean allowNull = true) { - if (ListFindNoCase("text,mediumtext,longtext,float", arguments.type)) { - return false; - } else { - return true; - } + return true; } /** diff --git a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc index e96d6f62da..9f4b8322cc 100755 --- a/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc +++ b/vendor/wheels/databaseAdapters/MySQL/MySQLModel.cfc @@ -88,6 +88,9 @@ component extends="wheels.databaseAdapters.Base" output=false { case "longtext": local.rv = "cf_sql_longvarchar"; break; + default: + $throwUnknownColumnType(arguments.type); + break; } return local.rv; } diff --git a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc index 61176c5776..8005e2a7eb 100755 --- a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc +++ b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc @@ -69,6 +69,9 @@ component extends="wheels.databaseAdapters.Base" output=false { case "rowid": local.rv = "cf_sql_varchar"; break; + default: + $throwUnknownColumnType(arguments.type); + break; } return local.rv; } @@ -160,7 +163,7 @@ component extends="wheels.databaseAdapters.Base" output=false { local.seq = local.q.sequence_name; } } catch (any e) { - // Catalog view absent (pre-12c) — fall through to the legacy lookup. + // Catalog view absent (pre-12c) — leave seq empty so $lastIdLookup throws. // Deliberately no local assignments in here (BoxLang catch-scope invariant). } if (Len(local.seq) && !REFind("^[A-Za-z][A-Za-z0-9_$##]*$", local.seq)) { @@ -201,9 +204,9 @@ component extends="wheels.databaseAdapters.Base" output=false { // 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 fallbacks below. This exact-row + // unexpected fall through to CURRVAL, then throw. This exact-row // lookup targets OUR insert, so it is race-free under concurrent - // inserts (unlike MAX(ROWID)). + // inserts. if (REFind("^[A-Za-z0-9/+]{18}$", local.generated) == 1) { local.query = $query( sql = "SELECT #arguments.primaryKey# AS lastId FROM #local.tbl# WHERE ROWID = CHARTOROWID('#local.generated#')", @@ -216,8 +219,8 @@ component extends="wheels.databaseAdapters.Base" output=false { } // No usable driver key (e.g. current BoxLang): read CURRVAL on the identity - // column's backing sequence. CURRVAL is session-scoped, so unlike MAX(ROWID) - // it cannot return another session's key under concurrent inserts. + // column's backing sequence. CURRVAL is session-scoped and cannot return + // another session's key under concurrent inserts. local.seq = $identitySequenceName( tableName = local.tbl, columnName = ListFirst(arguments.primaryKey), @@ -233,15 +236,7 @@ component extends="wheels.databaseAdapters.Base" output=false { } } - // Legacy heuristic, kept only for pre-12c schemas with no discoverable - // identity sequence. ROWID is physical location, not insertion order, so - // MAX(ROWID) races under concurrent inserts and can return another - // session's row. - local.query = $query( - sql = "SELECT #arguments.primaryKey# AS lastId FROM #local.tbl# WHERE ROWID = (SELECT MAX(ROWID) FROM #local.tbl#)", - argumentCollection = arguments.queryAttributes - ); - return local.query.lastId; + $throwIdentityNotFound(); } /** diff --git a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLMigrator.cfc b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLMigrator.cfc index ae8e7ae6ae..36f88cfbc9 100755 --- a/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLMigrator.cfc +++ b/vendor/wheels/databaseAdapters/PostgreSQL/PostgreSQLMigrator.cfc @@ -61,6 +61,7 @@ component extends="wheels.databaseAdapters.Abstract" { ) { if (StructKeyExists(arguments.options, 'type') && arguments.options.type != 'primaryKey') { if (StructKeyExists(arguments.options, 'default') && optionsIncludeDefault(argumentCollection = arguments.options)) { + $rejectEmptyStringDefault(arguments.options); if (arguments.alter) { arguments.sql = arguments.sql & " SET"; } @@ -74,11 +75,6 @@ component extends="wheels.databaseAdapters.Abstract" { arguments.sql = arguments.sql & " DEFAULT NULL"; } else if (arguments.options.type == 'boolean') { arguments.sql = arguments.sql & " DEFAULT #IIf(arguments.options.default, true, false)#"; - } else if (arguments.options.type == 'string' && arguments.options.default eq "") { - // Leading space required: when called with alter=true the upstream - // concatenates " SET" first, and without this space the resulting - // "SETDEFAULT ''" is invalid SQL (PG rejects the merged token). - arguments.sql = arguments.sql & " DEFAULT ''"; } else { arguments.sql = arguments.sql & " DEFAULT #quote(value = arguments.options.default, options = arguments.options)#"; } diff --git a/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc b/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc index c93a74692b..24095a067a 100755 --- a/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc +++ b/vendor/wheels/databaseAdapters/SQLite/SQLiteModel.cfc @@ -59,8 +59,7 @@ component extends="wheels.databaseAdapters.Base" output=false { break; default: - // SQLite is dynamically typed, so fallback to text if unknown. - local.rv = "cf_sql_varchar"; + $throwUnknownColumnType(arguments.type); break; } @@ -117,13 +116,13 @@ component extends="wheels.databaseAdapters.Base" output=false { } /** - * SQLite's lock methods are no-ops (file-level locking only) but they - * never throw, so the `withAdvisoryLock` contract is honored: callback - * runs and its return value flows through. Treated as supported for the - * purposes of capability checks. + * SQLite has no advisory-lock primitive — file-level locking only. + * `$acquireAdvisoryLock` / `$releaseAdvisoryLock` stay no-ops and are + * unused because this flag is false. Callers (`withAdvisoryLock`, the + * lockingSpec guard) skip the fake lock instead of pretending it works. */ public boolean function $supportsAdvisoryLocks() { - return true; + return false; } /** diff --git a/vendor/wheels/migrator/ForeignKeyDefinition.cfc b/vendor/wheels/migrator/ForeignKeyDefinition.cfc index 13842db47e..c134019ae6 100644 --- a/vendor/wheels/migrator/ForeignKeyDefinition.cfc +++ b/vendor/wheels/migrator/ForeignKeyDefinition.cfc @@ -78,17 +78,7 @@ component extends="Base" { public string function $appendReferentialActions(required string sql) { for (local.item in ListToArray("onUpdate,onDelete")) { if (StructKeyExists(this, local.item) && Len(this[local.item])) { - switch (this[local.item]) { - case "none": - arguments.sql &= " " & UCase(humanize(local.item)) & " NO ACTION"; - break; - case "null": - arguments.sql &= " " & UCase(humanize(local.item)) & " SET NULL"; - break; - default: - arguments.sql &= " " & UCase(humanize(local.item)) & " CASCADE"; - break; - } + arguments.sql &= this.adapter.$referentialActionSQL(item = local.item, action = this[local.item]); } } return arguments.sql; diff --git a/vendor/wheels/tests/_assets/adapters/OracleProbe.cfc b/vendor/wheels/tests/_assets/adapters/OracleProbe.cfc index c013badd5d..5f1e6b4d34 100644 --- a/vendor/wheels/tests/_assets/adapters/OracleProbe.cfc +++ b/vendor/wheels/tests/_assets/adapters/OracleProbe.cfc @@ -1,8 +1,8 @@ /** * Test double for the Oracle database adapter. Captures every SQL string * passed to $query() and serves mock resultsets from a FIFO queue instead of - * hitting a database, so adapter-unit specs can exercise the CURRVAL / - * MAX(ROWID) identity fallbacks without a live Oracle connection. + * hitting a database, so adapter-unit specs can exercise the CURRVAL + * identity path and the IdentityNotFound miss without a live Oracle connection. */ component extends="wheels.databaseAdapters.Oracle.OracleModel" output=false { diff --git a/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc b/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc index b5ed701f0f..8694bbb01d 100644 --- a/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc +++ b/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc @@ -1,6 +1,6 @@ /** * databaseAdapters Hardener S1–S18. - * S2/S6/S8/S12/S13/S14/S15 stay HELD. S16 proves last-resort only. + * Former HOLDs S2/S6/S8/S12/S13/S14/S15/S16 are flipped to the fail-loud contracts. */ component extends="wheels.WheelsTest" { @@ -8,7 +8,7 @@ component extends="wheels.WheelsTest" { g = application.wo; - describe("S2 HOLD $executeQuery string null after IS", () => { + describe("S2 $executeQuery keeps bound string null after IS", () => { it("binds the string null after IS and IS NOT", () => { var sql = g.model("post").$whereClause(where = "averagerating IS NULL"); @@ -33,12 +33,13 @@ component extends="wheels.WheelsTest" { expect(LCase(bound)).toBe("null"); }); - it("still converts that string to SQL NULL in $executeQuery", () => { + it("does not coerce that string to SQL NULL in $executeQuery", () => { var src = FileRead(ExpandPath("/wheels/databaseAdapters/Base.cfc")); - expect(src).toInclude('part.value == "null"'); - expect(src).toInclude('right(prev, 2) == "IS"'); - expect(src).toInclude('right(prev, 6) == "IS NOT"'); - expect(src).toInclude('writeOutput("NULL")'); + var start = Find("public struct function $executeQuery", src); + var body = Mid(src, start, 2500); + expect(body).notToInclude('writeOutput("NULL")'); + expect(body).notToInclude('right(prev, 2) == "IS"'); + expect(body).notToInclude('right(prev, 6) == "IS NOT"'); }); }); @@ -99,18 +100,18 @@ component extends="wheels.WheelsTest" { }); - describe("S6 HOLD MySQL optionsIncludeDefault vs Abstract", () => { + describe("S6 MySQL optionsIncludeDefault keeps DEFAULT", () => { - it("MySQL drops DEFAULT for text and float", () => { + it("MySQL emits DEFAULT for text and float", () => { var mysql = CreateObject("component", "wheels.databaseAdapters.MySQL.MySQLMigrator"); - expect(mysql.optionsIncludeDefault(type = "text", default = "long body")).toBeFalse(); - expect(mysql.optionsIncludeDefault(type = "float", default = "1.25")).toBeFalse(); + expect(mysql.optionsIncludeDefault(type = "text", default = "long body")).toBeTrue(); + expect(mysql.optionsIncludeDefault(type = "float", default = "1.25")).toBeTrue(); expect(mysql.optionsIncludeDefault(type = "string", default = "hello")).toBeTrue(); var sql = mysql.addColumnOptions( sql = "", options = {type: "text", default: "long body", allowNull: true} ); - expect(sql).notToInclude("DEFAULT"); + expect(sql).toInclude("DEFAULT"); }); it("Abstract optionsIncludeDefault stays always true", () => { @@ -155,37 +156,38 @@ component extends="wheels.WheelsTest" { }); - describe("S12 HOLD SQLite advisory locks stay no-op true", () => { + describe("S12 SQLite advisory locks are unsupported", () => { - it("reports support and acquire does not throw", () => { + it("reports no support so acquire is unused", () => { var adapter = CreateObject("component", "wheels.databaseAdapters.SQLite.SQLiteModel"); - expect(adapter.$supportsAdvisoryLocks()).toBeTrue(); + expect(adapter.$supportsAdvisoryLocks()).toBeFalse(); adapter.$acquireAdvisoryLock(name = "hardener_s12", timeout = 1); adapter.$releaseAdvisoryLock(name = "hardener_s12"); }); }); - describe("S13 HOLD foreignKeySQL unknown action is CASCADE", () => { + describe("S13 foreignKeySQL unknown action throws", () => { - it("maps restrict and other unknown values to CASCADE", () => { - var adapter = CreateObject("component", "wheels.databaseAdapters.Abstract"); - var sql = adapter.foreignKeySQL( - name = "fk_posts_users", - table = "posts", - referenceTable = "users", - column = "userid", - referenceColumn = "id", - onUpdate = "restrict", - onDelete = "set default" - ); - expect(sql).toInclude("ON UPDATE CASCADE"); - expect(sql).toInclude("ON DELETE CASCADE"); - expect(sql).notToInclude("RESTRICT"); - expect(sql).notToInclude("SET DEFAULT"); + it("throws Wheels.InvalidReferentialAction for restrict and set default", () => { + var state = {adapter = CreateObject("component", "wheels.databaseAdapters.Abstract"), type = ""}; + try { + state.adapter.foreignKeySQL( + name = "fk_posts_users", + table = "posts", + referenceTable = "users", + column = "userid", + referenceColumn = "id", + onUpdate = "restrict", + onDelete = "set default" + ); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.InvalidReferentialAction"); }); - it("still maps none and null", () => { + it("still maps none, null, cascade, and true", () => { var adapter = CreateObject("component", "wheels.databaseAdapters.Abstract"); var sql = adapter.foreignKeySQL( name = "fk_posts_users", @@ -198,94 +200,144 @@ component extends="wheels.WheelsTest" { ); expect(sql).toInclude("ON UPDATE NO ACTION"); expect(sql).toInclude("ON DELETE SET NULL"); + sql = adapter.foreignKeySQL( + name = "fk_posts_users", + table = "posts", + referenceTable = "users", + column = "userid", + referenceColumn = "id", + onUpdate = "cascade", + onDelete = "true" + ); + expect(sql).toInclude("ON UPDATE CASCADE"); + expect(sql).toInclude("ON DELETE CASCADE"); }); }); - describe("S14 HOLD Abstract vs PG empty string default", () => { + describe("S14 empty string default throws Wheels.InvalidDefault", () => { - it("Abstract omits DEFAULT for string default empty", () => { - var adapter = CreateObject("component", "wheels.databaseAdapters.Abstract"); - var sql = adapter.addColumnOptions( - sql = "", - options = {type: "string", default: "", allowNull: true} - ); - expect(sql).notToInclude("DEFAULT"); + it("Abstract throws Wheels.InvalidDefault for string default empty", () => { + var state = {adapter = CreateObject("component", "wheels.databaseAdapters.Abstract"), type = ""}; + try { + state.adapter.addColumnOptions( + sql = "", + options = {type: "string", default: "", allowNull: true} + ); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.InvalidDefault"); }); - it("PostgreSQL emits DEFAULT empty string for string default empty", () => { - var adapter = CreateObject("component", "wheels.databaseAdapters.PostgreSQL.PostgreSQLMigrator"); - var sql = adapter.addColumnOptions( - sql = "", - options = {type: "string", default: "", allowNull: true} - ); - expect(sql).toInclude("DEFAULT ''"); + it("PostgreSQL throws Wheels.InvalidDefault for string default empty", () => { + var state = { + adapter = CreateObject("component", "wheels.databaseAdapters.PostgreSQL.PostgreSQLMigrator"), + type = "" + }; + try { + state.adapter.addColumnOptions( + sql = "", + options = {type: "string", default: "", allowNull: true} + ); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.InvalidDefault"); }); }); - describe("S15 HOLD unmapped $getType", () => { + describe("S15 unmapped $getType throws Wheels.UnknownColumnType", () => { it("PostgreSQL throws Wheels.UnknownColumnType", () => { - var adapter = CreateObject("component", "wheels.databaseAdapters.PostgreSQL.PostgreSQLModel"); - expect(function() { - adapter.$getType(type = "definitely_not_a_type"); - }).toThrow("Wheels.UnknownColumnType"); - }); - - it("SQLite falls back to cf_sql_varchar", () => { - var adapter = CreateObject("component", "wheels.databaseAdapters.SQLite.SQLiteModel"); - expect(adapter.$getType(type = "definitely_not_a_type")).toBe("cf_sql_varchar"); + var state = { + adapter = CreateObject("component", "wheels.databaseAdapters.PostgreSQL.PostgreSQLModel"), + type = "" + }; + try { + state.adapter.$getType(type = "definitely_not_a_type"); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.UnknownColumnType"); }); - it("MySQL errors without Wheels.UnknownColumnType", () => { - var adapter = CreateObject("component", "wheels.databaseAdapters.MySQL.MySQLModel"); - var state = {type = ""}; + it("SQLite throws Wheels.UnknownColumnType", () => { + var state = { + adapter = CreateObject("component", "wheels.databaseAdapters.SQLite.SQLiteModel"), + type = "" + }; try { - adapter.$getType(type = "definitely_not_a_type"); + state.adapter.$getType(type = "definitely_not_a_type"); } catch (any e) { state.type = e.type; } - expect(Len(state.type)).toBeGT(0); - expect(state.type).notToBe("Wheels.UnknownColumnType"); + expect(state.type).toBe("Wheels.UnknownColumnType"); + }); + + it("MySQL, H2, Oracle, and MSSQL throw Wheels.UnknownColumnType", () => { + var paths = [ + "wheels.databaseAdapters.MySQL.MySQLModel", + "wheels.databaseAdapters.H2.H2Model", + "wheels.databaseAdapters.Oracle.OracleModel", + "wheels.databaseAdapters.MicrosoftSQLServer.MicrosoftSQLServerModel" + ]; + for (var path in paths) { + var state = {adapter = CreateObject("component", path), type = ""}; + try { + state.adapter.$getType(type = "definitely_not_a_type"); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.UnknownColumnType"); + } }); }); - describe("S16 HOLD last-resort identity stays", () => { + describe("S16 last-resort identity is gone", () => { - it("Oracle still emits MAX(ROWID) when no sequence is found", () => { + it("Oracle throws Wheels.IdentityNotFound when no sequence is found", () => { var probe = CreateObject("component", "wheels.tests._assets.adapters.OracleProbe"); ArrayAppend(probe.queryResults, QueryNew("sequence_name", "varchar", [])); - ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 9}])); - var rv = probe.$identitySelect( - queryAttributes = {}, - result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, - primaryKey = "id", - returningIdentity = "" - ); - expect(rv.lastId).toBe(9); - expect(probe.capturedSql[2]).toInclude("MAX(ROWID)"); + var state = {type = ""}; + try { + probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = "" + ); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.IdentityNotFound"); + expect(ArrayToList(probe.capturedSql, " ")).notToInclude("MAX(ROWID)"); }); - it("MSSQL still emits @@IDENTITY when the batch has no resultset", () => { + it("MSSQL throws Wheels.IdentityNotFound when the batch has no resultset", () => { var probe = CreateObject("component", "wheels.tests._assets.adapters.MSSQLProbe"); - ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 7}])); - var rv = probe.$identitySelect( - queryAttributes = {}, - result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, - primaryKey = "id", - returningIdentity = QueryNew("lastId", "varchar", []) - ); - expect(rv.identitycol).toBe(7); - expect(probe.capturedSql[1]).toInclude("@@IDENTITY"); + var state = {type = ""}; + try { + probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = QueryNew("lastId", "varchar", []) + ); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.IdentityNotFound"); + expect(ArrayToList(probe.capturedSql, " ")).notToInclude("@@IDENTITY"); }); - it("does not remove the last-resort SQL from the adapters", () => { + it("does not keep last-resort SQL in the adapters", () => { var oracleSrc = FileRead(ExpandPath("/wheels/databaseAdapters/Oracle/OracleModel.cfc")); var mssqlSrc = FileRead(ExpandPath("/wheels/databaseAdapters/MicrosoftSQLServer/MicrosoftSQLServerModel.cfc")); - expect(oracleSrc).toInclude("MAX(ROWID)"); - expect(mssqlSrc).toInclude("@@IDENTITY"); + expect(oracleSrc).notToInclude("MAX(ROWID)"); + expect(mssqlSrc).notToInclude("@@IDENTITY"); }); }); diff --git a/vendor/wheels/tests/specs/database/MicrosoftSQLServerUnitSpec.cfc b/vendor/wheels/tests/specs/database/MicrosoftSQLServerUnitSpec.cfc index b35d2d98a8..955da08105 100644 --- a/vendor/wheels/tests/specs/database/MicrosoftSQLServerUnitSpec.cfc +++ b/vendor/wheels/tests/specs/database/MicrosoftSQLServerUnitSpec.cfc @@ -200,19 +200,21 @@ component extends="wheels.WheelsTest" { expect(ArrayLen(probe.capturedSql)).toBe(0); }); - it("falls back to @@IDENTITY when the batch surfaces no usable resultset", () => { + it("throws Wheels.IdentityNotFound when the batch surfaces no usable resultset", () => { var probe = CreateObject("component", "wheels.tests._assets.adapters.MSSQLProbe"); - ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 7}])); - var rv = probe.$identitySelect( - queryAttributes = {}, - result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, - primaryKey = "id", - returningIdentity = QueryNew("lastId", "varchar", []) - ); - expect(rv).toBeStruct(); - expect(rv).toHaveKey("identitycol"); - expect(rv.identitycol).toBe(7); - expect(probe.capturedSql[1]).toInclude("@@IDENTITY"); + var state = {type = ""}; + try { + probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = QueryNew("lastId", "varchar", []) + ); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.IdentityNotFound"); + expect(ArrayToList(probe.capturedSql, " ")).notToInclude("@@IDENTITY"); }); }); }); diff --git a/vendor/wheels/tests/specs/database/OracleUnitSpec.cfc b/vendor/wheels/tests/specs/database/OracleUnitSpec.cfc index 0cb6e67127..0d2f828622 100644 --- a/vendor/wheels/tests/specs/database/OracleUnitSpec.cfc +++ b/vendor/wheels/tests/specs/database/OracleUnitSpec.cfc @@ -136,38 +136,43 @@ component extends="wheels.WheelsTest" { expect(ArrayToList(probe.capturedSql, " ")).notToInclude("MAX(ROWID)"); }); - it("falls back to MAX(ROWID) when no identity sequence is discoverable", () => { - // Pre-12c schemas have no user_tab_identity_cols rows — the legacy - // last-resort lookup must survive for them. + it("throws Wheels.IdentityNotFound when no identity sequence is discoverable", () => { var probe = CreateObject("component", "wheels.tests._assets.adapters.OracleProbe"); ArrayAppend(probe.queryResults, QueryNew("sequence_name", "varchar", [])); - ArrayAppend(probe.queryResults, QueryNew("lastId", "integer", [{lastId: 9}])); - var rv = probe.$identitySelect( - queryAttributes = {}, - result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, - primaryKey = "id", - returningIdentity = "" - ); - expect(rv).toBeStruct(); - expect(rv).toHaveKey("lastId"); - expect(rv.lastId).toBe(9); - expect(probe.capturedSql[2]).toInclude("MAX(ROWID)"); + var state = {type = ""}; + try { + probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = "" + ); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.IdentityNotFound"); + expect(ArrayToList(probe.capturedSql, " ")).notToInclude("MAX(ROWID)"); }); - it("rejects unsafe sequence names and falls back to MAX(ROWID)", () => { + it("rejects unsafe sequence names and throws Wheels.IdentityNotFound", () => { // $query has no parameter binding, so the discovered sequence name is // whitelisted before interpolation — anything unexpected is discarded. var probe = CreateObject("component", "wheels.tests._assets.adapters.OracleProbe"); ArrayAppend(probe.queryResults, QueryNew("sequence_name", "varchar", [{sequence_name: "BAD;NAME"}])); - var rv = probe.$identitySelect( - queryAttributes = {}, - result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, - primaryKey = "id", - returningIdentity = "" - ); - expect(rv).toBeStruct(); + var state = {type = ""}; + try { + probe.$identitySelect( + queryAttributes = {}, + result = {sql: "INSERT INTO users (firstname) VALUES ('x')"}, + primaryKey = "id", + returningIdentity = "" + ); + } catch (any e) { + state.type = e.type; + } + expect(state.type).toBe("Wheels.IdentityNotFound"); expect(ArrayToList(probe.capturedSql, " ")).notToInclude("BAD;NAME"); - expect(probe.capturedSql[2]).toInclude("MAX(ROWID)"); + expect(ArrayToList(probe.capturedSql, " ")).notToInclude("MAX(ROWID)"); }); }); }); diff --git a/vendor/wheels/tests/specs/database/QuoteValueSpec.cfc b/vendor/wheels/tests/specs/database/QuoteValueSpec.cfc index cdafaa52f3..63f44f293f 100644 --- a/vendor/wheels/tests/specs/database/QuoteValueSpec.cfc +++ b/vendor/wheels/tests/specs/database/QuoteValueSpec.cfc @@ -56,9 +56,9 @@ component extends="wheels.WheelsTest" { }).toThrow("Wheels.InvalidValue"); }); - it("S8 HOLD leaves boolean yes and no unquoted", () => { - expect(adapter.$quoteValue(str = "yes", type = "boolean")).toBe("yes"); - expect(adapter.$quoteValue(str = "no", type = "boolean")).toBe("no"); + it("quotes boolean yes and no", () => { + expect(adapter.$quoteValue(str = "yes", type = "boolean")).toBe("'yes'"); + expect(adapter.$quoteValue(str = "no", type = "boolean")).toBe("'no'"); }); }); diff --git a/vendor/wheels/tests/specs/migrator/addColumnOptionsSpec.cfc b/vendor/wheels/tests/specs/migrator/addColumnOptionsSpec.cfc index 990be06c13..027f09a21f 100644 --- a/vendor/wheels/tests/specs/migrator/addColumnOptionsSpec.cfc +++ b/vendor/wheels/tests/specs/migrator/addColumnOptionsSpec.cfc @@ -1,55 +1,19 @@ /** - * Regression coverage for fresh-VM journal F17 — `addColumnOptions` emitted - * asymmetric DDL for `default=""` between string-like column types. + * Regression coverage for addColumnOptions default handling. * - * t.string(...,default="") → no DEFAULT clause - * t.text(...,default="") → DEFAULT '' - * t.char(...,default="") → DEFAULT '' + * S14 fail-loud contract: `default=""` on string/text/char throws + * `Wheels.InvalidDefault` on every adapter (Abstract and PostgreSQL). + * The old F17 asymmetry (Abstract omitted DEFAULT, PG emitted DEFAULT '') + * is gone so the two adapters cannot silently diverge. * - * That asymmetry then interacts with the presence-check skip in - * validatesPresenceOf (vendor/wheels/model/validations.cfc) — which checks - * whether the underlying column has a database default — making the user's - * `validatesPresenceOf` rule fire for `title` (string, no default emitted) - * but silently skip for `body` (text, DEFAULT '' emitted), even though - * the user wrote both columns identically. Tutorial chapter 7's model spec - * `requires a body` failed because of this. - * - * The F17 fix lives in `wheels.databaseAdapters.Abstract.addColumnOptions`: - * after the fix all three string-like types with `default=""` produce the - * same DDL (no DEFAULT clause) on Abstract-based adapters (MySQL, SQLite, - * H2, Oracle, Microsoft SQL Server). - * - * PostgreSQL and its CockroachDB subclass have their own `addColumnOptions` - * implementation that intentionally emits `DEFAULT ''` for empty strings, - * and serializes booleans as `true` / `false` (vs `1` / `0`). Those - * surface differences are part of those adapters' contract — this spec - * documents them rather than asserting them away. See #2661 for the cross- - * adapter triage that motivated this adapter-aware shape. - * - * MySQL is also a documented divergence: `MySQLMigrator.optionsIncludeDefault` - * returns false for `text` / `mediumtext` / `longtext` / `float`, so the - * Abstract `addColumnOptions` short-circuits the entire DEFAULT clause for - * those types on MySQL — a real, non-empty `default="long body"` is silently - * suppressed in the emitted DDL. The legacy MySQL constraint that motivated - * this (pre-8.0.13 TEXT/BLOB columns reject DEFAULT) is documented on the - * adapter; this spec asserts the resulting cross-engine contract. See #2742. + * S6: MySQL emits DEFAULT for TEXT-family and FLOAT instead of dropping it. */ component extends="wheels.WheelsTest" { function beforeAll() { variables.adapter = createObject("component", "wheels.migrator.Migration").init().adapter; - // PostgreSQL and CockroachDB share the PostgreSQLMigrator addColumnOptions - // implementation, which diverges from Abstract on empty string defaults - // and boolean serialization. Use the same adapterName() idiom that - // vendor/wheels/tests/specs/migrator/migrationSpec.cfc already uses - // for cross-adapter branching. var name = variables.adapter.adapterName(); variables.isPostgresFamily = (name == "PostgreSQL" || name == "CockroachDB"); - // MySQL suppresses the entire DEFAULT clause for TEXT-family and FLOAT - // columns via optionsIncludeDefault, so any text-with-real-default - // assertion must carve out MySQL the same way isPostgresFamily does for - // the empty-default cases. - variables.isMySQLFamily = (name == "MySQL"); } private string function buildOptions(string type, string default = "", boolean allowNull = true) { @@ -63,34 +27,45 @@ component extends="wheels.WheelsTest" { function run() { - describe("addColumnOptions — symmetric default handling for string-like types (F17)", () => { + describe("addColumnOptions — default handling for string-like types", () => { - it("string with default='' omits the DEFAULT clause on Abstract-based adapters", () => { - var sql = buildOptions(type = "string", default = ""); - if (variables.isPostgresFamily) { - // PG adapter intentionally emits `DEFAULT ''` for empty strings. - expect(sql).toInclude("DEFAULT"); - } else { - expect(sql).notToInclude("DEFAULT"); + it("string with default='' throws Wheels.InvalidDefault", () => { + var state = {adapter = variables.adapter, type = ""}; + try { + state.adapter.addColumnOptions( + sql = "", + options = {type: "string", default: "", allowNull: true} + ); + } catch (any e) { + state.type = e.type; } + expect(state.type).toBe("Wheels.InvalidDefault"); }); - it("text with default='' omits the DEFAULT clause on Abstract-based adapters (F17)", () => { - var sql = buildOptions(type = "text", default = ""); - if (variables.isPostgresFamily) { - expect(sql).toInclude("DEFAULT"); - } else { - expect(sql).notToInclude("DEFAULT"); + it("text with default='' throws Wheels.InvalidDefault", () => { + var state = {adapter = variables.adapter, type = ""}; + try { + state.adapter.addColumnOptions( + sql = "", + options = {type: "text", default: "", allowNull: true} + ); + } catch (any e) { + state.type = e.type; } + expect(state.type).toBe("Wheels.InvalidDefault"); }); - it("char with default='' omits the DEFAULT clause on Abstract-based adapters (F17)", () => { - var sql = buildOptions(type = "char", default = ""); - if (variables.isPostgresFamily) { - expect(sql).toInclude("DEFAULT"); - } else { - expect(sql).notToInclude("DEFAULT"); + it("char with default='' throws Wheels.InvalidDefault", () => { + var state = {adapter = variables.adapter, type = ""}; + try { + state.adapter.addColumnOptions( + sql = "", + options = {type: "char", default: "", allowNull: true} + ); + } catch (any e) { + state.type = e.type; } + expect(state.type).toBe("Wheels.InvalidDefault"); }); it("string with a real default (non-empty) still emits DEFAULT", () => { @@ -99,20 +74,10 @@ component extends="wheels.WheelsTest" { expect(sql).toInclude("'hello'"); }); - it("text with a real default (non-empty): DEFAULT clause is adapter-dependent", () => { + it("text with a real default (non-empty) emits DEFAULT", () => { var sql = buildOptions(type = "text", default = "long body"); - if (variables.isMySQLFamily) { - // MySQL's optionsIncludeDefault returns false for TEXT, so the - // Abstract addColumnOptions short-circuits the DEFAULT clause - // entirely. The user's `default="long body"` is silently - // suppressed in the emitted DDL — surprising but intentional, - // rooted in the pre-8.0.13 MySQL constraint that TEXT/BLOB - // columns cannot carry a DEFAULT. - expect(sql).notToInclude("DEFAULT"); - } else { - expect(sql).toInclude("DEFAULT"); - expect(sql).toInclude("'long body'"); - } + expect(sql).toInclude("DEFAULT"); + expect(sql).toInclude("'long body'"); }); it("integer with default='' becomes DEFAULT NULL across adapters", () => { @@ -123,10 +88,8 @@ component extends="wheels.WheelsTest" { it("boolean with default=true emits the adapter's true literal", () => { var sql = buildOptions(type = "boolean", default = true); if (variables.isPostgresFamily) { - // PG adapter serializes booleans as `true` / `false` literals. expect(sql).toInclude("DEFAULT true"); } else { - // Abstract-based adapters serialize booleans as `1` / `0`. expect(sql).toInclude("DEFAULT 1"); } }); From 2c969baadb3683d94ef9c477a21e9f684a939659 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 13:46:08 +0000 Subject: [PATCH 4/6] test(migrator): drop empty string defaults from fixtures S14 throws Wheels.InvalidDefault for default="" on string columns. Fixtures meant "no default clause"; omit the argument instead. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- vendor/wheels/migrator/templates/change-table.cfc | 2 +- .../_assets/migrator/migrations/001_create_bunyips_table.cfc | 2 +- .../_assets/migrator/migrations/002_create_dropbears_table.cfc | 2 +- .../_assets/migrator/migrations/003_create_hoopsnakes_table.cfc | 2 +- .../_assets/migrator/migrations/001_create_bunyips_table.cfc | 2 +- .../_assets/migrator/migrations/002_create_dropbears_table.cfc | 2 +- .../_assets/migrator/migrations/003_create_hoopsnakes_table.cfc | 2 +- vendor/wheels/tests/specs/migrator/migratorSpec.cfc | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/vendor/wheels/migrator/templates/change-table.cfc b/vendor/wheels/migrator/templates/change-table.cfc index 958bd25401..c5da4dfc66 100644 --- a/vendor/wheels/migrator/templates/change-table.cfc +++ b/vendor/wheels/migrator/templates/change-table.cfc @@ -7,7 +7,7 @@ EXAMPLE: t = changeTable(name='employees'); - t.string(columnNames="fullName", default="", allowNull=true, limit="255"); + t.string(columnNames="fullName", allowNull=true, limit="255"); t.change(); */ component extends="[extends]" hint="[description]" { diff --git a/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/001_create_bunyips_table.cfc b/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/001_create_bunyips_table.cfc index fe7ba287cc..e83beccef5 100644 --- a/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/001_create_bunyips_table.cfc +++ b/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/001_create_bunyips_table.cfc @@ -4,7 +4,7 @@ component extends="wheels.migrator.Migration" hint="create c_o_r_e_bunyips table transaction { try { t = createTable(name = "c_o_r_e_bunyips"); - t.string(columnNames = "name", default = "", allowNull = true, limit = 255); + t.string(columnNames = "name", allowNull = true, limit = 255); t.timestamps(); t.create(); } catch (any ex) { diff --git a/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/002_create_dropbears_table.cfc b/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/002_create_dropbears_table.cfc index c79af35c6e..4f8acd1ea6 100644 --- a/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/002_create_dropbears_table.cfc +++ b/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/002_create_dropbears_table.cfc @@ -4,7 +4,7 @@ component extends="wheels.migrator.Migration" hint="create kangaroos table" { transaction { try { t = createTable(name = "c_o_r_e_dropbears"); - t.string(columnNames = "name", default = "", allowNull = true, limit = 255); + t.string(columnNames = "name", allowNull = true, limit = 255); t.timestamps(); t.create(); } catch (any ex) { diff --git a/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/003_create_hoopsnakes_table.cfc b/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/003_create_hoopsnakes_table.cfc index 616c827841..d3a1ce676b 100644 --- a/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/003_create_hoopsnakes_table.cfc +++ b/vendor/wheels/rocketunit_tests/_assets/migrator/migrations/003_create_hoopsnakes_table.cfc @@ -4,7 +4,7 @@ component extends="wheels.migrator.Migration" hint="create kangaroos table" { transaction { try { t = createTable(name = "c_o_r_e_hoopsnakes"); - t.string(columnNames = "name", default = "", allowNull = true, limit = 255); + t.string(columnNames = "name", allowNull = true, limit = 255); t.timestamps(); t.create(); } catch (any ex) { diff --git a/vendor/wheels/tests/_assets/migrator/migrations/001_create_bunyips_table.cfc b/vendor/wheels/tests/_assets/migrator/migrations/001_create_bunyips_table.cfc index fe7ba287cc..e83beccef5 100644 --- a/vendor/wheels/tests/_assets/migrator/migrations/001_create_bunyips_table.cfc +++ b/vendor/wheels/tests/_assets/migrator/migrations/001_create_bunyips_table.cfc @@ -4,7 +4,7 @@ component extends="wheels.migrator.Migration" hint="create c_o_r_e_bunyips table transaction { try { t = createTable(name = "c_o_r_e_bunyips"); - t.string(columnNames = "name", default = "", allowNull = true, limit = 255); + t.string(columnNames = "name", allowNull = true, limit = 255); t.timestamps(); t.create(); } catch (any ex) { diff --git a/vendor/wheels/tests/_assets/migrator/migrations/002_create_dropbears_table.cfc b/vendor/wheels/tests/_assets/migrator/migrations/002_create_dropbears_table.cfc index c79af35c6e..4f8acd1ea6 100644 --- a/vendor/wheels/tests/_assets/migrator/migrations/002_create_dropbears_table.cfc +++ b/vendor/wheels/tests/_assets/migrator/migrations/002_create_dropbears_table.cfc @@ -4,7 +4,7 @@ component extends="wheels.migrator.Migration" hint="create kangaroos table" { transaction { try { t = createTable(name = "c_o_r_e_dropbears"); - t.string(columnNames = "name", default = "", allowNull = true, limit = 255); + t.string(columnNames = "name", allowNull = true, limit = 255); t.timestamps(); t.create(); } catch (any ex) { diff --git a/vendor/wheels/tests/_assets/migrator/migrations/003_create_hoopsnakes_table.cfc b/vendor/wheels/tests/_assets/migrator/migrations/003_create_hoopsnakes_table.cfc index 616c827841..d3a1ce676b 100644 --- a/vendor/wheels/tests/_assets/migrator/migrations/003_create_hoopsnakes_table.cfc +++ b/vendor/wheels/tests/_assets/migrator/migrations/003_create_hoopsnakes_table.cfc @@ -4,7 +4,7 @@ component extends="wheels.migrator.Migration" hint="create kangaroos table" { transaction { try { t = createTable(name = "c_o_r_e_hoopsnakes"); - t.string(columnNames = "name", default = "", allowNull = true, limit = 255); + t.string(columnNames = "name", allowNull = true, limit = 255); t.timestamps(); t.create(); } catch (any ex) { diff --git a/vendor/wheels/tests/specs/migrator/migratorSpec.cfc b/vendor/wheels/tests/specs/migrator/migratorSpec.cfc index 4b068245e7..1a7accf864 100644 --- a/vendor/wheels/tests/specs/migrator/migratorSpec.cfc +++ b/vendor/wheels/tests/specs/migrator/migratorSpec.cfc @@ -429,7 +429,7 @@ component extends="wheels.WheelsTest" { migration.dropTable(tableName) t = migration.createTable(name = tableName) - t.string(columnNames = "name", default = "", allowNull = true, limit = 255) + t.string(columnNames = "name", allowNull = true, limit = 255) t.create() migration.removeRecord(table = "c_o_r_e_migrator_versions") migration.addRecord(table = "c_o_r_e_migrator_versions", version = "001") From 51e80088f94d131a87f34708bb545d96011b5c63 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 13:55:59 +0000 Subject: [PATCH 5/6] fix(database): bind keyword NULL as SQL NULL Keep the literal string "null" after IS / IS NOT as a bound parameter. Mark the unquoted NULL keyword and CFML/Java null as SQL NULL so an absent uniqueness-scope property does not cast [NULL] to a number. Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- vendor/wheels/databaseAdapters/Base.cfc | 36 +++++++++--- vendor/wheels/model/sql.cfc | 12 +++- vendor/wheels/model/validations.cfc | 9 ++- .../database/DatabaseAdapterHardenerSpec.cfc | 56 ++++++++++++++----- 4 files changed, 90 insertions(+), 23 deletions(-) diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index 96f9898533..29dda61aaa 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -27,10 +27,20 @@ component output=false extends="wheels.Global"{ if (isStruct(part)) { local.qp = $queryParams(part); - // The string "null" after IS / IS NOT stays a bound parameter. - // Do not coerce it to SQL NULL — a literal NULL is written as - // raw SQL by the query builder, not as a parameterized "null". - if (structKeyExists(qp, "list")) { + // The literal string "null" after IS / IS NOT stays a bound + // parameter. Do not coerce that string to SQL NULL. A missing + // value (cfqueryparam null=true, or a CFML/Java null) is a + // different thing and must still bind as SQL NULL. + if (structKeyExists(qp, "null") && qp.null) { + if (args.parameterize) { + if (!structKeyExists(qp, "value") || IsNull(qp.value) || !Len(ToString(qp.value))) { + qp.value = ""; + } + cfqueryParam(attributeCollection = qp); + } else { + writeOutput("NULL"); + } + } else if (structKeyExists(qp, "list")) { writeOutput("("); if (args.parameterize) { cfqueryParam(attributeCollection = qp); @@ -527,7 +537,9 @@ component output=false extends="wheels.Global"{ * Internal function. */ public struct function $queryParams(required struct settings) { - if (!StructKeyExists(arguments.settings, "value")) { + local.hasValue = StructKeyExists(arguments.settings, "value"); + local.valueIsNull = local.hasValue && IsNull(arguments.settings.value); + if (!local.hasValue && !(StructKeyExists(arguments.settings, "null") && arguments.settings.null)) { Throw( type = "Wheels.QueryParamValue", message = "The value for `cfqueryparam` cannot be determined for property `#arguments.settings.property#`.
This usually happens due to a syntax error in the WHERE clause (e.g., using unquoted strings or invalid values).", @@ -536,9 +548,17 @@ component output=false extends="wheels.Global"{ } local.rv = {}; local.rv.cfsqltype = arguments.settings.type; - local.rv.value = arguments.settings.value; - if (StructKeyExists(arguments.settings, "null")) { - local.rv.null = arguments.settings.null; + if (local.valueIsNull || (StructKeyExists(arguments.settings, "null") && arguments.settings.null)) { + // CFML/Java null and an explicit SQL-NULL flag bind as SQL NULL. + // Do not pass the strings "null" / "[NULL]" as the typed value — + // integer cfqueryparam cannot cast them. + local.rv.null = true; + local.rv.value = ""; + } else { + local.rv.value = arguments.settings.value; + if (StructKeyExists(arguments.settings, "null")) { + local.rv.null = arguments.settings.null; + } } if (StructKeyExists(arguments.settings, "scale") && arguments.settings.scale > 0) { local.rv.scale = arguments.settings.scale; diff --git a/vendor/wheels/model/sql.cfc b/vendor/wheels/model/sql.cfc index fc4f1c666e..43bd2107d5 100644 --- a/vendor/wheels/model/sql.cfc +++ b/vendor/wheels/model/sql.cfc @@ -1064,11 +1064,15 @@ component { if (Len(arguments.where)) { local.start = 1; local.originalValues = []; + local.sqlNullFlags = []; while (!StructKeyExists(local, "temp") || ArrayLen(local.temp.len) > 1) { local.temp = ReFind(variables.wheels.class.RESQLWhere, arguments.where, local.start, true); if (ArrayLen(local.temp.len) > 1) { local.start = local.temp.pos[4] + local.temp.len[4]; local.extractedValue = Mid(arguments.where, local.temp.pos[4], local.temp.len[4]); + // Unquoted SQL keyword NULL (from `IS NULL` / `IS NOT NULL`) is a + // missing value. The quoted literal `'null'` is a bound string. + local.isSqlNullKeyword = (ReFindNoCase("^NULL$", Trim(local.extractedValue)) == 1); // Handle comma-separated values in IN clauses if ($engineAdapter().isBoxLang()) { @@ -1095,6 +1099,7 @@ component { ) ); } + ArrayAppend(local.sqlNullFlags, local.isSqlNullKeyword); } } if ( @@ -1116,8 +1121,13 @@ component { structDelete(arguments.sql[local.i], 'property'); } arguments.sql[local.i].value = local.originalValues[local.pos]; - if (local.originalValues[local.pos] == "") { + if (local.originalValues[local.pos] == "" || local.sqlNullFlags[local.pos]) { arguments.sql[local.i].null = true; + // Dummy value so integer cfqueryparam does not try to cast + // the keyword string "NULL" / "[NULL]" to a number. + if (local.sqlNullFlags[local.pos]) { + arguments.sql[local.i].value = ""; + } } local.pos--; } diff --git a/vendor/wheels/model/validations.cfc b/vendor/wheels/model/validations.cfc index 514daaf53b..c2884dec1c 100644 --- a/vendor/wheels/model/validations.cfc +++ b/vendor/wheels/model/validations.cfc @@ -787,7 +787,14 @@ component { // `$shouldInvokeValidation()` skips the validation in that case — so this guard only // ever fires for scopes. Treat absent as blank, which is the branch below that turns // an empty numeric into `IS NULL`. - local.value = StructKeyExists(this, arguments.property) ? this[arguments.property] : ""; + if (!StructKeyExists(this, arguments.property)) { + local.value = ""; + } else { + local.value = this[arguments.property]; + if (IsNull(local.value)) { + local.value = ""; + } + } local.part = arguments.property & "=" & variables.wheels.class.adapter.$quoteValue( str = local.value, type = validationTypeForProperty(arguments.property) diff --git a/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc b/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc index 8694bbb01d..d88d34c804 100644 --- a/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc +++ b/vendor/wheels/tests/specs/database/DatabaseAdapterHardenerSpec.cfc @@ -10,34 +10,64 @@ component extends="wheels.WheelsTest" { describe("S2 $executeQuery keeps bound string null after IS", () => { - it("binds the string null after IS and IS NOT", () => { + it("keeps the quoted string null as a bound parameter after IS and IS NOT", () => { + var sql = g.model("post").$whereClause(where = "title IS 'null'"); + sql = g.model("post").$addWhereClauseParameters(sql = sql, where = "title IS 'null'"); + var found = {value = "", flaggedNull = false}; + for (var part in sql) { + if (IsStruct(part) && StructKeyExists(part, "value") && LCase(ToString(part.value)) == "null") { + found.value = part.value; + if (StructKeyExists(part, "null") && part.null) { + found.flaggedNull = true; + } + } + } + expect(LCase(found.value)).toBe("null"); + expect(IsSimpleValue(found.value)).toBeTrue(); + expect(found.flaggedNull).toBeFalse(); + + sql = g.model("post").$whereClause(where = "title IS NOT 'null'"); + sql = g.model("post").$addWhereClauseParameters(sql = sql, where = "title IS NOT 'null'"); + found = {value = "", flaggedNull = false}; + for (part in sql) { + if (IsStruct(part) && StructKeyExists(part, "value") && LCase(ToString(part.value)) == "null") { + found.value = part.value; + if (StructKeyExists(part, "null") && part.null) { + found.flaggedNull = true; + } + } + } + expect(LCase(found.value)).toBe("null"); + expect(found.flaggedNull).toBeFalse(); + }); + + it("marks the unquoted NULL keyword as SQL NULL", () => { var sql = g.model("post").$whereClause(where = "averagerating IS NULL"); sql = g.model("post").$addWhereClauseParameters(sql = sql, where = "averagerating IS NULL"); - var bound = ""; + var found = {flaggedNull = false}; for (var part in sql) { - if (IsStruct(part) && StructKeyExists(part, "value") && LCase(ToString(part.value)) == "null") { - bound = part.value; + if (IsStruct(part) && StructKeyExists(part, "null") && part.null) { + found.flaggedNull = true; } } - expect(LCase(bound)).toBe("null"); - expect(IsSimpleValue(bound)).toBeTrue(); + expect(found.flaggedNull).toBeTrue(); sql = g.model("post").$whereClause(where = "averagerating IS NOT NULL"); sql = g.model("post").$addWhereClauseParameters(sql = sql, where = "averagerating IS NOT NULL"); - bound = ""; + found = {flaggedNull = false}; for (part in sql) { - if (IsStruct(part) && StructKeyExists(part, "value") && LCase(ToString(part.value)) == "null") { - bound = part.value; + if (IsStruct(part) && StructKeyExists(part, "null") && part.null) { + found.flaggedNull = true; } } - expect(LCase(bound)).toBe("null"); + expect(found.flaggedNull).toBeTrue(); }); - it("does not coerce that string to SQL NULL in $executeQuery", () => { + it("does not coerce the literal string null after IS in $executeQuery", () => { var src = FileRead(ExpandPath("/wheels/databaseAdapters/Base.cfc")); var start = Find("public struct function $executeQuery", src); - var body = Mid(src, start, 2500); - expect(body).notToInclude('writeOutput("NULL")'); + var body = Mid(src, start, 2800); + expect(body).notToInclude('part.value == "null"'); expect(body).notToInclude('right(prev, 2) == "IS"'); expect(body).notToInclude('right(prev, 6) == "IS NOT"'); }); From 4ca43b796a5a6402099f60e43fcf8be271199694 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 13:56:11 +0000 Subject: [PATCH 6/6] docs(database): note keyword NULL vs string null bind Signed-off-by: Cursor Agent Co-authored-by: Peter Amiri --- changelog.d/database-adapters-hardener-s2-s16.changed.md | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.d/database-adapters-hardener-s2-s16.changed.md b/changelog.d/database-adapters-hardener-s2-s16.changed.md index 1812e9f1d3..1c76316fd2 100644 --- a/changelog.d/database-adapters-hardener-s2-s16.changed.md +++ b/changelog.d/database-adapters-hardener-s2-s16.changed.md @@ -1 +1,2 @@ - Database adapters no longer coerce the bound string `"null"` after `IS` / `IS NOT` to SQL NULL, drop MySQL TEXT/float `DEFAULT`, leave boolean `yes`/`no` unquoted, advertise fake SQLite advisory locks, default unknown foreign-key actions to `CASCADE`, emit asymmetric empty-string defaults, map unknown column types silently, or fall back to Oracle `MAX(ROWID)` / SQL Server `@@IDENTITY` +- The unquoted SQL keyword `NULL` and a CFML/Java null still bind as SQL NULL (so an absent uniqueness-scope property does not send the string `[NULL]` to an integer `cfqueryparam`)