From 4241e1e1323b7c84d8555d34d0f286f73978b09b Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Wed, 21 Oct 2026 10:29:57 -0700 Subject: [PATCH] fix(model): quote H2 identifiers in the datasource's fold case H2's adapter inherited Base's no-op $quoteIdentifier(), so reserved-word columns such as `order` and `group` failed on SELECT, WHERE, ORDER BY and writes. Probe INFORMATION_SCHEMA.SETTINGS once per datasource to learn whether H2 folds identifiers to upper or lower, or not at all, and quote every table and column in that fold case, doubling embedded quotes. The probe result is cached in the application scope per datasource. Datasources with case folding disabled (DATABASE_TO_UPPER=FALSE) keep today's unquoted SQL byte-for-byte. The migrator still emits H2 DDL names unquoted; that stays a follow-up, as with the PostgreSQL and Oracle migrators. Refs #4440 Signed-off-by: Peter Amiri --- changelog.d/h2-quote-identifiers.fixed.md | 1 + vendor/wheels/databaseAdapters/H2/H2Model.cfc | 129 ++++++++++++++++++ .../tests/_assets/adapters/H2CaseProbe.cfc | 15 ++ .../database/AdapterSqlGenerationSpec.cfc | 28 ++++ .../specs/model/OrderReservedWordSpec.cfc | 99 +++++++++++--- 5 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 changelog.d/h2-quote-identifiers.fixed.md create mode 100644 vendor/wheels/tests/_assets/adapters/H2CaseProbe.cfc 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); + } + }); + + }); + } }