From aa796c1920be2c689c4daf0632330ea0f70b4e41 Mon Sep 17 00:00:00 2001 From: Nicos Panayides Date: Tue, 8 Sep 2026 11:24:01 +0300 Subject: [PATCH 1/2] Add Database.deepAssociations option for path based join aliases 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/cakephp#18929, cakephp/cakephp#17679 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CufZXaq7Vyk9J4MUcX2otP --- phpstan-baseline.neon | 2 +- src/ORM/Association.php | 188 ++++++++- src/ORM/Association/BelongsTo.php | 2 +- src/ORM/Association/BelongsToMany.php | 38 +- src/ORM/Association/HasMany.php | 6 +- src/ORM/Association/HasOne.php | 2 +- src/ORM/EagerLoadable.php | 46 +++ src/ORM/EagerLoader.php | 157 ++++++- src/ORM/Query/CommonQueryTrait.php | 6 +- src/ORM/ResultSetFactory.php | 11 +- tests/TestCase/ORM/DeepAssociationsTest.php | 432 ++++++++++++++++++++ 11 files changed, 838 insertions(+), 52 deletions(-) create mode 100644 tests/TestCase/ORM/DeepAssociationsTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e47233b8c60..b13520f85c8 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -153,7 +153,7 @@ parameters: - message: '#^Unsafe usage of new static\(\)\.$#' identifier: new.static - count: 2 + count: 1 path: src/ORM/EagerLoader.php - diff --git a/src/ORM/Association.php b/src/ORM/Association.php index 28447254ae8..3e3f3d846ab 100644 --- a/src/ORM/Association.php +++ b/src/ORM/Association.php @@ -20,6 +20,7 @@ use Cake\Core\App; use Cake\Core\ConventionsTrait; use Cake\Database\Exception\DatabaseException; +use Cake\Database\Expression\FieldInterface; use Cake\Database\Expression\IdentifierExpression; use Cake\Database\Expression\QueryExpression; use Cake\Database\ExpressionInterface; @@ -695,6 +696,10 @@ protected function _options(array $options): void * - joinType: The SQL join type to use in the query. * - negateMatch: Will append a condition to the passed query for excluding matches. * with this association. + * - alias: The alias to join the target table with. Defaults to the association name. + * Conditions and fields referencing the target table alias are rewritten to use it. + * - sourceAlias: The alias under which the source table appears in the query. + * Defaults to the source table alias. * * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query to be altered to include the target table data * @param array $options Any extra options or overrides to be taken into account @@ -715,6 +720,8 @@ public function attachTo(SelectQuery $query, array $options = []): void 'table' => $table, 'finder' => $this->getFinder(), ]; + $options['alias'] ??= $this->_name; + $options['sourceAlias'] ??= $this->getSource()->getAlias(); // This is set by joinWith to disable matching results if ($options['fields'] === false) { @@ -759,9 +766,14 @@ public function attachTo(SelectQuery $query, array $options = []): void $dummy->where($options['conditions']); $this->_dispatchBeforeFind($dummy); - $query->join([$this->_name => [ + $conditions = $dummy->clause('where'); + if ($conditions instanceof ExpressionInterface) { + $this->_rewriteAliases($conditions, $this->_aliasMap($query, $dummy, $options)); + } + + $query->join([$options['alias'] => [ 'table' => $options['table'], - 'conditions' => $dummy->clause('where'), + 'conditions' => $conditions, 'type' => $options['joinType'], ]]); @@ -783,7 +795,8 @@ protected function _appendNotMatching(SelectQuery $query, array $options): void { $target = $this->getTarget(); if (!empty($options['negateMatch'])) { - $primaryKey = $query->aliasFields((array)$target->getPrimaryKey(), $this->_name); + $alias = $options['alias'] ?? $this->_name; + $primaryKey = $query->aliasFields((array)$target->getPrimaryKey(), $alias); $query->andWhere(function ($exp) use ($primaryKey) { /** @var callable $callable */ $callable = [$exp, 'isNull']; @@ -805,11 +818,18 @@ protected function _appendNotMatching(SelectQuery $query, array $options): void * with this association * @param string|null $targetProperty The property name in the source results where the association * data should be nested in. Will use the default one if not provided. + * @param string|null $sourceAlias The key in the row under which the source results are found. + * Will use the source table alias if not provided. * @return array */ - public function transformRow(array $row, string $nestKey, bool $joined, ?string $targetProperty = null): array - { - $sourceAlias = $this->getSource()->getAlias(); + public function transformRow( + array $row, + string $nestKey, + bool $joined, + ?string $targetProperty = null, + ?string $sourceAlias = null, + ): array { + $sourceAlias = $sourceAlias ?: $this->getSource()->getAlias(); $nestKey = $nestKey ?: $this->_name; $targetProperty = $targetProperty ?: $this->getProperty(); if (isset($row[$sourceAlias])) { @@ -828,11 +848,13 @@ public function transformRow(array $row, string $nestKey, bool $joined, ?string * @param array $row The row to set a default on. * @param bool $joined Whether the row is a result of a direct join * with this association + * @param string|null $sourceAlias The key in the row under which the source results are found. + * Will use the source table alias if not provided. * @return array */ - public function defaultRowValue(array $row, bool $joined): array + public function defaultRowValue(array $row, bool $joined, ?string $sourceAlias = null): array { - $sourceAlias = $this->getSource()->getAlias(); + $sourceAlias = $sourceAlias ?: $this->getSource()->getAlias(); if (isset($row[$sourceAlias])) { $row[$sourceAlias][$this->getProperty()] = null; } @@ -995,8 +1017,150 @@ protected function _appendFields(SelectQuery $query, SelectQuery $surrogate, arr } } - $query->select($query->aliasFields($fields, $this->_name)); - $query->addDefaultTypes($this->getTarget()); + $alias = $options['alias'] ?? $this->_name; + $aliasMap = $this->_aliasMap($query, $surrogate, $options); + if ($aliasMap) { + $rewritten = []; + foreach ($fields as $key => $field) { + if (is_string($field)) { + $field = $this->_rewriteIdentifier($field, $aliasMap); + } elseif ($field instanceof ExpressionInterface) { + $this->_rewriteAliases($field, $aliasMap); + } + if (is_int($key)) { + $rewritten[] = $field; + continue; + } + // Already aliased fields (`Alias__field`) need their alias rewritten too. + $pos = strpos($key, '__'); + if ($pos > 0 && isset($aliasMap[substr($key, 0, $pos)])) { + $key = $aliasMap[substr($key, 0, $pos)] . substr($key, $pos); + } + $rewritten[$key] = $field; + } + $fields = $rewritten; + } + + $query->select($query->aliasFields($fields, $alias)); + $query->addDefaultTypes($this->getTarget(), $alias); + } + + /** + * Returns a map of table aliases that need to be rewritten in conditions and + * fields built against the target table, when the association is attached + * to a query under a different alias than the target table one. + * + * When the query uses deep association aliases, associations contained in + * the surrogate query that will be joined are mapped as well, as they are + * attached to `$query` under an alias derived from their full path. + * + * The returned array maps the original alias to the alias used in the query. + * An empty array is returned when no rewriting is needed. + * + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query the association is attached to + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $surrogate the query built for the target table + * @param array $options options passed to the method `attachTo` + * @return array + */ + protected function _aliasMap(SelectQuery $query, SelectQuery $surrogate, array $options): array + { + $map = []; + $targetAlias = $this->getTarget()->getAlias(); + $alias = $options['alias'] ?? $this->_name; + if ($alias !== $targetAlias) { + $map[$targetAlias] = $alias; + } + + $sourceTableAlias = $this->getSource()->getAlias(); + $sourceAlias = $options['sourceAlias'] ?? $sourceTableAlias; + if ($sourceAlias !== $sourceTableAlias && $sourceTableAlias !== $targetAlias) { + $map[$sourceTableAlias] = $sourceAlias; + } + + if (!$query->getEagerLoader()->isDeepAssociationsEnabled()) { + return $map; + } + + $loader = $surrogate->getEagerLoader(); + if (!$loader->getContain() && !$loader->getMatching()) { + return $map; + } + + foreach ($loader->attachableAssociations($this->getTarget()) as $nestedAlias => $loadable) { + $map[$nestedAlias] = EagerLoader::deepAlias($alias . '.' . $loadable->aliasPath()); + } + + return $map; + } + + /** + * Rewrites the table alias prefix of a `Alias.field` identifier + * according to the passed alias map. + * + * @param string $identifier The identifier to rewrite. + * @param array $aliasMap Map of original aliases to query aliases. + * @return string + */ + protected function _rewriteIdentifier(string $identifier, array $aliasMap): string + { + $pos = strpos($identifier, '.'); + if ($pos === false) { + return $identifier; + } + $prefix = substr($identifier, 0, $pos); + if (!isset($aliasMap[$prefix])) { + return $identifier; + } + + return $aliasMap[$prefix] . substr($identifier, $pos); + } + + /** + * Rewrites all the field identifiers found in an expression tree so that + * references to the aliases in `$aliasMap` keys use the corresponding values. + * + * Sub-queries are left untouched as they carry their own table aliases. + * + * @param \Cake\Database\ExpressionInterface $expression The expression to rewrite. + * @param array $aliasMap Map of original aliases to query aliases. + * @return void + */ + protected function _rewriteAliases(ExpressionInterface $expression, array $aliasMap): void + { + if (!$aliasMap) { + return; + } + + $rewrite = function (mixed $expression, ?string $clause = null) use ($aliasMap): void { + // Query::traverse() passes its clauses (with the clause name) instead of + // nested expressions. Sub-queries keep their own aliases, skip them. + if ($clause !== null || !$expression instanceof ExpressionInterface) { + return; + } + + if ($expression instanceof IdentifierExpression) { + $expression->setIdentifier($this->_rewriteIdentifier($expression->getIdentifier(), $aliasMap)); + + return; + } + + if ($expression instanceof FieldInterface) { + $field = $expression->getField(); + if (is_string($field)) { + $expression->setField($this->_rewriteIdentifier($field, $aliasMap)); + } elseif (is_array($field)) { + foreach ($field as $k => $f) { + if (is_string($f)) { + $field[$k] = $this->_rewriteIdentifier($f, $aliasMap); + } + } + $expression->setField($field); + } + } + }; + + $rewrite($expression); + $expression->traverse($rewrite); } /** @@ -1113,8 +1277,8 @@ protected function _bindNewAssociations(SelectQuery $query, SelectQuery $surroga protected function _joinCondition(array $options): array { $conditions = []; - $tAlias = $this->_name; - $sAlias = $this->getSource()->getAlias(); + $tAlias = $options['alias'] ?? $this->_name; + $sAlias = $options['sourceAlias'] ?? $this->getSource()->getAlias(); $foreignKey = (array)$options['foreignKey']; $bindingKey = (array)$this->getBindingKey(); diff --git a/src/ORM/Association/BelongsTo.php b/src/ORM/Association/BelongsTo.php index a504900c476..2fb5d101770 100644 --- a/src/ORM/Association/BelongsTo.php +++ b/src/ORM/Association/BelongsTo.php @@ -164,7 +164,7 @@ public function eagerLoader(array $options): Closure { $loader = new SelectLoader([ 'alias' => $this->getAlias(), - 'sourceAlias' => $this->getSource()->getAlias(), + 'sourceAlias' => $options['sourceAlias'] ?? $this->getSource()->getAlias(), 'targetAlias' => $this->getTarget()->getAlias(), 'foreignKey' => $this->getForeignKey(), 'bindingKey' => $this->getBindingKey(), diff --git a/src/ORM/Association/BelongsToMany.php b/src/ORM/Association/BelongsToMany.php index e6072495cf9..2f707364a2b 100644 --- a/src/ORM/Association/BelongsToMany.php +++ b/src/ORM/Association/BelongsToMany.php @@ -232,9 +232,9 @@ public function getSort(): ExpressionInterface|Closure|array|string|null /** * @inheritDoc */ - public function defaultRowValue(array $row, bool $joined): array + public function defaultRowValue(array $row, bool $joined, ?string $sourceAlias = null): array { - $sourceAlias = $this->getSource()->getAlias(); + $sourceAlias = $sourceAlias ?: $this->getSource()->getAlias(); if (isset($row[$sourceAlias])) { $row[$sourceAlias][$this->getProperty()] = $joined ? null : []; } @@ -476,8 +476,21 @@ public function attachTo(SelectQuery $query, array $options = []): void } $junction = $this->junction(); + $alias = $options['alias'] ?? $this->_name; + $sourceAlias = $options['sourceAlias'] ?? $this->getSource()->getAlias(); + $junctionAlias = $junction->getAlias(); + if ($alias !== $this->_name) { + // The association is joined with a path based alias (deep associations), + // the junction table needs a matching unique alias as well. + $junctionAlias = $sourceAlias . '_' . $junctionAlias; + } + $belongsTo = $junction->getAssociation($this->getSource()->getAlias()); - $cond = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->getForeignKey()]); + $cond = $belongsTo->_joinCondition([ + 'foreignKey' => $belongsTo->getForeignKey(), + 'alias' => $sourceAlias, + 'sourceAlias' => $junctionAlias, + ]); $cond += $this->junctionConditions(); $includeFields = $options['includeFields'] ?? null; @@ -489,17 +502,23 @@ public function attachTo(SelectQuery $query, array $options = []): void 'conditions' => $cond, 'includeFields' => $includeFields, 'foreignKey' => false, + 'alias' => $junctionAlias, + 'sourceAlias' => $alias, ]; $assoc->attachTo($query, $newOptions); - $query->getEagerLoader()->addToJoinsMap($junction->getAlias(), $assoc, true); + $query->getEagerLoader()->addToJoinsMap($junctionAlias, $assoc, true); parent::attachTo($query, $options); $foreignKey = $this->getTargetForeignKey(); - $thisJoin = $query->clause('join')[$this->getName()]; + $thisJoin = $query->clause('join')[$alias]; /** @var \Cake\Database\Expression\QueryExpression $conditions */ $conditions = $thisJoin['conditions']; - $conditions->add($assoc->_joinCondition(['foreignKey' => $foreignKey])); + $conditions->add($assoc->_joinCondition([ + 'foreignKey' => $foreignKey, + 'alias' => $junctionAlias, + 'sourceAlias' => $alias, + ])); } /** @@ -515,7 +534,10 @@ protected function _appendNotMatching(SelectQuery $query, array $options): void $options['conditions'] ??= []; $junction = $this->junction(); $belongsTo = $junction->getAssociation($this->getSource()->getAlias()); - $conds = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->getForeignKey()]); + $conds = $belongsTo->_joinCondition([ + 'foreignKey' => $belongsTo->getForeignKey(), + 'alias' => $options['sourceAlias'] ?? $this->getSource()->getAlias(), + ]); $subquery = $this->find() ->select(array_values($conds)) @@ -575,7 +597,7 @@ public function eagerLoader(array $options): Closure $name = $this->_junctionAssociationName(); $loader = new SelectWithPivotLoader([ 'alias' => $this->getAlias(), - 'sourceAlias' => $this->getSource()->getAlias(), + 'sourceAlias' => $options['sourceAlias'] ?? $this->getSource()->getAlias(), 'targetAlias' => $this->getTarget()->getAlias(), 'foreignKey' => $this->getForeignKey(), 'bindingKey' => $this->getBindingKey(), diff --git a/src/ORM/Association/HasMany.php b/src/ORM/Association/HasMany.php index 2fc46d71f6a..f44ce5ab9db 100644 --- a/src/ORM/Association/HasMany.php +++ b/src/ORM/Association/HasMany.php @@ -654,9 +654,9 @@ public function getSort(): ExpressionInterface|Closure|array|string|null /** * @inheritDoc */ - public function defaultRowValue(array $row, bool $joined): array + public function defaultRowValue(array $row, bool $joined, ?string $sourceAlias = null): array { - $sourceAlias = $this->getSource()->getAlias(); + $sourceAlias = $sourceAlias ?: $this->getSource()->getAlias(); if (isset($row[$sourceAlias])) { $row[$sourceAlias][$this->getProperty()] = $joined ? null : []; } @@ -687,7 +687,7 @@ public function eagerLoader(array $options): Closure { $loader = new SelectLoader([ 'alias' => $this->getAlias(), - 'sourceAlias' => $this->getSource()->getAlias(), + 'sourceAlias' => $options['sourceAlias'] ?? $this->getSource()->getAlias(), 'targetAlias' => $this->getTarget()->getAlias(), 'foreignKey' => $this->getForeignKey(), 'bindingKey' => $this->getBindingKey(), diff --git a/src/ORM/Association/HasOne.php b/src/ORM/Association/HasOne.php index 51012c7733f..613afa10f87 100644 --- a/src/ORM/Association/HasOne.php +++ b/src/ORM/Association/HasOne.php @@ -149,7 +149,7 @@ public function eagerLoader(array $options): Closure { $loader = new SelectLoader([ 'alias' => $this->getAlias(), - 'sourceAlias' => $this->getSource()->getAlias(), + 'sourceAlias' => $options['sourceAlias'] ?? $this->getSource()->getAlias(), 'targetAlias' => $this->getTarget()->getAlias(), 'foreignKey' => $this->getForeignKey(), 'bindingKey' => $this->getBindingKey(), diff --git a/src/ORM/EagerLoadable.php b/src/ORM/EagerLoadable.php index 65e4551284c..d90a93ffe09 100644 --- a/src/ORM/EagerLoadable.php +++ b/src/ORM/EagerLoadable.php @@ -112,6 +112,26 @@ class EagerLoadable */ protected ?string $_targetProperty = null; + /** + * The alias under which this association is joined in the generated query. + * + * By default this is the association name. When deep associations are + * enabled (see `Database.deepAssociations`) nested associations are aliased + * using their full association path (e.g. `Creator_Contacts`) so that the + * same association can be joined through different paths. + * + * @var string|null + */ + protected ?string $_queryAlias = null; + + /** + * The alias under which the source (parent) table of this association + * appears in the generated query. + * + * @var string|null + */ + protected ?string $_sourceAlias = null; + /** * Constructor. The $config parameter accepts the following array * keys: @@ -124,6 +144,8 @@ class EagerLoadable * - propertyPath * - forMatching * - targetProperty + * - queryAlias + * - sourceAlias * * The keys maps to the settable properties in this class. * @@ -136,6 +158,7 @@ public function __construct(string $name, array $config = []) $allowed = [ 'associations', 'instance', 'config', 'canBeJoined', 'aliasPath', 'propertyPath', 'forMatching', 'targetProperty', + 'queryAlias', 'sourceAlias', ]; foreach ($allowed as $property) { if (isset($config[$property])) { @@ -192,6 +215,29 @@ public function aliasPath(): string return $this->_aliasPath; } + /** + * Gets the alias under which this association is joined in the generated query. + * + * Defaults to the association name. + * + * @return string + */ + public function queryAlias(): string + { + return $this->_queryAlias ?? $this->_name; + } + + /** + * Gets the alias under which the source (parent) table of this association + * appears in the generated query, or null when unknown. + * + * @return string|null + */ + public function sourceAlias(): ?string + { + return $this->_sourceAlias; + } + /** * Gets a dot separated string representing the path of entity properties * in which results for this level should be placed. diff --git a/src/ORM/EagerLoader.php b/src/ORM/EagerLoader.php index 5b753b8ae86..adf4d4bcc50 100644 --- a/src/ORM/EagerLoader.php +++ b/src/ORM/EagerLoader.php @@ -16,6 +16,7 @@ */ namespace Cake\ORM; +use Cake\Core\Configure; use Cake\ORM\Query\SelectQuery; use Closure; use InvalidArgumentException; @@ -102,6 +103,17 @@ class EagerLoader */ protected bool $_autoFields = true; + /** + * Whether nested associations should be joined using aliases derived from + * their full association path instead of the plain association name. + * + * When null, the value is read from the `Database.deepAssociations` + * configuration key. + * + * @var bool|null + */ + protected ?bool $_deepAssociations = null; + /** * Sets the list of associations that should be eagerly loaded along for a * specific table using when a query is provided. The list of associated tables @@ -219,6 +231,59 @@ public function isAutoFieldsEnabled(): bool return $this->_autoFields; } + /** + * Sets whether nested associations are joined using aliases derived from their + * full association path. + * + * When enabled, an association contained through a path like `Creator.Contacts` + * is joined as `Creator_Contacts` instead of `Contacts`. This allows the same + * association to be joined multiple times through different paths (for + * example `Creator.Contacts` and `Modifier.Contacts`) without alias conflicts. + * + * Top level associations keep using their association name as alias. + * + * The default value is read from the `Database.deepAssociations` configuration key. + * + * @param bool $enable Whether to enable deep association aliases. + * @return $this + */ + public function setDeepAssociations(bool $enable) + { + $this->_deepAssociations = $enable; + $this->_normalized = null; + $this->_loadExternal = []; + $this->_aliasList = []; + $this->_matching?->setDeepAssociations($enable); + + return $this; + } + + /** + * Gets whether nested associations are joined using aliases derived from + * their full association path. + * + * @return bool + */ + public function isDeepAssociationsEnabled(): bool + { + return $this->_deepAssociations ??= (bool)Configure::read('Database.deepAssociations'); + } + + /** + * Returns the eager loader used for `matching` associations, creating it if required. + * + * @return \Cake\ORM\EagerLoader + */ + protected function _matchingLoader(): EagerLoader + { + if ($this->_matching === null) { + $this->_matching = new static(); + $this->_matching->_deepAssociations = $this->_deepAssociations; + } + + return $this->_matching; + } + /** * Adds a new association to the list that will be used to filter the results of * any given query based on the results of finding records for that association. @@ -240,7 +305,7 @@ public function isAutoFieldsEnabled(): bool */ public function setMatching(string $associationPath, ?Closure $builder = null, array $options = []) { - $this->_matching ??= new static(); + $this->_matchingLoader(); $options += ['joinType' => SelectQuery::JOIN_TYPE_INNER]; $sharedOptions = ['negateMatch' => false, 'matching' => true] + $options; @@ -259,7 +324,7 @@ public function setMatching(string $associationPath, ?Closure $builder = null, a // Add all options to target association contain which is the last in nested chain $nested = ['matching' => true, 'queryBuilder' => $builder ?? fn($q) => $q] + $options; - $this->_matching->contain($contains); + $this->_matchingLoader()->contain($contains); return $this; } @@ -271,9 +336,7 @@ public function setMatching(string $associationPath, ?Closure $builder = null, a */ public function getMatching(): array { - $this->_matching ??= new static(); - - return $this->_matching->getContain(); + return $this->_matchingLoader()->getContain(); } /** @@ -304,7 +367,7 @@ public function normalized(Table $repository): array $repository, $alias, $options, - ['root' => ''], + ['root' => '', 'sourceAlias' => $repository->getAlias(), 'joinPath' => ''], ); } @@ -422,6 +485,8 @@ public function attachAssociations(SelectQuery $query, Table $repository, bool $ 'aliasPath' => $loadable->aliasPath(), 'propertyPath' => $loadable->propertyPath(), 'includeFields' => $includeFields, + 'alias' => $loadable->queryAlias(), + 'sourceAlias' => $loadable->sourceAlias(), ]; $loadable->instance()->attachTo($query, $config); $processed[$alias] = true; @@ -477,10 +542,13 @@ public function externalAssociations(Table $repository): array * @param \Cake\ORM\Table $parent Owning side of the association. * @param string $alias Name of the association to be loaded. * @param array $options List of extra options to use for this association. - * @param array $paths An array with two values, the first one is a list of dot - * separated strings representing associations that lead to this `$alias` in the - * chain of associations to be loaded. The second value is the path to follow in - * entities' properties to fetch a record of the corresponding association. + * @param array $paths An array with the following keys: `aliasPath`, a dot + * separated string representing associations that lead to this `$alias` in the chain of + * associations to be loaded; `propertyPath`, the path to follow in entities' properties to + * fetch a record of the corresponding association; `root`, the closest ancestor that is + * loaded with a separate query; `sourceAlias`, the alias under which the parent table + * appears in the query; `joinPath`, the dot separated path of associations joined in the + * same query that lead to this `$alias`. * @return \Cake\ORM\EagerLoadable Object with normalized associations * @throws \InvalidArgumentException When containments refer to associations that do not exist. */ @@ -489,14 +557,26 @@ protected function _normalizeContain(Table $parent, string $alias, array $option $defaults = $this->_containOptions; $instance = $parent->getAssociation($alias); - $paths += ['aliasPath' => '', 'propertyPath' => '', 'root' => $alias]; + $paths += [ + 'aliasPath' => '', + 'propertyPath' => '', + 'root' => $alias, + 'sourceAlias' => $parent->getAlias(), + 'joinPath' => '', + ]; $paths['aliasPath'] .= '.' . $alias; + $paths['joinPath'] = trim($paths['joinPath'] . '.' . $alias, '.'); + + $queryAlias = $alias; + if ($this->isDeepAssociationsEnabled() && str_contains($paths['joinPath'], '.')) { + $queryAlias = static::deepAlias($paths['joinPath']); + } if ( isset($options['matching']) && $options['matching'] === true ) { - $paths['propertyPath'] = '_matchingData.' . $alias; + $paths['propertyPath'] = '_matchingData.' . $queryAlias; } else { $paths['propertyPath'] .= '.' . $instance->getProperty(); } @@ -511,6 +591,8 @@ protected function _normalizeContain(Table $parent, string $alias, array $option 'aliasPath' => trim($paths['aliasPath'], '.'), 'propertyPath' => trim($paths['propertyPath'], '.'), 'targetProperty' => $instance->getProperty(), + 'queryAlias' => $queryAlias, + 'sourceAlias' => $paths['sourceAlias'], ]; $config['canBeJoined'] = $instance->canBeJoined($config['config']); $eagerLoadable = new EagerLoadable($alias, $config); @@ -519,7 +601,12 @@ protected function _normalizeContain(Table $parent, string $alias, array $option $this->_aliasList[$paths['root']][$alias][] = $eagerLoadable; } else { $paths['root'] = $config['aliasPath']; + // Associations below this one are loaded with a separate query + // on the target table, where this association is the main table. + $paths['joinPath'] = ''; + $queryAlias = $table->getAlias(); } + $paths['sourceAlias'] = $queryAlias; foreach ($extra as $t => $assoc) { $eagerLoadable->addAssociation( @@ -531,6 +618,21 @@ protected function _normalizeContain(Table $parent, string $alias, array $option return $eagerLoadable; } + /** + * Returns the query alias to use for a nested association when deep + * associations are enabled. + * + * The alias is built from the dot separated path of joined associations that + * lead to it, e.g. `Creator.Contacts` becomes `Creator_Contacts`. + * + * @param string $joinPath Dot separated path of joined associations. + * @return string + */ + public static function deepAlias(string $joinPath): string + { + return str_replace('.', '_', $joinPath); + } + /** * Iterates over the joinable aliases list and corrects the fetching strategies * in order to avoid aliases collision in the generated queries. @@ -542,6 +644,12 @@ protected function _normalizeContain(Table $parent, string $alias, array $option */ protected function _fixStrategies(): void { + if ($this->isDeepAssociationsEnabled()) { + // Nested associations get unique aliases derived from their path, + // so there is nothing to fix. + return; + } + foreach ($this->_aliasList as $aliases) { foreach ($aliases as $configs) { if (count($configs) < 2) { @@ -588,14 +696,14 @@ protected function _correctStrategy(EagerLoadable $loadable): void protected function _resolveJoins(array $associations, array $matching = []): array { $result = []; - foreach ($matching as $table => $loadable) { - $result[$table] = $loadable; + foreach ($matching as $loadable) { + $result[$loadable->queryAlias()] = $loadable; $result = $this->mergeJoins($result, $this->_resolveJoins($loadable->associations(), [])); } foreach ($associations as $table => $loadable) { $inMatching = isset($matching[$table]); if (!$inMatching && $loadable->canBeJoined()) { - $result[$table] = $loadable; + $result[$loadable->queryAlias()] = $loadable; $result = $this->mergeJoins($result, $this->_resolveJoins($loadable->associations(), [])); continue; } @@ -669,7 +777,7 @@ public function loadExternal(SelectQuery $query, iterable $results): iterable $contain = $meta->associations(); $instance = $meta->instance(); $config = $meta->getConfig(); - $alias = $instance->getSource()->getAlias(); + $alias = $meta->sourceAlias() ?? $instance->getSource()->getAlias(); $path = $meta->aliasPath(); $requiresKeys = $instance->requiresKeys($config); @@ -699,6 +807,7 @@ public function loadExternal(SelectQuery $query, iterable $results): iterable 'contain' => $contain, 'keys' => $keys, 'nestKey' => $meta->aliasPath(), + 'sourceAlias' => $alias, ], ); $results = array_map($callback, $results); @@ -711,12 +820,14 @@ public function loadExternal(SelectQuery $query, iterable $results): iterable * Returns an array having as keys a dotted path of associations that participate * in this eager loader. The values of the array will contain the following keys: * - * - `alias`: The association alias + * - `alias`: The alias the association is joined with in the query * - `instance`: The association instance * - `canBeJoined`: Whether the association will be loaded using a JOIN * - `entityClass`: The entity that should be used for hydrating the results * - `nestKey`: A dotted path that can be used to correctly insert the data into the results. * - `matching`: Whether it is an association loaded through `matching()`. + * - `targetProperty`: The property name where the association results are nested. + * - `sourceAlias`: The alias of the source table in the query, or null if it is the default one. * * @param \Cake\ORM\Table $table The table containing the association that * will be normalized. @@ -749,19 +860,21 @@ public function associationsMap(Table $table): array */ protected function _buildAssociationsMap(array $map, array $level, bool $matching = false): array { - foreach ($level as $assoc => $meta) { + foreach ($level as $meta) { $canBeJoined = $meta->canBeJoined(); $instance = $meta->instance(); $associations = $meta->associations(); $forMatching = $meta->forMatching(); + $queryAlias = $meta->queryAlias(); $map[] = [ - 'alias' => $assoc, + 'alias' => $queryAlias, 'instance' => $instance, 'canBeJoined' => $canBeJoined, 'entityClass' => $instance->getTarget()->getEntityClass(), - 'nestKey' => $canBeJoined ? $assoc : $meta->aliasPath(), + 'nestKey' => $canBeJoined ? $queryAlias : $meta->aliasPath(), 'matching' => $forMatching ?? $matching, 'targetProperty' => $meta->targetProperty(), + 'sourceAlias' => $meta->sourceAlias(), ]; if ($canBeJoined && $associations) { $map = $this->_buildAssociationsMap($map, $associations, $matching); @@ -793,6 +906,7 @@ public function addToJoinsMap( ): void { $this->_joinsMap[$alias] = new EagerLoadable($alias, [ 'aliasPath' => $alias, + 'queryAlias' => $alias, 'instance' => $assoc, 'canBeJoined' => true, 'forMatching' => $asMatching, @@ -855,12 +969,11 @@ protected function _collectKeys(array $external, SelectQuery $query, array $resu continue; } - $source = $instance->getSource(); $keys = $instance->type() === Association::MANY_TO_ONE ? (array)$instance->getForeignKey() : (array)$instance->getBindingKey(); - $alias = $source->getAlias(); + $alias = $meta->sourceAlias() ?? $instance->getSource()->getAlias(); $pkFields = []; /** @var string $key */ foreach ($keys as $key) { diff --git a/src/ORM/Query/CommonQueryTrait.php b/src/ORM/Query/CommonQueryTrait.php index 08aa7cf13e4..00162343639 100644 --- a/src/ORM/Query/CommonQueryTrait.php +++ b/src/ORM/Query/CommonQueryTrait.php @@ -42,11 +42,13 @@ trait CommonQueryTrait * This method returns the same query object for chaining. * * @param \Cake\ORM\Table $table The table to pull types from + * @param string|null $alias The alias the table is used with in this query, + * defaults to the table alias. * @return $this */ - public function addDefaultTypes(Table $table) + public function addDefaultTypes(Table $table, ?string $alias = null) { - $alias = $table->getAlias(); + $alias = $alias ?: $table->getAlias(); $map = $table->getSchema()->typeMap(); $fields = []; foreach ($map as $f => $type) { diff --git a/src/ORM/ResultSetFactory.php b/src/ORM/ResultSetFactory.php index 983848e0d56..2ae488e569d 100644 --- a/src/ORM/ResultSetFactory.php +++ b/src/ORM/ResultSetFactory.php @@ -185,8 +185,9 @@ protected function groupResult(array $row, array $data): EntityInterface|array $instance = $assoc['instance']; assert($instance instanceof Association); + $sourceAlias = $assoc['sourceAlias'] ?? null; if (!$canBeJoined && !isset($row[$alias])) { - $results = $instance->defaultRowValue($results, $canBeJoined); + $results = $instance->defaultRowValue($results, $canBeJoined, $sourceAlias); continue; } @@ -217,7 +218,13 @@ protected function groupResult(array $row, array $data): EntityInterface|array $results[$alias] = $entity; } - $results = $instance->transformRow($results, $alias, $assoc['canBeJoined'], $assoc['targetProperty']); + $results = $instance->transformRow( + $results, + $alias, + $assoc['canBeJoined'], + $assoc['targetProperty'], + $sourceAlias, + ); } foreach ($presentAliases as $alias => $present) { diff --git a/tests/TestCase/ORM/DeepAssociationsTest.php b/tests/TestCase/ORM/DeepAssociationsTest.php new file mode 100644 index 00000000000..df989f2fd1c --- /dev/null +++ b/tests/TestCase/ORM/DeepAssociationsTest.php @@ -0,0 +1,432 @@ + Users -> Profiles + * - Comments -> Articles -> Authors -> Profiles + */ +class DeepAssociationsTest extends TestCase +{ + /** + * @var array + */ + protected array $fixtures = [ + 'core.Articles', + 'core.ArticlesTags', + 'core.Authors', + 'core.Comments', + 'core.Profiles', + 'core.Tags', + 'core.Users', + ]; + + protected Table $comments; + + protected function setUp(): void + { + parent::setUp(); + Configure::write('Database.deepAssociations', true); + + $this->comments = $this->getTableLocator()->get('Comments'); + $this->comments->belongsTo('Articles'); + $this->comments->belongsTo('Users'); + + $articles = $this->comments->Articles->getTarget(); + $articles->belongsTo('Authors'); + $articles->belongsToMany('Tags'); + + $authors = $articles->Authors->getTarget(); + $authors->hasMany('Articles'); + $authors->hasOne('Profiles', ['foreignKey' => 'user_id']); + + $this->comments->Users->getTarget()->hasOne('Profiles', ['foreignKey' => 'user_id']); + } + + /** + * Strips identifier quoting so the SQL can be asserted regardless of the driver. + */ + protected function sql(SelectQuery $query): string + { + return preg_replace('/[`"\[\]]/', '', $query->sql()); + } + + public function testConfigureDefault(): void + { + $loader = new EagerLoader(); + $this->assertTrue($loader->isDeepAssociationsEnabled()); + + Configure::write('Database.deepAssociations', false); + $loader = new EagerLoader(); + $this->assertFalse($loader->isDeepAssociationsEnabled()); + + $this->assertSame($loader, $loader->setDeepAssociations(true)); + $this->assertTrue($loader->isDeepAssociationsEnabled()); + } + + public function testDisabledKeepsAssociationNameAsAlias(): void + { + Configure::write('Database.deepAssociations', false); + + $query = $this->comments->find()->contain(['Users.Profiles']); + $sql = $this->sql($query); + + $this->assertStringContainsString('LEFT JOIN profiles Profiles ON Users.id = Profiles.user_id', $sql); + $this->assertStringNotContainsString('Users_Profiles', $sql); + } + + public function testNestedJoinsUsePathAliases(): void + { + $query = $this->comments->find()->contain(['Users.Profiles', 'Articles.Authors.Profiles']); + $sql = $this->sql($query); + + $this->assertStringContainsString( + 'LEFT JOIN profiles Users_Profiles ON Users.id = Users_Profiles.user_id', + $sql, + ); + $this->assertStringContainsString( + 'LEFT JOIN authors Articles_Authors ON Articles_Authors.id = Articles.author_id', + $sql, + ); + $this->assertStringContainsString( + 'LEFT JOIN profiles Articles_Authors_Profiles ON Articles_Authors.id = Articles_Authors_Profiles.user_id', + $sql, + ); + $this->assertStringContainsString('Users_Profiles.first_name AS Users_Profiles__first_name', $sql); + $this->assertStringContainsString( + 'Articles_Authors_Profiles.first_name AS Articles_Authors_Profiles__first_name', + $sql, + ); + // Top level associations keep their name as alias. + $this->assertStringContainsString('LEFT JOIN users Users ON Users.id = Comments.user_id', $sql); + $this->assertStringContainsString('LEFT JOIN articles Articles ON Articles.id = Comments.article_id', $sql); + } + + public function testContainSameAssociationThroughDifferentPaths(): void + { + $comment = $this->comments->find() + ->contain(['Users.Profiles', 'Articles.Authors.Profiles']) + ->where(['Comments.id' => 1]) + ->firstOrFail(); + + $this->assertSame(2, $comment->user->id); + $this->assertSame('nate', $comment->user->profile->first_name); + $this->assertSame(1, $comment->article->author->id); + $this->assertSame('mariano', $comment->article->author->profile->first_name); + $this->assertSame('Profiles', $comment->user->profile->getSource()); + $this->assertSame('Profiles', $comment->article->author->profile->getSource()); + + $comment = $this->comments->find() + ->contain(['Users.Profiles', 'Articles.Authors.Profiles']) + ->where(['Comments.id' => 1]) + ->disableHydration() + ->firstOrFail(); + + $this->assertSame('nate', $comment['user']['profile']['first_name']); + $this->assertSame('mariano', $comment['article']['author']['profile']['first_name']); + $this->assertArrayNotHasKey('Users_Profiles', $comment); + $this->assertArrayNotHasKey('Articles_Authors_Profiles', $comment); + } + + public function testConditionsOnPathAlias(): void + { + $results = $this->comments->find() + ->contain(['Users.Profiles', 'Articles.Authors.Profiles']) + ->where([ + 'Users_Profiles.first_name' => 'nate', + 'Articles_Authors_Profiles.first_name' => 'mariano', + ]) + ->orderBy(['Comments.id' => 'ASC']) + ->all() + ->extract('id') + ->toList(); + + $this->assertSame([1], $results); + } + + public function testContainQueryBuilderReferencesTargetAlias(): void + { + $query = $this->comments->find() + ->contain([ + 'Users.Profiles' => function (SelectQuery $q) { + return $q + ->select(['Profiles.first_name', 'Profiles.is_active']) + ->where(['Profiles.is_active' => false]); + }, + ]) + ->where(['Comments.id' => 1]); + $sql = $this->sql($query); + + $this->assertStringContainsString('Users_Profiles.first_name AS Users_Profiles__first_name', $sql); + $this->assertStringContainsString('Users_Profiles.is_active = :c', $sql); + $this->assertStringNotContainsString(' Profiles.', $sql); + + $comment = $query->firstOrFail(); + $this->assertSame('nate', $comment->user->profile->first_name); + $this->assertFalse($comment->user->profile->is_active); + $this->assertNull($comment->user->profile->last_name); + } + + public function testAssociationConditionsReferencingTargetAlias(): void + { + $users = $this->comments->Users->getTarget(); + $users->associations()->remove('Profiles'); + $users->hasOne('Profiles', [ + 'foreignKey' => 'user_id', + 'conditions' => ['Profiles.is_active' => true], + ]); + + $query = $this->comments->find() + ->contain(['Users.Profiles']) + ->where(['Comments.id' => 1]); + $sql = $this->sql($query); + + $this->assertStringContainsString('Users_Profiles.is_active = :c', $sql); + + $comment = $query->firstOrFail(); + $this->assertNull($comment->user->profile); + } + + public function testBeforeFindConditionsAreRewritten(): void + { + $profiles = $this->getTableLocator()->get('Profiles'); + $profiles->getEventManager()->on('Model.beforeFind', function ($event, SelectQuery $query) { + $query->where(['Profiles.first_name !=' => 'nate']); + }); + + $query = $this->comments->find() + ->contain(['Users.Profiles']) + ->where(['Comments.id' => 1]); + + $this->assertStringContainsString('Users_Profiles.first_name != :c', $this->sql($query)); + $this->assertNull($query->firstOrFail()->user->profile); + } + + public function testExternalAssociationBelowPathAlias(): void + { + // Authors hasMany Articles is loaded with a separate query, using the keys + // selected from the `Articles_Authors` join. + $comment = $this->comments->find() + ->contain(['Articles.Authors.Articles']) + ->where(['Comments.id' => 1]) + ->firstOrFail(); + + $this->assertSame(1, $comment->article->author->id); + $articles = array_map(fn($article) => $article->id, $comment->article->author->articles); + $this->assertSame([1, 3], $articles); + + $comment = $this->comments->find() + ->contain(['Articles.Authors.Articles']) + ->where(['Comments.id' => 1]) + ->disableHydration() + ->firstOrFail(); + $this->assertCount(2, $comment['article']['author']['articles']); + } + + public function testExternalAssociationBelowPathAliasWithMissingParent(): void + { + $comments = $this->comments; + $comment = $comments->newEntity(['article_id' => 999, 'user_id' => 1, 'comment' => 'orphan']); + $comments->saveOrFail($comment); + + $result = $comments->find() + ->contain(['Articles.Authors.Articles']) + ->where(['Comments.id' => $comment->id]) + ->firstOrFail(); + + $this->assertNull($result->article); + } + + public function testMatchingSameAssociationThroughDifferentPaths(): void + { + $query = $this->comments->find() + ->matching('Users.Profiles') + ->matching('Articles.Authors.Profiles') + ->where(['Comments.id' => 1]); + $sql = $this->sql($query); + + $this->assertStringContainsString( + 'INNER JOIN profiles Users_Profiles ON Users.id = Users_Profiles.user_id', + $sql, + ); + $this->assertStringContainsString( + 'INNER JOIN profiles Articles_Authors_Profiles ON ' . + 'Articles_Authors.id = Articles_Authors_Profiles.user_id', + $sql, + ); + + $comment = $query->firstOrFail(); + $matching = $comment->_matchingData; + $this->assertSame( + ['Users', 'Users_Profiles', 'Articles', 'Articles_Authors', 'Articles_Authors_Profiles'], + array_keys($matching), + ); + $this->assertSame('nate', $matching['Users_Profiles']->first_name); + $this->assertSame('mariano', $matching['Articles_Authors_Profiles']->first_name); + $this->assertSame('Profiles', $matching['Users_Profiles']->getSource()); + } + + public function testMatchingWithConditionsOnTargetAlias(): void + { + $ids = $this->comments->find() + ->matching('Users.Profiles', function (SelectQuery $q) { + return $q->where(['Profiles.first_name' => 'nate']); + }) + ->orderBy(['Comments.id' => 'ASC']) + ->all() + ->extract('id') + ->toList(); + + $this->assertSame([1, 6], $ids); + + $ids = $this->comments->find() + ->leftJoinWith('Users.Profiles') + ->where(['Users_Profiles.first_name' => 'garrett']) + ->all() + ->extract('id') + ->toList(); + + $this->assertSame([2], $ids); + } + + public function testNotMatchingNested(): void + { + $query = $this->comments->find() + ->notMatching('Users.Profiles', function (SelectQuery $q) { + return $q->where(['Profiles.first_name' => 'nate']); + }) + ->orderBy(['Comments.id' => 'ASC']); + + $this->assertStringContainsString('(Users_Profiles.id) IS NULL', $this->sql($query)); + $this->assertSame([2, 3, 4, 5], $query->all()->extract('id')->toList()); + } + + public function testMatchingNestedBelongsToMany(): void + { + $query = $this->comments->find() + ->matching('Articles.Tags', function (SelectQuery $q) { + return $q->where(['Tags.id' => 2]); + }) + ->orderBy(['Comments.id' => 'ASC']); + $sql = $this->sql($query); + + $this->assertStringContainsString( + 'INNER JOIN articles_tags Articles_ArticlesTags ON Articles.id = Articles_ArticlesTags.article_id', + $sql, + ); + $this->assertStringContainsString( + 'INNER JOIN tags Articles_Tags ON (Articles_Tags.id = :c0 AND Articles_Tags.id = Articles_ArticlesTags.tag_id)', + $sql, + ); + + $results = $query->all(); + $this->assertSame([1, 2, 3, 4], $results->extract('id')->toList()); + + $matching = $results->first()->_matchingData; + $keys = array_keys($matching); + sort($keys); + $this->assertSame(['Articles', 'Articles_ArticlesTags', 'Articles_Tags'], $keys); + $this->assertSame('tag2', $matching['Articles_Tags']->name); + $this->assertSame(2, $matching['Articles_ArticlesTags']->tag_id); + } + + public function testNotMatchingNestedBelongsToMany(): void + { + $ids = $this->comments->find() + ->notMatching('Articles.Tags', function (SelectQuery $q) { + return $q->where(['Tags.id' => 2]); + }) + ->orderBy(['Comments.id' => 'ASC']) + ->all() + ->extract('id') + ->toList(); + + $this->assertSame([5, 6], $ids); + } + + public function testJoinWithConflictingAliasesResolved(): void + { + $comments = $this->getTableLocator()->get('Comments'); + $comments->belongsTo('Authors', [ + 'className' => 'Authors', + 'foreignKey' => 'user_id', + ]); + + $query = $comments->find() + ->leftJoinWith('Authors') + ->leftJoinWith('Articles', fn(SelectQuery $q) => $q->leftJoinWith('Authors')) + ->where(['Comments.id' => 1]); + + $this->assertStringContainsString('LEFT JOIN authors Articles_Authors', $this->sql($query)); + + $result = $query + ->contain(['Authors', 'Articles.Authors']) + ->firstOrFail(); + + $this->assertSame(2, $result->author->id); + $this->assertSame(1, $result->article->author->id); + } + + public function testMatchingLoaderInheritsSetting(): void + { + Configure::write('Database.deepAssociations', false); + + $query = $this->comments->find(); + $query->getEagerLoader()->setDeepAssociations(true); + $query->matching('Users.Profiles'); + + $this->assertStringContainsString('profiles Users_Profiles', $this->sql($query)); + + $query = $this->comments->find()->matching('Users.Profiles'); + $query->getEagerLoader()->setDeepAssociations(true); + + $this->assertStringContainsString('profiles Users_Profiles', $this->sql($query)); + } + + public function testDuplicateContainNoLongerDowngradesStrategy(): void + { + $articles = $this->getTableLocator()->get('Articles'); + $articles->belongsTo('Creator', ['className' => 'Authors', 'foreignKey' => 'author_id']); + $articles->belongsTo('Modifier', ['className' => 'Authors', 'foreignKey' => 'author_id']); + $articles->Creator->getTarget()->hasOne('Profiles', ['foreignKey' => 'user_id']); + $articles->Modifier->getTarget()->hasOne('Profiles', ['foreignKey' => 'user_id']); + + $query = $articles->find() + ->contain(['Creator.Profiles', 'Modifier.Profiles']) + ->where(['Articles.id' => 2]); + $sql = $this->sql($query); + + $this->assertStringContainsString('LEFT JOIN profiles Creator_Profiles', $sql); + $this->assertStringContainsString('LEFT JOIN profiles Modifier_Profiles', $sql); + $this->assertSame([], $query->getEagerLoader()->externalAssociations($articles)); + + $article = $query->firstOrFail(); + $this->assertSame('larry', $article->creator->profile->first_name); + $this->assertSame('larry', $article->modifier->profile->first_name); + } +} From f82622c8fb25b9b3dfdb799e71bca3105da6ff26 Mon Sep 17 00:00:00 2001 From: Nicos Panayides Date: Tue, 8 Sep 2026 11:34:38 +0300 Subject: [PATCH 2/2] Fix coding standard and rector findings 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 Claude-Session: https://claude.ai/code/session_01CufZXaq7Vyk9J4MUcX2otP --- src/ORM/EagerLoader.php | 4 ++-- tests/TestCase/ORM/DeepAssociationsTest.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ORM/EagerLoader.php b/src/ORM/EagerLoader.php index adf4d4bcc50..adf6f863b75 100644 --- a/src/ORM/EagerLoader.php +++ b/src/ORM/EagerLoader.php @@ -272,9 +272,9 @@ public function isDeepAssociationsEnabled(): bool /** * Returns the eager loader used for `matching` associations, creating it if required. * - * @return \Cake\ORM\EagerLoader + * @return self */ - protected function _matchingLoader(): EagerLoader + protected function _matchingLoader(): self { if ($this->_matching === null) { $this->_matching = new static(); diff --git a/tests/TestCase/ORM/DeepAssociationsTest.php b/tests/TestCase/ORM/DeepAssociationsTest.php index df989f2fd1c..5a9ce8d5d3c 100644 --- a/tests/TestCase/ORM/DeepAssociationsTest.php +++ b/tests/TestCase/ORM/DeepAssociationsTest.php @@ -215,7 +215,7 @@ public function testAssociationConditionsReferencingTargetAlias(): void public function testBeforeFindConditionsAreRewritten(): void { $profiles = $this->getTableLocator()->get('Profiles'); - $profiles->getEventManager()->on('Model.beforeFind', function ($event, SelectQuery $query) { + $profiles->getEventManager()->on('Model.beforeFind', function ($event, SelectQuery $query): void { $query->where(['Profiles.first_name !=' => 'nate']); });