Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ parameters:
-
message: '#^Unsafe usage of new static\(\)\.$#'
identifier: new.static
count: 2
count: 1
path: src/ORM/EagerLoader.php

-
Expand Down
188 changes: 176 additions & 12 deletions src/ORM/Association.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, mixed> $options Any extra options or overrides to be taken into account
Expand All @@ -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) {
Expand Down Expand Up @@ -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'],
]]);

Expand All @@ -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'];
Expand All @@ -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])) {
Expand All @@ -828,11 +848,13 @@ public function transformRow(array $row, string $nestKey, bool $joined, ?string
* @param array<string, mixed> $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<string, mixed>
*/
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;
}
Expand Down Expand Up @@ -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<string, mixed> $options options passed to the method `attachTo`
* @return array<string, string>
*/
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<string, string> $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<string, string> $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);
}

/**
Expand Down Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion src/ORM/Association/BelongsTo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
38 changes: 30 additions & 8 deletions src/ORM/Association/BelongsToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 : [];
}
Expand Down Expand Up @@ -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;
Expand All @@ -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,
]));
}

/**
Expand All @@ -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))
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 3 additions & 3 deletions src/ORM/Association/HasMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 : [];
}
Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src/ORM/Association/HasOne.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading