From 5eba78df80941489b2d9dcb34ea7976df3aa1161 Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Mon, 15 Jun 2026 08:16:14 +0200 Subject: [PATCH 1/4] 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 f925e3c..b7896e1 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 64ab9e5..ffd6f94 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/orm": "^2.4.0", "hostnet/entity-tracker-component": "^1.2.0||^2.0.0" }, From 237c442c11a9d6fa4afdd8d91f09790a1d873307 Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Mon, 15 Jun 2026 09:09:52 +0200 Subject: [PATCH 2/4] Attribute support --- src/Attributes/Blamable.php | 14 +++++ src/Blamable.php | 2 + src/BlamableInterface.php | 2 + src/Listener/BlamableListener.php | 51 ++++++++++++++-- src/Provider/BlamableProviderInterface.php | 3 + src/Resolver/BlamableResolver.php | 10 ++-- src/Resolver/BlamableResolverInterface.php | 3 + test/Attributes/BlamableTest.php | 23 ++++++++ test/Listener/BlamableListenerTest.php | 67 ++++++++++++++++++++++ test/Listener/EntityWithAttribute.php | 49 ++++++++++++++++ test/Listener/EntityWithInterfaceOnly.php | 47 +++++++++++++++ 11 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 src/Attributes/Blamable.php create mode 100644 test/Attributes/BlamableTest.php create mode 100644 test/Listener/EntityWithAttribute.php create mode 100644 test/Listener/EntityWithInterfaceOnly.php diff --git a/src/Attributes/Blamable.php b/src/Attributes/Blamable.php new file mode 100644 index 0000000..99eaae7 --- /dev/null +++ b/src/Attributes/Blamable.php @@ -0,0 +1,14 @@ +getCurrentEntity(); - $annotation = $this->resolver->getBlamableAnnotation($event->getEntityManager(), $entity); + $entity = $event->getCurrentEntity(); - if (null === $annotation || !$entity instanceof BlamableInterface) { + if (!$this->isBlamable($event->getEntityManager(), $entity)) { return; } @@ -64,4 +69,42 @@ public function entityChanged(EntityChangedEvent $event) $entity->setCreatedAt($changed_at); } } + + private function isBlamable($em, $entity): bool + { + $class = get_class($entity); + if (array_key_exists($class, $this->is_blamable_cache)) { + return $this->is_blamable_cache[$class]; + } + + if (!($entity instanceof BlamableInterface)) { + $this->is_blamable_cache[$class] = false; + + return false; + } + + if (null !== $this->resolver->getBlamableAnnotation($em, $entity)) { + $this->is_blamable_cache[$class] = true; + + return true; + } + + if ($this->hasBlamableAttribute($entity)) { + $this->is_blamable_cache[$class] = true; + + return true; + } + + $this->is_blamable_cache[$class] = false; + + return false; + } + + private function hasBlamableAttribute($entity): bool + { + $reflection = new \ReflectionClass($entity); + $attributes = $reflection->getAttributes(Blamable::class); + + return !empty($attributes); + } } diff --git a/src/Provider/BlamableProviderInterface.php b/src/Provider/BlamableProviderInterface.php index 97f5a56..987c486 100644 --- a/src/Provider/BlamableProviderInterface.php +++ b/src/Provider/BlamableProviderInterface.php @@ -6,6 +6,9 @@ namespace Hostnet\Component\EntityBlamable\Provider; +/** + * @TODO: add (return)typehints on next BC break, when removing doctrine/annotations + */ interface BlamableProviderInterface { /** diff --git a/src/Resolver/BlamableResolver.php b/src/Resolver/BlamableResolver.php index fbd1d0c..6220214 100644 --- a/src/Resolver/BlamableResolver.php +++ b/src/Resolver/BlamableResolver.php @@ -7,15 +7,11 @@ namespace Hostnet\Component\EntityBlamable\Resolver; use Doctrine\ORM\EntityManagerInterface; +use Hostnet\Component\EntityBlamable\Blamable; use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; class BlamableResolver implements BlamableResolverInterface { - /** - * @var string - */ - private $annotation = 'Hostnet\Component\EntityBlamable\Blamable'; - /** * @var EntityAnnotationMetadataProvider */ @@ -31,9 +27,11 @@ public function __construct(EntityAnnotationMetadataProvider $provider) /** * @see \Hostnet\Component\EntityBlamable\Resolver\BlamableResolverInterface::getBlamableAnnotation() + * + * @deprecated Please use the attribute instead. */ public function getBlamableAnnotation(EntityManagerInterface $em, $entity) { - return $this->provider->getAnnotationFromEntity($em, $entity, $this->annotation); + return $this->provider->getAnnotationFromEntity($em, $entity, Blamable::class); } } diff --git a/src/Resolver/BlamableResolverInterface.php b/src/Resolver/BlamableResolverInterface.php index 294761e..7d62ba7 100644 --- a/src/Resolver/BlamableResolverInterface.php +++ b/src/Resolver/BlamableResolverInterface.php @@ -7,6 +7,7 @@ namespace Hostnet\Component\EntityBlamable\Resolver; use Doctrine\ORM\EntityManagerInterface; +use Hostnet\Component\EntityBlamable\Blamable; interface BlamableResolverInterface { @@ -16,6 +17,8 @@ interface BlamableResolverInterface * @param EntityManagerInterface $em * @param mixed $entity * @return Blamable + * + * @deprecated Please use the attribute instead. */ public function getBlamableAnnotation(EntityManagerInterface $em, $entity); } diff --git a/test/Attributes/BlamableTest.php b/test/Attributes/BlamableTest.php new file mode 100644 index 0000000..57697b2 --- /dev/null +++ b/test/Attributes/BlamableTest.php @@ -0,0 +1,23 @@ +resolver, $this->provider); $listener->entityChanged($event); } + + public function testOnEntityChangedNewEntityWithAttribute(): void + { + $at = new \DateTime(); + $by = 'henk'; + + $this->provider + ->expects($this->once()) + ->method('getChangedAt') + ->willReturn($at); + + $this->provider + ->expects($this->once()) + ->method('getUpdatedBy') + ->willReturn($by); + + $this->resolver + ->expects($this->once()) + ->method('getBlamableAnnotation') + ->willReturn(null); + + $entity = new EntityWithAttribute(); + $event = new EntityChangedEvent($this->em, $entity, null, []); + + $this->assertNull($entity->getCreatedAt()); + $this->assertNull($entity->getUpdatedBy()); + $this->assertNull($entity->getUpdatedAt()); + + $listener = new BlamableListener($this->resolver, $this->provider); + $listener->entityChanged($event); + + $this->assertSame($at, $entity->getCreatedAt()); + $this->assertSame($by, $entity->getUpdatedBy()); + $this->assertSame($at, $entity->getUpdatedAt()); + } + + public function testOnEntityChangedNewEntityWithInterfaceOnly(): void + { + $this->provider + ->expects($this->never()) + ->method('getChangedAt'); + + $this->provider + ->expects($this->never()) + ->method('getUpdatedBy'); + + $this->resolver + ->expects($this->once()) + ->method('getBlamableAnnotation') + ->willReturn(null); + + $entity = new EntityWithInterfaceOnly(); + $event = new EntityChangedEvent($this->em, $entity, null, []); + + $this->assertNull($entity->getCreatedAt()); + $this->assertNull($entity->getUpdatedBy()); + $this->assertNull($entity->getUpdatedAt()); + + $listener = new BlamableListener($this->resolver, $this->provider); + $listener->entityChanged($event); + + $this->assertNull($entity->getCreatedAt()); + $this->assertNull($entity->getUpdatedBy()); + $this->assertNull($entity->getUpdatedAt()); + + $listener->entityChanged($event); // cover the case where the blamable cache is already filled. + } } diff --git a/test/Listener/EntityWithAttribute.php b/test/Listener/EntityWithAttribute.php new file mode 100644 index 0000000..a14aa75 --- /dev/null +++ b/test/Listener/EntityWithAttribute.php @@ -0,0 +1,49 @@ +updated_by = $by; + } + + public function getUpdatedBy(): ?string + { + return $this->updated_by; + } + + public function setUpdatedAt(\DateTime $at): void + { + $this->updated_at = $at; + } + + public function getUpdatedAt(): ?\DateTime + { + return $this->updated_at; + } + + public function setCreatedAt(\DateTime $at): void + { + $this->created_at = $at; + } + + public function getCreatedAt(): ?\DateTime + { + return $this->created_at; + } +} diff --git a/test/Listener/EntityWithInterfaceOnly.php b/test/Listener/EntityWithInterfaceOnly.php new file mode 100644 index 0000000..5fc50cd --- /dev/null +++ b/test/Listener/EntityWithInterfaceOnly.php @@ -0,0 +1,47 @@ +updated_by = $by; + } + + public function getUpdatedBy(): ?string + { + return $this->updated_by; + } + + public function setUpdatedAt(\DateTime $at): void + { + $this->updated_at = $at; + } + + public function getUpdatedAt(): ?\DateTime + { + return $this->updated_at; + } + + public function setCreatedAt(\DateTime $at): void + { + $this->created_at = $at; + } + + public function getCreatedAt(): ?\DateTime + { + return $this->created_at; + } +} From f6eab8d2af9afcc3b67bf74eecbf269be2e31bf8 Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Tue, 16 Jun 2026 16:19:36 +0200 Subject: [PATCH 3/4] Add Doctrine proxy support --- composer.json | 3 +- src/Listener/BlamableListener.php | 67 +++++++--------------- src/Resolver/BlamableResolver.php | 10 +++- src/Resolver/BlamableResolverInterface.php | 5 +- test/Listener/BlamableListenerTest.php | 17 +++++- test/Resolver/BlamableResolverTest.php | 19 +++++- 6 files changed, 67 insertions(+), 54 deletions(-) diff --git a/composer.json b/composer.json index ffd6f94..45d74e5 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,8 @@ "require": { "php": "^8.3", "doctrine/orm": "^2.4.0", - "hostnet/entity-tracker-component": "^1.2.0||^2.0.0" + "hostnet/entity-tracker-component": "^1.2.0||^2.0.0", + "symfony/cache": "^6.4|^7.4" }, "require-dev": { "hostnet/phpcs-tool": "^9.1.0", diff --git a/src/Listener/BlamableListener.php b/src/Listener/BlamableListener.php index 8bc40f1..c80ea60 100644 --- a/src/Listener/BlamableListener.php +++ b/src/Listener/BlamableListener.php @@ -6,11 +6,14 @@ namespace Hostnet\Component\EntityBlamable\Listener; -use Hostnet\Component\EntityBlamable\Attributes\Blamable; +use Doctrine\Persistence\ObjectManager; use Hostnet\Component\EntityBlamable\BlamableInterface; use Hostnet\Component\EntityBlamable\Provider\BlamableProviderInterface; use Hostnet\Component\EntityBlamable\Resolver\BlamableResolverInterface; use Hostnet\Component\EntityTracker\Event\EntityChangedEvent; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; /** * Listens to "Events::entityChanged" @@ -20,31 +23,11 @@ */ class BlamableListener { - /** - * @var BlamableResolverInterface - */ - private $resolver; - - /** - * @var BlamableProviderInterface - */ - private $provider; - - /** - * Caches the class names to prevent iterating over attribute and annotations again on the next entity. - */ - private array $is_blamable_cache = []; - - /** - * @param BlamableResolverInterface $resolver - * @param BlamableProviderInterface $provider - */ public function __construct( - BlamableResolverInterface $resolver, - BlamableProviderInterface $provider + private BlamableResolverInterface $resolver, + private BlamableProviderInterface $provider, + private CacheItemPoolInterface $is_blamable_cache = new ArrayAdapter() ) { - $this->resolver = $resolver; - $this->provider = $provider; } /** @@ -70,41 +53,35 @@ public function entityChanged(EntityChangedEvent $event): void } } - private function isBlamable($em, $entity): bool + private function isBlamable(ObjectManager $em, mixed $entity): bool { - $class = get_class($entity); - if (array_key_exists($class, $this->is_blamable_cache)) { - return $this->is_blamable_cache[$class]; + $cache_key = base64_encode('BLAMABLE-' . get_class($entity)); + $cached_item = $this->is_blamable_cache->getItem($cache_key); + + if ($cached_item->isHit()) { + return $cached_item->get(); } if (!($entity instanceof BlamableInterface)) { - $this->is_blamable_cache[$class] = false; - - return false; + return $this->save($cached_item, false); } if (null !== $this->resolver->getBlamableAnnotation($em, $entity)) { - $this->is_blamable_cache[$class] = true; - - return true; + return $this->save($cached_item, true); } - if ($this->hasBlamableAttribute($entity)) { - $this->is_blamable_cache[$class] = true; - - return true; + if (null !== $this->resolver->getBlamableAttribute($em, $entity)) { + return $this->save($cached_item, true); } - $this->is_blamable_cache[$class] = false; - - return false; + return $this->save($cached_item, false); } - private function hasBlamableAttribute($entity): bool + private function save(CacheItemInterface $item, bool $value): bool { - $reflection = new \ReflectionClass($entity); - $attributes = $reflection->getAttributes(Blamable::class); + $item->set($value); + $this->is_blamable_cache->save($item); - return !empty($attributes); + return $value; } } diff --git a/src/Resolver/BlamableResolver.php b/src/Resolver/BlamableResolver.php index 6220214..3ed1fa3 100644 --- a/src/Resolver/BlamableResolver.php +++ b/src/Resolver/BlamableResolver.php @@ -7,7 +7,8 @@ namespace Hostnet\Component\EntityBlamable\Resolver; use Doctrine\ORM\EntityManagerInterface; -use Hostnet\Component\EntityBlamable\Blamable; +use Hostnet\Component\EntityBlamable\Attributes\Blamable; +use Hostnet\Component\EntityBlamable\Blamable as BlamabeAnnotation; use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; class BlamableResolver implements BlamableResolverInterface @@ -32,6 +33,11 @@ public function __construct(EntityAnnotationMetadataProvider $provider) */ public function getBlamableAnnotation(EntityManagerInterface $em, $entity) { - return $this->provider->getAnnotationFromEntity($em, $entity, Blamable::class); + return $this->provider->getAnnotationFromEntity($em, $entity, BlamabeAnnotation::class); + } + + public function getBlamableAttribute(EntityManagerInterface $em, $entity): ?Blamable + { + return $this->provider->getAttributeFromEntity(Blamable::class, $em, $entity); } } diff --git a/src/Resolver/BlamableResolverInterface.php b/src/Resolver/BlamableResolverInterface.php index 7d62ba7..97898ff 100644 --- a/src/Resolver/BlamableResolverInterface.php +++ b/src/Resolver/BlamableResolverInterface.php @@ -7,7 +7,7 @@ namespace Hostnet\Component\EntityBlamable\Resolver; use Doctrine\ORM\EntityManagerInterface; -use Hostnet\Component\EntityBlamable\Blamable; +use Hostnet\Component\EntityBlamable\Attributes\Blamable; interface BlamableResolverInterface { @@ -16,9 +16,10 @@ interface BlamableResolverInterface * * @param EntityManagerInterface $em * @param mixed $entity - * @return Blamable * * @deprecated Please use the attribute instead. */ public function getBlamableAnnotation(EntityManagerInterface $em, $entity); + + public function getBlamableAttribute(EntityManagerInterface $em, $entity): ?Blamable; } diff --git a/test/Listener/BlamableListenerTest.php b/test/Listener/BlamableListenerTest.php index e3597e0..0ab4afa 100644 --- a/test/Listener/BlamableListenerTest.php +++ b/test/Listener/BlamableListenerTest.php @@ -6,7 +6,8 @@ namespace Hostnet\Component\EntityBlamable\Listener; -use Hostnet\Component\EntityBlamable\Blamable; +use Hostnet\Component\EntityBlamable\Attributes\Blamable; +use Hostnet\Component\EntityBlamable\Blamable as BlamableAnnotation; use Hostnet\Component\EntityTracker\Event\EntityChangedEvent; use PHPUnit\Framework\TestCase; @@ -68,7 +69,7 @@ public function testOnEntityChangedExistingEntity(): void $this->resolver ->expects($this->once()) ->method('getBlamableAnnotation') - ->willReturn(new Blamable()); + ->willReturn(new BlamableAnnotation()); $event = new EntityChangedEvent($this->em, $this->entity, new \stdClass(), []); $listener = new BlamableListener($this->resolver, $this->provider); @@ -108,7 +109,7 @@ public function testOnEntityChangedNewEntity(): void $this->resolver ->expects($this->once()) ->method('getBlamableAnnotation') - ->willReturn(new Blamable()); + ->willReturn(new BlamableAnnotation()); $event = new EntityChangedEvent($this->em, $this->entity, null, []); $listener = new BlamableListener($this->resolver, $this->provider); @@ -135,6 +136,11 @@ public function testOnEntityChangedNewEntityWithAttribute(): void ->method('getBlamableAnnotation') ->willReturn(null); + $this->resolver + ->expects($this->once()) + ->method('getBlamableAttribute') + ->willReturn(new Blamable()); + $entity = new EntityWithAttribute(); $event = new EntityChangedEvent($this->em, $entity, null, []); @@ -165,6 +171,11 @@ public function testOnEntityChangedNewEntityWithInterfaceOnly(): void ->method('getBlamableAnnotation') ->willReturn(null); + $this->resolver + ->expects($this->once()) + ->method('getBlamableAttribute') + ->willReturn(null); + $entity = new EntityWithInterfaceOnly(); $event = new EntityChangedEvent($this->em, $entity, null, []); diff --git a/test/Resolver/BlamableResolverTest.php b/test/Resolver/BlamableResolverTest.php index c910e1a..cb03318 100644 --- a/test/Resolver/BlamableResolverTest.php +++ b/test/Resolver/BlamableResolverTest.php @@ -6,6 +6,8 @@ namespace Hostnet\Component\EntityBlamable\Resolver; +use Hostnet\Component\EntityBlamable\Attributes\Blamable; +use Hostnet\Component\EntityBlamable\Blamable as BlamableAnnotation; use PHPUnit\Framework\TestCase; /** @@ -39,8 +41,23 @@ public function testGetBlamableAnnotation(): void $this->provider ->expects($this->once()) ->method('getAnnotationFromEntity') - ->with($this->em, $entity, 'Hostnet\Component\EntityBlamable\Blamable'); + ->with($this->em, $entity, BlamableAnnotation::class); $this->resolver->getBlamableAnnotation($this->em, $entity); } + + public function testGetRevisionAttribute(): void + { + $entity = new \stdClass(); + + $attribute = new Blamable(); + + $this->provider + ->expects($this->once()) + ->method('getAttributeFromEntity') + ->with(Blamable::class, $this->em, $entity) + ->willReturn($attribute); + + self::assertSame($attribute, $this->resolver->getBlamableAttribute($this->em, $entity)); + } } From d38b81affaf9bd2560bf676d6ede1096b7aa6fa5 Mon Sep 17 00:00:00 2001 From: Jan Lam Date: Thu, 18 Jun 2026 09:52:14 +0200 Subject: [PATCH 4/4] Update entity-tracker-component --- composer.json | 2 +- src/Resolver/BlamableResolver.php | 13 ++----------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 45d74e5..aaf4e30 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": "^8.3", "doctrine/orm": "^2.4.0", - "hostnet/entity-tracker-component": "^1.2.0||^2.0.0", + "hostnet/entity-tracker-component": "^2.3.0", "symfony/cache": "^6.4|^7.4" }, "require-dev": { diff --git a/src/Resolver/BlamableResolver.php b/src/Resolver/BlamableResolver.php index 3ed1fa3..ca3a7c2 100644 --- a/src/Resolver/BlamableResolver.php +++ b/src/Resolver/BlamableResolver.php @@ -9,21 +9,12 @@ use Doctrine\ORM\EntityManagerInterface; use Hostnet\Component\EntityBlamable\Attributes\Blamable; use Hostnet\Component\EntityBlamable\Blamable as BlamabeAnnotation; -use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; +use Hostnet\Component\EntityTracker\Provider\EntityMetadataProvider; class BlamableResolver implements BlamableResolverInterface { - /** - * @var EntityAnnotationMetadataProvider - */ - private $provider; - - /** - * @param EntityAnnotationMetadataProvider $provider - */ - public function __construct(EntityAnnotationMetadataProvider $provider) + public function __construct(private EntityMetadataProvider $provider) { - $this->provider = $provider; } /**