fix(Query): keep all occurrences of a repeated identification variable

Parser::$identVariableExpressions was keyed by alias and held a single
SelectExpression per alias, so listing the same identification variable
twice in a SELECT clause made the second node overwrite the first.

fixIdentificationVariableOrder() only unsets and re-appends the nodes it
knows about, so the forgotten first node stayed at the head of the
select clause, ahead of its own parent. The hydrator then reached the
child before the parent existed in resultPointers, took the "parent
object of relation not found" branch in ObjectHydrator and unset the
'fetched' hint for the association. Because the hints are only rebuilt
in prepare(), the association stayed marked as not fetched for the rest
of the hydration run: the fetch join was silently dropped and every
parent's collection lazy loaded, while the query still returned correct
data.

Track every select expression per identification variable and move all
of them when reordering, so duplicates are re-appended together behind
their parent, in declaration order. With a single occurrence per alias
the inner loop runs once and the behaviour is unchanged.
This commit is contained in:
dualfroz committed 2026-09-03 13:30:28 +02:00
1 parent 8703a8e5de
commit 2c75209ce3
2 files changed
+36 -7

No files matched your search

+15 -7
View File
@@ -19,6 +19,7 @@ use ReflectionClass;
use function array_intersect;
use function array_key_exists;
use function array_keys;
use function array_search;
use function assert;
use function class_exists;
@@ -167,7 +168,13 @@ final class Parser
*/
private $customOutputWalker;
/** @phpstan-var array<string, AST\SelectExpression> */
/**
* All select expressions that refer to an identification variable, grouped
* by that variable. An alias may be listed more than once in the select
* clause, so every occurrence has to be tracked, not just the last one.
*
* @phpstan-var array<string, non-empty-list<AST\SelectExpression>>
*/
private array $identVariableExpressions = [];
/**
@@ -405,12 +412,13 @@ final class Parser
continue;
}
$expr = $this->identVariableExpressions[$dqlAlias];
$key = array_search($expr, $AST->selectClause->selectExpressions, true);
foreach ($this->identVariableExpressions[$dqlAlias] as $expr) {
$key = array_search($expr, $AST->selectClause->selectExpressions, true);
unset($AST->selectClause->selectExpressions[$key]);
unset($AST->selectClause->selectExpressions[$key]);
$AST->selectClause->selectExpressions[] = $expr;
$AST->selectClause->selectExpressions[] = $expr;
}
}
}
@@ -796,7 +804,7 @@ final class Parser
return;
}
foreach ($this->identVariableExpressions as $dqlAlias => $expr) {
foreach (array_keys($this->identVariableExpressions) as $dqlAlias) {
if (isset($this->queryComponents[$dqlAlias]) && ! isset($this->queryComponents[$dqlAlias]['parent'])) {
return;
}
@@ -2290,7 +2298,7 @@ final class Parser
$expr = new AST\SelectExpression($expression, $aliasResultVariable, $hiddenAliasResultVariable);
if ($identVariable) {
$this->identVariableExpressions[$identVariable] = $expr;
$this->identVariableExpressions[$identVariable][] = $expr;
}
return $expr;
@@ -222,6 +222,27 @@ class SelectSqlGenerationTest extends OrmTestCase
);
}
#[Group('GH12585')]
public function testRepeatedIdentificationVariableKeepsSelectClauseInDeclarationOrder(): void
{
// The repeated collection alias must not be hoisted in front of its own parent,
// otherwise the hydrator sees the child before the parent and stops fetch joining.
$this->assertSqlGeneration(
'SELECT u, p, p FROM Doctrine\Tests\Models\CMS\CmsUser u JOIN u.phonenumbers p',
'SELECT c0_.id AS id_0, c0_.status AS status_1, c0_.username AS username_2, c0_.name AS name_3, c1_.phonenumber AS phonenumber_4, c1_.phonenumber AS phonenumber_5, c0_.email_id AS email_id_6, c1_.user_id AS user_id_7 FROM cms_users c0_ INNER JOIN cms_phonenumbers c1_ ON c0_.id = c1_.user_id',
);
$this->assertSqlGeneration(
'SELECT u, p, u FROM Doctrine\Tests\Models\CMS\CmsUser u JOIN u.phonenumbers p',
'SELECT c0_.id AS id_0, c0_.status AS status_1, c0_.username AS username_2, c0_.name AS name_3, c0_.id AS id_4, c0_.status AS status_5, c0_.username AS username_6, c0_.name AS name_7, c1_.phonenumber AS phonenumber_8, c0_.email_id AS email_id_9, c1_.user_id AS user_id_10 FROM cms_users c0_ INNER JOIN cms_phonenumbers c1_ ON c0_.id = c1_.user_id',
);
$this->assertSqlGeneration(
'SELECT u, p, a, p FROM Doctrine\Tests\Models\CMS\CmsUser u JOIN u.phonenumbers p JOIN u.articles a',
'SELECT c0_.id AS id_0, c0_.status AS status_1, c0_.username AS username_2, c0_.name AS name_3, c1_.phonenumber AS phonenumber_4, c1_.phonenumber AS phonenumber_5, c2_.id AS id_6, c2_.topic AS topic_7, c2_.text AS text_8, c2_.version AS version_9, c0_.email_id AS email_id_10, c1_.user_id AS user_id_11, c2_.user_id AS user_id_12 FROM cms_users c0_ INNER JOIN cms_phonenumbers c1_ ON c0_.id = c1_.user_id INNER JOIN cms_articles c2_ ON c0_.id = c2_.user_id',
);
}
public function testSupportsSelectWithSingleValuedAssociationJoin(): void
{
$this->assertSqlGeneration(