Skip to content

Add Database.deepAssociations option for path based join aliases - #2

Draft
nicosp wants to merge 2 commits into
5.xfrom
deep-associations
Draft

nicosp wants to merge 2 commits into
5.xfrom
deep-associations

Conversation

@nicosp

@nicosp nicosp commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Explores upstream cakephp#18929 (related: cakephp#17679, cakephp#18932), which upstream closed because rewriting join aliases is a major-version change. This puts the fix behind an opt-in option so it can be evaluated without affecting existing queries.

Problem: joining the same association through different paths in one query (e.g. Creator.Contacts and Modifier.Contacts) collides on the join alias. contain() silently degrades the duplicates to the select strategy; matching(), innerJoinWith(), leftJoinWith() and notMatching() overwrite the first join (5.3 now asserts on it).

Fix: when Configure::read('Database.deepAssociations') is true, or EagerLoader::setDeepAssociations(true) is called on a query's loader, every association joined below the top level is aliased by its full path with dots replaced by underscores:

Path Alias
Users Users (top level unchanged)
Users.Profiles Users_Profiles
Articles.Authors.Profiles Articles_Authors_Profiles
Articles.Tags (belongsToMany junction) Articles_ArticlesTags

Path aliases are used everywhere when the option is on, not only when a conflict exists.

Behaviour when enabled

  • Same association can be joined through any number of paths for contain, matching, joinWith and notMatching, including nested belongsToMany.
  • Conditions and fields written against the target alias inside contain callbacks, association conditions, finders and beforeFind listeners (incl. TranslateBehavior) are rewritten to the path alias automatically.
  • Conditions on the main query must use the path alias, e.g. where(['Creator_Contacts.name' => ...]).
  • _matchingData keys use the path alias, e.g. _matchingData['Articles_Authors'].
  • Duplicate nested contain() paths are joined instead of downgraded to separate select queries.
  • External (hasMany/select strategy) associations hanging off a renamed join collect their keys from the path alias.

Changes

  • EagerLoader: option handling, path alias computation in _normalizeContain(), joins keyed by query alias, deepAlias() helper, external loads keyed by the parent's query alias, _fixStrategies() skipped in deep mode.
  • EagerLoadable: new queryAlias() / sourceAlias().
  • Association::attachTo(): honours alias / sourceAlias options; rewrites identifiers in the surrogate query's where and select clauses (sub-queries left untouched); transformRow() / defaultRowValue() accept a source alias.
  • BelongsToMany: junction alias and join conditions follow the path alias.
  • ResultSetFactory, CommonQueryTrait::addDefaultTypes(), HasMany, HasOne, BelongsTo: plumbing for source alias and column types.
  • New tests/TestCase/ORM/DeepAssociationsTest.php (18 tests).
  • phpstan-baseline.neon: one count adjusted.

Verification

  • ORM suite with the option off: 1537 tests pass, no behaviour change.
  • ORM suite with the option forced on globally: 20 existing tests fail only because they hard-code nested aliases (Authors.created, count(tags.id)) or _matchingData['Authors']-style keys. Left untouched since the option defaults to off.
  • phpcs and phpstan clean on changed files.

Open questions for review

  • Alias separator: _ was chosen because __ is the select-key separator. An association name containing _ could theoretically produce an ambiguous alias.
  • Whether EagerLoader::setDeepAssociations() should stay public, or the Configure key should be the only switch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CufZXaq7Vyk9J4MUcX2otP

nicosp and others added 2 commits September 8, 2026 11:24
Joining the same association through different paths in one query
(e.g. `Creator.Contacts` and `Modifier.Contacts`) collides on the join
alias. contain() degrades the duplicates to the select strategy, while
matching()/joinWith()/notMatching() overwrite the first join and now
assert on the conflict.

When `Database.deepAssociations` is enabled (or
`EagerLoader::setDeepAssociations(true)` is called), associations joined
below the top level are aliased by their full path with dots replaced
by underscores (`Creator_Contacts`, `Articles_Authors_Profiles`). This
applies to contain, matching, joinWith and notMatching, including nested
belongsToMany junctions. Conditions and fields written against the target
alias inside contain callbacks, association conditions, finders and
beforeFind listeners are rewritten to the path alias. `_matchingData`
keys use the path alias as well.

The option defaults to off, so existing queries are unchanged.

Refs cakephp#18929, cakephp#17679

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CufZXaq7Vyk9J4MUcX2otP
Use `self` for the `_matchingLoader()` return type and declare the void
return type on the beforeFind closure in the deep associations test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CufZXaq7Vyk9J4MUcX2otP
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant