fix(model): read a join's type from association metadata, not from the SQL text - #3360
Conversation
…e SQL text Follow-up to #3334, raised by wheels-bot on PR #3354 and correct. The grouping decision that PR introduced still asked "is this an INNER join?" by searching the generated SQL: FindNoCase("INNER", local.joins[local.parentPosition]) That misclassifies every table whose name contains the substring — `winners`, `spinners`, `beginners`. A parent LEFT OUTER JOIN on `c_o_r_e_winners` reads as INNER, so its nested child is emitted flat instead of grouped, which silently drops the parent rows #3334 exists to preserve. Not a regression: the pre-fix code scanned the same way, and no fixture table hits it. Worth fixing anyway, because it is the exact anti-pattern that PR argues against — its whole thesis is that structure should come from the association tree rather than from re-parsing the SQL string, and this was ten lines of new code doing the opposite. `$associationJoinsInner()` reads the association's declared `joinType`, which is the value `$expandedAssociations` turns into the leading `INNER JOIN` / `LEFT OUTER JOIN` text in the first place — so it is the authoritative source by construction. It falls back to the text scan only when an entry carries no `joinType`, keeping it total for a hand-built struct. Red-first: with the helper reverted to the bare text scan, the new spec fails `Expected [true] to be false`. A note on that red-check, because it nearly fooled me: run against a WARM server the reverted build still reported green — `tools/test-local.sh` reuses a running server and the reload did not recompile the changed CFC. The failure only appears after killing the server first. Any red-check on this suite needs a cold start to mean anything. lucee7 + sqlite, full core suite: develop 2c98644 4755 pass / 0 fail / 0 error this branch 4756 pass / 0 fail / 0 error Exactly +1, the new spec. Refs #3334 Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — This is a follow-up to #3334 that replaces a substring scan of the generated SQL (FindNoCase("INNER", ...)) with a read of the association's declared joinType when deciding which joins get pulled into a parenthesized group. The old scan misclassifies any table whose name contains inner (winners, spinners, beginners), which would silently drop the very parent rows #3334 exists to preserve. The fix is correct, authoritative by construction, and covered by a focused unit spec. Verdict: comment — the change is sound; only a cosmetic doc-comment duplication is worth cleaning up.
Correctness
The fix is correct against the real code path. $expandedAssociations returns entries built with local.entry = StructCopy(local.classAssociations[local.name]) (vendor/wheels/model/sql.cfc:1574), so every entry $fromClause iterates carries joinType. That is the same field the leading join text is derived from at vendor/wheels/model/sql.cfc:1467:
local.joinType = UCase(ReplaceNoCase(local.classAssociations[local.name].joinType, "outer", "left outer", "one"));
so $associationJoinsInner reads the authoritative source rather than re-inferring it from the text — exactly the anti-pattern #3334 argues against. The && local.parentPosition > 0 guard still short-circuits ahead of the parent-index access at vendor/wheels/model/sql.cfc:155, so there is no index-0 deref. The empty-joinType fallback (vendor/wheels/model/sql.cfc:1308) keeps the helper total for hand-built structs, and CFML's case-insensitive == makes joinType == "inner" robust to "INNER"/"Inner". No correctness issue.
Conventions
Minor, non-blocking: the insertion left a duplicate/orphaned doc block. vendor/wheels/model/sql.cfc:1291-1293 now holds a bare /** Internal function. */ immediately above the new function's own doc block at :1294-1306, and $expandedAssociations (:1314) — which that orphaned block originally documented — is now left with no docstring. Consider deleting the stray :1291-1293 block so the new comment stands alone and $expandedAssociations keeps its own.
Tests
vendor/wheels/tests/specs/model/crudSpec.cfc:1345 unit-tests the decision function in both directions (an outer join whose table name contains inner renders false; a genuine inner renders true) plus the no-joinType fallback both ways. The ##3334 in the it() description is correctly hash-escaped, so it will not trip the suite-wide crash noted in CLAUDE.md. Pinning the boolean directly rather than adding a c_o_r_e_winners fixture across seven databases is a reasonable trade-off and is documented in the spec comment.
Cross-engine
Clean. The helper is public with a $ prefix (satisfies the mixin-integration invariant for vendor/wheels/model/*.cfc), and uses only StructKeyExists / Len / == / FindNoCase — no closures, struct member-function calls, application-scope function members, or reserved-scope parameter names.
Commits
fix(model): read a join's type from association metadata, not from the SQL text — valid conventional-commit header (type fix, scope model, ~78 chars, not ALL-CAPS), body explains the "why," and carries a Signed-off-by trailer. No changelog fragment is needed: #3334's own changelog.d/3334-include-join-scoping.fixed.md is still unreleased, so this correction lands before any user sees the bug.
Second wheels-bot review follow-up from the same batch, on PR #3358. Every sibling in vendor/wheels/tests/specs/jobs/ declares spec variables with `local.` (e.g. `local.bootstrapJob = new wheels.Job();` in JobRobustnessSpec). JobClassRoundTripSpec assigned them unscoped, which runs green but leaks the writes into the spec's `variables` scope. Purely a convention fix — no behaviour change. Folded in here rather than opened as a third PR, since it is the same review round on the same merged batch. lucee7 + sqlite, full core suite: 4756 pass / 0 fail / 0 error, unchanged. Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Follow-up to #3334 that replaces a substring scan of the generated SQL (FindNoCase("INNER", ...)) with a read of the association declared joinType when deciding which joins get pulled into a parenthesized group. The text scan misclassifies any table whose name contains "inner" (winners, spinners, beginners), silently dropping the parent rows #3334 exists to preserve. Since the prior COMMENTED review, the branch added a second commit that scopes JobClassRoundTripSpec locals with local., a clean test-hygiene change that is itself cross-engine-correct. The fix is authoritative by construction and covered by a focused unit spec. Verdict: comment — sound change; only a cosmetic orphaned doc block is worth cleaning up.
Correctness
The fix is correct against the real code path. $expandedAssociations builds each returned entry as local.entry = StructCopy(local.classAssociations[local.name]) (vendor/wheels/model/sql.cfc:1575), so every entry $fromClause iterates carries joinType. That is the same field the leading join text is derived from at vendor/wheels/model/sql.cfc:1467, so $associationJoinsInner reads the authoritative source rather than re-inferring from the emitted text — precisely the anti-pattern #3334 argues against. joinType is documented as only ever inner or outer (vendor/wheels/model/associations.cfc:13), so the exhaustive joinType == "inner" check at sql.cfc:1309 is total, and case-insensitive == makes it robust to casing. The parentPosition GT 0 guard still short-circuits ahead of the parent-index access at sql.cfc:155, so there is no index-0 deref, and the empty-joinType fallback (sql.cfc:1308) keeps the helper total for hand-built structs. No correctness issue.
Conventions
Minor, non-blocking, carried over from the prior review and still present: the insertion left a duplicate/orphaned doc block. vendor/wheels/model/sql.cfc:1291-1293 holds a bare Internal-function docstring immediately above the new function own doc block at :1294-1306, and $expandedAssociations (:1314), which that stray block originally documented, is now left with no docstring. Delete the orphaned :1291-1293 block so the new comment stands alone, and keep an Internal-function docstring above $expandedAssociations.
Tests
vendor/wheels/tests/specs/model/crudSpec.cfc:1346 unit-tests the decision function in both directions (an outer join whose table name contains "inner" renders false; a genuine inner renders true) plus the no-joinType fallback both ways. The escaped issue reference in the it() description is correctly hash-escaped, avoiding the suite-wide crash CLAUDE.md warns about. Unscoped locals (m, outerSql) match the file existing convention (sibling it() blocks at :1311, :1329 use the same bare-assignment style), so no inconsistency there. Pinning the boolean directly rather than adding a c_o_r_e_winners fixture across seven databases is a reasonable, documented trade-off.
Cross-engine
Clean. $associationJoinsInner is public with a $ prefix (satisfies the mixin-integration invariant for vendor/wheels/model/*.cfc) and uses only StructKeyExists / Len / == / FindNoCase. The JobClassRoundTripSpec scoping commit is also compliant: every catch-body write mutates a struct field on a struct declared before the try (local.thrown.type = e.type, local.resolved.name in the miscased-path test), which is exactly the persists-on-BoxLang pattern from cross-engine invariant 11 — it never declares a fresh simple local.X inside a catch. Hoisted bridge = new wheels.Job() receivers are preserved, so invariant 16a stays satisfied.
Commits
Both headers are valid conventional commits: "fix(model): read a join type from association metadata, not from the SQL text" (type fix, scope model, under 100 chars) and "test(job): scope JobClassRoundTripSpec locals with local." (type test, scope job). Bodies explain the why; DCO sign-off present. No changelog fragment is needed — #3334 own changelog.d/3334-include-join-scoping.fixed.md is still unreleased, so this correction lands before any user sees the bug.
Wheels Test Results 32 files 9 856 suites 21m 31s ⏱️ For more details on these failures and errors, see this check. Results for commit 7ecbf0c. ♻️ This comment has been updated with latest results. |
… BoxLang
The compat matrix for this branch came back +24 tests on all 28 legs, which is correct, and
two NEW failures on boxlang for every database:
Failed | throws Wheels.JobClassNotFound naming the row and the class
| Expected [Wheels.JobClassNotFound] but received []
Failed | throws Wheels.InvalidJobClass when the path resolves to something that is not a job
| Expected [Wheels.InvalidJobClass] but received []
Both are mine, and both were caused by the previous commit — the `local.`-scoping convention
nit from the bot review. `thrown` is written from inside a catch block, and on BoxLang the
catch body runs under a nested `local` that is discarded on exit. Prefixing the struct made
`local.thrown.type = e.type` land on that discarded copy instead of mutating the outer struct,
so the assertion read an empty type.
Cross-engine invariant 11 already covers the scalar case. What it did not say is that the
struct workaround it recommends only works when the struct is accessed WITHOUT the prefix —
`local.state.flag = true` fails exactly like `local.X = ...`. The prefix is what breaks it,
not the assignment shape. Widened the invariant with that, plus a worked example, because
`local.`-scoping spec variables IS the house style everywhere else, which makes tidying a
catch-using spec to match an easy and completely invisible way to break it.
So the original unscoped form was correct and the "nit" was wrong. Reverted for `thrown` only
— every other variable in the file stays `local.`-scoped, since those are written from try
bodies and are unaffected — with a comment at both sites explaining why, so it does not get
tidied back.
Worth noting the failure mode: green on Lucee, green on Adobe, wrong only on BoxLang, and
silent rather than an error. Nothing local would have caught it.
lucee7 + sqlite, full core suite: 4756 pass / 0 fail / 0 error, unchanged.
Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR — Follow-up to #3334/#3354 that stops answering "is this an INNER join?" by substring-scanning the generated SQL (FindNoCase("INNER", …)) and instead reads the association's declared joinType. The core fix is correct, well-documented, cross-engine-safe, and backed by a red-first unit test; the two accompanying test-scoping commits correctly apply cross-engine invariants 11 and 16a. Verdict: comment — no blocking findings, only two minor hygiene/docs nits. This does not change the two prior COMMENTED reviews' non-blocking posture.
Correctness
No issues. vendor/wheels/model/sql.cfc:1307 $associationJoinsInner() reads association.joinType when present and only falls back to the text scan for hand-built structs with no joinType:
if (StructKeyExists(arguments.association, "joinType") && Len(arguments.association.joinType)) {
return arguments.association.joinType == "inner";
}
return FindNoCase("INNER", arguments.join) > 0;Verified the value it compares is authoritative: $expandedAssociations copies joinType onto each entry (StructCopy(local.classAssociations[local.name])) and builds the leading join text from the same field at sql.cfc:1467 (UCase(ReplaceNoCase(joinType, "outer", "left outer", "one"))). The raw values are "inner"/"outer", and CFML == is case-insensitive, so the check is robust even if a value arrives cased. The winners/spinners/beginners misclassification the old FindNoCase path could produce is genuinely eliminated.
Cross-engine
No issues — both correctly handled:
JobClassRoundTripSpec.cfc:91,120keep the catch-observed struct asvar thrownwith bare access (thrown.type = e.type), exactly the invariant-11 pattern the PR also expands inCLAUDE.md. Thelocal.-scoped conversion in the siblingdescribeblock (threeits with no catch dependency) is safe.JobClassRoundTripSpec.cfc:95hoists the receiver (local.bridge = new wheels.Job()) rather than(new wheels.Job()).$instantiateJobClass(…), avoiding the AdobeMissingNameException(invariant 16a).$associationJoinsInnerispublicwith a$prefix, so it is integrated onto model instances (invariant 7) — the new spec calls it asm.$associationJoinsInner(…), which only works because of that.
Tests
Good. crudSpec.cfc:1346 adds a focused unit test that pins the exact trap (an outer join whose table name contains inner), the genuine-inner case, and both no-joinType fallback branches. PR body documents a cold-server red-first check. Unit-testing the boolean instead of standing up a c_o_r_e_winners fixture across seven DBs is a reasonable call.
Docs
Two minor, non-blocking nits:
- Unrelated stub churn in the
fix(test)commit.7ecbf0cdeadds 7 TestBox/MockBox generated stub files underpublic/testbox/system/stubs/(471 insertions — e.g.tmp_closeSSEStream_139184C0543CCDB338AEFB643110CB89). These are regenerated test artifacts unrelated to catch-block scoping and bloat the diff. Consider dropping them from the PR (and, separately, whetherpublic/testbox/system/stubs/**belongs in.gitignore). - No changelog fragment. This is a user-facing
fix(model)(silent parent-row loss for tables whose name containsinnerunder a nested include), yetchangelog.d/has no new entry. A one-linechangelog.d/3360-jointype-metadata.fixed.mdwould match the checklist. Skippable if you judge the substring edge case too narrow to surface to users — noting it so the omission is a decision, not an oversight.
Commits
All three conform to commitlint.config.js: fix(model), test(job), fix(test) — valid types, valid scopes, subjects under 100 chars, DCO sign-off present. Messages explain the "why" well (the fix(test) body in particular documents the BoxLang-only silent failure mode).
…ed them (#3364) Third occurrence of the same slip, with a wrinkle worth recording. PR #3363 untracked seven generated TestBox stubs and added the .gitignore entries that stop them coming back. PR #3360 then merged on top and re-added all seven — its branch was cut from 2c98644, before the ignore existed, and it was committed with `git add -A` while the paths were still untrackable-but-not-ignored. Merging the cleanup first and the older branch second undid the cleanup. The .gitignore is on develop now, so a branch cut from this point cannot repeat it. This commit just finishes what #3363 started: seven removals, no other change. Verified after committing that exactly one stub remains tracked — F952D54F…, which predates all of this and is deliberately left alone. The lesson is about ORDER, not just about `git add -A`: after merging a cleanup PR, every already-open branch that predates it still carries the thing that was cleaned, and merging it silently reverts the fix. Check the remaining branches for it before merging them, not after. Signed-off-by: Peter Amiri <peter@alurium.com>
Follow-up to #3334 (PR #3354, merged as
2c9864434). Raised by wheels-bot on that PR, and it was right.The problem
The grouping decision #3334 introduced still answered "is this an INNER join?" by searching the generated SQL:
FindNoCase("INNER", local.joins[local.parentPosition])That misclassifies every table whose name contains the substring —
winners,spinners,beginners. A parentLEFT OUTER JOINonc_o_r_e_winnersreads as INNER, so its nested child is emitted flat instead of grouped, which silently drops exactly the parent rows #3334 exists to preserve.Why fix it if it isn't a regression
It isn't one — the pre-#3334 code scanned the same way, and no fixture table hits it. The bot classified it non-blocking on that basis and that's fair.
But it is the precise anti-pattern #3334 argues against. That PR's whole thesis is structure should come from the association tree, not from re-parsing the SQL string — and this was ten lines of new code doing the opposite.
winnersis an ordinary table name, and the failure mode is silent wrong results rather than an error.The fix
$associationJoinsInner()reads the association's declaredjoinType— the value$expandedAssociationsturns into the leadingINNER JOIN/LEFT OUTER JOINtext in the first place, so it is authoritative by construction rather than by inference. It falls back to the text scan only when an entry carries nojoinType, which keeps it total for a caller assembling association structs by hand.Red-first, and a warning about how I nearly got it wrong
With the helper reverted to the bare text scan, the new spec fails:
That red only appears on a cold server. Run against a warm one, the reverted build reported a clean green —
tools/test-local.shreuses a running server and the?reload=truedid not recompile the changed CFC. I recorded a passing red-check before noticing. Any red-check on this suite needs the server killed first, or it proves nothing.The spec is a unit test on the decision function rather than a fixture, deliberately: pinning one boolean isn't worth a
c_o_r_e_winnerstable and its DDL on all seven databases.Verification
2c9864434Exactly +1 — the new spec.
Compat matrix not dispatched yet: this narrows a condition that already gated correctly on every fixture the matrix exercises, so I expect it to be a no-op there. Say the word if you'd like it run before merge and I'll block on it.
🤖 Generated with Claude Code