Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3208-hasmany-shortcut-include.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 38 additions & 3 deletions vendor/wheels/model/sql.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<bridge-assoc-to-far-side>,<far-side-assoc-to-bridge>";
// the first segment is the bridge model's association to the far
// side, so the shortcut joins as "<name>(<ListFirst(through)>)".
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;
}

Expand Down
30 changes: 30 additions & 0 deletions vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading