From 41448fca947b37d490e4c43d619e8427a456e0c2 Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Thu, 11 Jun 2026 11:41:47 +0200 Subject: [PATCH 1/7] Drop PHP<8.3 support --- .github/workflows/main.yaml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 1596c12..0aff590 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - php-versions: ['7.3', '7.4', '8.0'] + php-versions: ['8.3', '8.4'] name: PHP ${{ matrix.php-versions }} steps: - uses: actions/checkout@v2 diff --git a/composer.json b/composer.json index 2763898..5281bdb 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "license": "MIT", "minimum-stability": "stable", "require": { - "php": "^7.3|^8.0", + "php": "^8.3", "doctrine/annotations": "1.*,>=1.2.0", "doctrine/common": "^2.13.3|^3.0.0", "doctrine/orm": "^2.7.5", From f6f84811f673d3c860948c95d90565a46981bcf0 Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Thu, 11 Jun 2026 12:43:42 +0200 Subject: [PATCH 2/7] Support Tracked attribute, deprecating the annotation --- src/Annotation/Tracked.php | 6 +- src/Attributes/Tracked.php | 12 ++++ src/Listener/EntityChangedListener.php | 67 +++++++++++++------ .../EntityAnnotationMetadataProvider.php | 14 ++-- .../EntityMutationMetadataProvider.php | 27 +++----- test/Listener/EntityChangedListenerTest.php | 42 +++++++++++- test/Mocked/TrackedAttributeEntity.php | 14 ++++ 7 files changed, 133 insertions(+), 49 deletions(-) create mode 100644 src/Attributes/Tracked.php create mode 100644 test/Mocked/TrackedAttributeEntity.php diff --git a/src/Annotation/Tracked.php b/src/Annotation/Tracked.php index 237c9f4..aad7dee 100644 --- a/src/Annotation/Tracked.php +++ b/src/Annotation/Tracked.php @@ -6,10 +6,14 @@ namespace Hostnet\Component\EntityTracker\Annotation; +use Hostnet\Component\EntityTracker\Attributes\Tracked as TrackedAttribute; + /** * @Annotation * @Target({"CLASS"}) + * + * @deprecated Please use the Attribute instead */ -class Tracked +class Tracked extends TrackedAttribute { } diff --git a/src/Attributes/Tracked.php b/src/Attributes/Tracked.php new file mode 100644 index 0000000..92e934f --- /dev/null +++ b/src/Attributes/Tracked.php @@ -0,0 +1,12 @@ +logger = $logger ? : new NullLogger(); } + private function hasTrackedAttribute($entity): bool + { + $reflection = new \ReflectionClass($entity); + $attributes = $reflection->getAttributes(Tracked::class, \ReflectionAttribute::IS_INSTANCEOF); + + return !empty($attributes); + } + + private function isTracked($em, $entity): bool + { + $class = get_class($entity); + if (array_key_exists($class, $this->is_tracked_cache)) { + return $this->is_tracked_cache[$class]; + } + + $has_tracked_attribute = $this->hasTrackedAttribute($entity); + if ($has_tracked_attribute) { + $this->is_tracked_cache[$class] = true; + + return true; + } + + $has_tracked_annotation = $this->meta_annotation_provider->isTracked($em, $entity); + if ($has_tracked_annotation) { + $this->is_tracked_cache[$class] = true; + + return true; + } + + $this->is_tracked_cache[$class] = false; + + return false; + } + /** * Pre Flush event callback * * Checks if the entity contains an @Tracked (or derived) - * annotation. If so, it will attempt to calculate changes + * annotation or attribute. If so, it will attempt to calculate changes * made and dispatch 'Events::ENTITY_CHANGED' with the current * and original entity states. Note that the original entity * is not managed. - * - * @param PreFlushEventArgs $event */ - public function preFlush(PreFlushEventArgs $event) + public function preFlush(PreFlushEventArgs $event): void { $em = $event->getEntityManager(); $changes = $this->meta_mutation_provider->getFullChangeSet($em); @@ -80,7 +113,8 @@ public function preFlush(PreFlushEventArgs $event) continue; } - if (false === $this->meta_annotation_provider->isTracked($em, current($updates))) { + $entity = current($updates); + if (!$this->isTracked($em, $entity)) { continue; } @@ -111,18 +145,9 @@ public function preFlush(PreFlushEventArgs $event) } /** - * Pre Persist event callback - * - * Checks if the entity contains an @Tracked (or derived) - * annotation. If so, it will dispatch 'Events::ENTITY_CHANGED' - * with the new entity states. - * - * @deprecated Will be removed. Here so the bundle does not break. - * - * @param LifecycleEventArgs $event + * @deprecated Will be removed when removing doctrine/annotations, will break entity-tracker-bundle otherwise. */ - public function prePersist(LifecycleEventArgs $event) + public function prePersist(LifecycleEventArgs $event): void { - // do nothing, will be removed later on. } } diff --git a/src/Provider/EntityAnnotationMetadataProvider.php b/src/Provider/EntityAnnotationMetadataProvider.php index e5c555e..00f67cc 100644 --- a/src/Provider/EntityAnnotationMetadataProvider.php +++ b/src/Provider/EntityAnnotationMetadataProvider.php @@ -10,6 +10,9 @@ use Doctrine\ORM\EntityManagerInterface; use Hostnet\Component\EntityTracker\Annotation\Tracked; +/** + * @deprecated Please use the Tracked and related attributes instead + */ class EntityAnnotationMetadataProvider { /** @@ -26,13 +29,11 @@ public function __construct(Reader $reader) } /** - * Get the annotation from a class or null if it doesn't exists. - * - * @param EntityManagerInterface $em * @param mixed $entity - * @return bool + * + * @deprecated Please use the Tracked attribute instead */ - public function isTracked(EntityManagerInterface $em, $entity) + public function isTracked(EntityManagerInterface $em, $entity): bool { $class = get_class($entity); $annotations = $this->reader->getClassAnnotations($em->getClassMetadata($class)->getReflectionClass()); @@ -50,9 +51,8 @@ public function isTracked(EntityManagerInterface $em, $entity) * @param EntityManagerInterface $em * @param mixed $entity * @param string $annotation - * @return mixed */ - public function getAnnotationFromEntity(EntityManagerInterface $em, $entity, $annotation) + public function getAnnotationFromEntity(EntityManagerInterface $em, $entity, $annotation): mixed { return $this->reader->getClassAnnotation( $em->getClassMetadata(get_class($entity))->getReflectionClass(), diff --git a/src/Provider/EntityMutationMetadataProvider.php b/src/Provider/EntityMutationMetadataProvider.php index 9684545..062a7fa 100644 --- a/src/Provider/EntityMutationMetadataProvider.php +++ b/src/Provider/EntityMutationMetadataProvider.php @@ -18,23 +18,16 @@ class EntityMutationMetadataProvider { - /** - * @var Reader - */ - private $reader; - /** * @var LoggerInterface */ private $logger; /** - * @param Reader $reader * @param LoggerInterface $logger */ - public function __construct(Reader $reader, LoggerInterface $logger = null) + public function __construct(Reader $unused, LoggerInterface $logger = null) { - $this->reader = $reader; $this->logger = $logger ?: new NullLogger(); } @@ -44,9 +37,8 @@ public function __construct(Reader $reader, LoggerInterface $logger = null) * * @param EntityManagerInterface $em * @param mixed $entity - * @return object */ - public function createOriginalEntity(EntityManagerInterface $em, $entity) + public function createOriginalEntity(EntityManagerInterface $em, $entity): object { $uow = $em->getUnitOfWork(); $id_data = $uow->isInIdentityMap($entity) ? $uow->getEntityIdentifier($entity) : []; @@ -84,7 +76,7 @@ public function createOriginalEntity(EntityManagerInterface $em, $entity) * @return string[] * @throws \InvalidArgumentException */ - public function getMutatedFields(EntityManagerInterface $em, $entity, $original) + public function getMutatedFields(EntityManagerInterface $em, $entity, $original): array { $mutation_data = []; /** @var \Doctrine\ORM\Mapping\ClassMetadata $metadata */ @@ -138,9 +130,8 @@ public function getMutatedFields(EntityManagerInterface $em, $entity, $original) * @param ClassMetadata $association_meta * @param string $left * @param string $right - * @return bool */ - private function hasAssociationChanged(ClassMetadata $association_meta, $left, $right) + private function hasAssociationChanged(ClassMetadata $association_meta, $left, $right): bool { // check if the PK of the related entity has changed (thus different link) if (null !== $left && null !== $right) { @@ -179,18 +170,16 @@ function ($a, $b) { /** * @param EntityManagerInterface $em * @param mixed $entity - * @return bool */ - public function isEntityManaged(EntityManagerInterface $em, $entity) + public function isEntityManaged(EntityManagerInterface $em, $entity): bool { return $em->getUnitOfWork()->getEntityState($entity) === UnitOfWork::STATE_MANAGED; } /** * @param EntityManagerInterface $em - * @return array */ - public function getFullChangeSet(EntityManagerInterface $em) + public function getFullChangeSet(EntityManagerInterface $em): array { $change_set = []; @@ -228,7 +217,7 @@ private function addToChangeSet( ClassMetadata $metadata, $entity, array &$change_set - ) { + ): void { if (!isset($change_set[$metadata->rootEntityName])) { $change_set[$metadata->rootEntityName] = []; } @@ -252,7 +241,7 @@ private function appendAssociations( ClassMetadata $metadata, $entity, array &$change_set - ) { + ): void { // does the entity have any associations? // Look for changes in associations of the entity foreach ($metadata->associationMappings as $field => $assoc) { diff --git a/test/Listener/EntityChangedListenerTest.php b/test/Listener/EntityChangedListenerTest.php index e6dbe9b..3dbcda4 100644 --- a/test/Listener/EntityChangedListenerTest.php +++ b/test/Listener/EntityChangedListenerTest.php @@ -13,6 +13,7 @@ use Doctrine\ORM\Proxy\Proxy; use Hostnet\Component\EntityTracker\Event\EntityChangedEvent; use Hostnet\Component\EntityTracker\Events; +use Hostnet\Component\EntityTracker\Mocked\TrackedAttributeEntity; use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; use Hostnet\Component\EntityTracker\Provider\EntityMutationMetadataProvider; use PHPUnit\Framework\TestCase; @@ -141,7 +142,7 @@ public function testPreFlushWithMutatedFields(): void $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); } - public function testPreFlushWithNewEntity(): void + public function testPreFlushWithNewAnnotatedEntity(): void { $entity = new \stdClass(); @@ -160,6 +161,45 @@ public function testPreFlushWithNewEntity(): void $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); } + public function testPreFlushWithNewAttributedEntity(): void + { + $entity = new TrackedAttributeEntity(); + + $this->meta_mutation_provider + ->getFullChangeSet($this->em->reveal()) + ->willReturn($this->genericEntityDataProvider($entity)); + $this->meta_annotation_provider->isTracked(Argument::cetera())->shouldNotBeCalled(); + $this->logger->debug(Argument::cetera())->shouldBeCalled(); + $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); + $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn(null); + $this->meta_mutation_provider->getMutatedFields($this->em->reveal(), $entity, null)->willReturn(['id']); + $this->event_manager + ->dispatchEvent(Events::ENTITY_CHANGED, Argument::type(EntityChangedEvent::class)) + ->shouldBeCalledTimes(1); + + $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); + } + + public function testPreFlushWithNewEntityCached(): void + { + $entity = new \stdClass(); + $entity_2nd_flush = new \stdClass(); + + $this->meta_mutation_provider + ->getFullChangeSet($this->em->reveal()) + ->willReturn( + $this->genericEntityDataProvider($entity), + $this->genericEntityDataProvider($entity_2nd_flush) + ); + + $this->meta_annotation_provider->isTracked(Argument::cetera())->willReturn(false); + $this->event_manager + ->dispatchEvent(Argument::cetera())->shouldNotBeCalled(); + + $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); + $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); + } + public function testPreFlushWithProxy(): void { $entity = $this->prophesize(Proxy::class)->reveal(); diff --git a/test/Mocked/TrackedAttributeEntity.php b/test/Mocked/TrackedAttributeEntity.php new file mode 100644 index 0000000..09ddf49 --- /dev/null +++ b/test/Mocked/TrackedAttributeEntity.php @@ -0,0 +1,14 @@ + Date: Thu, 11 Jun 2026 13:36:23 +0200 Subject: [PATCH 3/7] Replace object returntypehint --- src/Provider/EntityMutationMetadataProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Provider/EntityMutationMetadataProvider.php b/src/Provider/EntityMutationMetadataProvider.php index 062a7fa..e420501 100644 --- a/src/Provider/EntityMutationMetadataProvider.php +++ b/src/Provider/EntityMutationMetadataProvider.php @@ -38,7 +38,7 @@ public function __construct(Reader $unused, LoggerInterface $logger = null) * @param EntityManagerInterface $em * @param mixed $entity */ - public function createOriginalEntity(EntityManagerInterface $em, $entity): object + public function createOriginalEntity(EntityManagerInterface $em, $entity): mixed { $uow = $em->getUnitOfWork(); $id_data = $uow->isInIdentityMap($entity) ? $uow->getEntityIdentifier($entity) : []; From b5712e4aaf3a8968b03364a0f08446c39f3101bd Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Thu, 11 Jun 2026 14:28:18 +0200 Subject: [PATCH 4/7] One more deprecation --- src/Provider/EntityAnnotationMetadataProvider.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Provider/EntityAnnotationMetadataProvider.php b/src/Provider/EntityAnnotationMetadataProvider.php index 00f67cc..705dd1f 100644 --- a/src/Provider/EntityAnnotationMetadataProvider.php +++ b/src/Provider/EntityAnnotationMetadataProvider.php @@ -51,6 +51,8 @@ public function isTracked(EntityManagerInterface $em, $entity): bool * @param EntityManagerInterface $em * @param mixed $entity * @param string $annotation + * + * @deprecated Please use the Tracked attribute instead */ public function getAnnotationFromEntity(EntityManagerInterface $em, $entity, $annotation): mixed { From e8a49ac6f70a30f82cf6edca0166bbac1eee94ae Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Tue, 16 Jun 2026 14:47:45 +0200 Subject: [PATCH 5/7] Also support doctrine Proxy objects --- composer.json | 13 +-- src/Listener/EntityChangedListener.php | 82 ++++++------------- .../EntityAnnotationMetadataProvider.php | 29 +++++-- test/Listener/EntityChangedListenerTest.php | 39 +++++++-- test/Mocked/ProxiedTrackedAttributeEntity.php | 20 +++++ .../EntityAnnotationMetadataProviderTest.php | 56 ++++++++++++- 6 files changed, 164 insertions(+), 75 deletions(-) create mode 100644 test/Mocked/ProxiedTrackedAttributeEntity.php diff --git a/composer.json b/composer.json index 5281bdb..4739881 100644 --- a/composer.json +++ b/composer.json @@ -6,16 +6,17 @@ "require": { "php": "^8.3", "doctrine/annotations": "1.*,>=1.2.0", - "doctrine/common": "^2.13.3|^3.0.0", - "doctrine/orm": "^2.7.5", - "psr/log": "^1.0.0|^2.0.0|^3.0.0" + "doctrine/common": "^3.4.4", + "doctrine/orm": "^2.19.7", + "doctrine/persistence": "^2.5.7|^3.4.0", + "psr/log": "^1.0.0|^2.0.0|^3.0.0", + "symfony/cache": "^6.4|^7.4" }, "require-dev": { "hostnet/database-test-lib": "^2.0.2", "hostnet/phpcs-tool": "^9.1.0", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.4", - "symfony/cache": "^5.3" + "phpspec/prophecy-phpunit": "^2.5.0", + "phpunit/phpunit": "^9.4" }, "autoload": { "psr-4": { diff --git a/src/Listener/EntityChangedListener.php b/src/Listener/EntityChangedListener.php index efd0dfb..8b375b4 100644 --- a/src/Listener/EntityChangedListener.php +++ b/src/Listener/EntityChangedListener.php @@ -9,13 +9,17 @@ use Doctrine\ORM\Event\LifecycleEventArgs; use Doctrine\ORM\Event\PreFlushEventArgs; use Doctrine\ORM\Proxy\Proxy; +use Doctrine\Persistence\ObjectManager; use Hostnet\Component\EntityTracker\Attributes\Tracked; use Hostnet\Component\EntityTracker\Event\EntityChangedEvent; use Hostnet\Component\EntityTracker\Events; use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; use Hostnet\Component\EntityTracker\Provider\EntityMutationMetadataProvider; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Symfony\Component\Cache\Adapter\ArrayAdapter; /** * Listener for entities that use the Tracked Annotation or attribute. @@ -25,73 +29,41 @@ */ class EntityChangedListener { - /** - * @var EntityAnnotationMetadataProvider - */ - private $meta_annotation_provider; - - /** - * @var EntityMutationMetadataProvider - */ - private $meta_mutation_provider; - - /** - * @var LoggerInterface - */ - private $logger; - - /** - * Caches the class names to prevent iterating over attribute and annotations again on the next flush. - */ - private array $is_tracked_cache = []; - - /** - * @param EntityAnnotationMetadataProvider $meta_annotation_provider - * @param EntityMutationMetadataProvider $meta_mutation_provider - * @param LoggerInterface $logger - */ public function __construct( - EntityAnnotationMetadataProvider $meta_annotation_provider, - EntityMutationMetadataProvider $meta_mutation_provider, - LoggerInterface $logger = null + private EntityAnnotationMetadataProvider $meta_annotation_provider, + private EntityMutationMetadataProvider $meta_mutation_provider, + private ?LoggerInterface $logger = null, + private CacheItemPoolInterface $is_tracked_cache = new ArrayAdapter() ) { - $this->meta_annotation_provider = $meta_annotation_provider; - $this->meta_mutation_provider = $meta_mutation_provider; - $this->logger = $logger ? : new NullLogger(); + $this->logger = $logger ? : new NullLogger(); } - private function hasTrackedAttribute($entity): bool + private function isTracked(ObjectManager $em, mixed $entity): bool { - $reflection = new \ReflectionClass($entity); - $attributes = $reflection->getAttributes(Tracked::class, \ReflectionAttribute::IS_INSTANCEOF); + $cache_key = base64_encode('TRACKED-' . get_class($entity)); + $cached_item = $this->is_tracked_cache->getItem($cache_key); - return !empty($attributes); - } - - private function isTracked($em, $entity): bool - { - $class = get_class($entity); - if (array_key_exists($class, $this->is_tracked_cache)) { - return $this->is_tracked_cache[$class]; + if ($cached_item->isHit()) { + return $cached_item->get(); } - $has_tracked_attribute = $this->hasTrackedAttribute($entity); - if ($has_tracked_attribute) { - $this->is_tracked_cache[$class] = true; - - return true; + if (null !== $this->meta_annotation_provider->getAttributeFromEntity(Tracked::class, $em, $entity)) { + return $this->save($cached_item, true); } - $has_tracked_annotation = $this->meta_annotation_provider->isTracked($em, $entity); - if ($has_tracked_annotation) { - $this->is_tracked_cache[$class] = true; - - return true; + if ($this->meta_annotation_provider->isTracked($em, $entity)) { + return $this->save($cached_item, true); } - $this->is_tracked_cache[$class] = false; + return $this->save($cached_item, false); + } + + private function save(CacheItemInterface $item, bool $value): bool + { + $item->set($value); + $this->is_tracked_cache->save($item); - return false; + return $value; } /** @@ -105,7 +77,7 @@ private function isTracked($em, $entity): bool */ public function preFlush(PreFlushEventArgs $event): void { - $em = $event->getEntityManager(); + $em = $event->getObjectManager(); $changes = $this->meta_mutation_provider->getFullChangeSet($em); foreach ($changes as $updates) { diff --git a/src/Provider/EntityAnnotationMetadataProvider.php b/src/Provider/EntityAnnotationMetadataProvider.php index 705dd1f..00f179f 100644 --- a/src/Provider/EntityAnnotationMetadataProvider.php +++ b/src/Provider/EntityAnnotationMetadataProvider.php @@ -8,11 +8,10 @@ use Doctrine\Common\Annotations\Reader; use Doctrine\ORM\EntityManagerInterface; -use Hostnet\Component\EntityTracker\Annotation\Tracked; +use Doctrine\Persistence\Proxy; +use Hostnet\Component\EntityTracker\Annotation\Tracked as TrackedAnnotation; +use Hostnet\Component\EntityTracker\Attributes\Tracked as Tracked; -/** - * @deprecated Please use the Tracked and related attributes instead - */ class EntityAnnotationMetadataProvider { /** @@ -39,7 +38,7 @@ public function isTracked(EntityManagerInterface $em, $entity): bool $annotations = $this->reader->getClassAnnotations($em->getClassMetadata($class)->getReflectionClass()); foreach ($annotations as $annotation) { - if ($annotation instanceof Tracked) { + if ($annotation instanceof TrackedAnnotation) { return true; } } @@ -61,4 +60,24 @@ public function getAnnotationFromEntity(EntityManagerInterface $em, $entity, $an $annotation ); } + + public function getAttributeFromEntity(string $attribute_class, EntityManagerInterface $em, mixed $entity): ?Tracked + { + $class = get_class($entity); + if ($entity instanceof Proxy) { + $class = $em->getClassMetadata($class)->getName(); + } + + $reflection = new \ReflectionClass($class); + $attributes = $reflection->getAttributes($attribute_class, \ReflectionAttribute::IS_INSTANCEOF); + + if (empty($attributes)) { + return null; + } + + /** @var Tracked $attribute */ + $attribute = $attributes[0]->newInstance(); + + return $attribute; + } } diff --git a/test/Listener/EntityChangedListenerTest.php b/test/Listener/EntityChangedListenerTest.php index 3dbcda4..1aa682d 100644 --- a/test/Listener/EntityChangedListenerTest.php +++ b/test/Listener/EntityChangedListenerTest.php @@ -11,9 +11,9 @@ use Doctrine\ORM\Event\LifecycleEventArgs; use Doctrine\ORM\Event\PreFlushEventArgs; use Doctrine\ORM\Proxy\Proxy; +use Hostnet\Component\EntityTracker\Attributes\Tracked; use Hostnet\Component\EntityTracker\Event\EntityChangedEvent; use Hostnet\Component\EntityTracker\Events; -use Hostnet\Component\EntityTracker\Mocked\TrackedAttributeEntity; use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; use Hostnet\Component\EntityTracker\Provider\EntityMutationMetadataProvider; use PHPUnit\Framework\TestCase; @@ -70,6 +70,9 @@ public function testPreFlushNoAnnotation(): void ->dispatchEvent(Events::ENTITY_CHANGED, Argument::type(EntityChangedEvent::class)) ->shouldNotBeCalled(); $this->meta_annotation_provider->isTracked($this->em->reveal(), $entity)->willReturn(false); + $this->meta_annotation_provider + ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) + ->willReturn(null); $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); } @@ -90,6 +93,9 @@ public function testPreFlushUnmanaged(): void ->getFullChangeSet($this->em->reveal()) ->willReturn($this->genericEntityDataProvider($entity)); $this->meta_annotation_provider->isTracked($this->em->reveal(), $entity)->willReturn(true); + $this->meta_annotation_provider + ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) + ->willReturn(null); $this->logger->debug(Argument::cetera())->shouldBeCalled(); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn(null); @@ -113,6 +119,9 @@ public function testPreFlushWithoutMutatedFields(): void ->dispatchEvent(Events::ENTITY_CHANGED, Argument::type(EntityChangedEvent::class)) ->shouldNotBeCalled(); $this->meta_annotation_provider->isTracked($this->em->reveal(), $entity)->willReturn(true); + $this->meta_annotation_provider + ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) + ->willReturn(null); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn($original); $this->meta_mutation_provider->getMutatedFields($this->em->reveal(), $entity, $entity)->willReturn([]); @@ -131,6 +140,9 @@ public function testPreFlushWithMutatedFields(): void ->getFullChangeSet($this->em->reveal()) ->willReturn($this->genericEntityDataProvider($entity)); $this->meta_annotation_provider->isTracked($this->em->reveal(), $entity)->willReturn(true); + $this->meta_annotation_provider + ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) + ->willReturn(null); $this->logger->debug(Argument::cetera())->shouldBeCalled(); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn($original); @@ -150,6 +162,9 @@ public function testPreFlushWithNewAnnotatedEntity(): void ->getFullChangeSet($this->em->reveal()) ->willReturn($this->genericEntityDataProvider($entity)); $this->meta_annotation_provider->isTracked($this->em->reveal(), $entity)->willReturn(true); + $this->meta_annotation_provider + ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) + ->willReturn(null); $this->logger->debug(Argument::cetera())->shouldBeCalled(); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn(null); @@ -163,11 +178,14 @@ public function testPreFlushWithNewAnnotatedEntity(): void public function testPreFlushWithNewAttributedEntity(): void { - $entity = new TrackedAttributeEntity(); + $entity = new \stdClass(); $this->meta_mutation_provider ->getFullChangeSet($this->em->reveal()) ->willReturn($this->genericEntityDataProvider($entity)); + $this->meta_annotation_provider + ->getAttributeFromEntity(Tracked::class, $this->em->reveal(), $entity) + ->willReturn(new Tracked())->shouldBeCalled(); $this->meta_annotation_provider->isTracked(Argument::cetera())->shouldNotBeCalled(); $this->logger->debug(Argument::cetera())->shouldBeCalled(); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); @@ -191,10 +209,15 @@ public function testPreFlushWithNewEntityCached(): void $this->genericEntityDataProvider($entity), $this->genericEntityDataProvider($entity_2nd_flush) ); - - $this->meta_annotation_provider->isTracked(Argument::cetera())->willReturn(false); + $this->meta_mutation_provider->getMutatedFields($this->em->reveal(), $entity, null)->willReturn(['id']); + $this->meta_mutation_provider->createOriginalEntity($this->em->reveal(), $entity)->willReturn(null); + $this->meta_annotation_provider->isTracked(Argument::cetera())->shouldNotBeCalled(); + $this->meta_annotation_provider + ->getAttributeFromEntity(Tracked::class, $this->em->reveal(), $entity) + ->willReturn(new Tracked())->shouldBeCalledTimes(1); $this->event_manager - ->dispatchEvent(Argument::cetera())->shouldNotBeCalled(); + ->dispatchEvent(Events::ENTITY_CHANGED, Argument::type(EntityChangedEvent::class)) + ->shouldBeCalledTimes(2); $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); @@ -210,6 +233,9 @@ public function testPreFlushWithProxy(): void ->dispatchEvent(Events::ENTITY_CHANGED, Argument::type(EntityChangedEvent::class)) ->shouldNotBeCalled(); $this->meta_annotation_provider->isTracked($this->em->reveal(), $entity)->willReturn(true); + $this->meta_annotation_provider + ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) + ->willReturn(null); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity)->willReturn(true); $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); } @@ -224,6 +250,9 @@ public function testPreFlushWithInitializedProxy(): void ->getFullChangeSet($this->em->reveal()) ->willReturn($this->genericEntityDataProvider($entity->reveal())); $this->meta_annotation_provider->isTracked($this->em->reveal(), $entity->reveal())->willReturn(true); + $this->meta_annotation_provider + ->getAttributeFromEntity(Argument::any(), $this->em->reveal(), $entity) + ->willReturn(null); $this->meta_mutation_provider->isEntityManaged($this->em->reveal(), $entity->reveal())->willReturn(true); $entity->__isInitialized()->willReturn(true); $this->logger->debug(Argument::cetera())->shouldBeCalled(); diff --git a/test/Mocked/ProxiedTrackedAttributeEntity.php b/test/Mocked/ProxiedTrackedAttributeEntity.php new file mode 100644 index 0000000..a042d7a --- /dev/null +++ b/test/Mocked/ProxiedTrackedAttributeEntity.php @@ -0,0 +1,20 @@ +prophesize(ClassMetadata::class); + $metadata->getName()->willReturn($proxied_class)->shouldBeCalled(); + + $this->em + ->expects($this->once()) + ->method('getClassMetadata') + ->with(get_class($entity)) + ->willReturn($metadata->reveal()); + } else { + $this->em + ->expects($this->never()) + ->method('getClassMetadata'); + } + + $result = $this->provider->getAttributeFromEntity(Tracked::class, $this->em, $entity); + + if ($has) { + $this->assertEquals(Tracked::class, get_class($result)); + } else { + $this->assertNull($result); + } + } + + /** + * @return array + */ + public function getAttributeFromEntityProvider(): iterable + { + return [ + [new \stdClass(), false, null], + [new MockEntity(), false, null], + [new TrackedAttributeEntity(), true, null], + [new ProxiedTrackedAttributeEntity(), true, TrackedAttributeEntity::class], ]; } @@ -110,7 +159,6 @@ public function getAnnotationFromEntityProvider(): iterable * @param mixed $entity * @param string[] $field_names * @param string[] $assoc_names - * @return MockObject */ private function buildMetadata($entity, array $field_names, array $assoc_names): MockObject { From 69354e0aa4bde6fdd0938a6643574b4690360f03 Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Tue, 16 Jun 2026 15:54:41 +0200 Subject: [PATCH 6/7] Exclude phpcs.xml.dit in export --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index d41c778..d9f4a3b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,4 @@ .gitignore export-ignore /test export-ignore /phpunit.xml.dist export-ignore +/phpcs.xml.dist export-ignore From fac9d78f64a9d4c04683ba40b7665930087f9373 Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Wed, 17 Jun 2026 08:58:46 +0200 Subject: [PATCH 7/7] Rename EntityAnnotationMetadataProvider --- src/Listener/EntityChangedListener.php | 4 +- .../EntityAnnotationMetadataProvider.php | 77 +---------------- src/Provider/EntityMetadataProvider.php | 83 +++++++++++++++++++ test/Functional/EventListenerTest.php | 4 +- test/Listener/EntityChangedListenerTest.php | 4 +- ...est.php => EntityMetadataProviderTest.php} | 6 +- 6 files changed, 96 insertions(+), 82 deletions(-) create mode 100644 src/Provider/EntityMetadataProvider.php rename test/Provider/{EntityAnnotationMetadataProviderTest.php => EntityMetadataProviderTest.php} (96%) diff --git a/src/Listener/EntityChangedListener.php b/src/Listener/EntityChangedListener.php index 8b375b4..0118624 100644 --- a/src/Listener/EntityChangedListener.php +++ b/src/Listener/EntityChangedListener.php @@ -13,7 +13,7 @@ use Hostnet\Component\EntityTracker\Attributes\Tracked; use Hostnet\Component\EntityTracker\Event\EntityChangedEvent; use Hostnet\Component\EntityTracker\Events; -use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; +use Hostnet\Component\EntityTracker\Provider\EntityMetadataProvider; use Hostnet\Component\EntityTracker\Provider\EntityMutationMetadataProvider; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; @@ -30,7 +30,7 @@ class EntityChangedListener { public function __construct( - private EntityAnnotationMetadataProvider $meta_annotation_provider, + private EntityMetadataProvider $meta_annotation_provider, private EntityMutationMetadataProvider $meta_mutation_provider, private ?LoggerInterface $logger = null, private CacheItemPoolInterface $is_tracked_cache = new ArrayAdapter() diff --git a/src/Provider/EntityAnnotationMetadataProvider.php b/src/Provider/EntityAnnotationMetadataProvider.php index 00f179f..70e49e4 100644 --- a/src/Provider/EntityAnnotationMetadataProvider.php +++ b/src/Provider/EntityAnnotationMetadataProvider.php @@ -6,78 +6,9 @@ namespace Hostnet\Component\EntityTracker\Provider; -use Doctrine\Common\Annotations\Reader; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\Persistence\Proxy; -use Hostnet\Component\EntityTracker\Annotation\Tracked as TrackedAnnotation; -use Hostnet\Component\EntityTracker\Attributes\Tracked as Tracked; - -class EntityAnnotationMetadataProvider +/** + * @deprecated Will be removed on the next BC break, when removing the doctrine/annotations dependency. + */ +class EntityAnnotationMetadataProvider extends EntityMetadataProvider { - /** - * @var Reader - */ - private $reader; - - /** - * @param Reader $reader - */ - public function __construct(Reader $reader) - { - $this->reader = $reader; - } - - /** - * @param mixed $entity - * - * @deprecated Please use the Tracked attribute instead - */ - public function isTracked(EntityManagerInterface $em, $entity): bool - { - $class = get_class($entity); - $annotations = $this->reader->getClassAnnotations($em->getClassMetadata($class)->getReflectionClass()); - - foreach ($annotations as $annotation) { - if ($annotation instanceof TrackedAnnotation) { - return true; - } - } - - return false; - } - - /** - * @param EntityManagerInterface $em - * @param mixed $entity - * @param string $annotation - * - * @deprecated Please use the Tracked attribute instead - */ - public function getAnnotationFromEntity(EntityManagerInterface $em, $entity, $annotation): mixed - { - return $this->reader->getClassAnnotation( - $em->getClassMetadata(get_class($entity))->getReflectionClass(), - $annotation - ); - } - - public function getAttributeFromEntity(string $attribute_class, EntityManagerInterface $em, mixed $entity): ?Tracked - { - $class = get_class($entity); - if ($entity instanceof Proxy) { - $class = $em->getClassMetadata($class)->getName(); - } - - $reflection = new \ReflectionClass($class); - $attributes = $reflection->getAttributes($attribute_class, \ReflectionAttribute::IS_INSTANCEOF); - - if (empty($attributes)) { - return null; - } - - /** @var Tracked $attribute */ - $attribute = $attributes[0]->newInstance(); - - return $attribute; - } } diff --git a/src/Provider/EntityMetadataProvider.php b/src/Provider/EntityMetadataProvider.php new file mode 100644 index 0000000..b6c9efa --- /dev/null +++ b/src/Provider/EntityMetadataProvider.php @@ -0,0 +1,83 @@ +reader = $reader; + } + + /** + * @param mixed $entity + * + * @deprecated Please use the Tracked attribute instead + */ + public function isTracked(EntityManagerInterface $em, $entity): bool + { + $class = get_class($entity); + $annotations = $this->reader->getClassAnnotations($em->getClassMetadata($class)->getReflectionClass()); + + foreach ($annotations as $annotation) { + if ($annotation instanceof TrackedAnnotation) { + return true; + } + } + + return false; + } + + /** + * @param EntityManagerInterface $em + * @param mixed $entity + * @param string $annotation + * + * @deprecated Please use the Tracked attribute instead + */ + public function getAnnotationFromEntity(EntityManagerInterface $em, $entity, $annotation): mixed + { + return $this->reader->getClassAnnotation( + $em->getClassMetadata(get_class($entity))->getReflectionClass(), + $annotation + ); + } + + public function getAttributeFromEntity(string $attribute_class, EntityManagerInterface $em, mixed $entity): ?Tracked + { + $class = get_class($entity); + if ($entity instanceof Proxy) { + $class = $em->getClassMetadata($class)->getName(); + } + + $reflection = new \ReflectionClass($class); + $attributes = $reflection->getAttributes($attribute_class, \ReflectionAttribute::IS_INSTANCEOF); + + if (empty($attributes)) { + return null; + } + + /** @var Tracked $attribute */ + $attribute = $attributes[0]->newInstance(); + + return $attribute; + } +} diff --git a/test/Functional/EventListenerTest.php b/test/Functional/EventListenerTest.php index dfe664d..4d9578d 100644 --- a/test/Functional/EventListenerTest.php +++ b/test/Functional/EventListenerTest.php @@ -20,7 +20,7 @@ use Hostnet\Component\EntityTracker\Functional\Entity\Tool; use Hostnet\Component\EntityTracker\Functional\Entity\Toolbox; use Hostnet\Component\EntityTracker\Listener\EntityChangedListener; -use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; +use Hostnet\Component\EntityTracker\Provider\EntityMetadataProvider; use Hostnet\Component\EntityTracker\Provider\EntityMutationMetadataProvider; use PHPUnit\Framework\TestCase; @@ -64,7 +64,7 @@ protected function setUp(): void // setup required providers $mutation_metadata_provider = new EntityMutationMetadataProvider($annotation_reader); - $annotation_metadata_provider = new EntityAnnotationMetadataProvider($annotation_reader); + $annotation_metadata_provider = new EntityMetadataProvider($annotation_reader); // pre flush event listener that uses the @Tracked annotation $entity_changed_listener = new EntityChangedListener( diff --git a/test/Listener/EntityChangedListenerTest.php b/test/Listener/EntityChangedListenerTest.php index 1aa682d..80049ea 100644 --- a/test/Listener/EntityChangedListenerTest.php +++ b/test/Listener/EntityChangedListenerTest.php @@ -14,7 +14,7 @@ use Hostnet\Component\EntityTracker\Attributes\Tracked; use Hostnet\Component\EntityTracker\Event\EntityChangedEvent; use Hostnet\Component\EntityTracker\Events; -use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; +use Hostnet\Component\EntityTracker\Provider\EntityMetadataProvider; use Hostnet\Component\EntityTracker\Provider\EntityMutationMetadataProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -44,7 +44,7 @@ class EntityChangedListenerTest extends TestCase protected function setUp(): void { - $this->meta_annotation_provider = $this->prophesize(EntityAnnotationMetadataProvider::class); + $this->meta_annotation_provider = $this->prophesize(EntityMetadataProvider::class); $this->meta_mutation_provider = $this->prophesize(EntityMutationMetadataProvider::class); $this->em = $this->prophesize(EntityManagerInterface::class); $this->event = $this->prophesize(PreFlushEventArgs::class); diff --git a/test/Provider/EntityAnnotationMetadataProviderTest.php b/test/Provider/EntityMetadataProviderTest.php similarity index 96% rename from test/Provider/EntityAnnotationMetadataProviderTest.php rename to test/Provider/EntityMetadataProviderTest.php index 47c9a9e..2110484 100644 --- a/test/Provider/EntityAnnotationMetadataProviderTest.php +++ b/test/Provider/EntityMetadataProviderTest.php @@ -18,9 +18,9 @@ use Prophecy\PhpUnit\ProphecyTrait; /** - * @covers \Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider + * @covers \Hostnet\Component\EntityTracker\Provider\EntityMetadataProvider */ -class EntityAnnotationMetadataProviderTest extends TestCase +class EntityMetadataProviderTest extends TestCase { use ProphecyTrait; @@ -31,7 +31,7 @@ class EntityAnnotationMetadataProviderTest extends TestCase public function setUp(): void { $this->reader = new AnnotationReader(); - $this->provider = new EntityAnnotationMetadataProvider($this->reader); + $this->provider = new EntityMetadataProvider($this->reader); $this->em = $this->createMock('Doctrine\ORM\EntityManagerInterface'); }