fetch join using composite key on join entity fails to hydrate correctly #5083

Closed
opened 2026-01-22 14:57:55 +01:00 by admin · 3 comments
Owner

Originally created by @jakestay on GitHub (Apr 7, 2016).

Originally assigned to: @Ocramius on GitHub.

When doing a fetch join to hydrate an entity, using a join table with a composite key and no other defined fields, the hydration incorrectly creates duplicate associated entities.

This issue may possibly be related to #2548. I cannot find any more recent related bug reports.

Steps to reproduce:
Create these three entities and their corresponding database tables:

/**
 * @ORM\Entity
 * @ORM\Table(name="driver")
 */
class Driver
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255);
     */
    private $name;

    /**
     * @ORM\OneToMany(targetEntity="DriverRide", mappedBy="driver")
     */
    private $driverRides;

    function getId() { return $this->id; } 
    function getName() { return $this->name; } 
    function getDriverRides() { return $this->driverRides; }
}
/**
 * @ORM\Entity
 * @ORM\Table(name="driver_ride")
 */
class DriverRide
{
    /**
     * @ORM\Id
     * @ORM\ManyToOne(targetEntity="Driver", inversedBy="driverRides")
     * @ORM\JoinColumn(name="driver_id", referencedColumnName="id")
     */
    private $driver;

    /**
     * @ORM\Id
     * @ORM\ManyToOne(targetEntity="Car", inversedBy="carRides")
     * @ORM\JoinColumn(name="car", referencedColumnName="brand")
     */
    private $car;

    function getDriver() { return $this->driver; }
    function getCar() { return $this->car; }
}
/**
 * @ORM\Entity
 * @ORM\Table(name="car")
 */
class Car
{
    /**
     * @ORM\Id
     * @ORM\Column(type="string", length=25)
     * @ORM\GeneratedValue(strategy="NONE")
     */
    private $brand;

    /**
     * @ORM\Column(type="string", length=255);
     */
    private $model;

    /**
     * @ORM\OneToMany(targetEntity="DriverRide", mappedBy="car")
     */
    private $carRides;

    function getBrand() { return $this->brand; }
    function getModel() { return $this->model; }
    function getCarRides() { return $this->carRides; }
}

Populate the database tables with this data:

INSERT INTO car (brand, model) VALUES ('BMW', '7 Series');
INSERT INTO car (brand, model) VALUES ('Crysler', '300');
INSERT INTO car (brand, model) VALUES ('Mercedes', 'C-Class');
INSERT INTO car (brand, model) VALUES ('Volvo', 'XC90');
INSERT INTO car (brand, model) VALUES ('Dodge', 'Dart');

INSERT INTO driver (id, name) VALUES (1, 'John Doe');

INSERT INTO driver_ride (driver_id, car) VALUES (1, 'Crysler');
INSERT INTO driver_ride (driver_id, car) VALUES (1, 'Mercedes');
INSERT INTO driver_ride (driver_id, car) VALUES (1, 'Volvo');
INSERT INTO driver_ride (driver_id, car) VALUES (1, 'BMW');
INSERT INTO driver_ride (driver_id, car) VALUES (1, 'Dodge');

Use this code to hydrate a Driver entity and display its contents:

$qb = $em->createQueryBuilder();
$driver = $qb->select('d, dr, c')
   ->from('Driver', 'd')
   ->leftJoin('d.driverRides', 'dr')
   ->leftJoin('dr.car', 'c')
   ->where('d.id = 1')
   ->getQuery()->getSingleResult();

print '<p>' . $driver->getName() . ':';
foreach ($driver->getDriverRides() as $ride) {
    print '<br>' . $ride->getCar()->getBrand() . ' ' . $ride->getCar()->getModel();
}

Expected output:
John Doe:
BMW 7 Series
Crysler 300
Dodge Dart
Mercedes C-Class
Volvo XC90

Actual output:
John Doe:
BMW 7 Series
Dodge Dart
Dodge Dart
Volvo XC90
Volvo XC90

There is a strange duplication going on here with the associated entities. Note that the problem disappears if there are additional fields defined in the join entity (DriverRide), or if all the associated entities (Car) have already been previously loaded from the database.

Originally created by @jakestay on GitHub (Apr 7, 2016). Originally assigned to: @Ocramius on GitHub. When doing a fetch join to hydrate an entity, using a join table with a composite key and no other defined fields, the hydration incorrectly creates duplicate associated entities. This issue may possibly be related to #2548. I cannot find any more recent related bug reports. **Steps to reproduce:** Create these three entities and their corresponding database tables: ``` php /** * @ORM\Entity * @ORM\Table(name="driver") */ class Driver { /** * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @ORM\Column(type="string", length=255); */ private $name; /** * @ORM\OneToMany(targetEntity="DriverRide", mappedBy="driver") */ private $driverRides; function getId() { return $this->id; } function getName() { return $this->name; } function getDriverRides() { return $this->driverRides; } } ``` ``` php /** * @ORM\Entity * @ORM\Table(name="driver_ride") */ class DriverRide { /** * @ORM\Id * @ORM\ManyToOne(targetEntity="Driver", inversedBy="driverRides") * @ORM\JoinColumn(name="driver_id", referencedColumnName="id") */ private $driver; /** * @ORM\Id * @ORM\ManyToOne(targetEntity="Car", inversedBy="carRides") * @ORM\JoinColumn(name="car", referencedColumnName="brand") */ private $car; function getDriver() { return $this->driver; } function getCar() { return $this->car; } } ``` ``` php /** * @ORM\Entity * @ORM\Table(name="car") */ class Car { /** * @ORM\Id * @ORM\Column(type="string", length=25) * @ORM\GeneratedValue(strategy="NONE") */ private $brand; /** * @ORM\Column(type="string", length=255); */ private $model; /** * @ORM\OneToMany(targetEntity="DriverRide", mappedBy="car") */ private $carRides; function getBrand() { return $this->brand; } function getModel() { return $this->model; } function getCarRides() { return $this->carRides; } } ``` Populate the database tables with this data: ``` sql INSERT INTO car (brand, model) VALUES ('BMW', '7 Series'); INSERT INTO car (brand, model) VALUES ('Crysler', '300'); INSERT INTO car (brand, model) VALUES ('Mercedes', 'C-Class'); INSERT INTO car (brand, model) VALUES ('Volvo', 'XC90'); INSERT INTO car (brand, model) VALUES ('Dodge', 'Dart'); INSERT INTO driver (id, name) VALUES (1, 'John Doe'); INSERT INTO driver_ride (driver_id, car) VALUES (1, 'Crysler'); INSERT INTO driver_ride (driver_id, car) VALUES (1, 'Mercedes'); INSERT INTO driver_ride (driver_id, car) VALUES (1, 'Volvo'); INSERT INTO driver_ride (driver_id, car) VALUES (1, 'BMW'); INSERT INTO driver_ride (driver_id, car) VALUES (1, 'Dodge'); ``` Use this code to hydrate a `Driver` entity and display its contents: ``` php $qb = $em->createQueryBuilder(); $driver = $qb->select('d, dr, c') ->from('Driver', 'd') ->leftJoin('d.driverRides', 'dr') ->leftJoin('dr.car', 'c') ->where('d.id = 1') ->getQuery()->getSingleResult(); print '<p>' . $driver->getName() . ':'; foreach ($driver->getDriverRides() as $ride) { print '<br>' . $ride->getCar()->getBrand() . ' ' . $ride->getCar()->getModel(); } ``` **Expected output:** John Doe: BMW 7 Series Crysler 300 Dodge Dart Mercedes C-Class Volvo XC90 **Actual output:** John Doe: BMW 7 Series Dodge Dart Dodge Dart Volvo XC90 Volvo XC90 There is a strange duplication going on here with the associated entities. Note that the problem disappears if there are additional fields defined in the join entity (`DriverRide`), or if all the associated entities (`Car`) have already been previously loaded from the database.
admin added the Bug label 2026-01-22 14:57:55 +01:00
admin closed this issue 2026-01-22 14:57:56 +01:00
Author
Owner

@jakestay commented on GitHub (Apr 12, 2016):

I think the main cause of this bug is located in the ObjectHydrator class. In the hydrateRowData() method, $resultPointers is used by to store a reference to the most recently hydrated object of each entity type. When a child entity needs to be linked to its parent, this method looks in $resultPointers to find a reference to the parent and, if it finds one, links the child to that parent.

The problem is that $resultPointers is an instance/object (rather than local/method) variable that does not get reinitialized every time hydrateRowData() is called, and so it may retain a reference to an entity that was hydrated the previous time the method was called rather than the current time.

In this particular example, the Car entity is hydrated before the DriverRide entity each time hydrateRowData() is called. When the method looks for the parent of the Car entity, it finds nothing the first time (because DriverRide has not yet been processed at all) and, on every subsequent call, finds a reference to the DriverRide object that was hydrated the previous time the method was called (because DriverRide has not yet been processed for the current row, and $resultPointers still retains a reference to the result of processing for the previous row).

The bug disappears when additional fields are added to DriverRide only because doing so happens to cause the DriverRide entity to be processed before the Car entity in hydrateRowData(). The duplication of records happens because some other weird part of this method causes the child entity to be identified as not fetch joined (and so lazy loaded) every other time the method is called (not counting the first time), and so those times (the third, fifth, etc.) the link between child and parent happens to turn out correctly.

I believe that the fundamental problem is that $resultPointers is neither a local variable nor reinitialized each time hydrateRowData() is called. I cannot think of any scenario in which you would need a reference to an object that was hydrated with data from the previous row of data, so I would recommend simply reinitializing this variable at the beginning of this method. That should fix the bug.

@jakestay commented on GitHub (Apr 12, 2016): I think the main cause of this bug is located in the `ObjectHydrator` class. In the `hydrateRowData()` method, `$resultPointers` is used by to store a reference to the most recently hydrated object of each entity type. When a child entity needs to be linked to its parent, this method looks in `$resultPointers` to find a reference to the parent and, if it finds one, links the child to that parent. The problem is that `$resultPointers` is an instance/object (rather than local/method) variable that does not get reinitialized every time `hydrateRowData()` is called, and so it may retain a reference to an entity that was hydrated the _previous_ time the method was called rather than the _current_ time. In this particular example, the `Car` entity is hydrated _before_ the `DriverRide` entity each time `hydrateRowData()` is called. When the method looks for the parent of the `Car` entity, it finds nothing the first time (because `DriverRide` has not yet been processed at all) and, on every subsequent call, finds a reference to the `DriverRide` object that was hydrated the _previous_ time the method was called (because `DriverRide` has not yet been processed for the _current_ row, and `$resultPointers` still retains a reference to the result of processing for the _previous_ row). The bug disappears when additional fields are added to `DriverRide` only because doing so happens to cause the `DriverRide` entity to be processed before the `Car` entity in `hydrateRowData()`. The duplication of records happens because some other weird part of this method causes the child entity to be identified as not fetch joined (and so lazy loaded) every other time the method is called (not counting the first time), and so those times (the third, fifth, etc.) the link between child and parent happens to turn out correctly. I believe that the fundamental problem is that `$resultPointers` is neither a local variable nor reinitialized each time `hydrateRowData()` is called. I cannot think of any scenario in which you would need a reference to an object that was hydrated with data from the _previous_ row of data, so I would recommend simply reinitializing this variable at the beginning of this method. That should fix the bug.
Author
Owner

@Ocramius commented on GitHub (Sep 7, 2016):

Fixed in #5975

@Ocramius commented on GitHub (Sep 7, 2016): Fixed in #5975
Author
Owner

@sobeslavsky commented on GitHub (Aug 21, 2017):

An issue remains in 2.5.10. When using array hydration, the last joined entity in result set is not hydrated.

I'm having the following entity:

/**
 * ChallengeSkill
 *
 * @ORM\Table(name="jn_challenge_skill")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\JnChallengeSkillRepository")
 */
class JnChallengeSkill {

    /**
     * @var Challenge
     *
     * @ORM\Id
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Challenge", inversedBy="challengeSkills")
     * @ORM\JoinColumns({
     *   @ORM\JoinColumn(name="challenge_id", referencedColumnName="id")
     * })
     */
    private $challenge;

    /**
     * @var Skill
     *
     * @ORM\Id
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Skill", inversedBy="challengeSkills")
     * @ORM\JoinColumns({
     *   @ORM\JoinColumn(name="skill_id", referencedColumnName="id")
     * })
     */
    private $skill;
}

Now I call this code:

$qb = $this->getEntityManager()->createQueryBuilder()
                        ->select('cs', 's')
                        ->from('AppBundle\Entity\JnChallengeSkill', 'cs')
                        ->leftJoin('cs.challenge', 'ch')
                        ->leftJoin('cs.skill', 's')
                        ->where('IDENTITY(cs.challenge) = :challengeId')->setParameter('challengeId', $challengeId);

$ret = $qb->getQuery()->execute(array(), Query::HYDRATE_ARRAY);
print_r($ret);

And get this result:

Array
(
    [0] => Array
        (
            [challenge_id] => 1
            [skill_id] => 1
            [skill] => Array
                (
                    [id] => 2
                    [name] => Accounting
                    [alwaysShow] => 1
                )

        )

    [1] => Array
        (
            [challenge_id] => 1
            [skill_id] => 2
            [skill] => Array
                (
                    [id] => 3
                    [name] => Webdesign
                    [alwaysShow] => 1
                )

        )

    [2] => Array
        (
            [challenge_id] => 1
            [skill_id] => 3
        )

)

Notice that the last skill in result set is not hydrated. When I use scalar hydration, all fields are returned correctly. When I add an additional field to JnChallengeSkill (as @jakestay did above), the last result gets hydrated properly as well.

@sobeslavsky commented on GitHub (Aug 21, 2017): An issue remains in 2.5.10. When using array hydration, the last joined entity in result set is not hydrated. I'm having the following entity: ``` /** * ChallengeSkill * * @ORM\Table(name="jn_challenge_skill") * @ORM\Entity(repositoryClass="AppBundle\Repository\JnChallengeSkillRepository") */ class JnChallengeSkill { /** * @var Challenge * * @ORM\Id * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Challenge", inversedBy="challengeSkills") * @ORM\JoinColumns({ * @ORM\JoinColumn(name="challenge_id", referencedColumnName="id") * }) */ private $challenge; /** * @var Skill * * @ORM\Id * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Skill", inversedBy="challengeSkills") * @ORM\JoinColumns({ * @ORM\JoinColumn(name="skill_id", referencedColumnName="id") * }) */ private $skill; } ``` Now I call this code: ``` $qb = $this->getEntityManager()->createQueryBuilder() ->select('cs', 's') ->from('AppBundle\Entity\JnChallengeSkill', 'cs') ->leftJoin('cs.challenge', 'ch') ->leftJoin('cs.skill', 's') ->where('IDENTITY(cs.challenge) = :challengeId')->setParameter('challengeId', $challengeId); $ret = $qb->getQuery()->execute(array(), Query::HYDRATE_ARRAY); print_r($ret); ``` And get this result: ``` Array ( [0] => Array ( [challenge_id] => 1 [skill_id] => 1 [skill] => Array ( [id] => 2 [name] => Accounting [alwaysShow] => 1 ) ) [1] => Array ( [challenge_id] => 1 [skill_id] => 2 [skill] => Array ( [id] => 3 [name] => Webdesign [alwaysShow] => 1 ) ) [2] => Array ( [challenge_id] => 1 [skill_id] => 3 ) ) ``` Notice that the last skill in result set is not hydrated. When I use scalar hydration, all fields are returned correctly. When I add an additional field to JnChallengeSkill (as @jakestay did above), the last result gets hydrated properly as well.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: doctrine/archived-orm#5083