Repository navigation
useUnderscoreReferenceColumns=true (the wheels new default) produces columns the belongsTo/hasMany foreignKey default never matches #3337
Description
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 newtemplate atcli/lucli/templates/app/config/settings.cfmopts new apps intotrueso they match Wheels modelbelongsTodefaults 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 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 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 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. - If changing the default is considered too invasive, correct
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. - 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 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
Activity