diff --git a/changelog.d/3208-hasmany-shortcut-include.fixed.md b/changelog.d/3208-hasmany-shortcut-include.fixed.md new file mode 100644 index 0000000000..1762f7ed41 --- /dev/null +++ b/changelog.d/3208-hasmany-shortcut-include.fixed.md @@ -0,0 +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) diff --git a/vendor/wheels/model/sql.cfc b/vendor/wheels/model/sql.cfc index ab5c0375d0..773068beb9 100644 --- a/vendor/wheels/model/sql.cfc +++ b/vendor/wheels/model/sql.cfc @@ -1219,11 +1219,46 @@ component { } } } else { - // No through association, use as-is - local.rv = ListAppend(local.rv, local.currentInclude); + // `currentInclude` is not a this-model `through` association. It may, + // however, be the `shortcut` name of a many-to-many `hasMany` — a + // convenience accessor registered as a dynamic method (consumed by the + // shortcut dispatcher in $associationMethod), NOT as a first-class + // includable association. When such a name reaches `include`, resolve it + // to the nested this-model bridge include so the join still happens + // instead of throwing Wheels.AssociationNotFound (issue #3208). + // + // Only the shortcut path is rewritten here: a plain association without + // a `through` is left untouched, and a real association whose name was + // passed (even one carrying a shortcut's own through-chain) never enters + // this branch — preserving the issue #3109 contract. + local.shortcutExpanded = ""; + if (!StructKeyExists(local.associations, local.currentInclude)) { + for (local.assocName in local.associations) { + local.assoc = local.associations[local.assocName]; + if ( + StructKeyExists(local.assoc, "shortcut") + && Len(local.assoc.shortcut) + && local.assoc.shortcut == local.currentInclude + && StructKeyExists(local.assoc, "through") + && ListLen(local.assoc.through) == 2 + ) { + // through = ","; + // the first segment is the bridge model's association to the far + // side, so the shortcut joins as "()". + local.shortcutExpanded = local.assocName & "(" & ListFirst(local.assoc.through) & ")"; + break; + } + } + } + if (Len(local.shortcutExpanded)) { + local.rv = ListAppend(local.rv, local.shortcutExpanded); + } else { + // No through / shortcut match, use as-is + local.rv = ListAppend(local.rv, local.currentInclude); + } } } - + return local.rv; } diff --git a/vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc b/vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc index 653901060b..03a5b1e8c6 100644 --- a/vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc +++ b/vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc @@ -72,6 +72,36 @@ component extends="wheels.WheelsTest" { }); }); + describe("hasMany shortcut name as an include (issue ##3208)", () => { + + it("expands a shortcut name into the nested bridge include", () => { + // `memberTeams` declares `shortcut="teams"`, so the convenience name + // "teams" is NOT an association on Member — it only exists as a dynamic + // accessor. Used in `include`, it must expand into the this-model bridge + // include "memberTeams(team)" (Member -> memberTeams -> Team) instead of + // throwing Wheels.AssociationNotFound. ListFirst(through) ("team") is the + // bridge model's association to the far side. + var expanded = g.model("member").$expandThroughAssociations("teams"); + expect(expanded).toBe("memberTeams(team)"); + }); + + it("eager-loads the far side through a shortcut include", () => { + // Alice carries two join rows, Bob one — joining through the bridge to + // Team yields one row per join row, and must not throw. + var members = g.model("member").findAll(include = "teams", order = "id"); + expect(members.recordCount).toBe(3); + }); + + it("expands a shortcut declared with an explicit through override", () => { + // `rosterSpots` declares `shortcut="squads"` with an explicit + // `through="squad,rosterEntries"`. ListFirst(through) ("squad") is the + // bridge model's association to Team, so "squads" expands to the nested + // this-model include "rosterSpots(squad)". + var expanded = g.model("member").$expandThroughAssociations("squads"); + expect(expanded).toBe("rosterSpots(squad)"); + }); + }); + describe("$expandThroughAssociations this-model rewrite (preserved PR ##449 behavior)", () => { it("rewrites a 2-element through whose first segment IS an association on the model", () => { diff --git a/web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx b/web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx index 64d90a5fbf..2dbe3c4df8 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx @@ -239,7 +239,7 @@ component extends="Model" { } ``` -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. +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). The join model needs both `belongsTo` declarations: diff --git a/web/tests/visual-baselines/api.png b/web/tests/visual-baselines/api.png index 4b65188349..507f04ce9e 100644 Binary files a/web/tests/visual-baselines/api.png and b/web/tests/visual-baselines/api.png differ diff --git a/web/tests/visual-baselines/guides.png b/web/tests/visual-baselines/guides.png index e3ec16100e..75ae73d563 100644 Binary files a/web/tests/visual-baselines/guides.png and b/web/tests/visual-baselines/guides.png differ