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 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..4739881 100644 --- a/composer.json +++ b/composer.json @@ -4,18 +4,19 @@ "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", - "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/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(); + } - /** - * @var EntityMutationMetadataProvider - */ - private $meta_mutation_provider; + private function isTracked(ObjectManager $em, mixed $entity): bool + { + $cache_key = base64_encode('TRACKED-' . get_class($entity)); + $cached_item = $this->is_tracked_cache->getItem($cache_key); - /** - * @var string[] - */ - private $annotations = []; + if ($cached_item->isHit()) { + return $cached_item->get(); + } - /** - * @var LoggerInterface - */ - private $logger; + if (null !== $this->meta_annotation_provider->getAttributeFromEntity(Tracked::class, $em, $entity)) { + return $this->save($cached_item, true); + } - /** - * @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 - ) { - $this->meta_annotation_provider = $meta_annotation_provider; - $this->meta_mutation_provider = $meta_mutation_provider; - $this->logger = $logger ? : new NullLogger(); + if ($this->meta_annotation_provider->isTracked($em, $entity)) { + return $this->save($cached_item, true); + } + + return $this->save($cached_item, false); + } + + private function save(CacheItemInterface $item, bool $value): bool + { + $item->set($value); + $this->is_tracked_cache->save($item); + + return $value; } /** * 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(); + $em = $event->getObjectManager(); $changes = $this->meta_mutation_provider->getFullChangeSet($em); foreach ($changes as $updates) { @@ -80,7 +85,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 +117,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..70e49e4 100644 --- a/src/Provider/EntityAnnotationMetadataProvider.php +++ b/src/Provider/EntityAnnotationMetadataProvider.php @@ -6,57 +6,9 @@ namespace Hostnet\Component\EntityTracker\Provider; -use Doctrine\Common\Annotations\Reader; -use Doctrine\ORM\EntityManagerInterface; -use Hostnet\Component\EntityTracker\Annotation\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; - } - - /** - * Get the annotation from a class or null if it doesn't exists. - * - * @param EntityManagerInterface $em - * @param mixed $entity - * @return bool - */ - public function isTracked(EntityManagerInterface $em, $entity) - { - $class = get_class($entity); - $annotations = $this->reader->getClassAnnotations($em->getClassMetadata($class)->getReflectionClass()); - - foreach ($annotations as $annotation) { - if ($annotation instanceof Tracked) { - return true; - } - } - - return false; - } - - /** - * @param EntityManagerInterface $em - * @param mixed $entity - * @param string $annotation - * @return mixed - */ - public function getAnnotationFromEntity(EntityManagerInterface $em, $entity, $annotation) - { - return $this->reader->getClassAnnotation( - $em->getClassMetadata(get_class($entity))->getReflectionClass(), - $annotation - ); - } } 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/src/Provider/EntityMutationMetadataProvider.php b/src/Provider/EntityMutationMetadataProvider.php index 9684545..e420501 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): mixed { $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/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 e6dbe9b..80049ea 100644 --- a/test/Listener/EntityChangedListenerTest.php +++ b/test/Listener/EntityChangedListenerTest.php @@ -11,9 +11,10 @@ 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\Provider\EntityAnnotationMetadataProvider; +use Hostnet\Component\EntityTracker\Provider\EntityMetadataProvider; use Hostnet\Component\EntityTracker\Provider\EntityMutationMetadataProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -43,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); @@ -69,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())); } @@ -89,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); @@ -112,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([]); @@ -130,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); @@ -141,7 +154,7 @@ public function testPreFlushWithMutatedFields(): void $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); } - public function testPreFlushWithNewEntity(): void + public function testPreFlushWithNewAnnotatedEntity(): void { $entity = new \stdClass(); @@ -149,6 +162,9 @@ public function testPreFlushWithNewEntity(): 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); @@ -160,6 +176,53 @@ public function testPreFlushWithNewEntity(): void $this->listener->preFlush(new PreFlushEventArgs($this->em->reveal())); } + public function testPreFlushWithNewAttributedEntity(): void + { + $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); + $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_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(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())); + } + public function testPreFlushWithProxy(): void { $entity = $this->prophesize(Proxy::class)->reveal(); @@ -170,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())); } @@ -184,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 @@ +reader = new AnnotationReader(); - $this->provider = new EntityAnnotationMetadataProvider($this->reader); + $this->provider = new EntityMetadataProvider($this->reader); $this->em = $this->createMock('Doctrine\ORM\EntityManagerInterface'); } @@ -102,7 +109,49 @@ public function getAnnotationFromEntityProvider(): iterable { return [ [new \stdClass(), null, false], - [new MockEntity(), new Tracked(), true], + [new MockEntity(), new TrackedAnnotation(), true], + ]; + } + + /** + * @dataProvider getAttributeFromEntityProvider + */ + public function testGetAttributeFromEntity(mixed $entity, bool $has, ?string $proxied_class): void + { + if ($proxied_class) { + $metadata = $this->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 {