Skip to content
Open
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/3245-nested-include-flat-joins.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Nested `include` strings whose parenthesized intermediate is a `belongsTo` (e.g. `findAll(include="SecondaryContact(User)")`) again generate flat sibling joins, keeping the root `FROM` table in scope for every `ON` condition. The issue #449 HABTM/`through` parenthesized-grouping heuristic was over-firing on plain `belongsTo`-chain includes, producing a nested join expression that MySQL rejected with `Unknown column '<table>.<column>' in 'on clause'` — a regression from Wheels 2. The grouping now consults the association metadata and only nests for a genuine `hasMany`/`hasOne` bridge, so HABTM/`through` includes still nest as before (#3245)
29 changes: 26 additions & 3 deletions vendor/wheels/model/sql.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,36 @@ component {
local.hasThroughAssociation = false;
local.iEnd = ArrayLen(local.associations);

// Check if this is specifically a through association pattern
// Check if this is specifically a HABTM / through bridge pattern. The
// parenthesized-INNER-join grouping below was added for issue #449 so a
// many-to-many bridge (e.g. `memberTeams(member)`) keeps its nested inner
// join scoped to the OUTER-joined bridge table. It must NOT fire for a plain
// `belongsTo`-chain nested include (e.g. `SecondaryContact(User)`): there the
// inner join's ON clause references the root FROM table, and wrapping it
// inside the OUTER group scopes the root out — the MySQL "Unknown column ...
// in 'on clause'" regression reported in issue #3245. So consult the actual
// association metadata for the parenthesized intermediate instead of trusting
// the include string alone: only a `hasMany` / `hasOne` intermediate (the
// OUTER-joined bridge the grouping was designed for) qualifies; a `belongsTo`
// intermediate falls through to the flat-join branch Wheels 2 emitted.
local.originalInclude = Replace(arguments.include, " ", "", "all");
if (Find("(", local.originalInclude)) {
// Parse the include to see if it matches through pattern: intermediate(target)
// Parse the include to see if it matches the pattern: intermediate(target)
local.includePattern = ReFindNoCase("^([^(]+)\(([^)]+)\)$", local.originalInclude, 1, true);
if (ArrayLen(local.includePattern.pos) >= 3) {
local.hasThroughAssociation = true;
// The association that parents the parenthesized target is the last
// entry in the comma-list before the "(" (the only level this single-
// paren pattern can match), so it is always a root-model association.
local.intermediateName = ListLast(Mid(local.originalInclude, local.includePattern.pos[2], local.includePattern.len[2]));
if (
StructKeyExists(variables.wheels.class.associations, local.intermediateName)
&& ListFindNoCase(
"hasMany,hasOne",
variables.wheels.class.associations[local.intermediateName].type
)
) {
local.hasThroughAssociation = true;
}
}
}

Expand Down
35 changes: 35 additions & 0 deletions vendor/wheels/tests/specs/model/crudSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,41 @@ component extends="wheels.WheelsTest" {

expect(actual).toBe("FROM #qi('c_o_r_e_authors')# USE INDEX(idx_authors_123) LEFT OUTER JOIN #qi('c_o_r_e_posts')# USE INDEX(idx_posts_123) ON #qi('c_o_r_e_authors')#.#qi('id')# = #qi('c_o_r_e_posts')#.#qi('authorid')# AND #qi('c_o_r_e_posts')#.#qi('deletedat')# IS NULL")
})

// Regression for issue #3245: a belongsTo-chain nested include
// (intermediate is `belongsTo`) mixed with an OUTER-joined sibling must
// emit FLAT sibling joins so the root FROM table stays in scope for every
// ON condition. Wheels 3 over-fired the issue #449 parenthesized grouping
// here, scoping the root out and triggering MySQL "Unknown column ... in
// 'on clause'". `author.user` is a belongsTo (inner) and `author.posts` /
// `user.galleries` are hasMany (outer) — exactly the reported shape.
it("emits flat joins for a belongsTo-chain nested include (issue ##3245)", () => {
actual = g.model("author").$fromClause(include = "posts,user(galleries)")

// the parenthesized intermediate (`user`) is a belongsTo, so NO grouping:
// every join sits at the top level and the root `authors` stays in scope.
expect(actual).notToInclude("LEFT OUTER JOIN (")
expect(actual).toBe(
"FROM #qi('c_o_r_e_authors')#"
& " LEFT OUTER JOIN #qi('c_o_r_e_posts')# ON #qi('c_o_r_e_authors')#.#qi('id')# = #qi('c_o_r_e_posts')#.#qi('authorid')# AND #qi('c_o_r_e_posts')#.#qi('deletedat')# IS NULL"
& " INNER JOIN #qi('c_o_r_e_users')# ON #qi('c_o_r_e_authors')#.#qi('firstname')# = #qi('c_o_r_e_users')#.#qi('firstname')#"
& " LEFT OUTER JOIN #qi('c_o_r_e_galleries')# ON #qi('c_o_r_e_users')#.#qi('id')# = #qi('c_o_r_e_galleries')#.#qi('userid')#"
)
})

// Regression for issue #449 (must NOT be undone by the #3245 fix): a genuine
// HABTM / `through` bridge nested include keeps the parenthesized grouping so
// the bridge's INNER join stays scoped to the OUTER-joined bridge table.
// Team.memberTeams is a hasMany (outer bridge); its nested `member` inner
// join references the bridge table, so the grouping is correct here.
it("preserves nested grouping for a HABTM/through bridge include (issue ##449)", () => {
actual = g.model("team").$fromClause(include = "memberTeams(member)")

expect(actual).toBe(
"FROM #qi('c_o_r_e_teams')#"
& " LEFT OUTER JOIN (#qi('c_o_r_e_memberteams')# INNER JOIN #qi('c_o_r_e_members')# ON #qi('c_o_r_e_memberteams')#.#qi('memberid')# = #qi('c_o_r_e_members')#.#qi('id')#) ON #qi('c_o_r_e_teams')#.#qi('id')# = #qi('c_o_r_e_memberteams')#.#qi('teamid')#"
)
})
})

describe("Tests that group", () => {
Expand Down
Loading