Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Binary file modified web/tests/visual-baselines/api.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified web/tests/visual-baselines/guides.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading