diff --git a/UPGRADE.md b/UPGRADE.md index 988670fab4c..d68a1dfecf4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -393,17 +393,6 @@ bugs/scenarios. The method `UnitOfWork::merge()` has been removed. The method `EntityManager::merge()` will throw an exception on each call. -## BC BREAK: Removed ability to partially clear entity manager and unit of work - -* Passing an argument other than `null` to `EntityManager::clear()` will raise - an exception. -* The unit of work cannot be cleared partially anymore. Passing an argument to - `UnitOfWork::clear()` does not have any effect anymore; the unit of work is - cleared completely. -* The method `EntityRepository::clear()` has been removed. -* The methods `getEntityClass()` and `clearsAllEntities()` have been removed - from `OnClearEventArgs`. - ## BC BREAK: Remove support for Doctrine Cache The Doctrine Cache library is not supported anymore. The following methods diff --git a/lib/Doctrine/ORM/EntityManager.php b/lib/Doctrine/ORM/EntityManager.php index c0cffacbda1..d0954fcec0e 100644 --- a/lib/Doctrine/ORM/EntityManager.php +++ b/lib/Doctrine/ORM/EntityManager.php @@ -24,11 +24,13 @@ use Doctrine\ORM\Query\FilterCollection; use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\ORM\Repository\RepositoryFactory; +use Doctrine\Persistence\Mapping\MappingException; use Throwable; use function array_keys; use function is_array; use function is_object; +use function is_string; use function ltrim; use function method_exists; @@ -438,10 +440,24 @@ public function getPartialReference(string $entityName, mixed $identifier): obje /** * Clears the EntityManager. All entities that are currently managed * by this EntityManager become detached. + * + * @param string|null $entityName if given, only entities of this type will get detached + * + * @throws ORMInvalidArgumentException If a non-null non-string value is given. + * @throws MappingException If a $entityName is given, but that entity is not + * found in the mappings. */ - public function clear(): void + public function clear($entityName = null): void { - $this->unitOfWork->clear(); + if ($entityName !== null && ! is_string($entityName)) { + throw ORMInvalidArgumentException::invalidEntityName($entityName); + } + + $this->unitOfWork->clear( + $entityName === null + ? null + : $this->metadataFactory->getMetadataFor($entityName)->getName(), + ); } public function close(): void diff --git a/lib/Doctrine/ORM/EntityRepository.php b/lib/Doctrine/ORM/EntityRepository.php index a53c5284881..4779331555b 100644 --- a/lib/Doctrine/ORM/EntityRepository.php +++ b/lib/Doctrine/ORM/EntityRepository.php @@ -70,6 +70,14 @@ public function createResultSetMappingBuilder(string $alias): ResultSetMappingBu return $rsm; } + /** + * Clears the repository, causing all managed entities to become detached. + */ + public function clear(): void + { + $this->em->clear($this->class->rootEntityName); + } + /** * Finds an entity by its primary key / identifier. * diff --git a/lib/Doctrine/ORM/Event/OnClearEventArgs.php b/lib/Doctrine/ORM/Event/OnClearEventArgs.php index 29a42f24aad..7ff69a8d3e3 100644 --- a/lib/Doctrine/ORM/Event/OnClearEventArgs.php +++ b/lib/Doctrine/ORM/Event/OnClearEventArgs.php @@ -16,4 +16,29 @@ */ class OnClearEventArgs extends BaseOnClearEventArgs { + /** @param string|null $entityClass Optional entity class. */ + public function __construct(EntityManagerInterface $em, private $entityClass = null) + { + parent::__construct($em); + } + + /** + * Name of the entity class that is cleared, or empty if all are cleared. + * + * @return string|null + */ + public function getEntityClass() + { + return $this->entityClass; + } + + /** + * Checks if event clears all entities. + * + * @return bool + */ + public function clearsAllEntities() + { + return $this->entityClass === null; + } } diff --git a/lib/Doctrine/ORM/ORMInvalidArgumentException.php b/lib/Doctrine/ORM/ORMInvalidArgumentException.php index ff5d8487fed..1c2810dbe43 100644 --- a/lib/Doctrine/ORM/ORMInvalidArgumentException.php +++ b/lib/Doctrine/ORM/ORMInvalidArgumentException.php @@ -144,6 +144,18 @@ public static function invalidAssociation(ClassMetadata $targetClass, Associatio )); } + /** + * Used when a given entityName hasn't the good type + * + * @param mixed $entityName The given entity (which shouldn't be a string) + * + * @return self + */ + public static function invalidEntityName($entityName) + { + return new self(sprintf('Entity name must be a string, %s given', get_debug_type($entityName))); + } + /** * Helper method to show an object as string. */ diff --git a/lib/Doctrine/ORM/UnitOfWork.php b/lib/Doctrine/ORM/UnitOfWork.php index 1f350a5e1ec..23b694f1c59 100644 --- a/lib/Doctrine/ORM/UnitOfWork.php +++ b/lib/Doctrine/ORM/UnitOfWork.php @@ -15,7 +15,6 @@ use Doctrine\Deprecations\Deprecation; use Doctrine\ORM\Cache\Persister\CachedPersister; use Doctrine\ORM\Event\ListenersInvoker; -use Doctrine\ORM\Event\OnClearEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\PostFlushEventArgs; use Doctrine\ORM\Event\PostPersistEventArgs; @@ -2229,30 +2228,38 @@ public function getCommitOrderCalculator(): CommitOrderCalculator /** * Clears the UnitOfWork. - */ - public function clear(): void - { - $this->identityMap = - $this->entityIdentifiers = - $this->originalEntityData = - $this->entityChangeSets = - $this->entityStates = - $this->scheduledForSynchronization = - $this->entityInsertions = - $this->entityUpdates = - $this->entityDeletions = - $this->nonCascadedNewDetectedEntities = - $this->collectionDeletions = - $this->collectionUpdates = - $this->extraUpdates = - $this->readOnlyObjects = - $this->pendingCollectionElementRemovals = - $this->visitedCollections = - $this->eagerLoadingEntities = - $this->orphanRemovals = []; + * + * @param string|null $entityName if given, only entities of this type will get detached. + * + * @throws ORMInvalidArgumentException if an invalid entity name is given. + */ + public function clear(string|null $entityName = null): void + { + if ($entityName === null) { + $this->identityMap = + $this->entityIdentifiers = + $this->originalEntityData = + $this->entityChangeSets = + $this->entityStates = + $this->scheduledForSynchronization = + $this->entityInsertions = + $this->entityUpdates = + $this->entityDeletions = + $this->nonCascadedNewDetectedEntities = + $this->collectionDeletions = + $this->collectionUpdates = + $this->extraUpdates = + $this->readOnlyObjects = + $this->visitedCollections = + $this->eagerLoadingEntities = + $this->orphanRemovals = []; + } else { + $this->clearIdentityMapForEntityName($entityName); + $this->clearEntityInsertionsForEntityName($entityName); + } if ($this->evm->hasListeners(Events::onClear)) { - $this->evm->dispatchEvent(Events::onClear, new OnClearEventArgs($this->em)); + $this->evm->dispatchEvent(Events::onClear, new Event\OnClearEventArgs($this->em, $entityName)); } } @@ -3074,6 +3081,29 @@ public function hydrationComplete(): void $this->hydrationCompleteHandler->hydrationComplete(); } + private function clearIdentityMapForEntityName(string $entityName): void + { + if (! isset($this->identityMap[$entityName])) { + return; + } + + $visited = []; + + foreach ($this->identityMap[$entityName] as $entity) { + $this->doDetach($entity, $visited, false); + } + } + + private function clearEntityInsertionsForEntityName(string $entityName): void + { + foreach ($this->entityInsertions as $hash => $entity) { + // note: performance optimization - `instanceof` is much faster than a function call + if ($entity instanceof $entityName && get_class($entity) === $entityName) { + unset($this->entityInsertions[$hash]); + } + } + } + /** @throws MappingException if the entity has more than a single identifier. */ private function convertSingleFieldIdentifierToPHPValue(ClassMetadata $class, mixed $identifierValue): mixed { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7c9da4ca432..cb3fdccd4d6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -110,6 +110,11 @@ parameters: count: 1 path: lib/Doctrine/ORM/Decorator/EntityManagerDecorator.php + - + message: "#^Result of && is always false\\.$#" + count: 1 + path: lib/Doctrine/ORM/EntityManager.php + - message: "#^Return type \\(Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadataFactory\\) of method Doctrine\\\\ORM\\\\EntityManager\\:\\:getMetadataFactory\\(\\) should be compatible with return type \\(Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadataFactory\\\\>\\) of method Doctrine\\\\Persistence\\\\ObjectManager\\:\\:getMetadataFactory\\(\\)$#" count: 1 @@ -130,6 +135,11 @@ parameters: count: 1 path: lib/Doctrine/ORM/EntityRepository.php + - + message: "#^Method Doctrine\\\\Persistence\\\\ObjectManager\\:\\:clear\\(\\) invoked with 1 parameter, 0 required\\.$#" + count: 1 + path: lib/Doctrine/ORM/EntityRepository.php + - message: "#^Method Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata\\:\\:fullyQualifiedClassName\\(\\) should return class\\-string\\|null but returns string\\|null\\.$#" count: 1 diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 482d3d683b9..43aa6079af0 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -176,7 +176,11 @@ $className + $entityName + + + $entity $entity diff --git a/tests/Doctrine/Tests/ORM/EntityManagerTest.php b/tests/Doctrine/Tests/ORM/EntityManagerTest.php index fbd9811b88e..8704a4dd510 100644 --- a/tests/Doctrine/Tests/ORM/EntityManagerTest.php +++ b/tests/Doctrine/Tests/ORM/EntityManagerTest.php @@ -10,13 +10,16 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Exception\EntityManagerClosed; use Doctrine\ORM\Mapping\ClassMetadataFactory; +use Doctrine\ORM\ORMInvalidArgumentException; use Doctrine\ORM\Proxy\ProxyFactory; use Doctrine\ORM\Query; use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\UnitOfWork; +use Doctrine\Persistence\Mapping\MappingException; use Doctrine\Tests\Mocks\EntityManagerMock; use Doctrine\Tests\Models\CMS\CmsUser; +use Doctrine\Tests\Models\GeoNames\Country; use Doctrine\Tests\OrmTestCase; use Generator; use PHPUnit\Framework\Attributes\DataProvider; @@ -24,6 +27,10 @@ use stdClass; use TypeError; +use function get_class; +use function random_int; +use function uniqid; + class EntityManagerTest extends OrmTestCase { private EntityManagerMock $entityManager; @@ -180,4 +187,52 @@ public function testWrapInTransactionReThrowsThrowables(): void self::assertFalse($this->entityManager->isOpen()); } } + + #[Group('6017')] + public function testClearManagerWithObject(): void + { + $entity = new Country('456', 'United Kingdom'); + + $this->expectException(ORMInvalidArgumentException::class); + + $this->entityManager->clear($entity); + } + + #[Group('6017')] + public function testClearManagerWithUnknownEntityName(): void + { + $this->expectException(MappingException::class); + + $this->entityManager->clear(uniqid('nonExisting', true)); + } + + #[Group('6017')] + public function testClearManagerWithProxyClassName(): void + { + $proxy = $this->entityManager->getReference(Country::class, ['id' => random_int(457, 100000)]); + + $entity = new Country('456', 'United Kingdom'); + + $this->entityManager->persist($entity); + + self::assertTrue($this->entityManager->contains($entity)); + + $this->entityManager->clear(get_class($proxy)); + + self::assertFalse($this->entityManager->contains($entity)); + } + + #[Group('6017')] + public function testClearManagerWithNullValue(): void + { + $entity = new Country('456', 'United Kingdom'); + + $this->entityManager->persist($entity); + + self::assertTrue($this->entityManager->contains($entity)); + + $this->entityManager->clear(null); + + self::assertFalse($this->entityManager->contains($entity)); + } } diff --git a/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php b/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php index ab774f95b88..1ab5e2e64ec 100644 --- a/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/BasicFunctionalTest.php @@ -531,9 +531,10 @@ public function testSetSetAssociationWithGetReference(): void $this->_em->persist($address); $this->_em->flush(); - $userId = $user->getId(); - $this->_em->clear(); - $user = $this->_em->find(CmsUser::class, $userId); + $this->_em->clear(CmsAddress::class); + + self::assertFalse($this->_em->contains($address)); + self::assertTrue($this->_em->contains($user)); // Assume we only got the identifier of the address and now want to attach // that address to the user without actually loading it, using getReference(). @@ -916,6 +917,53 @@ public function testManyToOneFetchModeQuery(): void $this->assertQueryCount(2); } + #[Group('DDC-1278')] + public function testClearWithEntityName(): void + { + $user = new CmsUser(); + $user->name = 'Dominik'; + $user->username = 'domnikl'; + $user->status = 'developer'; + + $address = new CmsAddress(); + $address->city = 'Springfield'; + $address->zip = '12354'; + $address->country = 'Germany'; + $address->street = 'Foo Street'; + $address->user = $user; + $user->address = $address; + + $article1 = new CmsArticle(); + $article1->topic = 'Foo'; + $article1->text = 'Foo Text'; + + $article2 = new CmsArticle(); + $article2->topic = 'Bar'; + $article2->text = 'Bar Text'; + + $user->addArticle($article1); + $user->addArticle($article2); + + $this->_em->persist($article1); + $this->_em->persist($article2); + $this->_em->persist($address); + $this->_em->persist($user); + $this->_em->flush(); + + $unitOfWork = $this->_em->getUnitOfWork(); + + $this->_em->clear(CmsUser::class); + + self::assertEquals(UnitOfWork::STATE_DETACHED, $unitOfWork->getEntityState($user)); + self::assertEquals(UnitOfWork::STATE_DETACHED, $unitOfWork->getEntityState($article1)); + self::assertEquals(UnitOfWork::STATE_DETACHED, $unitOfWork->getEntityState($article2)); + self::assertEquals(UnitOfWork::STATE_MANAGED, $unitOfWork->getEntityState($address)); + + $this->_em->clear(); + + self::assertEquals(UnitOfWork::STATE_DETACHED, $unitOfWork->getEntityState($address)); + } + public function testFlushManyExplicitEntities(): void { $userA = new CmsUser(); diff --git a/tests/Doctrine/Tests/ORM/Functional/EntityRepositoryTest.php b/tests/Doctrine/Tests/ORM/Functional/EntityRepositoryTest.php index 38d78823e28..b2d2b8d31b4 100644 --- a/tests/Doctrine/Tests/ORM/Functional/EntityRepositoryTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/EntityRepositoryTest.php @@ -8,6 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Criteria; use Doctrine\DBAL\LockMode; +use Doctrine\Deprecations\PHPUnit\VerifyDeprecations; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\Exception\UnrecognizedIdentifierFields; @@ -33,6 +34,8 @@ class EntityRepositoryTest extends OrmFunctionalTestCase { + use VerifyDeprecations; + protected function setUp(): void { $this->useModelSet('cms'); diff --git a/tests/Doctrine/Tests/ORM/Functional/ReadOnlyTest.php b/tests/Doctrine/Tests/ORM/Functional/ReadOnlyTest.php index f566b316ff1..d3e723bc36f 100644 --- a/tests/Doctrine/Tests/ORM/Functional/ReadOnlyTest.php +++ b/tests/Doctrine/Tests/ORM/Functional/ReadOnlyTest.php @@ -12,6 +12,8 @@ use Doctrine\Tests\OrmFunctionalTestCase; use PHPUnit\Framework\Attributes\Group; +use function get_class; + /** * Functional Query tests. */ @@ -63,7 +65,7 @@ public function testClearEntitiesReadOnly(): void $this->_em->flush(); $this->_em->getUnitOfWork()->markReadOnly($readOnly); - $this->_em->clear(); + $this->_em->clear(get_class($readOnly)); self::assertFalse($this->_em->getUnitOfWork()->isReadOnly($readOnly)); } diff --git a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2106Test.php b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2106Test.php index 0107bf8ca6a..6cb7bcf0c9f 100644 --- a/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2106Test.php +++ b/tests/Doctrine/Tests/ORM/Functional/Ticket/DDC2106Test.php @@ -32,7 +32,7 @@ public function testDetachedEntityAsId(): void $entity = new DDC2106Entity(); $this->_em->persist($entity); $this->_em->flush(); - $this->_em->clear(); + $this->_em->clear(DDC2106Entity::class); $entity = $this->_em->getRepository(DDC2106Entity::class)->findOneBy([]); // ... and a managed entity without id diff --git a/tests/Doctrine/Tests/ORM/ORMInvalidArgumentExceptionTest.php b/tests/Doctrine/Tests/ORM/ORMInvalidArgumentExceptionTest.php index a4ce25192dc..654266e9c69 100644 --- a/tests/Doctrine/Tests/ORM/ORMInvalidArgumentExceptionTest.php +++ b/tests/Doctrine/Tests/ORM/ORMInvalidArgumentExceptionTest.php @@ -16,7 +16,15 @@ #[CoversClass(ORMInvalidArgumentException::class)] class ORMInvalidArgumentExceptionTest extends TestCase { - /** @psalm-return list */ + #[DataProvider('invalidEntityNames')] + public function testInvalidEntityName(mixed $value, string $expectedMessage): void + { + $exception = ORMInvalidArgumentException::invalidEntityName($value); + + self::assertInstanceOf(ORMInvalidArgumentException::class, $exception); + self::assertSame($expectedMessage, $exception->getMessage()); + } + public static function invalidEntityNames(): array { return [ diff --git a/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php b/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php index a3dd86d61c3..ba30cde1370 100644 --- a/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php +++ b/tests/Doctrine/Tests/ORM/UnitOfWorkTest.php @@ -31,6 +31,8 @@ use Doctrine\Tests\Models\CMS\CmsUser; use Doctrine\Tests\Models\Forum\ForumAvatar; use Doctrine\Tests\Models\Forum\ForumUser; +use Doctrine\Tests\Models\GeoNames\City; +use Doctrine\Tests\Models\GeoNames\Country; use Doctrine\Tests\OrmTestCase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -297,6 +299,26 @@ public function testRemovedAndRePersistedEntitiesAreInTheIdentityMapAndAreNotGar self::assertTrue($this->_unitOfWork->isInIdentityMap($entity)); } + #[Group('5849')] + #[Group('5850')] + public function testPersistedEntityAndClearManager(): void + { + $entity1 = new City(123, 'London'); + $entity2 = new Country('456', 'United Kingdom'); + + $this->_unitOfWork->persist($entity1); + self::assertTrue($this->_unitOfWork->isInIdentityMap($entity1)); + + $this->_unitOfWork->persist($entity2); + self::assertTrue($this->_unitOfWork->isInIdentityMap($entity2)); + + $this->_unitOfWork->clear(Country::class); + self::assertTrue($this->_unitOfWork->isInIdentityMap($entity1)); + self::assertFalse($this->_unitOfWork->isInIdentityMap($entity2)); + self::assertTrue($this->_unitOfWork->isScheduledForInsert($entity1)); + self::assertFalse($this->_unitOfWork->isScheduledForInsert($entity2)); + } + /** * Data Provider *