Skip to content

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

Draft
bpamiri wants to merge 1 commit into
developfrom
fix/bot-3245-nested-include-flat-joins
Draft

fix(model): emit flat joins for belongsTo-chain nested includes (#3245)#3249
bpamiri wants to merge 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>
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