#12585·orm

Duplicate variable in select clause silently disables fetch joining

Author: wayne530Created Aug 27, 2026Updated Sep 9, 2026

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:

php
// 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:

sql
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, cfc

Expected 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
<?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/12

Relevant code, all on 3.6.8:

  • src/Query/Parser.php:2293identVariableExpressions keyed by alias, last write wins
  • src/Query/Parser.php:395fixIdentificationVariableOrder(), moves only registered nodes
  • src/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.