Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .ai/wheels/cross-engine-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 5 additions & 1 deletion vendor/wheels/Model.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions vendor/wheels/databaseAdapters/Base.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions vendor/wheels/databaseAdapters/H2/H2Model.cfc
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down
9 changes: 9 additions & 0 deletions vendor/wheels/databaseAdapters/Oracle/OracleModel.cfc
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down
11 changes: 11 additions & 0 deletions vendor/wheels/tests/_assets/models/CasePreservation.cfc
Original file line number Diff line number Diff line change
@@ -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");
}

}
17 changes: 16 additions & 1 deletion vendor/wheels/tests/populate.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
</cfloop>

<!--- list of tables to delete --->
<cfset local.tables = "c_o_r_e_polycomments,c_o_r_e_polyarticles,c_o_r_e_polyphotos,c_o_r_e_authors,c_o_r_e_cities,c_o_r_e_classifications,c_o_r_e_comments,c_o_r_e_galleries,c_o_r_e_photos,c_o_r_e_posts,c_o_r_e_profiles,c_o_r_e_shops,c_o_r_e_trucks,c_o_r_e_tags,c_o_r_e_users,c_o_r_e_collisiontests,c_o_r_e_combikeys,c_o_r_e_tblusers,c_o_r_e_sqltypes,c_o_r_e_CATEGORIES,c_o_r_e_bulkitems">
<cfset local.tables = "c_o_r_e_polycomments,c_o_r_e_polyarticles,c_o_r_e_polyphotos,c_o_r_e_authors,c_o_r_e_cities,c_o_r_e_classifications,c_o_r_e_comments,c_o_r_e_galleries,c_o_r_e_photos,c_o_r_e_posts,c_o_r_e_profiles,c_o_r_e_shops,c_o_r_e_trucks,c_o_r_e_tags,c_o_r_e_users,c_o_r_e_collisiontests,c_o_r_e_combikeys,c_o_r_e_tblusers,c_o_r_e_sqltypes,c_o_r_e_CATEGORIES,c_o_r_e_bulkitems,c_o_r_e_casepreservation">
<!---
On Oracle, append CASCADE CONSTRAINTS so the drop removes incoming FK
references along with the table. PURGE skips the recycle bin so the
Expand Down Expand Up @@ -127,6 +127,21 @@ CREATE TABLE c_o_r_e_authors
) #local.storageEngine#
</cfquery>

<!---
Fixture for property-name case preservation (auto-derived properties).
The `isHidden` column is intentionally declared with mixed case and left
unquoted, so each engine folds it per its own rules; the spec asserts the
resulting property name matches the engine's reported column casing.
--->
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE TABLE c_o_r_e_casepreservation
(
id #local.identityColumnType#
,isHidden #local.intColumnType# NULL
,PRIMARY KEY(id)
) #local.storageEngine#
</cfquery>

<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE TABLE c_o_r_e_cities
(
Expand Down
22 changes: 22 additions & 0 deletions vendor/wheels/tests/specs/model/propertyCasePreservationSpec.cfc
Original file line number Diff line number Diff line change
@@ -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);
});
});
}

}
Loading