You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(model): resolve association foreign key defaults against either reference convention (#3353)
`useUnderscoreReferenceColumns` (framework default `false`, `wheels new` template default
`true`) makes the migrator emit `user_id`, but the model layer derived `userid` by
unconditional concatenation. A stock new app that declared `belongsTo("user")` without an
explicit `foreignKey` therefore threw `key [userid] doesn't exist` the first time any
`include=` traversed the association — a migrator and a model layer that could never agree.
Reproduced before changing anything, same flag state throughout:
migrator_emits = author_id
model_derives_flagON = authorid
model_derives_flagOFF = authorid <- identical, so the model never read the flag
The default is now resolved against the columns that actually exist on whichever side owns
the foreign key — `belongsTo` looks at the declaring model, `hasMany`/`hasOne` at the
associated one (verified empirically, not assumed). Both conventions work, including apps
that enabled the flag mid-life and hold a mix of both shapes.
Schema-driven rather than flag-driven, deliberately. `references()` re-reads `$get()` on
every call, while this result is memoized for the application lifetime
(`expandedMetadataFilled`), so a flag-driven default would let a runtime flip change
migrations without changing models — replacing one inconsistency with another. It is also
strictly error-reducing: the underscore form is only consulted when the legacy form is
absent, which is a case that threw before, so it cannot break a working app. That matters
because the CLI ships a mixed-convention advisory (`Module.cfc:5267`) precisely because
apps with the flag on and legacy columns exist — an unconditional flag-driven change would
have broken exactly those.
Also adds `Wheels.AssociationForeignKeyNotFound`: when a DERIVED default matches no column
on the owning model, throw at association-resolution time naming the association, both
candidate shapes, and `foreignKey=` as the fix. Runs inside the existing memo so the
success path costs one check per application lifetime, gated on `showErrorInformation`, and
never second-guesses an explicit `foreignKey=`.
Scope: polymorphic associations are NOT covered. `belongsTo(polymorphic=true)` and
`hasMany`/`hasOne` with `as=` pin their foreign key at registration time
(`associations.cfc:30`, `:81`, `:134`), before the schema is available, so the join-time
resolution never sees a blank to fill. Documented rather than silently left — the root
CLAUDE.md previously claimed the `_type` half worked too.
Corrects three documents that asserted an alignment no code implemented: root CLAUDE.md,
`vendor/wheels/migrator/CLAUDE.md`, and — worst — the comment `wheels new` writes into
every generated app's `config/settings.cfm`.
7 regression specs plus a legacy-shape guard, red-first: with `sql.cfc` reverted, 7 of the
8 fail and the two central ones fail with the reported symptom, `key [refParentid] doesn't
exist`. Fixtures `RefParent` / `RefChild` and their tables use the underscore shape; the
framework's own fixtures are all legacy-shaped, which the guard spec pins.
Verification, lucee7, full core suite in one container:
sqlite 4721 pass / 7 fail / 4 error / 4750 specs
mysql 4729 pass / 7 fail / 4 error / 4746 specs
All 8 new specs pass on both. The 11 remaining failures are an identical pre-existing
cluster on both databases (`app.controllers.Controller` missing its mixed-in helpers),
local to this container and absent from CI's legs.
Closes#3337
Signed-off-by: Peter Amiri <peter@alurium.com>
For new migrator helpers or anywhere you accept a column-name argument: declare`string columnNames` (NOT `required`), and call `$combineArguments(args=arguments, combine="columnNames,columnName", required=true)` at the top of the body. The pattern is documented in [vendor/wheels/migrator/CLAUDE.md](vendor/wheels/migrator/CLAUDE.md). Boolean nullable flag is `allowNull` everywhere — never `null`.
268
268
269
-
`t.references()` also respects `useUnderscoreReferenceColumns` (boolean, framework default `false`, `wheels new` template default `true`) — when true it produces `<name>_id` / `<name>_type` columns matching Wheels model `belongsTo` defaults.
269
+
`t.references()` also respects `useUnderscoreReferenceColumns` (boolean, framework default `false`, `wheels new` template default `true`) — when true it produces `<name>_id` / `<name>_type` columns instead of `<name>id` / `<name>type`.
270
+
271
+
Association foreign-key defaults resolve **either** convention: the default derivation checks which column actually exists on whichever side owns the foreign key, rather than reading the setting ([#3337](https://github.com/wheels-dev/wheels/issues/3337) — before that fix the model layer derived `<modelName><key>` unconditionally and a stock `wheels new` app threw `key [<name>id] doesn't exist` on any `include=`). It is schema-driven on purpose: the migrator reads the flag per call, but the model-side default is memoized for the application lifetime, so honouring the flag there would let a runtime flip change migrations without changing models. Apps holding a mix of both shapes work for the same reason.
272
+
273
+
**Polymorphic associations are not covered.**`belongsTo(polymorphic=true)` and `hasMany`/`hasOne` with `as=` fix their foreign key to `<name>id` at *registration*time (`vendor/wheels/model/associations.cfc:30`, `:81`, `:134`), before the schema is available, so the join-time resolution never sees a blank to fill. Against an underscore-shaped schema those still need an explicit `foreignKey="<name>_id"`.
- Association foreign-key defaults now resolve either reference-column convention instead of only the legacy `<modelName><key>` one. `useUnderscoreReferenceColumns` (framework default `false`, `wheels new` template default `true`) makes the migrator emit `user_id`, but the model layer derived `userid` unconditionally — so a stock new app that declared `belongsTo("user")` without an explicit `foreignKey` threw `key [userid] doesn't exist` the first time any `include=` traversed the association. The default is now resolved against the columns that actually exist on whichever side owns the foreign key (`belongsTo` looks at the declaring model, `hasMany`/`hasOne` at the associated one), so both conventions work — including apps that enabled the flag mid-life and hold a mix of both shapes. This is deliberately schema-driven rather than reading the setting: `references()` re-reads the flag on every call while the model-side default is memoized for the application lifetime, so a flag-driven default would let a runtime flip change migrations without changing models. It is also strictly error-reducing — the underscore form is only consulted when the legacy form is absent, which is a case that used to throw. Polymorphic associations are not covered; they pin their foreign key at registration time, before the schema is available, and still need an explicit `foreignKey=` under the underscore convention (#3337)
2
+
- An association whose *derived* default foreign key matches no column on the model that owns it now throws `Wheels.AssociationForeignKeyNotFound` at association-resolution time, naming the association, both candidate column shapes, and `foreignKey=` as the fix. Previously this surfaced as `key [userid] doesn't exist` from deep inside the join builder, which named neither the association nor the argument that resolves it. Development and testing only, and only for defaults Wheels derived itself — an explicit `foreignKey=` is left alone (#3337)
The framework default is `false` so existing apps with applied migrations keep matching their database schemas. The `wheels new` template at `cli/lucli/templates/app/config/settings.cfm` opts new apps into `true` so they match Wheels model `belongsTo` defaults out of the box.
51
+
The framework default is `false` so existing apps with applied migrations keep matching their database schemas. The `wheels new` template at `cli/lucli/templates/app/config/settings.cfm` opts new apps into `true`.
52
+
53
+
**The model side does not read this flag, and must not.** Association foreign-key defaults resolve against the columns that actually exist — `vendor/wheels/model/sql.cfc::$deriveAssociationForeignKey()` tries the legacy `<modelName><key>` shape first and falls back to `<modelName>_<key>` — so both conventions work, including a schema holding a mix of the two. Making it flag-driven instead would break: this function's result is memoized for the application lifetime (`expandedMetadataFilled`), whereas `references()` re-reads `$get()` on every call, so a runtime flip would change migrations without changing models. Before [#3337](https://github.com/wheels-dev/wheels/issues/3337) the model layer derived `<modelName><key>` unconditionally, which meant a stock `wheels new` app had a migrator and a model layer that could never agree.
54
+
55
+
The exception is **polymorphic** associations, which pin their foreign key to `<name>id` at registration time — see the note in the root `CLAUDE.md`. Those still need an explicit `foreignKey=` under the underscore convention.
52
56
53
57
The flag is read via `$get("useUnderscoreReferenceColumns")` inside `references()` at runtime — apps can flip the setting in `config/settings.cfm` without reloading the framework. Migrations already applied to a real database are unaffected; only the column name the *next* migration produces changes.
message ="The `#arguments.associationName#` association derives a default foreign key of `#arguments.foreignKey#`, which is not a property on the `#local.ownerName#` model.",
1686
+
extendedInfo ="Wheels looks for the conventional `#local.legacy#` and, for schemas built with `useUnderscoreReferenceColumns` enabled, `#local.underscored#`. Neither exists on `#local.ownerName#`. Either pass `foreignKey=""<column>""` explicitly when setting up the `#arguments.associationName#` association, or rename the column on `#local.ownerName#` to one of those two forms."
0 commit comments