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/3109-hasmany-shortcut.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Declaring `shortcut` on a `hasMany` association no longer breaks that association: `$expandThroughAssociations` now only rewrites a 2-element `through` into a nested include when its first segment is an association on the current model, so the plain association method, the shortcut method, and `include` no longer throw `Wheels.AssociationNotFound` (#3109)
20 changes: 17 additions & 3 deletions vendor/wheels/model/sql.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -1200,9 +1200,23 @@ component {
} else {
local.firstAssociation = ListFirst(local.throughPath);
local.targetAssociation = ListLast(local.throughPath);

local.expandedInclude = local.firstAssociation & "(" & local.targetAssociation & ")";
local.rv = ListAppend(local.rv, local.expandedInclude);

// Only rewrite a 2-element `through` into a nested this-model include
// when its first segment is actually an association on the current
// model (mirroring the 1-element branch's existence check above). The
// `hasMany` `shortcut` argument stores an opposite-side chain in
// `through` ("#singularize(shortcut)#,#name#") that is consumed by the
// shortcut dispatcher in $associationMethod, not by include expansion.
// Rewriting it here turned the plain include (e.g. "userRoles") into a
// lookup for an association the current model does not have (e.g.
// "role(userRoles)"), throwing Wheels.AssociationNotFound (issue #3109).
if (StructKeyExists(local.associations, local.firstAssociation)) {
local.expandedInclude = local.firstAssociation & "(" & local.targetAssociation & ")";
local.rv = ListAppend(local.rv, local.expandedInclude);
} else {
// Not a this-model through chain (e.g. a shortcut's default through), use as-is.
local.rv = ListAppend(local.rv, local.currentInclude);
}
}
} else {
// No through association, use as-is
Expand Down
23 changes: 23 additions & 0 deletions vendor/wheels/tests/_assets/models/Member.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
component extends="Model" {

function config() {
table("c_o_r_e_members");
// Many-to-many shortcut: `member.teams()` reaches Team objects through the
// memberteams join model. Declaring `shortcut` must NOT break the plain
// `member.memberTeams()` association or `include="memberTeams"` (issue #3109).
hasMany(name = "memberTeams", shortcut = "teams");
// Explicit `through` override (see the @through docstring in associations.cfc):
// a second association over the same join table whose names don't follow
// singular/plural convention. The chain runs from the opposite side
// ("squad" on MemberTeam, then "rosterEntries" on Team), so include
// expansion must leave `rosterSpots` untouched — "squad" is not an
// association on Member (issue #3109, explicit-override form).
hasMany(
name = "rosterSpots",
modelName = "MemberTeam",
shortcut = "squads",
through = "squad,rosterEntries"
);
}

}
11 changes: 11 additions & 0 deletions vendor/wheels/tests/_assets/models/MemberTeam.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
component extends="Model" {

function config() {
table("c_o_r_e_memberteams");
belongsTo("member");
belongsTo("team");
// Opposite-side leg of Member's explicit `through` override ("squad,rosterEntries").
belongsTo(name = "squad", modelName = "Team", foreignKey = "teamid");
}

}
15 changes: 15 additions & 0 deletions vendor/wheels/tests/_assets/models/Team.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
component extends="Model" {

function config() {
table("c_o_r_e_teams");
hasMany(name = "memberTeams");
// Far-side leg of Member's explicit `through` override ("squad,rosterEntries").
hasMany(name = "rosterEntries", modelName = "MemberTeam");
// This-model through chain: ListFirst("memberTeams,member") IS an association
// on Team, so $expandThroughAssociations rewrites the include into the nested
// form "memberTeams(member)" (the preserved PR #449 behavior — the IF-side of
// the issue #3109 gate).
hasMany(name = "squadMembers", modelName = "Member", through = "memberTeams,member");
}

}
41 changes: 40 additions & 1 deletion vendor/wheels/tests/populate.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
</cfloop>

<!--- list of tables to delete --->
<cfset local.tables = "c_o_r_e_polycomments,c_o_r_e_polyarticles,c_o_r_e_polyphotos,c_o_r_e_authors,c_o_r_e_cities,c_o_r_e_classifications,c_o_r_e_comments,c_o_r_e_galleries,c_o_r_e_photos,c_o_r_e_posts,c_o_r_e_profiles,c_o_r_e_shops,c_o_r_e_trucks,c_o_r_e_tags,c_o_r_e_users,c_o_r_e_collisiontests,c_o_r_e_combikeys,c_o_r_e_tblusers,c_o_r_e_sqltypes,c_o_r_e_CATEGORIES,c_o_r_e_bulkitems,c_o_r_e_casepreservation,c_o_r_e_uuidrecords">
<cfset local.tables = "c_o_r_e_memberteams,c_o_r_e_members,c_o_r_e_teams,c_o_r_e_polycomments,c_o_r_e_polyarticles,c_o_r_e_polyphotos,c_o_r_e_authors,c_o_r_e_cities,c_o_r_e_classifications,c_o_r_e_comments,c_o_r_e_galleries,c_o_r_e_photos,c_o_r_e_posts,c_o_r_e_profiles,c_o_r_e_shops,c_o_r_e_trucks,c_o_r_e_tags,c_o_r_e_users,c_o_r_e_collisiontests,c_o_r_e_combikeys,c_o_r_e_tblusers,c_o_r_e_sqltypes,c_o_r_e_CATEGORIES,c_o_r_e_bulkitems,c_o_r_e_casepreservation,c_o_r_e_uuidrecords">
<!---
On Oracle, append CASCADE CONSTRAINTS so the drop removes incoming FK
references along with the table. PURGE skips the recycle bin so the
Expand Down Expand Up @@ -420,6 +420,35 @@ CREATE TABLE c_o_r_e_polycomments
) #local.storageEngine#
</cfquery>

<!--- many-to-many shortcut association fixtures (issue #3109) --->
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE TABLE c_o_r_e_members
(
id #local.identityColumnType#
,name varchar(100) NOT NULL
,PRIMARY KEY(id)
) #local.storageEngine#
</cfquery>

<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE TABLE c_o_r_e_teams
(
id #local.identityColumnType#
,name varchar(100) NOT NULL
,PRIMARY KEY(id)
) #local.storageEngine#
</cfquery>

<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE TABLE c_o_r_e_memberteams
(
id #local.identityColumnType#
,memberid #local.intColumnType# NOT NULL
,teamid #local.intColumnType# NOT NULL
,PRIMARY KEY(id)
) #local.storageEngine#
</cfquery>

<!--- create views --->
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
CREATE VIEW c_o_r_e_userphotos AS
Expand Down Expand Up @@ -682,3 +711,13 @@ INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES (
<cfquery name="local.query" datasource="#application.wheels.dataSourceName#">
INSERT INTO c_o_r_e_polycomments (body, commentableid, commentabletype) VALUES ('Comment on photo 2', #local.polyPhoto2.id#, 'PolyPhoto')
</cfquery>

<!--- many-to-many shortcut association data (issue #3109) --->
<cfset local.memberAlice = model("member").create(name = "Alice")>
<cfset local.memberBob = model("member").create(name = "Bob")>
<cfset local.teamRed = model("team").create(name = "Red")>
<cfset local.teamBlue = model("team").create(name = "Blue")>
<cfset local.teamGreen = model("team").create(name = "Green")>
<cfset model("memberTeam").create(memberid = local.memberAlice.id, teamid = local.teamRed.id)>
<cfset model("memberTeam").create(memberid = local.memberAlice.id, teamid = local.teamBlue.id)>
<cfset model("memberTeam").create(memberid = local.memberBob.id, teamid = local.teamGreen.id)>
95 changes: 95 additions & 0 deletions vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
component extends="wheels.WheelsTest" {

function run() {
g = application.wo;

describe("hasMany shortcut association (issue ##3109)", () => {

it("leaves a shortcut's own through-chain out of include expansion", () => {
// The `shortcut` default stores an opposite-side chain in `through`
// ("team,memberTeams") that the shortcut dispatcher consumes — it is
// NOT a this-model through-include. $expandThroughAssociations must
// return the plain include unchanged because `team` is not an
// association on Member; rewriting it to "team(memberTeams)" was the
// root cause of the AssociationNotFound throw.
var expanded = g.model("member").$expandThroughAssociations("memberTeams");
expect(expanded).toBe("memberTeams");
});

it("resolves the plain hasMany method when a shortcut is declared", () => {
var alice = g.model("member").findOne(where = "name = 'Alice'");
expect(alice.memberTeams().recordCount).toBe(2);
});

it("eager-loads the plain hasMany via include when a shortcut is declared", () => {
var members = g.model("member").findAll(include = "memberTeams", order = "id");
// Alice has two join rows, Bob has one — the include join must not throw.
expect(members.recordCount).toBe(3);
});

it("returns the far-side records through the shortcut method", () => {
var alice = g.model("member").findOne(where = "name = 'Alice'");
var teams = alice.teams();
expect(teams.recordCount).toBe(2);
expect(ListSort(ValueList(teams.name), "textnocase")).toBe("Blue,Red");

var bob = g.model("member").findOne(where = "name = 'Bob'");
expect(bob.teams().recordCount).toBe(1);
});
});

describe("hasMany shortcut with an explicit through= override (issue ##3109)", () => {

it("leaves the explicit override's through-chain out of include expansion", () => {
// `rosterSpots` declares `through="squad,rosterEntries"` explicitly (the
// @through override documented in associations.cfc). "squad" is an
// association on the JOIN model, not on Member, so the gate must return
// the plain include unchanged — exactly like the conventional default.
var expanded = g.model("member").$expandThroughAssociations("rosterSpots");
expect(expanded).toBe("rosterSpots");
});

it("resolves the plain hasMany method when an explicit through override is declared", () => {
var alice = g.model("member").findOne(where = "name = 'Alice'");
expect(alice.rosterSpots().recordCount).toBe(2);
});

it("eager-loads the plain hasMany via include when an explicit through override is declared", () => {
var members = g.model("member").findAll(include = "rosterSpots", order = "id");
// Alice has two join rows, Bob has one — the include join must not throw.
expect(members.recordCount).toBe(3);
});

it("returns the far-side records through the overridden shortcut method", () => {
var alice = g.model("member").findOne(where = "name = 'Alice'");
var squads = alice.squads();
expect(squads.recordCount).toBe(2);
expect(ListSort(ValueList(squads.name), "textnocase")).toBe("Blue,Red");

var bob = g.model("member").findOne(where = "name = 'Bob'");
expect(bob.squads().recordCount).toBe(1);
expect(bob.squads().name).toBe("Green");
});
});

describe("$expandThroughAssociations this-model rewrite (preserved PR ##449 behavior)", () => {

it("rewrites a 2-element through whose first segment IS an association on the model", () => {
// Team declares `squadMembers` with `through="memberTeams,member"`.
// "memberTeams" IS an association on Team, so the IF-side of the
// issue #3109 gate must keep rewriting the include into the nested
// this-model form — the contract PR #449 introduced.
var expanded = g.model("team").$expandThroughAssociations("squadMembers");
expect(expanded).toBe("memberTeams(member)");
});

it("eager-loads via the rewritten nested include", () => {
// Red, Blue and Green each carry exactly one join row, so the
// rewritten "memberTeams(member)" include returns one row per team.
var teams = g.model("team").findAll(include = "squadMembers", order = "id");
expect(teams.recordCount).toBe(3);
});
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -229,22 +229,17 @@ Include multiple associations by comma-separating: `include="comments,author"`.

## Many-to-many (through a join model)

<Aside type="caution">
**`shortcut` is currently broken in 4.0.x.** Declaring `hasMany(name="...", shortcut="...")` poisons the association's expansion: the shortcut method, the plain join-rows method, *and* `findAll(include=...)` for that association all throw `Wheels.AssociationNotFound` at runtime. Track the fix in [#3109](https://github.com/wheels-dev/wheels/issues/3109). Until it lands, declare the plain `hasMany` join-model associations (no `shortcut`) and traverse the join model explicitly — `user.userRoles(include="role")` — or query the far side with an explicit join.
</Aside>

Wheels handles many-to-many through a real join model plus the `shortcut` argument on `hasMany`. If a User has many Roles via a UserRole join table, model the relationship as three classes:

```cfm {test:compile}
component extends="Model" {
function config() {
hasMany(name="userRoles");
hasMany(name="userRoles", shortcut="roles");
}
}
```

The first `hasMany` gives you direct access to the join rows — `user.userRoles()`. The second, with `shortcut="roles"`, generates `user.roles()` that returns the Roles on the far side of the join. Behind the scenes Wheels walks both association chains to build the query.
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.

The join model needs both `belongsTo` declarations:

Expand All @@ -262,7 +257,6 @@ And the Role side mirrors the User side:
```cfm {test:compile}
component extends="Model" {
function config() {
hasMany(name="userRoles");
hasMany(name="userRoles", shortcut="users");
}
}
Expand Down
Loading