Skip to content

fix(model): resolve hasMany shortcut name in include expansion (#3208)#3209

Merged
bpamiri merged 3 commits into
developfrom
fix/bot-3208-hasmany-shortcut-association-not-recognised-by-inc
Jun 18, 2026
Merged

fix(model): resolve hasMany shortcut name in include expansion (#3208)#3209
bpamiri merged 3 commits into
developfrom
fix/bot-3208-hasmany-shortcut-association-not-recognised-by-inc

Conversation

@wheels-bot

@wheels-bot wheels-bot Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Summary

A hasMany shortcut name (e.g. hasMany(name="ProductCategories", shortcut="Category")) is registered as a dynamic accessor method on the model — not as a first-class, includable association. So model("Product").findAll(include="Category") reached $expandThroughAssociations, found no Category association to expand, fell through unchanged, and then threw Wheels.AssociationNotFound in $expandedAssociations.

This teaches include-expansion one extra step: when an include name is not a this-model association but does match a hasMany shortcut, it is rewritten into the nested this-model bridge include <assocName>(<ListFirst(through)>) — e.g. CategoryProductCategories(Category) — so the join through the bridge model happens as the user expects.

The narrowing introduced in #3109 is preserved: a real association name (even one carrying a shortcut's own through chain) is resolved by the existing direct-association branches and never enters the new shortcut branch, which only fires when the name is absent from the association set.

Related Issue

Fixes #3208

Type of Change

  • Bug fix

Feature Completeness Checklist

  • DCO sign-off — commit carries Signed-off-by: (git commit -s)
  • Testsvendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc gains an issue hasMany shortcut association not recognised by include - "association not found" error #3208 block: the shortcut name expands to memberTeams(team) / rosterSpots(squad) and eager-loads the far side without throwing
  • Framework Docs — left to bot-update-docs.yml
  • AI Reference Docs — left to bot-update-docs.yml
  • CLAUDE.md — left to bot-update-docs.yml
  • Changelog fragmentchangelog.d/3208-hasmany-shortcut-include.fixed.md
  • Test runner passes — could not be run in the bot sandbox (the wheels CLI is not installed and tools/test-local.sh cannot start a server here). CI (bot-tdd-gate.yml + compat-matrix.yml) is the validating gate; a human must confirm the suite is green before merge.

Test Plan

The new spec reuses the existing many-to-many fixtures (Member hasMany(name="memberTeams", shortcut="teams"), MemberTeam belongsTo "team"):

  • model("member").$expandThroughAssociations("teams")memberTeams(team)
  • model("member").$expandThroughAssociations("squads")rosterSpots(squad) (explicit through override form)
  • model("member").findAll(include="teams", order="id") returns the joined rows (3) without throwing Wheels.AssociationNotFound

Run locally with bash tools/test-local.sh model.

A hasMany shortcut name (e.g. shortcut="Category") is registered as a
dynamic accessor method, not a first-class association. Passing it to
findAll(include=...) fell through include expansion unchanged and then
threw Wheels.AssociationNotFound.

$expandThroughAssociations now resolves an include name that is not a
this-model association but matches a hasMany shortcut into the nested
bridge include (<assocName>(<ListFirst(through)>)), so the join through
the bridge model happens as expected. The issue #3109 contract is
preserved: real association names never enter the shortcut branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
…many-to-many

Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
@wheels-bot

wheels-bot Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Wheels Bot — Docs updated

Added a doc commit to this PR:

  • web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx — clarified that both the bridge-model association name and the shortcut name are accepted in include; findAll(include="roles") now documents that it expands automatically to userRoles(role)

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

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 teaches $expandThroughAssociations one extra step: when an include name is not a this-model association but matches a many-to-many hasMany shortcut, it is rewritten into the nested bridge include <assocName>(<ListFirst(through)>) (e.g. teamsmemberTeams(team)), fixing the Wheels.AssociationNotFound throw from #3208. The change is narrow, well-guarded, cross-engine-safe, and preserves the #3109 contract. Verdict: comment — no blocking findings, one minor test-coverage gap noted below.

Correctness

The fix is sound and the guard placement is exactly right:

  • The new shortcut branch lives in the outer else (reached when currentInclude is not an association with a through), and is itself gated by !StructKeyExists(local.associations, local.currentInclude) (vendor/wheels/model/sql.cfc:1238). So a real association lacking a through skips the loop entirely and falls through to the unchanged "use as-is" path — no regression for ordinary includes.
  • The ListFirst(through) choice resolves to a real bridge association in both fixture cases: memberTeams's default through="team,memberTeams"memberTeams(team) and MemberTeam.belongsTo("team") exists; rosterSpots's explicit through="squad,rosterEntries"rosterSpots(squad) and MemberTeam.belongsTo(name="squad", modelName="Team") exists. Both nested includes are valid joins.
  • The ListLen(local.assoc.through) == 2 constraint correctly limits the rewrite to the two-leg many-to-many shape that shortcut produces (vendor/wheels/model/associations.cfc:70, through = "#singularize(arguments.shortcut)#,#arguments.name#").

Cross-engine

Clean. The new code uses only StructKeyExists, Len, ListLen, ListFirst, ListAppend, &&, and a struct for (key in struct) loop — all behave identically across Lucee 5/6/7, Adobe 2018–2025, and BoxLang. No closures, no obj.map() member-function collisions, no reserved-scope parameter names, no application-scope members.

Tests

The happy path is well covered, with one gap:

  • vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc:82 and :101 assert the string expansion for both the default (teams) and explicit-override (squads) forms — good.
  • vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc:90 exercises the end-to-end eager load (findAll(include="teams")) for the default shortcut, which is what would actually throw if the expanded memberTeams(team) were invalid.
  • Minor nit: there is no end-to-end findAll(include="squads") for the explicit-through-override path — only its string expansion is asserted (:101). Since the override resolves ListFirst to a different bridge leg (squad vs the default team), an it("eager-loads the far side through an explicit-override shortcut include") running findAll(include="squads", order="id") and asserting recordCount would close the loop and guard the override join the same way the default form is guarded at :90. Not blocking.

Docs

Changelog fragment changelog.d/3208-hasmany-shortcut-include.fixed.md is present, correctly typed (.fixed.md), and self-contained — the right pattern (no direct [Unreleased] edit). Framework/AI/CLAUDE.md docs are honestly deferred to bot-update-docs.yml in the checklist.

Commits

fix(model): resolve hasMany shortcut name in include expansion (#3208) is a valid conventional-commit header (type fix, scope model, ~68 chars, sentence-case) and carries a Signed-off-by: trailer. The body explains the why and notes the #3109 contract preservation.

One caveat the PR body already flags honestly: the suite could not run in the bot sandbox. The default-form eager-load test (:90) is the real correctness check here, so CI (bot-tdd-gate.yml + compat-matrix.yml) green should be confirmed before merge.

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

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 re-review runs against head 15f99496, which adds one docs-only commit (docs(web/guides): note shortcut name accepted in findAll include) on top of the previously-reviewed code fix (0a1a2fa5). The code in vendor/wheels/model/sql.cfc is unchanged from the prior pass; the new commit only expands the prose in associations.mdx to document the #3208 behavior. I re-verified both the fix and the new doc claim against the fixtures. Verdict: comment — no blocking findings; the one minor test-coverage nit from the prior pass still stands.

Correctness

The fix remains sound and the new docs claim is accurate. The guide now states (web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx:242):

model("User").findAll(include="roles") … (Wheels expands it to userRoles(role) automatically).

That math checks out against the implementation. For hasMany(name="userRoles", shortcut="roles"), the default through is "#singularize(shortcut)#,#name#" = "role,userRoles", so ListFirst(through) = role and local.assocName & "(" & ListFirst(...) & ")" (vendor/wheels/model/sql.cfc:1248) yields exactly userRoles(role). The companion claim that include="userRoles" "loads just the join rows" is the preserved #3109 contract, pinned by hasManyShortcutSpec.cfc:24.

The new shortcut branch stays correctly gated: !StructKeyExists(local.associations, local.currentInclude) (sql.cfc:1235) means a real association name (e.g. comments) never enters the loop and falls through to the unchanged use-as-is path — no #3109 regression.

Cross-engine

Clean — no change since the prior pass. The added code uses only StructKeyExists, Len, ListLen, ListFirst, ListAppend, and a struct for (key in struct) loop, all uniform across Lucee 5/6/7, Adobe 2018–2025, and BoxLang. No closures, no obj.map() collisions, no reserved-scope parameter names. The new commit is prose-only.

Tests

Happy path well covered; one gap carried over from the prior review:

  • vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc:82 asserts the default-form expansion (teamsmemberTeams(team)), :90 exercises the end-to-end findAll(include="teams") eager load, and :101 asserts the explicit-override string expansion (squadsrosterSpots(squad)).
  • Minor nit (non-blocking): there is still no end-to-end findAll(include="squads") for the explicit-through-override path — only its string expansion is asserted. Because the override resolves ListFirst(through) to a different bridge leg (squad vs the default team), an it("eager-loads the far side through an explicit-override shortcut include") running findAll(include="squads", order="id") and asserting recordCount would guard that join the same way :90 guards the default form.

Docs

The new commit is exactly the right surface to update — the user-facing guide at web/sites/guides/src/content/docs/v4-0-0/. The prose is accurate (verified above) and the changelog fragment changelog.d/3208-hasmany-shortcut-include.fixed.md remains present and correctly typed (.fixed.md), with no direct [Unreleased] edit.

Commits

Both headers are commitlint-valid:

  • fix(model): resolve hasMany shortcut name in include expansion (#3208) — type fix, scope model, sentence-case, well under 100 chars.
  • docs(web/guides): note shortcut name accepted in findAll include for many-to-many — type docs, scope web/guides, ~81 chars, not ALL-CAPS.

One honest caveat the PR body already flags: the suite could not run in the bot sandbox, so CI (bot-tdd-gate.yml + compat-matrix.yml) green should be confirmed before merge — the findAll(include="teams") test (:90) is the real correctness gate.

Manually triggered baseline refresh via
.github/workflows/refresh-visual-baselines.yml
on branch fix/bot-3208-hasmany-shortcut-association-not-recognised-by-inc.

Run when an intentional content/layout change makes the visual-regression
check fail. The new PNG(s) under web/tests/visual-baselines/ are now the
expected rendering; re-run the failing visual-regression job to flip the
check green.

@wheels-bot wheels-bot Bot left a comment

Copy link
Copy Markdown
Contributor Author

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 #3208: a hasMany shortcut name passed to findAll(include=...) used to reach $expandThroughAssociations, fall through unchanged, and then throw Wheels.AssociationNotFound. The fix adds one narrow branch that rewrites a shortcut name into the nested this-model bridge include (teamsmemberTeams(team)). The logic is correct, narrowly scoped, cross-engine-safe, and backed by three new BDD specs plus a docs update and changelog fragment. Verdict: approve (submitted as a comment — GitHub forbids the bot approving its own PR; HTTP 422 fallback per the review playbook).

Correctness

The new branch (vendor/wheels/model/sql.cfc:1234-1252) only fires when the include name is not itself an association and does match an association whose shortcut equals it and whose through has exactly 2 elements:

if (!StructKeyExists(local.associations, local.currentInclude)) {
    for (local.assocName in local.associations) {
        ...
        && local.assoc.shortcut == local.currentInclude
        && StructKeyExists(local.assoc, "through")
        && ListLen(local.assoc.through) == 2

I traced the expansion against the association registration. For hasMany(name="memberTeams", shortcut="teams"), the default through = "#singularize(arguments.shortcut)#,#arguments.name#" (vendor/wheels/model/associations.cfc:70) resolves to "team,memberTeams", so ListFirst(through) = team and the branch emits memberTeams(team) — exactly the value the new spec asserts (hasManyShortcutSpec.cfc:85). The explicit-override case (rosterSpots, through="squad,rosterEntries") emits rosterSpots(squad), again matching the spec. The comment at lines 1245-1247 accurately describes the segment roles.

No regression for unknown names: a non-association, non-shortcut include still falls through to the use as-is branch (line 1257) and throws downstream as before, so this only converts a previously-erroring case into a working one. The #3109 narrowing in the sibling branch (lines 1213-1219) is untouched.

Cross-engine

Clean. The added code uses only StructKeyExists, ListLen, ListFirst, ListAppend, and a for-in loop — no closures, no obj.map() member-function collision, no reserved-scope shadowing, no Left(str, 0). The host function is public string function $expandThroughAssociations (sql.cfc:1140), so it satisfies the $-prefixed-public mixin contract (Cross-Engine Invariant #7). The shortcut == currentInclude compare is CFML-case-insensitive, so include="Teams" still resolves.

Tests

vendor/wheels/tests/specs/model/hasManyShortcutSpec.cfc:75-103 adds an issue-#3208 block covering the default shortcut (teamsmemberTeams(team)), the explicit-through override (squadsrosterSpots(squad)), and an end-to-end findAll(include="teams") that asserts 3 joined rows without throwing. Both the unit-level expansion and the runtime eager-load paths are exercised. Spec extends wheels.WheelsTest (BDD), correct for new tests. (CI's bot-tdd-gate + compat-matrix remain the validating gate — the suite was not runnable in the bot sandbox, as the PR body honestly notes.)

Docs

web/sites/guides/src/content/docs/v4-0-0/basics/associations.mdx:242 is updated to document that the shortcut name now works in include and expands to userRoles(role) — accurate against the implementation. Changelog fragment present (changelog.d/3208-hasmany-shortcut-include.fixed.md, .fixed type), no direct [Unreleased] edit. The visual baseline PNGs in the head commit are the expected mechanical refresh from the guides docs change.

Commits

All three conform to commitlint.config.js: fix(model): …, docs(web/guides): …, chore(web): … — valid types, valid/optional scopes, subjects within length and not ALL-CAPS.

@bpamiri bpamiri marked this pull request as ready for review June 15, 2026 16:32
@bpamiri bpamiri merged commit 66e8c2c into develop Jun 18, 2026
17 of 18 checks passed
@bpamiri bpamiri deleted the fix/bot-3208-hasmany-shortcut-association-not-recognised-by-inc branch June 18, 2026 02:53
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.

hasMany shortcut association not recognised by include - "association not found" error

1 participant