From 0d50a929d6bf6ad27b8f614519b2f4afe4c1a9c8 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 23 Jun 2026 19:38:43 -0700 Subject: [PATCH] fix(model): emit flat joins for belongsTo-chain nested includes (#3245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The $fromClause join builder decided whether to wrap inner joins in a parenthesized group bound to the LEFT OUTER JOIN purely from a regex on the include string (any `Intermediate(Target)` shape). That issue #449 HABTM/through grouping over-fired on plain belongsTo-chain nested includes such as `include="SecondaryContact(User)"`, nesting an inner join whose ON clause references the root FROM table — which scopes the root out and makes MySQL reject it with "Unknown column ... in 'on clause'". This was a regression from Wheels 2's flat joins. Consult the association metadata instead: only set needsNesting when the parenthesized intermediate association is a genuine hasMany/hasOne bridge (the OUTER-joined case the grouping was designed for). A belongsTo intermediate now falls through to the flat-join branch, restoring Wheels 2 behavior, while real HABTM/through includes still nest unchanged. Verified on SQLite and MySQL 9.7: crudSpec 163/0/0 (incl. new #3245 + #449 regression specs) and hasManyShortcutSpec 13/0/0; full model suite 923/0/0 on SQLite. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Peter Amiri --- .../3245-nested-include-flat-joins.fixed.md | 1 + vendor/wheels/model/sql.cfc | 29 +++++++++++++-- vendor/wheels/tests/specs/model/crudSpec.cfc | 35 +++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 changelog.d/3245-nested-include-flat-joins.fixed.md diff --git a/changelog.d/3245-nested-include-flat-joins.fixed.md b/changelog.d/3245-nested-include-flat-joins.fixed.md new file mode 100644 index 000000000..348842844 --- /dev/null +++ b/changelog.d/3245-nested-include-flat-joins.fixed.md @@ -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 '.' 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) diff --git a/vendor/wheels/model/sql.cfc b/vendor/wheels/model/sql.cfc index 773068beb..48f4f5c50 100644 --- a/vendor/wheels/model/sql.cfc +++ b/vendor/wheels/model/sql.cfc @@ -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; + } } } diff --git a/vendor/wheels/tests/specs/model/crudSpec.cfc b/vendor/wheels/tests/specs/model/crudSpec.cfc index a151074d8..da1e217ee 100644 --- a/vendor/wheels/tests/specs/model/crudSpec.cfc +++ b/vendor/wheels/tests/specs/model/crudSpec.cfc @@ -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", () => {