Summary
useUnderscoreReferenceColumns (framework default false, true in the wheels new template) changes the columns the migrator generates — t.references("user") produces user_id instead of userid — but the model layer's association default foreign key is not aware of the setting. It is unconditional string concatenation:
// vendor/wheels/model/sql.cfc:1334-1339
if (!Len(local.classAssociations[local.name].foreignKey)) {
...
local.classAssociations[local.name].foreignKey =
local.associatedClass.$classData().modelName
& Replace(local.associatedClass.$classData().keys, ",", ",#...modelName#", "all");
So belongsTo(name="tenant") defaults to tenantid, while the migrator on a new app has created tenant_id.
The result is that a stock wheels new app which declares an association without an explicit foreignKey and then uses include= throws at runtime:
key [tenantid] doesn't exist
Grepping the framework confirms the setting never reaches the model layer — useUnderscoreReferenceColumns appears only in events/init/orm.cfm (where it is defined), migrator/Migration.cfc, migrator/TableDefinition.cfc, their specs, and the reference docs. There is no code path under vendor/wheels/model/ that consults it.
The docs assert the opposite
vendor/wheels/migrator/CLAUDE.md:51:
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.
That sentence is the trap: it tells the reader the two halves line up, so nobody adds an explicit foreignKey. They line up only for the column name in the database; the association default still derives tenantid.
Reproduction
On a stock wheels new app (so set(useUnderscoreReferenceColumns=true)):
// migration
t = createTable(name="memberships");
t.references(columnNames="user"); // creates user_id
t.references(columnNames="tenant"); // creates tenant_id
t.create();
// app/models/Membership.cfc
component extends="Model" {
function config() {
belongsTo(name="user");
belongsTo(name="tenant");
}
}
model("Membership").findAll(include="tenant"); // throws: key [tenantid] doesn't exist
Plain finds work; the failure only appears once an association is traversed (include=, or the association accessor), which is why it can sit latent for a long time. In our case it shipped to a live screen — a tenant-picker page for users belonging to more than one tenant — and was only found weeks later when an unrelated task happened to add the first include= on that model.
Suggested fix
Options, roughly in order of preference:
- Make the association default honor the setting. In the
!Len(foreignKey) branch of sql.cfc, insert _ between the model name and each key when $get("useUnderscoreReferenceColumns") is true. That makes migrator output and association defaults genuinely agree, which is what the docs already promise. It is a behavior change for any existing app that has the flag on and relies on the current (broken-for-them) default — but such an app cannot be working today, precisely because the default doesn't match its schema.
- If changing the default is considered too invasive, correct
migrator/CLAUDE.md:51 and the references() reference doc to state plainly that belongsTo/hasMany still default to modelnameid and that apps with the flag enabled must pass foreignKey="<name>_id" explicitly.
- Either way, consider a dev-mode assertion: when an association's derived default foreign key does not exist among the target table's columns, throw a descriptive error at association-resolution time rather than surfacing
key [tenantid] doesn't exist from deep inside the join builder. That message currently gives no hint that the fix is a foreignKey argument.
Option 1 plus option 3 would have prevented this class of bug outright.
Environment
Wheels 4.0.5 (vendored), Lucee 7, PostgreSQL-wire (YugabyteDB). The same derivation is present at current dev HEAD.
Happy to open a PR for whichever option you prefer — option 2 is trivial, and I can add specs for option 1 covering both flag states.
/cc @claude
Summary
useUnderscoreReferenceColumns(framework defaultfalse,truein thewheels newtemplate) changes the columns the migrator generates —t.references("user")producesuser_idinstead ofuserid— but the model layer's association default foreign key is not aware of the setting. It is unconditional string concatenation:// vendor/wheels/model/sql.cfc:1334-1339 if (!Len(local.classAssociations[local.name].foreignKey)) { ... local.classAssociations[local.name].foreignKey = local.associatedClass.$classData().modelName & Replace(local.associatedClass.$classData().keys, ",", ",#...modelName#", "all");So
belongsTo(name="tenant")defaults totenantid, while the migrator on a new app has createdtenant_id.The result is that a stock
wheels newapp which declares an association without an explicitforeignKeyand then usesinclude=throws at runtime:Grepping the framework confirms the setting never reaches the model layer —
useUnderscoreReferenceColumnsappears only inevents/init/orm.cfm(where it is defined),migrator/Migration.cfc,migrator/TableDefinition.cfc, their specs, and the reference docs. There is no code path undervendor/wheels/model/that consults it.The docs assert the opposite
vendor/wheels/migrator/CLAUDE.md:51:That sentence is the trap: it tells the reader the two halves line up, so nobody adds an explicit
foreignKey. They line up only for the column name in the database; the association default still derivestenantid.Reproduction
On a stock
wheels newapp (soset(useUnderscoreReferenceColumns=true)):// migration t = createTable(name="memberships"); t.references(columnNames="user"); // creates user_id t.references(columnNames="tenant"); // creates tenant_id t.create(); // app/models/Membership.cfc component extends="Model" { function config() { belongsTo(name="user"); belongsTo(name="tenant"); } }model("Membership").findAll(include="tenant"); // throws: key [tenantid] doesn't existPlain finds work; the failure only appears once an association is traversed (
include=, or the association accessor), which is why it can sit latent for a long time. In our case it shipped to a live screen — a tenant-picker page for users belonging to more than one tenant — and was only found weeks later when an unrelated task happened to add the firstinclude=on that model.Suggested fix
Options, roughly in order of preference:
!Len(foreignKey)branch ofsql.cfc, insert_between the model name and each key when$get("useUnderscoreReferenceColumns")is true. That makes migrator output and association defaults genuinely agree, which is what the docs already promise. It is a behavior change for any existing app that has the flag on and relies on the current (broken-for-them) default — but such an app cannot be working today, precisely because the default doesn't match its schema.migrator/CLAUDE.md:51and thereferences()reference doc to state plainly thatbelongsTo/hasManystill default tomodelnameidand that apps with the flag enabled must passforeignKey="<name>_id"explicitly.key [tenantid] doesn't existfrom deep inside the join builder. That message currently gives no hint that the fix is aforeignKeyargument.Option 1 plus option 3 would have prevented this class of bug outright.
Environment
Wheels 4.0.5 (vendored), Lucee 7, PostgreSQL-wire (YugabyteDB). The same derivation is present at current dev HEAD.
Happy to open a PR for whichever option you prefer — option 2 is trivial, and I can add specs for option 1 covering both flag states.
/cc @claude