Skip to content

Commit 66e8c2c

Browse files
wheels-bot[bot]github-actions[bot]claude
authored
fix(model): resolve hasMany shortcut name in include expansion (#3208) (#3209)
* fix(model): resolve hasMany shortcut name in include expansion (#3208) 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> * docs(web/guides): note shortcut name accepted in findAll include for many-to-many Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * chore(web): refresh visual baseline(s) (all) 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. --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent c281b41 commit 66e8c2c

4 files changed

Lines changed: 70 additions & 4 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- `findAll(include=...)` now accepts a `hasMany` `shortcut` name and expands it into the nested bridge include (e.g. `include="Category"` joins through `ProductCategories`) instead of throwing `Wheels.AssociationNotFound` (#3208)

vendor/wheels/model/sql.cfc

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,11 +1219,46 @@ component {
12191219
}
12201220
}
12211221
} else {
1222-
// No through association, use as-is
1223-
local.rv = ListAppend(local.rv, local.currentInclude);
1222+
// `currentInclude` is not a this-model `through` association. It may,
1223+
// however, be the `shortcut` name of a many-to-many `hasMany` — a
1224+
// convenience accessor registered as a dynamic method (consumed by the
1225+
// shortcut dispatcher in $associationMethod), NOT as a first-class
1226+
// includable association. When such a name reaches `include`, resolve it
1227+
// to the nested this-model bridge include so the join still happens
1228+
// instead of throwing Wheels.AssociationNotFound (issue #3208).
1229+
//
1230+
// Only the shortcut path is rewritten here: a plain association without
1231+
// a `through` is left untouched, and a real association whose name was
1232+
// passed (even one carrying a shortcut's own through-chain) never enters
1233+
// this branch — preserving the issue #3109 contract.
1234+
local.shortcutExpanded = "";
1235+
if (!StructKeyExists(local.associations, local.currentInclude)) {
1236+
for (local.assocName in local.associations) {
1237+
local.assoc = local.associations[local.assocName];
1238+
if (
1239+
StructKeyExists(local.assoc, "shortcut")
1240+
&& Len(local.assoc.shortcut)
1241+
&& local.assoc.shortcut == local.currentInclude
1242+
&& StructKeyExists(local.assoc, "through")
1243+
&& ListLen(local.assoc.through) == 2
1244+
) {
1245+
// through = "<bridge-assoc-to-far-side>,<far-side-assoc-to-bridge>";
1246+
// the first segment is the bridge model's association to the far
1247+
// side, so the shortcut joins as "<name>(<ListFirst(through)>)".
1248+
local.shortcutExpanded = local.assocName & "(" & ListFirst(local.assoc.through) & ")";
1249+
break;
1250+
}
1251+
}
1252+
}
1253+
if (Len(local.shortcutExpanded)) {
1254+
local.rv = ListAppend(local.rv, local.shortcutExpanded);
1255+
} else {
1256+
// No through / shortcut match, use as-is
1257+
local.rv = ListAppend(local.rv, local.currentInclude);
1258+
}
12241259
}
12251260
}
1226-
1261+
12271262
return local.rv;
12281263
}
12291264

vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,36 @@ component extends="wheels.WheelsTest" {
7272
});
7373
});
7474

75+
describe("hasMany shortcut name as an include (issue ##3208)", () => {
76+
77+
it("expands a shortcut name into the nested bridge include", () => {
78+
// `memberTeams` declares `shortcut="teams"`, so the convenience name
79+
// "teams" is NOT an association on Member — it only exists as a dynamic
80+
// accessor. Used in `include`, it must expand into the this-model bridge
81+
// include "memberTeams(team)" (Member -> memberTeams -> Team) instead of
82+
// throwing Wheels.AssociationNotFound. ListFirst(through) ("team") is the
83+
// bridge model's association to the far side.
84+
var expanded = g.model("member").$expandThroughAssociations("teams");
85+
expect(expanded).toBe("memberTeams(team)");
86+
});
87+
88+
it("eager-loads the far side through a shortcut include", () => {
89+
// Alice carries two join rows, Bob one — joining through the bridge to
90+
// Team yields one row per join row, and must not throw.
91+
var members = g.model("member").findAll(include = "teams", order = "id");
92+
expect(members.recordCount).toBe(3);
93+
});
94+
95+
it("expands a shortcut declared with an explicit through override", () => {
96+
// `rosterSpots` declares `shortcut="squads"` with an explicit
97+
// `through="squad,rosterEntries"`. ListFirst(through) ("squad") is the
98+
// bridge model's association to Team, so "squads" expands to the nested
99+
// this-model include "rosterSpots(squad)".
100+
var expanded = g.model("member").$expandThroughAssociations("squads");
101+
expect(expanded).toBe("rosterSpots(squad)");
102+
});
103+
});
104+
75105
describe("$expandThroughAssociations this-model rewrite (preserved PR ##449 behavior)", () => {
76106

77107
it("rewrites a 2-element through whose first segment IS an association on the model", () => {

web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ component extends="Model" {
239239
}
240240
```
241241

242-
A single `hasMany` with `shortcut` provides both access paths: `user.userRoles()` for the join rows and `user.roles()` for the far-side records. Behind the scenes Wheels walks both association chains to build the query. The same declaration also works correctly with `include``model("User").findAll(include="userRoles")` joins the join table as expected.
242+
A single `hasMany` with `shortcut` provides both access paths: `user.userRoles()` for the join rows and `user.roles()` for the far-side records. Behind the scenes Wheels walks both association chains to build the query. Both names also work in `include`: `model("User").findAll(include="userRoles")` loads just the join rows, and `model("User").findAll(include="roles")` uses the shortcut name to load the far-side records through the bridge in one step (Wheels expands it to `userRoles(role)` automatically).
243243

244244
The join model needs both `belongsTo` declarations:
245245

0 commit comments

Comments
 (0)