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..adf6f863b75 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 self + */ + protected function _matchingLoader(): self + { + 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..5a9ce8d5d3c --- /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): void { + $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); + } +}