diff --git a/.ai/wheels/cross-engine-compatibility.md b/.ai/wheels/cross-engine-compatibility.md index 501db5f018..33b4fe7496 100644 --- a/.ai/wheels/cross-engine-compatibility.md +++ b/.ai/wheels/cross-engine-compatibility.md @@ -331,6 +331,40 @@ H2 is the embedded database used by default in tests. Key differences: - Some MySQL-specific functions (e.g., `GROUP_CONCAT`) not available - Simpler locking model than production databases +### Auto-Derived Property Casing — `$lowerCaseColumnNames()` Adapter Capability + +When a model declares no `property()` mappings, Wheels infers its properties from `cfdbinfo` column metadata. The reported column casing varies by database, so the adapter layer carries a capability flag — `$lowerCaseColumnNames()` on `Base.cfc` — that controls whether the derived property name keeps the reported case or is forced to lowercase. Adapters override this when their database folds unquoted identifiers to a non-meaningful default that would otherwise leak into Wheels-side property names. + +| Database | Folding behavior | `$lowerCaseColumnNames()` | Resulting property for column `isHidden` | +|----------|------------------|---------------------------|-------------------------------------------| +| SQL Server, MySQL, SQLite | Preserves declared case | `false` (Base default) | `isHidden` | +| PostgreSQL, CockroachDB | Folds unquoted identifiers to lowercase | `false` (Base default) | `ishidden` (database-reported) | +| Oracle | Folds unquoted identifiers to UPPERCASE | `true` (override) | `ishidden` (lowercased from `ISHIDDEN`) | +| H2 | Folds unquoted identifiers to UPPERCASE | `true` (override) | `ishidden` (lowercased from `ISHIDDEN`) | + +```cfm +// vendor/wheels/databaseAdapters/Base.cfc +public boolean function $lowerCaseColumnNames() { + return false; // preserve reported case by default +} + +// vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc — override +public boolean function $lowerCaseColumnNames() { + return true; // ISHIDDEN → ishidden (Oracle folds to UPPERCASE) +} + +// vendor/wheels/databaseAdapters/H2/H2Model.cfc — override +public boolean function $lowerCaseColumnNames() { + return true; // ISHIDDEN → ishidden (H2 folds to UPPERCASE) +} +``` + +**When adding a new database adapter**: check whether the database's unquoted-identifier folding rule produces case the Wheels developer actually declared. If it folds to UPPERCASE (Oracle/H2 family), override `$lowerCaseColumnNames()` to return `true`. If it preserves case (SQL Server/MySQL/SQLite) or folds to lowercase (PostgreSQL/CockroachDB), keep the Base default — the reported name is already the right property name. + +**Explicit `property(name=..., column=...)` declarations bypass this entirely** — they always win, regardless of the adapter flag. The capability only affects the auto-derived path. + +**Reference**: `vendor/wheels/Model.cfc` (auto-derivation site), `vendor/wheels/databaseAdapters/Base.cfc::$lowerCaseColumnNames`, regression spec `vendor/wheels/tests/specs/model/propertyCasePreservationSpec.cfc`, [#2852](https://github.com/wheels-dev/wheels/pull/2852). + ### Migration Date Functions Use `NOW()` for cross-database compatibility in migrations: diff --git a/CHANGELOG.md b/CHANGELOG.md index 55c265e93d..37725ac660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo ### Fixed +- Auto-derived model property names now preserve the database's reported column casing again, instead of being force-lowercased on every engine. When a model declares no `property()` mappings, Wheels infers its properties from the database column metadata; a change in the 3.0 line (`Model.cfc`, intended to normalize Oracle's fixed-case identifiers) began calling `lCase()` on every derived property name unconditionally, so an `isHidden` column surfaced as the property `ishidden` on SQL Server, MySQL, SQLite, etc. — silently breaking case-sensitive consumers of serialized model output (`returnAs="structs"`, `renderWith()`, `serializeJSON()`) for anyone upgrading from CFWheels 2.x (the same code preserved case in 2.5 on the same engine + database). Casing is now preserved by default and only lowercased on adapters whose database folds unquoted identifiers to a non-meaningful UPPERCASE default, gated by a new `$lowerCaseColumnNames()` capability on the database adapter (`Base` default `false`; `OracleModel` and `H2Model` override to `true`). So SQL Server / MySQL / SQLite preserve the declared case, PostgreSQL / CockroachDB use the database's own lowercase-folded name, and Oracle / H2 keep the lowercased behavior they have today. Models that explicitly declare `property(name="isHidden", column="isHidden")` were always unaffected and remain so. **Reverse-migration heads-up:** apps that adopted Wheels 3.x/4.x and adapted to the force-lowercased property names — e.g. JSON consumers, view templates, or client-side code that expects `{"ishidden": 1}` — will see that output revert to the originally declared casing (`{"isHidden": 1}`) after applying this patch on SQL Server / MySQL / SQLite. Review any serialized model output consumers before upgrading (#2852) - The Debian/Ubuntu `apt` install instructions now pipe the distribution key through `sudo gpg --dearmor` before writing `/usr/share/keyrings/wheels.gpg` instead of `tee`-ing it verbatim. The key published at `apt.wheels.dev/wheels.gpg` is ASCII-armored, and modern `apt` rejects an armored key in a `signed-by=` keyring with an "unsupported filetype" warning followed by `NO_PUBKEY` — so `apt update` failed signature verification and the install never worked. Corrected across the install guide, the CLI installation reference, the release-channels guide, the `apt.wheels.dev` landing page, and the `tools/distribution-drafts/` repo templates (#2838) - The `apt.wheels.dev` publishing template (`tools/distribution-drafts/apt-repo/`) no longer wipes the `stable` package index when a `bleeding-edge` snapshot publishes. `regenerate-apt-metadata.sh` rebuilt *both* channels on every run while the workflow synced only the dispatched channel's pool into the runner, so a frequent bleeding-edge publish scanned an empty local `pool/stable/`, produced an empty `Packages`, and the unscoped upload overwrote the good stable index on R2 — leaving `apt install wheels` with "Unable to locate package wheels" even though the `.deb` was present in the pool. The regen now honors a `CHANNELS` env (the workflow passes only the dispatched channel) and the upload is scoped to that channel's `dists/` subtree, so the two channels can no longer clobber each other (#2838) diff --git a/vendor/wheels/Model.cfc b/vendor/wheels/Model.cfc index 45105f407e..f69a234fa4 100644 --- a/vendor/wheels/Model.cfc +++ b/vendor/wheels/Model.cfc @@ -125,7 +125,11 @@ component output="false" displayName="Model" extends="wheels.Global"{ local.iEnd = local.columns.recordCount; for (local.i = 1; local.i <= local.iEnd; local.i++) { // set up properties and column mapping - local.columnName = lCase(local.columns["column_name"][local.i]); + // preserve the DB's reported column case; an unconditional lCase() here regressed non-Oracle engines in 3.0 (see $lowerCaseColumnNames) + local.columnName = local.columns["column_name"][local.i]; + if (variables.wheels.class.adapter.$lowerCaseColumnNames()) { + local.columnName = lCase(local.columnName); + } if (!StructKeyExists(local.processedColumns, local.columnName)) { // default the column to map to a property with the same name diff --git a/vendor/wheels/databaseAdapters/Base.cfc b/vendor/wheels/databaseAdapters/Base.cfc index ab6afe6eac..94e8e0148b 100755 --- a/vendor/wheels/databaseAdapters/Base.cfc +++ b/vendor/wheels/databaseAdapters/Base.cfc @@ -583,6 +583,23 @@ component output=false extends="wheels.Global"{ return false; } + /** + * Reports whether auto-derived property names should be lowercased. + * + * When a model declares no property() mappings, Wheels derives its + * properties from the database column metadata. Most databases either + * preserve the declared identifier case (SQL Server, MySQL, SQLite) or + * fold unquoted identifiers to lowercase (PostgreSQL, CockroachDB); in + * both cases the reported column name is the correct property name as-is, + * so the default preserves it. Databases that fold unquoted identifiers to + * a non-meaningful UPPERCASE default (Oracle, H2) override this to return + * `true`, so Wheels lowercases the derived property name instead of + * exposing e.g. `FIRSTNAME`. + */ + public boolean function $lowerCaseColumnNames() { + return false; + } + /** * Returns the SQL clause for pessimistic row locking (e.g., "FOR UPDATE"). * Individual database adapters override this when the default is not appropriate. diff --git a/vendor/wheels/databaseAdapters/H2/H2Model.cfc b/vendor/wheels/databaseAdapters/H2/H2Model.cfc index 55f9b9c2dd..34ecbbd485 100755 --- a/vendor/wheels/databaseAdapters/H2/H2Model.cfc +++ b/vendor/wheels/databaseAdapters/H2/H2Model.cfc @@ -1,5 +1,14 @@ component extends="wheels.databaseAdapters.Base" output=false { + /** + * H2 reports unquoted identifiers in uppercase, so lowercase auto-derived + * property names — otherwise models expose `FIRSTNAME` instead of + * `firstname`. See Base.$lowerCaseColumnNames(). + */ + public boolean function $lowerCaseColumnNames() { + return true; + } + /** * Map database types to the ones used in CFML. */ diff --git a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc index 95997770b2..f761ab1ec7 100755 --- a/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc +++ b/vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc @@ -1,5 +1,14 @@ component extends="wheels.databaseAdapters.Base" output=false { + /** + * Oracle reports unquoted identifiers in uppercase, so lowercase + * auto-derived property names — otherwise models expose `FIRSTNAME` + * instead of `firstname`. See Base.$lowerCaseColumnNames(). + */ + public boolean function $lowerCaseColumnNames() { + return true; + } + /** * Map database types to the ones used in CFML. */ diff --git a/vendor/wheels/tests/_assets/models/CasePreservation.cfc b/vendor/wheels/tests/_assets/models/CasePreservation.cfc new file mode 100644 index 0000000000..86a8a174a1 --- /dev/null +++ b/vendor/wheels/tests/_assets/models/CasePreservation.cfc @@ -0,0 +1,11 @@ +component extends="Model" { + + function config() { + // Intentionally declares NO property() mappings so that every property + // is auto-derived from the database column metadata. The table has an + // undeclared mixed-case `isHidden` column used to assert that Wheels + // preserves the database's column casing for auto-derived properties. + table("c_o_r_e_casepreservation"); + } + +} diff --git a/vendor/wheels/tests/populate.cfm b/vendor/wheels/tests/populate.cfm index 8e740bfab4..ad2afcc9ec 100644 --- a/vendor/wheels/tests/populate.cfm +++ b/vendor/wheels/tests/populate.cfm @@ -96,7 +96,7 @@ - + + +CREATE TABLE c_o_r_e_casepreservation +( + id #local.identityColumnType# + ,isHidden #local.intColumnType# NULL + ,PRIMARY KEY(id) +) #local.storageEngine# + + CREATE TABLE c_o_r_e_cities ( diff --git a/vendor/wheels/tests/specs/model/propertyCasePreservationSpec.cfc b/vendor/wheels/tests/specs/model/propertyCasePreservationSpec.cfc new file mode 100644 index 0000000000..a4c6ce130f --- /dev/null +++ b/vendor/wheels/tests/specs/model/propertyCasePreservationSpec.cfc @@ -0,0 +1,22 @@ +component extends="wheels.WheelsTest" { + + function run() { + g = application.wo + + // Regression for the 3.0-era force-lowercasing of auto-derived property names (#2852). + describe("Auto-derived property name casing", () => { + it("preserves the database column case for undeclared properties", () => { + // c_o_r_e_casepreservation has an undeclared, mixed-case `isHidden` column (see populate.cfm) + var names = g.model("CasePreservation").propertyNames(); + + // preserve-case engines report `isHidden`; lower/upper-folding engines report `ishidden` + var preservesCase = ListFindNoCase("SQLiteModel,MySQLModel,MicrosoftSQLServerModel", get("adapterName")) GT 0; + var expected = preservesCase ? "isHidden" : "ishidden"; + + // case-sensitive: the regression is invisible to ListFindNoCase + expect(ListFind(names, expected)).toBeGT(0); + }); + }); + } + +}