fix(model): resolve hasMany shortcut name in include expansion (#3208)#3209
Conversation
A hasMany shortcut name (e.g. shortcut="Category") is registered as a dynamic accessor method, not a first-class association. Passing it to findAll(include=...) fell through include expansion unchanged and then threw Wheels.AssociationNotFound. $expandThroughAssociations now resolves an include name that is not a this-model association but matches a hasMany shortcut into the nested bridge include (<assocName>(<ListFirst(through)>)), so the join through the bridge model happens as expected. The issue #3109 contract is preserved: real association names never enter the shortcut branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…many-to-many 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 PR teaches $expandThroughAssociations one extra step: when an include name is not a this-model association but matches a many-to-many hasMany shortcut, it is rewritten into the nested bridge include <assocName>(<ListFirst(through)>) (e.g. teams → memberTeams(team)), fixing the Wheels.AssociationNotFound throw from #3208. The change is narrow, well-guarded, cross-engine-safe, and preserves the #3109 contract. Verdict: comment — no blocking findings, one minor test-coverage gap noted below.
Correctness
The fix is sound and the guard placement is exactly right:
- The new shortcut branch lives in the outer
else(reached whencurrentIncludeis not an association with athrough), and is itself gated by!StructKeyExists(local.associations, local.currentInclude)(vendor/wheels/model/sql.cfc:1238). So a real association lacking athroughskips the loop entirely and falls through to the unchanged "use as-is" path — no regression for ordinary includes. - The
ListFirst(through)choice resolves to a real bridge association in both fixture cases:memberTeams's defaultthrough="team,memberTeams"→memberTeams(team)andMemberTeam.belongsTo("team")exists;rosterSpots's explicitthrough="squad,rosterEntries"→rosterSpots(squad)andMemberTeam.belongsTo(name="squad", modelName="Team")exists. Both nested includes are valid joins. - The
ListLen(local.assoc.through) == 2constraint correctly limits the rewrite to the two-leg many-to-many shape thatshortcutproduces (vendor/wheels/model/associations.cfc:70,through = "#singularize(arguments.shortcut)#,#arguments.name#").
Cross-engine
Clean. The new code uses only StructKeyExists, Len, ListLen, ListFirst, ListAppend, &&, and a struct for (key in struct) loop — all behave identically across Lucee 5/6/7, Adobe 2018–2025, and BoxLang. No closures, no obj.map() member-function collisions, no reserved-scope parameter names, no application-scope members.
Tests
The happy path is well covered, with one gap:
vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc:82and:101assert the string expansion for both the default (teams) and explicit-override (squads) forms — good.vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc:90exercises the end-to-end eager load (findAll(include="teams")) for the default shortcut, which is what would actually throw if the expandedmemberTeams(team)were invalid.- Minor nit: there is no end-to-end
findAll(include="squads")for the explicit-through-override path — only its string expansion is asserted (:101). Since the override resolvesListFirstto a different bridge leg (squadvs the defaultteam), anit("eager-loads the far side through an explicit-override shortcut include")runningfindAll(include="squads", order="id")and assertingrecordCountwould close the loop and guard the override join the same way the default form is guarded at:90. Not blocking.
Docs
Changelog fragment changelog.d/3208-hasmany-shortcut-include.fixed.md is present, correctly typed (.fixed.md), and self-contained — the right pattern (no direct [Unreleased] edit). Framework/AI/CLAUDE.md docs are honestly deferred to bot-update-docs.yml in the checklist.
Commits
fix(model): resolve hasMany shortcut name in include expansion (#3208) is a valid conventional-commit header (type fix, scope model, ~68 chars, sentence-case) and carries a Signed-off-by: trailer. The body explains the why and notes the #3109 contract preservation.
One caveat the PR body already flags honestly: the suite could not run in the bot sandbox. The default-form eager-load test (:90) is the real correctness check here, so CI (bot-tdd-gate.yml + compat-matrix.yml) green should be confirmed before merge.
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This re-review runs against head 15f99496, which adds one docs-only commit (docs(web/guides): note shortcut name accepted in findAll include) on top of the previously-reviewed code fix (0a1a2fa5). The code in vendor/wheels/model/sql.cfc is unchanged from the prior pass; the new commit only expands the prose in associations.mdx to document the #3208 behavior. I re-verified both the fix and the new doc claim against the fixtures. Verdict: comment — no blocking findings; the one minor test-coverage nit from the prior pass still stands.
Correctness
The fix remains sound and the new docs claim is accurate. The guide now states (web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx:242):
model("User").findAll(include="roles")… (Wheels expands it touserRoles(role)automatically).
That math checks out against the implementation. For hasMany(name="userRoles", shortcut="roles"), the default through is "#singularize(shortcut)#,#name#" = "role,userRoles", so ListFirst(through) = role and local.assocName & "(" & ListFirst(...) & ")" (vendor/wheels/model/sql.cfc:1248) yields exactly userRoles(role). The companion claim that include="userRoles" "loads just the join rows" is the preserved #3109 contract, pinned by hasManyShortcutSpec.cfc:24.
The new shortcut branch stays correctly gated: !StructKeyExists(local.associations, local.currentInclude) (sql.cfc:1235) means a real association name (e.g. comments) never enters the loop and falls through to the unchanged use-as-is path — no #3109 regression.
Cross-engine
Clean — no change since the prior pass. The added code uses only StructKeyExists, Len, ListLen, ListFirst, ListAppend, and a struct for (key in struct) loop, all uniform across Lucee 5/6/7, Adobe 2018–2025, and BoxLang. No closures, no obj.map() collisions, no reserved-scope parameter names. The new commit is prose-only.
Tests
Happy path well covered; one gap carried over from the prior review:
vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc:82asserts the default-form expansion (teams→memberTeams(team)),:90exercises the end-to-endfindAll(include="teams")eager load, and:101asserts the explicit-override string expansion (squads→rosterSpots(squad)).- Minor nit (non-blocking): there is still no end-to-end
findAll(include="squads")for the explicit-through-override path — only its string expansion is asserted. Because the override resolvesListFirst(through)to a different bridge leg (squadvs the defaultteam), anit("eager-loads the far side through an explicit-override shortcut include")runningfindAll(include="squads", order="id")and assertingrecordCountwould guard that join the same way:90guards the default form.
Docs
The new commit is exactly the right surface to update — the user-facing guide at web/sites/guides/src/content/docs/v4-0-0/. The prose is accurate (verified above) and the changelog fragment changelog.d/3208-hasmany-shortcut-include.fixed.md remains present and correctly typed (.fixed.md), with no direct [Unreleased] edit.
Commits
Both headers are commitlint-valid:
fix(model): resolve hasMany shortcut name in include expansion (#3208)— typefix, scopemodel, sentence-case, well under 100 chars.docs(web/guides): note shortcut name accepted in findAll include for many-to-many— typedocs, scopeweb/guides, ~81 chars, not ALL-CAPS.
One honest caveat the PR body already flags: the suite could not run in the bot sandbox, so CI (bot-tdd-gate.yml + compat-matrix.yml) green should be confirmed before merge — the findAll(include="teams") test (:90) is the real correctness gate.
Manually triggered baseline refresh via .github/workflows/refresh-visual-baselines.yml on branch fix/bot-3208-hasmany-shortcut-association-not-recognised-by-inc. Run when an intentional content/layout change makes the visual-regression check fail. The new PNG(s) under web/tests/visual-baselines/ are now the expected rendering; re-run the failing visual-regression job to flip the check green.
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This PR fixes #3208: a hasMany shortcut name passed to findAll(include=...) used to reach $expandThroughAssociations, fall through unchanged, and then throw Wheels.AssociationNotFound. The fix adds one narrow branch that rewrites a shortcut name into the nested this-model bridge include (teams → memberTeams(team)). The logic is correct, narrowly scoped, cross-engine-safe, and backed by three new BDD specs plus a docs update and changelog fragment. Verdict: approve (submitted as a comment — GitHub forbids the bot approving its own PR; HTTP 422 fallback per the review playbook).
Correctness
The new branch (vendor/wheels/model/sql.cfc:1234-1252) only fires when the include name is not itself an association and does match an association whose shortcut equals it and whose through has exactly 2 elements:
if (!StructKeyExists(local.associations, local.currentInclude)) {
for (local.assocName in local.associations) {
...
&& local.assoc.shortcut == local.currentInclude
&& StructKeyExists(local.assoc, "through")
&& ListLen(local.assoc.through) == 2I traced the expansion against the association registration. For hasMany(name="memberTeams", shortcut="teams"), the default through = "#singularize(arguments.shortcut)#,#arguments.name#" (vendor/wheels/model/associations.cfc:70) resolves to "team,memberTeams", so ListFirst(through) = team and the branch emits memberTeams(team) — exactly the value the new spec asserts (hasManyShortcutSpec.cfc:85). The explicit-override case (rosterSpots, through="squad,rosterEntries") emits rosterSpots(squad), again matching the spec. The comment at lines 1245-1247 accurately describes the segment roles.
No regression for unknown names: a non-association, non-shortcut include still falls through to the use as-is branch (line 1257) and throws downstream as before, so this only converts a previously-erroring case into a working one. The #3109 narrowing in the sibling branch (lines 1213-1219) is untouched.
Cross-engine
Clean. The added code uses only StructKeyExists, ListLen, ListFirst, ListAppend, and a for-in loop — no closures, no obj.map() member-function collision, no reserved-scope shadowing, no Left(str, 0). The host function is public string function $expandThroughAssociations (sql.cfc:1140), so it satisfies the $-prefixed-public mixin contract (Cross-Engine Invariant #7). The shortcut == currentInclude compare is CFML-case-insensitive, so include="Teams" still resolves.
Tests
vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc:75-103 adds an issue-#3208 block covering the default shortcut (teams → memberTeams(team)), the explicit-through override (squads → rosterSpots(squad)), and an end-to-end findAll(include="teams") that asserts 3 joined rows without throwing. Both the unit-level expansion and the runtime eager-load paths are exercised. Spec extends wheels.WheelsTest (BDD), correct for new tests. (CI's bot-tdd-gate + compat-matrix remain the validating gate — the suite was not runnable in the bot sandbox, as the PR body honestly notes.)
Docs
web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx:242 is updated to document that the shortcut name now works in include and expands to userRoles(role) — accurate against the implementation. Changelog fragment present (changelog.d/3208-hasmany-shortcut-include.fixed.md, .fixed type), no direct [Unreleased] edit. The visual baseline PNGs in the head commit are the expected mechanical refresh from the guides docs change.
Commits
All three conform to commitlint.config.js: fix(model): …, docs(web/guides): …, chore(web): … — valid types, valid/optional scopes, subjects within length and not ALL-CAPS.
Summary
A
hasManyshortcutname (e.g.hasMany(name="ProductCategories", shortcut="Category")) is registered as a dynamic accessor method on the model — not as a first-class, includable association. Somodel("Product").findAll(include="Category")reached$expandThroughAssociations, found noCategoryassociation to expand, fell through unchanged, and then threwWheels.AssociationNotFoundin$expandedAssociations.This teaches include-expansion one extra step: when an include name is not a this-model association but does match a
hasManyshortcut, it is rewritten into the nested this-model bridge include<assocName>(<ListFirst(through)>)— e.g.Category→ProductCategories(Category)— so the join through the bridge model happens as the user expects.The narrowing introduced in #3109 is preserved: a real association name (even one carrying a shortcut's own
throughchain) is resolved by the existing direct-association branches and never enters the new shortcut branch, which only fires when the name is absent from the association set.Related Issue
Fixes #3208
Type of Change
Feature Completeness Checklist
Signed-off-by:(git commit -s)vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfcgains an issue hasMany shortcut association not recognised by include - "association not found" error #3208 block: the shortcut name expands tomemberTeams(team)/rosterSpots(squad)and eager-loads the far side without throwingbot-update-docs.ymlbot-update-docs.ymlbot-update-docs.ymlchangelog.d/3208-hasmany-shortcut-include.fixed.mdwheelsCLI is not installed andtools/test-local.shcannot start a server here). CI (bot-tdd-gate.yml+compat-matrix.yml) is the validating gate; a human must confirm the suite is green before merge.Test Plan
The new spec reuses the existing many-to-many fixtures (
Member hasMany(name="memberTeams", shortcut="teams"),MemberTeam belongsTo "team"):model("member").$expandThroughAssociations("teams")→memberTeams(team)model("member").$expandThroughAssociations("squads")→rosterSpots(squad)(explicitthroughoverride form)model("member").findAll(include="teams", order="id")returns the joined rows (3) without throwingWheels.AssociationNotFoundRun locally with
bash tools/test-local.sh model.