diff --git a/changelog.d/h2-quote-identifiers.fixed.md b/changelog.d/h2-quote-identifiers.fixed.md new file mode 100644 index 0000000000..d0ed9eccb8 --- /dev/null +++ b/changelog.d/h2-quote-identifiers.fixed.md @@ -0,0 +1 @@ +- H2 now quotes table and column names in the datasource's own fold case (upper by default, lower with `DATABASE_TO_LOWER=TRUE`), so reserved-word columns such as `order` and `group` work in queries and `order=` clauses. Datasources with case folding disabled (`DATABASE_TO_UPPER=FALSE`) keep today's unquoted SQL unchanged (#4440) diff --git a/vendor/wheels/databaseAdapters/H2/H2Model.cfc b/vendor/wheels/databaseAdapters/H2/H2Model.cfc index 1a0f675c39..a88ef20364 100755 --- a/vendor/wheels/databaseAdapters/H2/H2Model.cfc +++ b/vendor/wheels/databaseAdapters/H2/H2Model.cfc @@ -9,6 +9,135 @@ component extends="wheels.databaseAdapters.Base" output=false { return true; } + /** + * The case this H2 datasource folds unquoted identifiers to: `upper` (H2's + * default), `lower` (`DATABASE_TO_LOWER=TRUE`) or `none` (case folding + * disabled with `DATABASE_TO_UPPER=FALSE`, no datasource, or an unreadable + * settings table). Probed once per datasource per application lifetime and + * cached in the application's Wheels settings beside `schemaColumnCache`, so + * a tenant datasource gets its own value, a reload re-probes, and the probe + * never runs per identifier. A failed probe degrades to `none`, which is the + * unquoted SQL H2 received before #4440. + */ + public string function $identifierCase() { + if (!StructKeyExists(variables, "dataSource") || !Len(variables.dataSource)) { + // A bare adapter (unit tests) has no datasource: leave identifiers alone. + return "none"; + } + local.dataSource = $effectiveDataSource(); + local.appKey = $appKey(); + try { + if ( + StructKeyExists(application, local.appKey) + && StructKeyExists(application[local.appKey], "h2IdentifierCase") + && StructKeyExists(application[local.appKey].h2IdentifierCase, local.dataSource) + ) { + return application[local.appKey].h2IdentifierCase[local.dataSource]; + } + } catch (any e) { + // fall through to a fresh probe + } + local.identifierCase = $readIdentifierCase(dataSource = local.dataSource); + try { + if (StructKeyExists(application, local.appKey)) { + lock name="wheels.h2IdentifierCase" type="exclusive" timeout="10" { + if (!StructKeyExists(application[local.appKey], "h2IdentifierCase")) { + application[local.appKey].h2IdentifierCase = {}; + } + application[local.appKey].h2IdentifierCase[local.dataSource] = local.identifierCase; + } + } + } catch (any e) { + // Caching is best-effort; the probed value is still correct for this call. + } + return local.identifierCase; + } + + /** + * Internal function. Reads H2's identifier folding settings. H2 2.x exposes + * `SETTING_NAME` / `SETTING_VALUE`; H2 1.3.x exposes `NAME` / `VALUE`, and + * has no `DATABASE_TO_LOWER` row at all. `NON_KEYWORDS` is read for the log + * line only — it never changes the classification. + */ + public string function $readIdentifierCase(required string dataSource) { + local.options = {datasource = arguments.dataSource}; + if (StructKeyExists(variables, "username") && Len(variables.username)) { + local.options.username = variables.username; + } + if (StructKeyExists(variables, "password") && Len(variables.password)) { + local.options.password = variables.password; + } + + local.settings = {}; + local.rows = ""; + try { + local.rows = QueryExecute( + "SELECT SETTING_NAME, SETTING_VALUE FROM INFORMATION_SCHEMA.SETTINGS WHERE SETTING_NAME IN ('DATABASE_TO_UPPER', 'DATABASE_TO_LOWER', 'NON_KEYWORDS')", + [], + local.options + ); + } catch (any e) { + try { + local.rows = QueryExecute( + "SELECT NAME, VALUE FROM INFORMATION_SCHEMA.SETTINGS WHERE NAME IN ('DATABASE_TO_UPPER', 'DATABASE_TO_LOWER', 'NON_KEYWORDS')", + [], + local.options + ); + } catch (any e2) { + writeLog( + type = "warning", + file = "wheels_h2_identifier_case", + text = "Could not read identifier folding settings for H2 datasource `#arguments.dataSource#`; identifiers will not be quoted. #e2.message#" + ); + return "none"; + } + } + + local.nameColumn = ListFindNoCase(local.rows.columnList, "SETTING_NAME") ? "SETTING_NAME" : "NAME"; + local.valueColumn = ListFindNoCase(local.rows.columnList, "SETTING_VALUE") ? "SETTING_VALUE" : "VALUE"; + for (local.i = 1; local.i <= local.rows.recordCount; local.i++) { + local.settings[UCase(Trim(local.rows[local.nameColumn][local.i]))] = UCase(Trim(local.rows[local.valueColumn][local.i])); + } + + if (!StructKeyExists(local.settings, "DATABASE_TO_LOWER")) { + // H2 1.3.x: no DATABASE_TO_LOWER setting, and quoted identifiers there are + // case-insensitive, so the default upper folding is always safe. + local.identifierCase = "upper"; + } else if (local.settings.DATABASE_TO_LOWER == "TRUE") { + local.identifierCase = "lower"; + } else if (StructKeyExists(local.settings, "DATABASE_TO_UPPER") && local.settings.DATABASE_TO_UPPER == "FALSE") { + local.identifierCase = "none"; + } else { + local.identifierCase = "upper"; + } + + local.nonKeywords = StructKeyExists(local.settings, "NON_KEYWORDS") ? local.settings.NON_KEYWORDS : ""; + writeLog( + type = "information", + file = "wheels_h2_identifier_case", + text = "H2 datasource `#arguments.dataSource#` folds identifiers to #local.identifierCase#; NON_KEYWORDS=#local.nonKeywords#" + ); + return local.identifierCase; + } + + /** + * Quote a table or column name when H2 folds it, so reserved words such as + * `order` and `group` are usable. The name is quoted in the datasource's own + * fold case and embedded double quotes are doubled. With folding off the name + * is returned unchanged — byte-for-byte the unquoted SQL H2 got before #4440, + * which also keeps duplicate-by-case schemas (only possible with folding off) + * unambiguous. Aliases are never quoted: table aliases stay bare and the + * select-list aliases read back by $lowerCaseColumnNames() are unchanged. + */ + public string function $quoteIdentifier(required string name) { + local.identifierCase = $identifierCase(); + if (local.identifierCase == "none") { + return arguments.name; + } + local.folded = (local.identifierCase == "upper") ? UCase(arguments.name) : LCase(arguments.name); + return """" & Replace(local.folded, """", """""", "all") & """"; + } + /** * H2 1.3.172 (the driver Lucee bundles) returns rows in the wrong order once * LIMIT + OFFSET exceeds the 32-bit maximum (#3684), and the query builder's diff --git a/vendor/wheels/tests/_assets/adapters/H2CaseProbe.cfc b/vendor/wheels/tests/_assets/adapters/H2CaseProbe.cfc new file mode 100644 index 0000000000..5817acdde3 --- /dev/null +++ b/vendor/wheels/tests/_assets/adapters/H2CaseProbe.cfc @@ -0,0 +1,15 @@ +/** + * An H2 model adapter whose identifier fold case is fixed by the spec, so the + * quoting rule for "upper" / "lower" / "none" is unit-testable without a live + * H2 datasource. Overrides $identifierCase() only; $quoteIdentifier() is the + * production rule under test. + */ +component extends="wheels.databaseAdapters.H2.H2Model" output=false { + + this.identifierCase = "upper"; + + public string function $identifierCase() { + return this.identifierCase; + } + +} diff --git a/vendor/wheels/tests/specs/database/AdapterSqlGenerationSpec.cfc b/vendor/wheels/tests/specs/database/AdapterSqlGenerationSpec.cfc index 96d4f257ad..36699deeae 100644 --- a/vendor/wheels/tests/specs/database/AdapterSqlGenerationSpec.cfc +++ b/vendor/wheels/tests/specs/database/AdapterSqlGenerationSpec.cfc @@ -174,6 +174,34 @@ component extends="wheels.WheelsTest" { expect(mssql.$quoteIdentifier(name = "a]]b")).toBe("[a]]]]b]"); }); + it("H2 $quoteIdentifier quotes in the datasource's own fold case (##4440)", () => { + var upper = CreateObject("component", "wheels.tests._assets.adapters.H2CaseProbe"); + upper.identifierCase = "upper"; + expect(upper.$quoteIdentifier(name = "users")).toBe('"USERS"'); + expect(upper.$quoteIdentifier(name = "order")).toBe('"ORDER"'); + expect(upper.$quoteIdentifier(name = 'we"ird')).toBe('"WE""IRD"'); + expect(upper.$quoteIdentifier(name = 'a""b')).toBe('"A""""B"'); + + var lower = CreateObject("component", "wheels.tests._assets.adapters.H2CaseProbe"); + lower.identifierCase = "lower"; + expect(lower.$quoteIdentifier(name = "USERS")).toBe('"users"'); + expect(lower.$quoteIdentifier(name = "ORDER")).toBe('"order"'); + expect(lower.$quoteIdentifier(name = 'We"ird')).toBe('"we""ird"'); + expect(lower.$quoteIdentifier(name = 'a""b')).toBe('"a""""b"'); + + var none = CreateObject("component", "wheels.tests._assets.adapters.H2CaseProbe"); + none.identifierCase = "none"; + expect(none.$quoteIdentifier(name = "users")).toBe("users"); + expect(none.$quoteIdentifier(name = "order")).toBe("order"); + expect(none.$quoteIdentifier(name = 'we"ird')).toBe('we"ird'); + }); + + it("H2 without a datasource reports no folding and leaves identifiers unquoted", () => { + var h2 = CreateObject("component", "wheels.databaseAdapters.H2.H2Model"); + expect(h2.$identifierCase()).toBe("none"); + expect(h2.$quoteIdentifier(name = "users")).toBe("users"); + }); + it("migrator quoteTableName/quoteColumnName double the closing delimiter, per adapter", () => { // lower-case inputs, so the output does not depend on migratorObjectCase var sqlite = CreateObject("component", "wheels.databaseAdapters.SQLite.SQLiteMigrator"); diff --git a/vendor/wheels/tests/specs/model/OrderReservedWordSpec.cfc b/vendor/wheels/tests/specs/model/OrderReservedWordSpec.cfc index 89a92fa7bf..39863244f5 100644 --- a/vendor/wheels/tests/specs/model/OrderReservedWordSpec.cfc +++ b/vendor/wheels/tests/specs/model/OrderReservedWordSpec.cfc @@ -2,20 +2,16 @@ * Ordering by a column whose name is a reserved word, through order="table.column" * as well as order="column" (4374): the qualified form is quoted like the bare one. * The spec owns its table, c_o_r_e_reservedorders, created with the adapter's own - * identifier quoting, and drops it afterwards. Skipped on H2, whose adapter doesn't - * quote identifiers. + * identifier quoting, and drops it afterwards. As of #4440 the H2 adapter quotes + * identifiers in the datasource's own fold case, so this runs unskipped there too; + * the second describe pins that probe and the default-folding behaviour on H2. */ component extends="wheels.WheelsTest" { function beforeAll() { variables.ds = application.wheels.dataSourceName; variables.table = "c_o_r_e_reservedorders"; - // The H2 adapter doesn't quote identifiers ($quoteIdentifier returns the name as is), - // so a table with reserved-word columns can't be created or ordered by there. - variables.isH2 = CreateObject("component", "wheels.migrator.Migration").init().adapter.adapterName() == "H2"; - if (variables.isH2) { - return; - } + variables.dialect = application.wo.model("author").$dialectName(); $dropTable(); QueryExecute( "CREATE TABLE #variables.table# (id INT NOT NULL PRIMARY KEY, #$q('order')# INT NOT NULL, #$q('group')# VARCHAR(20) NOT NULL)", @@ -33,9 +29,7 @@ component extends="wheels.WheelsTest" { } function afterAll() { - if (!variables.isH2) { - $dropTable(); - } + $dropTable(); } // An identifier quoted by the model adapter ("x", `x` or [x], per database). @@ -51,16 +45,21 @@ component extends="wheels.WheelsTest" { } } + // Hoisted out of the `finally` below: a for loop over a var inside `finally` + // miscompiles on Lucee 7 (see the finally-loop invariant in CLAUDE.md). + private void function $deleteRows(required array keys) { + for (var key in arguments.keys) { + try { + application.wo.model("CasePreservation").deleteByKey(key = key); + } catch (any e) { + } + } + } + function run() { describe("order by a reserved-word column (4374)", () => { - beforeEach(() => { - if (variables.isH2) { - skip("the H2 adapter doesn't quote identifiers, so reserved-word columns can't be used"); - } - }); - it("orders by table.column the same as by the bare column", () => { var model = application.wo.model("ReservedOrder"); var qualified = model.findAll(order = "#variables.table#.order DESC", returnAs = "query"); @@ -76,6 +75,72 @@ component extends="wheels.WheelsTest" { }); + describe("H2 identifier case (##4440)", () => { + + beforeEach(() => { + if (variables.dialect != "H2") { + skip("identifier folding is an H2 concern"); + } + }); + + it("probes the default fold case as upper", () => { + var adapter = CreateObject("component", "wheels.databaseAdapters.H2.H2Model"); + adapter.$init(dataSource = application.wheels.dataSourceName, username = "", password = ""); + // The suite's H2 datasource only sets MODE=MySQL, so H2's default folding applies. + expect(adapter.$readIdentifierCase(dataSource = application.wheels.dataSourceName)).toBe("upper"); + expect(adapter.$identifierCase()).toBe("upper"); + }); + + it("resolves reads and writes against an unquoted-created table under default folding", () => { + // tests/populate.cfm creates c_o_r_e_casepreservation unquoted with a mixed-case + // `isHidden` column. H2 folds that to ISHIDDEN; the adapter now sends "ISHIDDEN", + // which must still resolve for order=, where= and save() on a schema it did not create. + var model = application.wo.model("CasePreservation"); + var names = model.propertyNames(); + var hidden = ""; + for (var i = 1; i <= ListLen(names); i++) { + if (CompareNoCase(ListGetAt(names, i), "isHidden") == 0) { + hidden = ListGetAt(names, i); + } + } + expect(Len(hidden)).toBeGT(0); + + // The fixture is not seeded, and only this spec writes to it. + QueryExecute("DELETE FROM c_o_r_e_casepreservation", [], {datasource = variables.ds}); + + var created = []; + try { + var highProps = {}; + highProps[hidden] = 1; + var high = model.create(properties = highProps); + ArrayAppend(created, high.id); + + var lowProps = {}; + lowProps[hidden] = 0; + var low = model.create(properties = lowProps); + ArrayAppend(created, low.id); + + var byOrder = model.findAll(order = "#hidden# DESC", returnAs = "query"); + expect(byOrder.recordCount).toBe(2); + expect(byOrder.id[1]).toBe(high.id); + expect(byOrder.id[2]).toBe(low.id); + + var filtered = model.findAll(where = "#hidden# = 0", returnAs = "query"); + expect(filtered.recordCount).toBe(1); + expect(filtered.id[1]).toBe(low.id); + + var target = model.findByKey(low.id); + target[hidden] = 1; + expect(target.save()).toBeTrue(); + var reread = model.findByKey(low.id); + expect(reread[hidden]).toBe(1); + } finally { + $deleteRows(created); + } + }); + + }); + } }