From 79447cbb1872346bbebfc892ea3d023007690100 Mon Sep 17 00:00:00 2001 From: Alex Popov Date: Sun, 3 Jul 2022 13:25:34 +0400 Subject: [PATCH] ObjectHydrator: defer initialization of potentially empty collections If ObjectHydrator faces an empty row to an uninitialized collection, it initializes it, to prevent it from querying again (DDC-1526). However, if that row is the first but not the only in the collection, the next rows will be ignored, as the collection will be considered "existing", and "existing" collections are only replaced if REFRESH hint is present. To prevent it, we defer initialization to the end of the hydration. Fixes GH-9807 --- .../ORM/Internal/Hydration/ObjectHydrator.php | 31 +++-- .../ORM/Functional/Ticket/GH9807Test.php | 129 ++++++++++++++++++ 2 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 tests/Doctrine/Tests/ORM/Functional/Ticket/GH9807Test.php diff --git a/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php b/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php index 4f21a578a9..20865f1081 100644 --- a/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php +++ b/lib/Doctrine/ORM/Internal/Hydration/ObjectHydrator.php @@ -44,6 +44,9 @@ class ObjectHydrator extends AbstractHydrator /** @var mixed[] */ private $initializedCollections = []; + /** @var array */ + private $uninitializedCollections = []; + /** @var mixed[] */ private $existingCollections = []; @@ -112,10 +115,11 @@ protected function cleanup() parent::cleanup(); - $this->identifierMap = - $this->initializedCollections = - $this->existingCollections = - $this->resultPointers = []; + $this->identifierMap = + $this->initializedCollections = + $this->uninitializedCollections = + $this->existingCollections = + $this->resultPointers = []; if ($eagerLoad) { $this->_uow->triggerEagerLoads(); @@ -126,10 +130,11 @@ protected function cleanup() protected function cleanupAfterRowIteration(): void { - $this->identifierMap = - $this->initializedCollections = - $this->existingCollections = - $this->resultPointers = []; + $this->identifierMap = + $this->initializedCollections = + $this->uninitializedCollections = + $this->existingCollections = + $this->resultPointers = []; } /** @@ -148,6 +153,12 @@ protected function hydrateAllData() $coll->takeSnapshot(); } + foreach ($this->uninitializedCollections as $coll) { + if (! $coll->isInitialized()) { + $coll->setInitialized(true); + } + } + return $result; } @@ -411,8 +422,8 @@ protected function hydrateRowData(array $row, array &$result) } } elseif (! $reflFieldValue) { $this->initRelatedCollection($parentObject, $parentClass, $relationField, $parentAlias); - } elseif ($reflFieldValue instanceof PersistentCollection && $reflFieldValue->isInitialized() === false) { - $reflFieldValue->setInitialized(true); + } elseif ($reflFieldValue instanceof PersistentCollection && $reflFieldValue->isInitialized() === false && ! isset($this->uninitializedCollections[$oid . $relationField])) { + $this->uninitializedCollections[$oid . $relationField] = $reflFieldValue; } } else { // PATH B: Single-valued association diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9807Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9807Test.php new file mode 100644 index 0000000000..c7cb744d1c --- /dev/null +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/GH9807Test.php @@ -0,0 +1,129 @@ +createSchemaForModels(GH9807Main::class, GH9807Join::class); + } + + public function testHydrateJoinedCollectionWithFirstNullishRow(): void + { + $rsm = new ResultSetMapping(); + $rsm->addEntityResult(GH9807Main::class, 'm'); + $rsm->addJoinedEntityResult(GH9807Join::class, 'j', 'm', 'joins'); + + $rsm->addFieldResult('m', 'id_0', 'id'); + $rsm->addFieldResult('j', 'id_1', 'id'); + $rsm->addFieldResult('j', 'value_2', 'value'); + + $hydrator = new ObjectHydrator($this->_em); + + $uow = $this->_em->getUnitOfWork(); + + $uow->createEntity( + GH9807Main::class, + ['id' => 1] + ); + + $resultSet = [ + [ + 'id_0' => 1, + 'id_1' => null, + 'value_2' => null, + ], + [ + 'id_0' => 1, + 'id_1' => 1, + 'value_2' => '2', + ], + [ + 'id_0' => 1, + 'id_1' => 2, + 'value_2' => '2', + ], + ]; + + $stmt = ArrayResultFactory::createFromArray($resultSet); + + /** @var GH9807Main[] $result */ + $result = $hydrator->hydrateAll($stmt, $rsm); + + self::assertInstanceOf(GH9807Main::class, $result[0]); + self::assertCount(2, $result[0]->getJoins()); + } +} + +/** + * @Entity + */ +class GH9807Main +{ + /** + * @var int + * @Column(type="integer") + * @Id + * @GeneratedValue + */ + private $id; + + /** + * @ORM\ManyToMany(targetEntity="GH9807Join", inversedBy="starts") + * + * @var Collection + */ + private $joins; + + /** + * @return Collection + */ + public function getJoins(): Collection + { + return $this->joins; + } +} + +/** + * @Entity + */ +class GH9807Join +{ + /** + * @var int + * @Column(type="integer") + * @Id + * @GeneratedValue + */ + private $id; + + /** + * @ORM\ManyToMany(targetEntity="GH9807Main", mappedBy="bases") + * + * @var Collection + */ + private $mains; + + /** + * @ORM\Column(type="string", nullable=false) + * + * @var string + */ + private $value; +}