New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
jakestay opened this Issue Apr 7, 2016 · 3 comments

Comments

Projects
None yet
3 participants
@jakestay
Contributor

jakestay commented Apr 7, 2016

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.

@jakestay

This comment has been minimized.

Show comment
Hide comment
@jakestay

jakestay Apr 12, 2016

Contributor

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.

Contributor

jakestay commented 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 added a commit to jakestay/doctrine2 that referenced this issue Apr 12, 2016

kamazee added a commit to kamazee/doctrine2 that referenced this issue Aug 14, 2016

kamazee added a commit to kamazee/doctrine2 that referenced this issue Aug 14, 2016

kamazee added a commit to kamazee/doctrine2 that referenced this issue Aug 29, 2016

kamazee added a commit to kamazee/doctrine2 that referenced this issue Aug 29, 2016

Ocramius added a commit that referenced this issue Sep 7, 2016

@Ocramius Ocramius closed this in 2814d6e Sep 7, 2016

@Ocramius

This comment has been minimized.

Show comment
Hide comment
@Ocramius

Ocramius Sep 7, 2016

Member

Fixed in #5975

Member

Ocramius commented Sep 7, 2016

Fixed in #5975

@Ocramius Ocramius self-assigned this Sep 7, 2016

@Ocramius Ocramius added the Bug label Sep 7, 2016

@Ocramius Ocramius added this to the 2.5.5 milestone Sep 7, 2016

yvoyer added a commit to yvoyer/doctrine2 that referenced this issue Nov 21, 2016

yvoyer added a commit to yvoyer/doctrine2 that referenced this issue Nov 21, 2016

yvoyer added a commit to yvoyer/doctrine2 that referenced this issue Nov 21, 2016

alexgurrola added a commit to Sitetheory-Archive/doctrine2 that referenced this issue Apr 13, 2017

alexgurrola added a commit to Sitetheory-Archive/doctrine2 that referenced this issue Apr 13, 2017

alexgurrola added a commit to Sitetheory-Archive/doctrine2 that referenced this issue Apr 13, 2017

@sobeslavsky

This comment has been minimized.

Show comment
Hide comment
@sobeslavsky

sobeslavsky 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 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 up for free to join this conversation on GitHub. Already have an account? Sign in to comment