Duplicate variable in select clause silently disables fetch joining
Bug Report
| Q | A |
|---|---|
| Version | 3.6.8 |
| Previous Version if the bug is a regression | n/a — not a regression |
Summary
Listing the same identification variable twice in a SELECT clause silently disables fetch joining
for that association. The collection is still joined in SQL and its columns are still selected, but
the hydrator leaves the collection uninitialized, so it lazy loads one query per parent row.
Parser::fixIdentificationVariableOrder() exists to reorder select expressions into join-declaration
order, because "the hydration process relies on that order for proper operation". It reads its work
list from $this->identVariableExpressions, which is keyed by alias:
// Parser::SelectExpression()
if ($identVariable) {
$this->identVariableExpressions[$identVariable] = $expr;
}When an alias appears twice, the second SelectExpression node overwrites the first, so the map holds
only one of the two nodes. fixIdentificationVariableOrder() then unsets and re-appends every node it
knows about, and the first (unregistered) node is never moved. Once every other expression has been
re-appended behind it, that orphan is left sitting at the head of the select clause — ahead of its own
parent.
The hydrator then reaches the child before its parent exists in resultPointers, takes the "parent
object of relation not found" branch in ObjectHydrator::hydrateRowData(), and runs
unset($this->hints['fetched'][$parentAlias][$relationField]). Because hints is only rebuilt in
prepare(), the association stays marked as not fetched for the remainder of hydration, and every
parent's collection lazy loads.
Current behavior
For a query whose select clause names a joined collection alias twice, the generated SQL emits the child's columns first:
SELECT c0_.id AS ID_0, ... , c1_.id AS ID_5, ...
FROM cms_users c1_ LEFT JOIN cms_phonenumbers c0_ ON ...and the fetch-joined collection comes back uninitialized. In a real query of ours — nine root rows,
26 second-level rows, one duplicated third-level alias — this produced 26 extra SELECTs and zero
initialized collections, while returning byte-identical data.
The AST reordering is directly observable:
DQL select 'cc, dc, cf, cfc, cffe, cfc' -> AST order: cfc, cc, dc, cf, cffe, cfc
DQL select 'cc, dc, cf, cfc, cffe' -> AST order: cc, dc, cf, cffe, cfcExpected behavior
A duplicated alias in the select clause should not change hydration. Either every select expression
referencing an identification variable is accounted for when the clause is reordered, or repeated
identification variables are collapsed to one (SqlWalker::walkObjectExpression() already dedupes
them for selectedClasses, so the duplicate contributes only redundant columns anyway).
Failing loudly would also be an acceptable outcome. The present behavior is the bad one: valid DQL, correct results, and a silent N+1 with no diagnostic.
How to reproduce
Reproduces at its simplest with a root entity and one fetch-joined collection — the duplicated alias does not need to be nested.
<?php
declare(strict_types=1);
namespace Doctrine\Tests\ORM\Functional\Ticket;
use Doctrine\ORM\PersistentCollection;
use Doctrine\Tests\Models\CMS\CmsPhonenumber;
use Doctrine\Tests\Models\CMS\CmsUser;
use Doctrine\Tests\OrmFunctionalTestCase;
final class GHXXXXTest extends OrmFunctionalTestCase
{
protected function setUp(): void
{
$this->useModelSet('cms');
parent::setUp();
$user = new CmsUser();
$user->username = 'jwage';
$user->name = 'Jonathan';
$user->status = 'developer';
$phonenumber = new CmsPhonenumber();
$phonenumber->phonenumber = '12345';
$user->addPhonenumber($phonenumber);
$this->_em->persist($user);
$this->_em->flush();
$this->_em->clear();
}
public function testRepeatedSelectAliasKeepsCollectionFetchJoined(): void
{
$users = $this->_em->createQuery(
'SELECT u, p, p FROM ' . CmsUser::class . ' u LEFT JOIN u.phonenumbers p',
)->getResult();
$phonenumbers = $users[0]->phonenumbers;
self::assertInstanceOf(PersistentCollection::class, $phonenumbers);
self::assertTrue($phonenumbers->isInitialized()); // fails: collection lazy loads
}
}Dropping the repeated p from the select clause makes it pass. Measured on our own schema, with the
identical query otherwise:
select 'cc, cf, cf' -> first table in SELECT list: child collections initialized: 0/12
select 'cc, cf' -> first table in SELECT list: root collections initialized: 12/12Relevant code, all on 3.6.8:
src/Query/Parser.php:2293—identVariableExpressionskeyed by alias, last write winssrc/Query/Parser.php:395—fixIdentificationVariableOrder(), moves only registered nodessrc/Internal/Hydration/ObjectHydrator.php:361-375— parent-not-found branch,unset(hints['fetched'])
fixIdentificationVariableOrder() and the identVariableExpressions assignment are byte-identical on
2.20.x, so 2.x is very likely affected too, though I have only confirmed the behavior on 3.6.8.
Source: doctrine/orm