diff --git a/docs/en/cookbook/validation-of-entities.rst b/docs/en/cookbook/validation-of-entities.rst index c2a928dd688..7c44f41c109 100644 --- a/docs/en/cookbook/validation-of-entities.rst +++ b/docs/en/cookbook/validation-of-entities.rst @@ -11,7 +11,7 @@ What we offer are hooks to execute any kind of validation. .. note:: You don't need to validate your entities in the lifecycle - events. Its only one of many options. Of course you can also + events. It is only one of many options. Of course you can also perform validations in value setters or any other method of your entities that are used in your code. diff --git a/docs/en/reference/association-mapping.rst b/docs/en/reference/association-mapping.rst index bbe2631dfac..fcb2c8a42df 100644 --- a/docs/en/reference/association-mapping.rst +++ b/docs/en/reference/association-mapping.rst @@ -870,8 +870,8 @@ This is essentially the same as the following, more verbose, mapping: * @var Collection */ #[JoinTable(name: 'User_Group')] - #[JoinColumn(name: 'User_id', referencedColumnName: 'id')] - #[InverseJoinColumn(name: 'Group_id', referencedColumnName: 'id')] + #[JoinColumn(name: 'user_id', referencedColumnName: 'id')] + #[InverseJoinColumn(name: 'group_id', referencedColumnName: 'id')] #[ManyToMany(targetEntity: Group::class)] private Collection $groups; // ... @@ -884,10 +884,10 @@ This is essentially the same as the following, more verbose, mapping: - + - + diff --git a/src/Internal/Hydration/AbstractHydrator.php b/src/Internal/Hydration/AbstractHydrator.php index 232b9c3ae9a..d8bffe4ad39 100644 --- a/src/Internal/Hydration/AbstractHydrator.php +++ b/src/Internal/Hydration/AbstractHydrator.php @@ -104,29 +104,31 @@ final public function toIterable(Result $stmt, ResultSetMapping $resultSetMappin $this->prepare(); - while (true) { - $row = $this->statement()->fetchAssociative(); - - if ($row === false) { - $this->cleanup(); + try { + while (true) { + $row = $this->statement()->fetchAssociative(); - break; - } + if ($row === false) { + break; + } - $result = []; + $result = []; - $this->hydrateRowData($row, $result); + $this->hydrateRowData($row, $result); - $this->cleanupAfterRowIteration(); - if (count($result) === 1) { - if (count($resultSetMapping->indexByMap) === 0) { - yield end($result); + $this->cleanupAfterRowIteration(); + if (count($result) === 1) { + if (count($resultSetMapping->indexByMap) === 0) { + yield end($result); + } else { + yield from $result; + } } else { - yield from $result; + yield $result; } - } else { - yield $result; } + } finally { + $this->cleanup(); } } diff --git a/src/UnitOfWork.php b/src/UnitOfWork.php index b07bf8aa518..d1659de272b 100644 --- a/src/UnitOfWork.php +++ b/src/UnitOfWork.php @@ -1143,6 +1143,8 @@ private function executeDeletions(): void $eventsToDispatch = []; foreach ($entities as $entity) { + $this->removeFromIdentityMap($entity); + $oid = spl_object_id($entity); $class = $this->em->getClassMetadata($entity::class); $persister = $this->getEntityPersister($class->name); @@ -1484,8 +1486,6 @@ public function scheduleForDelete(object $entity): void return; } - $this->removeFromIdentityMap($entity); - unset($this->entityUpdates[$oid]); if (! isset($this->entityDeletions[$oid])) { @@ -2653,7 +2653,7 @@ private function eagerLoadCollections(array $collections, ToManyInverseSideMappi $entities[] = $collection->getOwner(); } - $found = $this->getEntityPersister($targetEntity)->loadAll([$mappedBy => $entities]); + $found = $this->getEntityPersister($targetEntity)->loadAll([$mappedBy => $entities], $mapping['orderBy'] ?? null); $targetClass = $this->em->getClassMetadata($targetEntity); $targetProperty = $targetClass->getReflectionProperty($mappedBy); diff --git a/tests/Tests/ORM/Functional/QueryDqlFunctionTest.php b/tests/Tests/ORM/Functional/QueryDqlFunctionTest.php index 5a6aa5da3ab..fcc82cf35c6 100644 --- a/tests/Tests/ORM/Functional/QueryDqlFunctionTest.php +++ b/tests/Tests/ORM/Functional/QueryDqlFunctionTest.php @@ -252,7 +252,6 @@ public function testOperatorMultiply(): void self::assertEquals(1_600_000, $result[3]['op']); } - #[Group('test')] public function testOperatorDiv(): void { $result = $this->_em->createQuery('SELECT m, (m.salary/0.5) AS op FROM Doctrine\Tests\Models\Company\CompanyManager m ORDER BY m.salary ASC') diff --git a/tests/Tests/ORM/Functional/Ticket/GH11163Test.php b/tests/Tests/ORM/Functional/Ticket/GH11163Test.php new file mode 100644 index 00000000000..d4e75389c19 --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH11163Test.php @@ -0,0 +1,112 @@ +setUpEntitySchema([ + GH11163Bucket::class, + GH11163BucketItem::class, + ]); + } + + public function tearDown(): void + { + parent::tearDown(); + + $conn = static::$sharedConn; + $conn->executeStatement('DELETE FROM GH11163BucketItem'); + $conn->executeStatement('DELETE FROM GH11163Bucket'); + } + + public function testFetchEagerModeWithOrderBy(): void + { + // Load entities into database + $this->_em->persist($bucket = new GH11163Bucket(11163)); + $this->_em->persist(new GH11163BucketItem(1, $bucket, 2)); + $this->_em->persist(new GH11163BucketItem(2, $bucket, 3)); + $this->_em->persist(new GH11163BucketItem(3, $bucket, 1)); + $this->_em->flush(); + $this->_em->clear(); + + // Fetch entity from database + $dql = 'SELECT bucket FROM ' . GH11163Bucket::class . ' bucket WHERE bucket.id = :id'; + $bucket = $this->_em->createQuery($dql) + ->setParameter('id', 11163) + ->getSingleResult(); + + // Assert associated entity is loaded eagerly + static::assertInstanceOf(GH11163Bucket::class, $bucket); + static::assertInstanceOf(PersistentCollection::class, $bucket->items); + static::assertTrue($bucket->items->isInitialized()); + + static::assertCount(3, $bucket->items); + + // Assert order of entities + static::assertSame(1, $bucket->items[0]->position); + static::assertSame(3, $bucket->items[0]->id); + + static::assertSame(2, $bucket->items[1]->position); + static::assertSame(1, $bucket->items[1]->id); + + static::assertSame(3, $bucket->items[2]->position); + static::assertSame(2, $bucket->items[2]->id); + } +} + +#[ORM\Entity] +class GH11163Bucket +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + private int $id; + + /** @var Collection */ + #[ORM\OneToMany( + targetEntity: GH11163BucketItem::class, + mappedBy: 'bucket', + fetch: 'EAGER', + )] + #[ORM\OrderBy(['position' => 'ASC'])] + public Collection $items; + + public function __construct(int $id) + { + $this->id = $id; + $this->items = new ArrayCollection(); + } +} + +#[ORM\Entity] +class GH11163BucketItem +{ + #[ORM\ManyToOne(targetEntity: GH11163Bucket::class, inversedBy: 'items')] + #[ORM\JoinColumn(nullable: false)] + private GH11163Bucket $bucket; + + #[ORM\Id] + #[ORM\Column(type: 'integer')] + public int $id; + + #[ORM\Column(type: 'integer')] + public int $position; + + public function __construct(int $id, GH11163Bucket $bucket, int $position) + { + $this->id = $id; + $this->bucket = $bucket; + $this->position = $position; + } +} diff --git a/tests/Tests/ORM/Functional/Ticket/GH6123Test.php b/tests/Tests/ORM/Functional/Ticket/GH6123Test.php new file mode 100644 index 00000000000..60bea99a7e6 --- /dev/null +++ b/tests/Tests/ORM/Functional/Ticket/GH6123Test.php @@ -0,0 +1,79 @@ +createSchemaForModels( + GH6123Entity::class, + ); + } + + public function testLoadingRemovedEntityFromDatabaseDoesNotCreateNewManagedEntityInstance(): void + { + $entity = new GH6123Entity(); + $this->_em->persist($entity); + $this->_em->flush(); + + self::assertSame(UnitOfWork::STATE_MANAGED, $this->_em->getUnitOfWork()->getEntityState($entity)); + self::assertFalse($this->_em->getUnitOfWork()->isScheduledForDelete($entity)); + + $this->_em->remove($entity); + + $freshEntity = $this->loadEntityFromDatabase($entity->id); + self::assertSame($entity, $freshEntity); + + self::assertSame(UnitOfWork::STATE_REMOVED, $this->_em->getUnitOfWork()->getEntityState($freshEntity)); + self::assertTrue($this->_em->getUnitOfWork()->isScheduledForDelete($freshEntity)); + } + + public function testRemovedEntityCanBePersistedAgain(): void + { + $entity = new GH6123Entity(); + $this->_em->persist($entity); + $this->_em->flush(); + + $this->_em->remove($entity); + self::assertSame(UnitOfWork::STATE_REMOVED, $this->_em->getUnitOfWork()->getEntityState($entity)); + self::assertTrue($this->_em->getUnitOfWork()->isScheduledForDelete($entity)); + + $this->loadEntityFromDatabase($entity->id); + + $this->_em->persist($entity); + self::assertSame(UnitOfWork::STATE_MANAGED, $this->_em->getUnitOfWork()->getEntityState($entity)); + self::assertFalse($this->_em->getUnitOfWork()->isScheduledForDelete($entity)); + + $this->_em->flush(); + } + + private function loadEntityFromDatabase(int $id): GH6123Entity|null + { + return $this->_em->createQueryBuilder() + ->select('e') + ->from(GH6123Entity::class, 'e') + ->where('e.id = :id') + ->setParameter('id', $id) + ->getQuery() + ->getOneOrNullResult(); + } +} + +#[ORM\Entity] +class GH6123Entity +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: Types::INTEGER)] + public int $id; +} diff --git a/tests/Tests/ORM/Hydration/AbstractHydratorTest.php b/tests/Tests/ORM/Hydration/AbstractHydratorTest.php index a9b451e1c19..d83ee1a8647 100644 --- a/tests/Tests/ORM/Hydration/AbstractHydratorTest.php +++ b/tests/Tests/ORM/Hydration/AbstractHydratorTest.php @@ -13,6 +13,7 @@ use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Internal\Hydration\AbstractHydrator; use Doctrine\ORM\Query\ResultSetMapping; +use Doctrine\Tests\Models\Hydration\SimpleEntity; use Doctrine\Tests\OrmFunctionalTestCase; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; @@ -149,4 +150,33 @@ public function testHydrateAllClearsAllAttachedListenersEvenOnError(): void $this->expectException(ORMException::class); $this->hydrator->hydrateAll($this->mockResult, $this->mockResultMapping); } + + public function testToIterableIfYieldAndBreakBeforeFinishAlwaysCleansUp(): void + { + $this->setUpEntitySchema([SimpleEntity::class]); + + $entity1 = new SimpleEntity(); + $this->_em->persist($entity1); + $entity2 = new SimpleEntity(); + $this->_em->persist($entity2); + + $this->_em->flush(); + $this->_em->clear(); + + $evm = $this->_em->getEventManager(); + + $q = $this->_em->createQuery('SELECT e.id FROM ' . SimpleEntity::class . ' e'); + + // select two entities, but do no iterate + $q->toIterable(); + self::assertCount(0, $evm->getListeners(Events::onClear)); + + // select two entities, but abort after first record + foreach ($q->toIterable() as $result) { + self::assertCount(1, $evm->getListeners(Events::onClear)); + break; + } + + self::assertCount(0, $evm->getListeners(Events::onClear)); + } } diff --git a/tests/Tests/ORM/UnitOfWorkTest.php b/tests/Tests/ORM/UnitOfWorkTest.php index 550b1cfe1c8..a9112fa5d51 100644 --- a/tests/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Tests/ORM/UnitOfWorkTest.php @@ -288,12 +288,18 @@ public function testRemovedAndRePersistedEntitiesAreInTheIdentityMapAndAreNotGar $entity->id = 123; $this->_unitOfWork->registerManaged($entity, ['id' => 123], []); + self::assertSame(UnitOfWork::STATE_MANAGED, $this->_unitOfWork->getEntityState($entity)); + self::assertFalse($this->_unitOfWork->isScheduledForDelete($entity)); self::assertTrue($this->_unitOfWork->isInIdentityMap($entity)); $this->_unitOfWork->remove($entity); - self::assertFalse($this->_unitOfWork->isInIdentityMap($entity)); + self::assertSame(UnitOfWork::STATE_REMOVED, $this->_unitOfWork->getEntityState($entity)); + self::assertTrue($this->_unitOfWork->isScheduledForDelete($entity)); + self::assertTrue($this->_unitOfWork->isInIdentityMap($entity)); $this->_unitOfWork->persist($entity); + self::assertSame(UnitOfWork::STATE_MANAGED, $this->_unitOfWork->getEntityState($entity)); + self::assertFalse($this->_unitOfWork->isScheduledForDelete($entity)); self::assertTrue($this->_unitOfWork->isInIdentityMap($entity)); }