fix(model): hasMany shortcut no longer breaks the association via include expansion - #3133
Conversation
…lude expansion
Declaring `shortcut` on a `hasMany` set its default `through` to an
opposite-side chain ("#singularize(shortcut)#,#name#") consumed by the
shortcut dispatcher in $associationMethod. $expandThroughAssociations,
however, treated any 2-element `through` as a this-model through-include
and unconditionally rewrote the include (e.g. "userRoles" ->
"role(userRoles)"), so the plain association method, the shortcut method,
and `findAll(include=...)` all threw Wheels.AssociationNotFound.
Gate the 2-element branch on its first segment actually being an
association on the current model (mirroring the 1-element branch's
existence check), so the shortcut default falls through unchanged while
the #449 nested-join expansion is preserved.
Adds a Member/MemberTeam/Team HABTM fixture and runtime specs covering
the plain association method, the shortcut method, and include expansion.
Fixes #3109
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: This PR fixes #3109 by gating $expandThroughAssociations's 2-element through rewrite on the first segment actually being an association on the current model (vendor/wheels/model/sql.cfc:1213). I verified the diagnosis independently and it holds: hasMany defaults through to "#singularize(shortcut)#,#name#" (vendor/wheels/model/associations.cfc:70) and documents it as "a list of two association names representing the chain from the opposite side of the many-to-many relationship to this model" (associations.cfc:59), and the shortcut dispatcher consumes that chain on the join model via ListFirst/ListLast (vendor/wheels/model/onmissingmethod.cfc:286-291) — so the unconditional rewrite introduced in 6579f246c (#449) was wrong for every conventional shortcut, exactly as the PR body claims. Verdict: comment — no blocking findings; one non-blocking test-coverage note below.
Correctness
Verified, no findings:
- The gate is the right discriminator between the two
throughshapes. A this-model chain (the #449 nested-join case) by definition has its first segment registered on the current model, so it still expands; a shortcut's opposite-side chain (e.g."team,memberTeams"onMember) never does, so it now falls through unchanged. I attempted to refute this with the "model coincidentally has an association named aftersingularize(shortcut)" edge and concluded that case is precisely the configuration #449's expansion was written for — the gate preserves it rather than breaking it. - The fallback branch appends
local.currentInclude(sql.cfc:1218), consistent with the function's other as-is fallbacks atsql.cfc:1194-1198. - Plain
hasManywithoutshortcutstoresthrough = ",name"(leading empty list element), whichListLencounts as 1, so it routes to the 1-element branch and falls through as before — no behavior change outside the shortcut path.
Tests
- The new spec (
vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc) covers the bug well: the$expandThroughAssociationsrewrite directly, the plain association method,findAll(include=...), and far-side row correctness through the shortcut — and the PR body documents the failing-before/passing-after run. Good. - Non-blocking: the preserved branch — a 2-element
throughwhose first segment IS a current-model association (sql.cfc:1213-1215, the #449 path) — still has zero coverage: no fixture undervendor/wheels/tests/_assets/models/declares such a chain (grep -rn "through" vendor/wheels/tests/_assets/models/matches only a comment in the newMember.cfc). This gap predates the PR, but since this change touches exactly that branch's guard, a follow-up fixture exercising the gate-true path would lock in that #449's expansion survived. Fine to defer to a separate issue.
Conventions / Cross-engine / Docs / Commits — all clean
- Fix uses only safe builtins (
StructKeyExists,ListFirst,ListLast,ListAppend);$expandThroughAssociationskeepspublic+$-prefix (Cross-Engine Invariant 7). - Spec follows house patterns:
g = application.wopreamble (matchescrudSpec.cfc:5), arrow-function BDD onwheels.WheelsTest, and##3109correctly escaped in the describe-string (unescaped#appears only in//comments, which is safe). populate.cfmfixture data uses baremodel().create()— established pattern in that file (lines 462+); new tables use the sharedlocal.identityColumnType/local.storageEnginemachinery and the join table is first in the drop list.- Changelog fragment
changelog.d/3109-hasmany-shortcut.fixed.mdpresent with a validfixedtype; no directCHANGELOG.mdedit. - Single commit conforms to commitlint (
fix(model): …, header < 100 chars, body explains the why, DCO sign-off matches the author email). Docs checkboxes honestly deferred tobot-update-docs.yml.
…laration provides both access paths Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Wheels Bot — Docs updatedAdded a doc commit to this PR:
|
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: This head adds one commit on top of the previously-reviewed fix for #3109: a web/sites/guides correction removing the redundant (and pre-fix, broken) duplicate hasMany(name="userRoles") declaration from the many-to-many guide. The code fix is byte-identical to the head I reviewed at e7d5f8ce2 (no blocking findings there — re-verified below), and the docs change is accurate. However, the new commit's header is 104 characters, exceeding commitlint.config.js's header-max-length: [2, 'always', 100]. Per the repo's commit rules that is a blocking finding, so the verdict is request changes — the fix is a one-line reword/amend. (Submitted as a comment-state review because GitHub rejects a changes-requested state on the bot's own PR; please treat it as request-changes.)
Correctness
No findings. Re-verified at this head:
- The gate at
vendor/wheels/model/sql.cfc:1212(StructKeyExists(local.associations, local.firstAssociation)) correctly discriminates a this-model through chain (the #449 nested-join case, still expanded) from a shortcut's opposite-side chain, whichhasManydefaults to"#singularize(shortcut)#,#name#"(vendor/wheels/model/associations.cfc:71) and documents as the chain "from the opposite side" (associations.cfc:59) — that chain's first segment is never a current-model association, so it now falls through unchanged. - The updated guide text is factually correct against the fixed behavior: a single
hasMany(name="userRoles", shortcut="roles")yields bothuser.userRoles()(plain association method) anduser.roles()(shortcut dispatcher), andfindAll(include="userRoles")no longer throws — each path is exercised byvendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc. The old example's two same-namehasManydeclarations would have overwritten each other anyway, so the removal also fixes a latent doc bug.
Tests
- Coverage of the bug itself is good (expansion unit check, plain method,
include, far-side shortcut rows) — unchanged from the prior review. - Non-blocking, carried forward: the gate-true path — a 2-element
throughwhose first segment IS a current-model association (sql.cfc:1212-1214, the #449 expansion) — still has no fixture:git grep -rn "through" vendor/wheels/tests/_assets/models/matches only a comment in the newMember.cfc. Fine to defer to a follow-up issue, as the prior review said.
Docs
- The guide edit is complete: both example blocks (User side at
associations.mdx:235-239and Role side atassociations.mdx:257-262) were fixed, and no otherhasMany(name="userRoles")duplicate remains anywhere inweb/,.ai/, orCLAUDE.md(grep clean). - Nit: the PR body's checklist still says "Framework Docs — left for
bot-update-docs.yml", but commit095d36fe9updatedweb/sites/guides/src/content/docs/v4-0-0/basics/associations.mdxin this very PR. Please tick that box / update the note so the checklist reflects reality.
Commits
-
Blocking — commitlint
header-max-length: commit095d36fe9's header is 104 characters:docs(web/guides): fix many-to-many shortcut example — one hasMany declaration provides both access pathscommitlint.config.jssets'header-max-length': [2, 'always', 100](error level), and CLAUDE.md § Commit Message Conventions names the config as canonical ("header ≤ 100 chars"). CI'spr.ymlonly lints the PR title, so this won't fail a check, but the convention is uniformly observed in practice — 0 of the last 300 commits ondevelopexceed 100 characters. Fix: reword the subject to ≤ 100 chars and force-push, e.g.:docs(web/guides): single hasMany with shortcut provides both access paths(74 chars) -
The fix commit
e7d5f8ce2remains conformant (84-char header,fix(model)type/scope, body explains the why, DCO sign-off matches the author email). The new commit's DCO sign-off also matches its author (claude[bot] <41898282+claude[bot]@users.noreply.github.com>).
Conventions / Cross-engine / Security — all clean
Unchanged from the prior review at e7d5f8ce2: safe builtins only (StructKeyExists, ListFirst, ListLast, ListAppend), public + $-prefix preserved on $expandThroughAssociations (Cross-Engine Invariant 7), ##3109 correctly escaped in the spec's describe string, fixture tables use the shared local.identityColumnType / local.storageEngine machinery, changelog fragment present with a valid fixed type, no direct CHANGELOG.md edit, no user-input paths touched.
…3109 fix The merged develop branch carries the #3130 caution Aside warning that hasMany shortcut is broken in 4.0.x. This PR ships the fix, so the Aside would contradict the corrected prose directly below it. Remove it and keep the prose documenting the working behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
…include rewrite Two gaps in the #3109 gate coverage: - The explicit `through=` override form (per the @through docstring in associations.cfc) exercised the gated else-branch with zero specs. New rosterSpots/squads fixtures declare `through="squad,rosterEntries"` over the same join table; specs assert the plain association, the include, and the overridden shortcut method all work. Red-checked against pre-fix sql.cfc: 1 fail + 3 errors, matching the conventional form. - The IF-side (the kept PR #449 rewrite when ListFirst(through) IS an association on the current model) had no direct assertion. Team gains `squadMembers` with `through="memberTeams,member"`; specs lock $expandThroughAssociations to "memberTeams(member)" plus the end-to-end eager load. Red-checked by disabling the if-branch: both specs fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: This head adds two substantive commits on top of the previously-reviewed state: a093c31dc closes both test-coverage gaps the earlier reviews carried forward (the explicit through= override form and the gate-true #449 rewrite path), and e0df81a6b removes the now-contradictory "shortcut is broken" caution Aside that arrived via the develop merge. All new code is correct, cross-engine-safe, and well-evidenced — but the blocking commitlint finding from the previous review is still unresolved: commit 095d36fe9's header remains 104 characters (> 100), unchanged at this head. Per the repo's commit rules that stays a blocking finding, so the verdict is request changes. It is resolvable without touching any code: reword the one subject line, or squash-merge the PR under its (conformant, 85-char) title. (Submitted as a comment-state review because GitHub rejects a changes-requested state on the bot's own PR — please treat it as request-changes.)
Correctness
No findings. Verified for the new content at this head:
- Explicit-override fixtures are wired correctly.
Member.cfcdeclareshasMany(name="rosterSpots", modelName="MemberTeam", shortcut="squads", through="squad,rosterEntries"); the chain matches the dispatcher's contract invendor/wheels/model/onmissingmethod.cfc:286-291—ListFirst(through)("squad") must be an association on the join model (MemberTeam.cfcaddsbelongsTo(name="squad", modelName="Team", foreignKey="teamid")) andListLast(through)("rosterEntries") must be an association on the far-side model (Team.cfcaddshasMany(name="rosterEntries", modelName="MemberTeam")). Walking it by hand:alice.squads()→findAllon Team,include="rosterEntries",where memberid = <alice>→ Red, Blue — exactly what the spec asserts (ListSort(...) toBe "Blue,Red"). - The gate-true fixture exercises the preserved #449 branch for real.
Team.squadMembersdeclaresthrough="memberTeams,member"wherememberTeamsIS an association on Team, so$expandThroughAssociations("squadMembers")takes the IF-side atvendor/wheels/model/sql.cfc:1213-1215and rewrites to"memberTeams(member)"; both nested segments resolve (memberTeamshasMany on Team,memberbelongsTo on MemberTeam). The row-count expectation (3) is right: each team carries exactly one join row, as the spec comment notes. - The Aside removal is correct, not premature. The caution block removed from
associations.mdx(formerly lines 232-234) says "Track the fix in [#3109]" — this PR is that fix, and the prose directly below it now documents the working single-declaration behavior, each path of which is asserted inhasManyShortcutSpec.cfc. Keeping the Aside would have shipped a self-contradicting page. The final file state is clean — no remaining shortcut/broken caution (grep overassociations.mdxat this head). $expandThroughAssociationsremainspublicwith astringreturn (sql.cfc:1140), so the spec's direct string assertions (toBe("rosterSpots"),toBe("memberTeams(member)")) match the actual contract.
Tests
- Both carried-forward gaps are now closed. The prior reviews noted the gate-true path and the explicit-override form had no fixtures; this head adds direct unit assertions on the rewrite for all three shapes (conventional shortcut default, explicit override, this-model chain) plus end-to-end specs (plain method,
include, far-side shortcut rows) for each. The commit body documents red-checks for both additions (1 fail + 3 errors pre-fix for the override form; both #449 specs fail with the if-branch disabled), satisfying the failing-first expectation. g = application.woat the top ofrun()and baremodel()calls inpopulate.cfmare both established suite patterns (e.g.vendor/wheels/tests/specs/view/html5FormHelpersSpec.cfc:10,populate.cfm:462onward) — not findings.- Fixture hygiene checks out: no name collisions in
_assets/models/, the new tables reuse the sharedlocal.identityColumnType/local.intColumnType/local.storageEnginemachinery, andc_o_r_e_memberteamsis prepended to the delete list ahead ofc_o_r_e_members/c_o_r_e_teams(join table dropped first).
Docs
- Nit, carried forward and still stale: the PR body's checklist says "Framework Docs — left for
bot-update-docs.yml", yet the PR now contains two guide commits touchingweb/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx. The Test Plan section is similarly stale ("New spec alone (after fix): 4 pass" — the spec now contains 8 specs). Please refresh the PR body so the audit trail matches the diff.
Commits
-
Blocking — commitlint
header-max-length, unresolved from the previous review: commit095d36fe9's header is 104 characters:docs(web/guides): fix many-to-many shortcut example — one hasMany declaration provides both access pathscommitlint.config.js:27sets'header-max-length': [2, 'always', 100](error level), and CLAUDE.md § Commit Message Conventions names the config as canonical. The previous review flagged exactly this and proposed a ≤100-char reword; this head adds commits on top instead of amending. Two acceptable resolutions: (1) reword the subject and force-push (now requires rebasing across the5343717bbmerge), or (2) squash-merge the PR — the PR title (fix(model): hasMany shortcut no longer breaks the association via include expansion, 85 chars) is conformant and would become the only commit header ondevelop. If maintainers intend (2), say so on the thread and this finding is satisfied at merge time. -
The two new substantive commits are conformant:
a093c31dc(test(model): …, 85 chars, body explains the why and documents the red-checks) ande0df81a6b(docs(web/guides): …, 78 chars, body explains the develop-merge interaction). Both carrySigned-off-by: Peter Amiri <peter@alurium.com>matching the author email (DCO ✓).5343717bbis a merge commit, which commitlint's default ignores exempt.
Conventions / Cross-engine / Security — all clean
- All-named arguments throughout the new fixtures (
hasMany(name=..., modelName=..., shortcut=..., through=...)) — no mixed styles. - Cross-engine: safe builtins only; no inline-closure constructor args, no
Left(str, 0), no bracket-notation calls, no reserved-scope names (squad,teams,gare all safe);##3109/##449correctly escaped inside describe strings (the unescaped-#suite-killer is avoided);$expandThroughAssociationskeepspublic+$prefix (Cross-Engine Invariant 7). - Changelog fragment
changelog.d/3109-hasmany-shortcut.fixed.mdpresent with a validfixedtype; no directCHANGELOG.mdedit. - No user-input paths touched; spec
wherestrings are literal test data.
Summary
Declaring
shortcuton ahasManyassociation poisoned every call path through that association. ThehasManyshortcutargument defaultsthroughto an opposite-side chain ("#singularize(shortcut)#,#name#", e.g."role,userRoles") that is consumed by the shortcut dispatcher in$associationMethod. But$expandThroughAssociationstreated any 2-elementthroughas a this-model through-include and unconditionally rewrote the include (e.g."userRoles"→"role(userRoles)"). The include parser then looked up associationroleon the current model, which doesn't exist, and threwWheels.AssociationNotFound— breaking the plain association method (user.userRoles()), the shortcut method (user.roles()), andfindAll(include="userRoles").The fix gates the 2-element branch on its first segment actually being an association on the current model (mirroring the existence check already present in the 1-element branch). The shortcut default never satisfies that, so it now falls through unchanged — restoring pre-
6579f246cbehaviour for shortcuts while preserving the nested-join expansion added for #449.Related Issue
Fixes #3109
Type of Change
Feature Completeness Checklist
Signed-off-by:vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc(failing → passing) with a new Member/MemberTeam/Team HABTM fixture covering the plain association method, the shortcut method (far-side row correctness), include expansion, and the$expandThroughAssociationsrewrite directlybot-update-docs.yml(the documented many-to-many pattern inbasics/associations.mdxdepends on this fix)bot-update-docs.ymlbot-update-docs.ymlchangelog.d/3109-hasmany-shortcut.fixed.mdTest Plan
Ran the framework model suite against Lucee 7 + SQLite:
0 pass, 1 fail+ 3 errors — first assertionExpected [memberTeams] but received [team(memberTeams)]; the other three threwassociation named \team` could not be found on the `member` model` (the reported bug, reproduced).4 pass, 0 fail.wheels.tests.specs.model) after fix: 897 pass, 0 fail, 0 error (11 skipped, 908 specs) — no regressions.The change uses only cross-engine-safe builtins (
StructKeyExists,ListFirst,ListLast,ListAppend); CI's compat-matrix will confirm Adobe/BoxLang.