Skip to content

fix(model): quote H2 identifiers in the datasource's fold case - #4628

Merged
bpamiri merged 1 commit into
developfrom
fix/h2-identifier-quoting
Oct 21, 2026
Merged

bpamiri merged 1 commit into
developfrom
fix/h2-identifier-quoting

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

H2Model inherited Base's no-op $quoteIdentifier(), so H2's generated SQL left table and column names unquoted. H2 only accepts reserved-word identifiers (order, group, value, …) when they are quoted, so ORDER BY order, where= on such a column, and writes failed.

The H2 adapter now probes once per datasource which case the datasource folds unquoted identifiers to — from INFORMATION_SCHEMA.SETTINGS (DATABASE_TO_UPPER / DATABASE_TO_LOWER; H2 2.x SETTING_NAME/SETTING_VALUE, H2 1.3.x NAME/VALUE) — and quotes every table and column identifier in that fold case, doubling embedded ":

Probe result Quoting
DATABASE_TO_LOWER=TRUE "users", "order"
default folding (or H2 1.3.x, which has no DATABASE_TO_LOWER) "USERS", "ORDER"
DATABASE_TO_UPPER=FALSE identifiers returned unchanged
no datasource / unreadable settings identifiers returned unchanged (today's behaviour), one warning logged

The result is cached in the application scope per effective datasource (application.wheels.h2IdentifierCase, beside schemaColumnCache), so a tenant datasource gets its own value, a reload re-probes, and there is no per-identifier or per-query cost. Table aliases and select-list aliases are deliberately not quoted, and no new quoting call sites were added — the shared model SQL builder already routes every model identifier through $quoteIdentifier / $quoteColumn / $quotedTableColumn.

Datasources with case folding disabled (DATABASE_TO_UPPER=FALSE) are byte-for-byte unchanged — the adapter emits exactly the unquoted SQL it emitted before this change.

OrderReservedWordSpec no longer skips H2: its reserved-word table is created with the adapter's own quoting, and its two ordering tests now execute on H2. A new H2-only block pins the probe (upper on the suite datasource) and proves quoted-upper still resolves against an unquoted-created schema with a mixed-case column for order=, where= and save().

Scope / follow-ups

  • Migrator DDL quoting is a follow-up: H2Migrator.quoteTableName/quoteColumnName still emit unquoted names, like the PostgreSQL, Oracle and CockroachDB migrators. This change targets the model SQL path, which is used against schemas created by hand, raw SQL, or the app's own migrations — the way the regression spec's fixture table is built.
  • Reserved-word table names on writes remain unquoted on every adapter (INSERT INTO <table>, MERGE INTO <table>) and are out of scope.
  • Schemas created with quoted lower/mixed-case identifiers on a default-folding database were unsupported before and remain unsupported.
  • This supersedes the closed draft fix(model): quote identifiers in the H2 model adapter #4599 (keyword-classification approach).

Refs #4440

Verification

Command Engine / DB Result
bash tools/test-local.sh database Lucee 7 + SQLite 189 passed
bash tools/test-local.sh model Lucee 7 + SQLite 1501 passed
tools/test-matrix.sh lucee7 h2 Lucee 7 + H2 2.1.214 7287 passed, 0 failed, 0 errors

Per-spec proof from the H2 run (/wheels/core/tests?db=h2&directory=wheels.tests.specs.model): OrderReservedWordSpec — 4 specs, 4 passed, 0 skipped, including H2 identifier case (#4440) > probes the default fold case as upper and … > resolves reads and writes against an unquoted-created table under default folding. AdapterSqlGenerationSpec on H2: 51 passed, 0 skipped, including the new upper/lower/none quoting cases.

Unit coverage for all three fold modes is in AdapterSqlGenerationSpec via the new H2CaseProbe asset (upper/lower/none, embedded-quote doubling in both quoted modes).

Deviation from the implementation brief

The brief's default-folding regression proposed a new c_o_r_e_caseprobe table plus a new test model. The task's write scope allowed no new model asset, so the spec reuses the existing c_o_r_e_casepreservation fixture (tests/populate.cfm creates it unquoted with a mixed-case isHidden column) and the existing CasePreservation model. Same property under test, no extra files.

Residual risk

  • Only H2 2.1.214 is exercised by the suite; the H2 1.3.x NAME/VALUE fallback and its "no DATABASE_TO_LOWER ⇒ upper" rule are probe-verified in the brief but not covered by CI.
  • Folded-off (DATABASE_TO_UPPER=FALSE) and DATABASE_TO_LOWER=TRUE are covered by unit tests only — the suite has one H2 folding configuration. Optional follow-up: add a DATABASE_TO_UPPER=FALSE H2 datasource to the Lucee CFConfig.
  • Quoted-upper changes the SQL text of every H2 model query; the green full H2 suite is the mitigation for the string-parsing helpers ($columnAlias, $removeColumnAliasesInOrderClause, $addColumnsToSelectAndGroupBy, $moveAggregateToHaving).

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 <peter@alurium.com>
@bpamiri
bpamiri merged commit e7437cd into develop Oct 21, 2026
20 checks passed
@bpamiri
bpamiri deleted the fix/h2-identifier-quoting branch October 21, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant