Skip to content

fix(model): emit flat joins for belongsTo-chain nested includes (#3245) - #3249

Merged
bpamiri merged 1 commit into
developfrom
fix/bot-3245-nested-include-flat-joins
Jun 24, 2026
Merged

fix(model): emit flat joins for belongsTo-chain nested includes (#3245)#3249
bpamiri merged 1 commit into
developfrom
fix/bot-3245-nested-include-flat-joins

Conversation

@bpamiri

@bpamiri bpamiri commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Closes #3245

Problem

findAll(include="ParentModel(ChildModel)") regressed between Wheels 2 and 3: when the parenthesized intermediate is a plain belongsTo and the include mixes inner + outer joins, $fromClause wrapped the inner joins in a parenthesized group bound to the LEFT OUTER JOIN. That nested join expression scopes the root FROM table out of the inner join's ON clause, so MySQL rejects it with:

Unknown column 'relationships.relatedcontactid' in 'on clause'

The reporter's case: Relationship belongsTo SecondaryContact belongsTo User, called as findAll(include="RelationshipType,SecondaryContact(User)").

Root cause

vendor/wheels/model/sql.cfc $fromClause derived hasThroughAssociation purely from a regex on the include string (^([^(]+)\(([^)]+)\)$). Any Intermediate(Target) shape matched — including a belongsTo chain — so the issue #449 HABTM/through parenthesized-grouping heuristic over-fired. #449 introduced that grouping (commit 6579f246c, July 2025), which is why this is absent in Wheels 2.

Fix

Consult the association metadata instead of the string. When the single-paren pattern matches, look up the parenthesized intermediate association (ListLast of the text before () in variables.wheels.class.associations and only set needsNesting when its type is hasMany/hasOne — the OUTER-joined bridge the grouping was actually designed for. A belongsTo intermediate falls through to the existing flat-join branch (the Wheels 2 behavior).

Before (bug) vs after, for model("author").$fromClause(include="posts,user(galleries)"):

-- before: inner `users` join wrapped inside the OUTER group → root `authors` scoped out
FROM authors LEFT OUTER JOIN (posts INNER JOIN users ON authors.firstname = users.firstname) ON ... LEFT OUTER JOIN (galleries INNER JOIN users ON authors.firstname = ...) ON ...

-- after: flat sibling joins → every table in scope for every ON
FROM authors LEFT OUTER JOIN posts ON ... INNER JOIN users ON authors.firstname = users.firstname LEFT OUTER JOIN galleries ON users.id = galleries.userid

The genuine HABTM/through case (teammemberTeams(member), a hasMany bridge) still nests:

FROM teams LEFT OUTER JOIN (memberteams INNER JOIN members ON memberteams.memberid = members.id) ON teams.id = memberteams.teamid

Tests

Two regression specs added to the directly-callable $fromClause harness in vendor/wheels/tests/specs/model/crudSpec.cfc:

Test status

Verified locally on SQLite (Lucee 7) and MySQL 9.7 (Lucee 7) — the engine in the report:

Cross-engine notes

Pure metadata read using StructKeyExists / ListFindNoCase / ListLast / Mid — no closures, reserved scopes, attributeCollection=arguments, obj.map(), or bare cfabort. The .type field is set on every association (belongsTo/hasMany/hasOne). Behavior is adapter-independent (the change only decides flat vs nested join structure); MySQL was the engine in the report and is verified green.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Signed-off-by: Peter Amiri <petera@pai.com>
@bpamiri
bpamiri marked this pull request as ready for review June 24, 2026 12:01

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wheels Bot — Reviewer

TL;DR — This PR fixes the #3245 regression where findAll(include="Intermediate(Target)") with a belongsTo intermediate emitted a nested parenthesized join that scoped the root FROM table out of the inner join's ON clause (MySQL Unknown column ... in 'on clause'). The fix replaces the string-only Intermediate(Target) heuristic with an association-metadata lookup, nesting only when the parenthesized intermediate is a genuine hasMany/hasOne bridge (the #449 case). The change is tightly scoped, correct, and well-tested in both directions. Verdict: approve.

Correctness

The root cause and fix are accurate. vendor/wheels/model/sql.cfc:122-131 now derives hasThroughAssociation from the association metadata instead of the include string. Verified against the codebase:

  • .type is set to belongsTo/hasMany/hasOne in associations.cfc:25/76/129 and stored verbatim at associations.cfc:213, so the variables.wheels.class.associations[name].type lookup is valid.
  • pos[2]/len[2] is capture group 1 (text before the open paren); ListLast of posts,user correctly yields user. For memberTeams(member) it yields memberTeams.
  • The StructKeyExists guard means an unrecognized intermediate falls through to the flat-join branch — the safe Wheels 2 behavior.
  • The anchored regex single-paren/leading-position limitation is pre-existing and unchanged by this PR.

Cross-engine

Clean. Only StructKeyExists, ListFindNoCase, ListLast, Mid, Find, ReFindNoCase — no closures, reserved scopes, obj.map(), attributeCollection=arguments, or bare cfabort. The for loops are not inside a finally (invariant #12 N/A), and the Mid length comes from a one-or-more capture so it is always >= 1 (no Left(str,0)/zero-length hazard).

Tests

Both regression directions are covered in crudSpec.cfc (extends wheels.WheelsTest):

  • crudSpec.cfc:1271 asserts flat joins for a belongsTo-chain include (posts,user(galleries)), including notToInclude("LEFT OUTER JOIN (") — matches the Author.cfc:8 belongsTo(name="user", foreignKey="firstName") setup and the reported shape.
  • crudSpec.cfc:1296 asserts the #449 HABTM/through grouping is preserved for memberTeams(member) (a hasMany bridge), guarding against undoing #449.

The issue references in the it() descriptions are correctly hash-escaped in the source.

Docs

Changelog fragment present and well-formed: changelog.d/3245-nested-include-flat-joins.fixed.md (<slug>.fixed.md). No direct CHANGELOG.md edit. The in-code comment block (sql.cfc:102-113) clearly documents why the metadata check exists.

Commits

fix(model): emit flat joins for belongsTo-chain nested includes (#3245) — valid type/scope, subject within 100 chars, not ALL-CAPS, conveys the "why."

@bpamiri
bpamiri merged commit 4ac94b7 into develop Jun 24, 2026
11 checks passed
@bpamiri
bpamiri deleted the fix/bot-3245-nested-include-flat-joins branch June 24, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MySQL nested include regression

1 participant