Skip to content

useUnderscoreReferenceColumns=true (the wheels new default) produces columns the belongsTo/hasMany foreignKey default never matches #3337

Description

@bpamiri

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:

  1. 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.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions