From 30635565479941035f76a5fa8d8d70cfa36e5849 Mon Sep 17 00:00:00 2001 From: acoulton Date: Tue, 10 Dec 2024 14:52:20 +0000 Subject: [PATCH 1/2] change: Allow doctrine/persistence:3.x for projects that support it Keep supporting 2.x as well (in line with orm 2.x) so that we can still pin back in specific projects if required. --- CHANGELOG.md | 2 ++ composer.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fede3..a1ed7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ - drops the composer dependency on doctrine/cache in favour of symfony/cache - DoctrineCacheFactory methods are now hard-typehinted to return CacheItemPoolInterface * [BREAKING] All classes and methods now have hard typehints and return values +* [CHANGE] Now allows doctrine/persistence 3.x as well as 2.x - projects that specifically + require doctrine/persistence 2.x may need to pin it in their own composer.json. * Narrow supported dependency versions to the current latest minor of the suppoerted major version. * Drop support for PHP < 8.2 diff --git a/composer.json b/composer.json index 380cada..c7314af 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ "doctrine/common": "^3.4", "doctrine/dbal": "^3.9", "doctrine/orm": "^2.20", - "doctrine/persistence": "^2.5", + "doctrine/persistence": "^2.5 || ^3.4", "ingenerator/kohana-core": "^4.12", "ingenerator/kohana-dependencies": "^1.4", "symfony/cache": "^6.4 || ^7.2" From 203ec6cd53b48fbb0acc79fcec466ad55a6c987d Mon Sep 17 00:00:00 2001 From: acoulton Date: Fri, 13 Dec 2024 12:45:17 +0000 Subject: [PATCH 2/2] Add `EntityDetacher::detachAllOfType` to bulk-remove from entity manager To replace the `$entityManager->clear($entityClass)` which was removed in doctrine/persistence 3. --- CHANGELOG.md | 1 + src/EntityManagerUtils/EntityDetacher.php | 50 ++++ .../EntityManagerUtils/EntityDetacherTest.php | 227 ++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 src/EntityManagerUtils/EntityDetacher.php create mode 100644 test/unit/EntityManagerUtils/EntityDetacherTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ed7b5..dce95ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * [BREAKING] All classes and methods now have hard typehints and return values * [CHANGE] Now allows doctrine/persistence 3.x as well as 2.x - projects that specifically require doctrine/persistence 2.x may need to pin it in their own composer.json. +* Add `EntityDetacher::detachAllOfType` as a migration path for the old `$entityManager->clear($entityName)` * Narrow supported dependency versions to the current latest minor of the suppoerted major version. * Drop support for PHP < 8.2 diff --git a/src/EntityManagerUtils/EntityDetacher.php b/src/EntityManagerUtils/EntityDetacher.php new file mode 100644 index 0000000..41f8b33 --- /dev/null +++ b/src/EntityManagerUtils/EntityDetacher.php @@ -0,0 +1,50 @@ +clear($class) in doctrine/persistence <= 2. + */ + public static function detachAllOfType( + EntityManagerInterface $em, + string $entity_class + ): void { + $meta = $em->getClassMetadata($entity_class); + if ($meta->subClasses !== []) { + // We can't safely work out which they want to clear (the IdentityMap is organised by concrete class names, + // and it is too much runtime work to check all possible children). Callers will just need to be explicit + // about which classes should be detached. + throw new InvalidArgumentException( + sprintf( + "Cannot call %s with %s as it has child entities. Instead, call it for each subclass you wish to clear.", + __METHOD__, + $entity_class + ) + ); + } + + // The code snippet at https://github.com/doctrine/orm/issues/8460 doesn't cater for entities that were + // persisted with an empty database-generated ID and have not yet been flushed. We want to be certain that + // we're detaching *all* the entities of this type. + foreach ($em->getUnitOfWork()->getScheduledEntityInsertions() as $insertion) { + // Strict compare on class name, rather than instanceof, as these should only be concrete entity types + if ($insertion::class === $entity_class) { + $em->detach($insertion); + } + } + + + $entities = $em->getUnitOfWork()->getIdentityMap()[$entity_class] ?? []; + foreach ($entities as $entity) { + $em->detach($entity); + } + } +} diff --git a/test/unit/EntityManagerUtils/EntityDetacherTest.php b/test/unit/EntityManagerUtils/EntityDetacherTest.php new file mode 100644 index 0000000..f487d95 --- /dev/null +++ b/test/unit/EntityManagerUtils/EntityDetacherTest.php @@ -0,0 +1,227 @@ +createEntityManager(); + + EntityDetacher::detachAllOfType($em, TestEntityOne::class); + $this->assertSame([], $em->getUnitOfWork()->getIdentityMap()); + } + + public function test_it_detaches_all_persisted_entities_of_expected_type() + { + $entities = [ + 'e1' => new TestEntityOne(1), + 'e2' => new TestEntityOne(5), + 'e3' => new TestEntityOne(123), + ]; + + $em = $this->createEntityManagerWithManagedEntities(...$entities); + + EntityDetacher::detachAllOfType($em, TestEntityOne::class); + + $this->assertEntityPersistenceState( + array_fill_keys(array_keys($entities), FALSE), + $em, + $entities, + 'All entities should be detached' + ); + } + + public function test_it_only_detaches_the_specified_type() + { + $entities = [ + 'e1-1' => new TestEntityOne(1), + 'e2-1' => new TestEntityTwo(1), + 'e1-2' => new TestEntityOne(2), + 'e1-3' => new TestEntityOne(3), + 'e2-2' => new TestEntityTwo(2), + 'e2-3' => new TestEntityTwo(3), + ]; + + $em = $this->createEntityManagerWithManagedEntities(...$entities); + + EntityDetacher::detachAllOfType($em, TestEntityOne::class); + + $this->assertEntityPersistenceState( + [ + 'e1-1' => FALSE, + 'e2-1' => TRUE, + 'e1-2' => FALSE, + 'e1-3' => FALSE, + 'e2-2' => TRUE, + 'e2-3' => TRUE, + ], + $em, + $entities, + 'Should have detached expected entities' + ); + } + + public function test_it_detaches_entities_even_if_they_have_not_been_flushed_and_ids_generated() + { + // The snippet at https://github.com/doctrine/orm/issues/8460 only detaches entities where an ID has already + // been generated. We really want to clear everything of the type, even if it has not yet been flushed to the + // database. + $entities = [ + 'e1-new-1' => new TestEntityWithGeneratedId(NULL), + 'e2-1' => new TestEntityTwo(1), + 'e1-new-2' => new TestEntityWithGeneratedId(NULL), + 'e1-existing' => new TestEntityWithGeneratedId(1), + ]; + + $em = $this->createEntityManagerWithManagedEntities(...$entities); + + EntityDetacher::detachAllOfType($em, TestEntityWithGeneratedId::class); + + $this->assertEntityPersistenceState( + [ + 'e1-new-1' => FALSE, + 'e2-1' => TRUE, + 'e1-new-2' => FALSE, + 'e1-existing' => FALSE, + ], + $em, + $entities, + 'Should have detached expected entities' + ); + } + + public function test_it_throws_if_class_is_a_base_class() + { + $em = $this->createEntityManagerWithManagedEntities(new TestChildEntityOne(15)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('TestParentEntity as it has child entities'); + + EntityDetacher::detachAllOfType($em, TestParentEntity::class); + } + + public function test_it_throws_if_class_is_not_an_entity() + { + $em = $this->createEntityManager(); + + $this->expectException(MappingException::class); + $this->expectExceptionMessage('not a valid entity'); + EntityDetacher::detachAllOfType($em, self::class); + } + + private function createEntityManager(): EntityManager + { + $config = new Configuration(); + $config->setMetadataDriverImpl(new AttributeDriver([])); + $config->setProxyDir(sys_get_temp_dir()); + $config->setProxyNamespace('Proxies'); + + return DoctrineFactory::buildEntityManager(new ConnectionConfigProvider(), $config, new EventManager()); + } + + private function assertEntityPersistenceState( + array $expected, + EntityManager $em, + array $entities, + string $message + ): void { + $this->assertSame( + $expected, + array_map( + fn($e) => $em->contains($e), + $entities + ), + $message + ); + } + + private function createEntityManagerWithManagedEntities( + TestEntityBaseWithId|TestEntityWithGeneratedId ...$entities + ): EntityManager { + $em = $this->createEntityManager(); + foreach ($entities as $entity) { + if ($entity->id) { + // Simulate this being already managed e.g. hydrated by the entity manager + $em->getUnitOfWork()->registerManaged($entity, ['id' => $entity->id], (array) $entity); + } else { + // Simulate this being persisted as a new entity + $em->persist($entity); + } + } + + $this->assertEntityPersistenceState( + array_fill_keys(array_keys($entities), TRUE), + $em, + $entities, + 'All entities should be persisted at the start of the test' + ); + + return $em; + } +} + +class TestEntityBaseWithId +{ + public function __construct( + #[Id] + #[Column] + public readonly int $id + ) { + } +} + +#[Entity] +class TestEntityOne extends TestEntityBaseWithId +{ +} + +#[Entity] +class TestEntityTwo extends TestEntityBaseWithId +{ +} + +#[Entity] +class TestEntityWithGeneratedId +{ + + public function __construct( + #[Id] + #[Column] + #[GeneratedValue] + public ?int $id = NULL + ) { + } +} + +#[Entity] +#[InheritanceType('SINGLE_TABLE')] +#[DiscriminatorMap(['one' => TestChildEntityOne::class])] +abstract class TestParentEntity extends TestEntityBaseWithId +{ + +} + +#[Entity] +class TestChildEntityOne extends TestParentEntity +{ + +}