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 b7896e1..757191c 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -15,7 +15,7 @@ jobs: php-versions: ['8.3', '8.4'] name: PHP ${{ matrix.php-versions }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} @@ -23,7 +23,7 @@ jobs: id: composer-cache run: | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - uses: actions/cache@v4 + - uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} diff --git a/README.md b/README.md index f9bc4e0..09b4472 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This component lets you automatically store revisions for a set of entities per Requirements ------------ -The revision component requires a minimum of php 7.3 and runs on Doctrine2. For specific requirements, please check [composer.json](../master/composer.json). +The revision component requires a minimum of php 8.3 and runs on Doctrine2. For specific requirements, please check [composer.json](../master/composer.json). Installation ------------ @@ -33,7 +33,7 @@ Installing is pretty easy, this package is available on [packagist](https://pack ```javascript "require" : { - "hostnet/entity-revision-component" : "1.*" + "hostnet/entity-revision-component" : "2.*" } ``` @@ -46,7 +46,7 @@ Documentation How does it work? ----------------- -It works by putting the `@Revision` annotation and RevisionableInterface on your Entity and registering the listener on the entityChanged event, assuming you have already configured the [Entity Tracker Component](https://github.com/hostnet/entity-tracker-component/#setup). +It works by putting the `#[Revision]` attribute and RevisionableInterface on your Entity and registering the listener on the entityChanged event, assuming you have already configured the [Entity Tracker Component](https://github.com/hostnet/entity-tracker-component/#setup). For a usage example, follow the setup below. @@ -54,7 +54,7 @@ For a usage example, follow the setup below. Setup ----- - - You have to add `@Revision` to your entity + - You have to add `#[Revision]` to your entity - You have to add the RevisionableInterface to your entity - You need a Revision Entity @@ -72,29 +72,26 @@ It might look a bit complicated to set up, but it's pretty much setting up the t use Acme\Bundle\AcmeBundle\AcmeRevisionFactory; use Hostnet\Component\EntityRevision\Resolver\RevisionResolver; use Hostnet\Component\EntityTracker\Listener\EntityChangedListener; -use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; +use Hostnet\Component\EntityTracker\Provider\EntityMetadataProvider; use Hostnet\Component\EntityTracker\Provider\EntityMutationMetadataProvider; /* @var $em \Doctrine\ORM\EntityManager */ $event_manager = $em->getEventManager(); -// default doctrine annotation reader -$annotation_reader = new AnnotationReader(); - // setup required providers -$mutation_metadata_provider = new EntityMutationMetadataProvider($annotation_reader); -$annotation_metadata_provider = new EntityAnnotationMetadataProvider($annotation_reader); +$mutation_metadata_provider = new EntityMutationMetadataProvider(); +$metadata_provider = new EntityMetadataProvider(); $logger = ...; // instance of LoggerInterface from the psr/log package, optional argument - -// pre flush event listener that uses the @Revision annotation + +// pre flush event listener that uses the #[Tracked]-derived attributes, such as #[Revision] $entity_changed_listener = new EntityChangedListener( + $metadata_provider, $mutation_metadata_provider, - $annotation_metadata_provider, $logger ); -// the resolver is used to find the correct annotation -$revision_resolver = new RevisionResolver($annotation_metadata_provider); +// the resolver is used to find the correct attribute +$revision_resolver = new RevisionResolver($metadata_provider); // this factory will provide the revision and as author Henk $revision_factory = new AcmeRevisionFactory('Henk'); @@ -118,22 +115,15 @@ Revisions are stored in the database, thus need an Entity. You're free to decide use Doctrine\ORM\Mapping as ORM; use Hostnet\Component\EntityRevision\RevisionInterface; -/** - * @ORM\Entity - */ +#[ORM\Entity] class Revision implements RevisionInterface { - - /** - * @ORM\... - */ + #[ORM\...] private $author; - - /** - * @ORM\... - */ + + #[ORM\...] private $created_at; - + public function __construct($author, \DateTime $created_at) { $this->author = $author; @@ -155,34 +145,34 @@ class Revision implements RevisionInterface #### Configuring the Entity -All we have to do now is put the `@Revision` annotation and RevisionableInterface on our Entity. +All we have to do now is put the `#[Revision]` attribute and RevisionableInterface on our Entity. ```php use Doctrine\ORM\Mapping as ORM; +use Hostnet\Component\EntityRevision\Attributes\Revision as RevisionAttribute; use Hostnet\Component\EntityRevision\RevisionableInterface; +use Hostnet\Component\EntityRevision\RevisionInterface; -/** - * @ORM\Entity - * @Revision - */ +#[ORM\Entity] +#[RevisionAttribute] class MyEntity implements RevisionableInterface { /** * The current revision of the contact person - * - * @ORM\ManyToOne(targetEntity="Revision") - * @ORM\JoinColumn(name="revision_id", referencedColumnName="id") - * @var Revision */ - private $revision; - - public function setRevision(RevisionInterface $revision) + #[ORM\ManyToOne(targetEntity: Revision::class)] + #[ORM\JoinColumn(name: 'revision_id', referencedColumnName: 'id')] + private ?RevisionInterface $revision = null; + + public function setRevision(RevisionInterface $revision): static { $this->revision = $revision; + + return $this; } - - public function getRevision() + + public function getRevision(): RevisionInterface { return $this->revision; } @@ -217,7 +207,7 @@ class AcmeRevisionFactory implements RevisionFactoryInterface ``` #### What's next? -All you have to do now is change multiple entities with `@Revision` and they will be grouped by a Revision Entity. +All you have to do now is change multiple entities with `#[Revision]` and they will be grouped by a Revision Entity. ```php // imagine all instances to be Revisionable $user->setName('Henk'); diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 0000000..7d389d8 --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,22 @@ +UPGRADE FROM 1.x to 2.0 +=============================== + +### General Changes + +- Dropped support for `doctrine/annotations`. Entities must use the `#[Revision]` + attribute instead of the `@Revision` annotation. + +- Removed `RevisionListener::preFlush()`. This method has been deprecated since 1.1 + and its functionality already lives in `RevisionListener::entityChanged()` and + `RevisionListener::postFlush()`. Remove any `preFlush` registration of the + `RevisionListener` from your event manager, if you still have one. + +- Added return type hints to `RevisionableInterface`, `RevisionInterface` and + `RevisionFactoryInterface`. Any implementation of these interfaces must add + matching return types: + - `RevisionableInterface::setRevision()`: `static` (now fluent, must `return $this;`) + - `RevisionableInterface::getRevision()`: `RevisionInterface` + - `RevisionInterface::getUser()`: `string` + - `RevisionInterface::getCreatedAt()`: `\DateTimeInterface` + - `RevisionFactoryInterface::createRevision()`: `RevisionInterface` (no longer + nullable) diff --git a/composer.json b/composer.json index 183437a..badfeaa 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "require": { "php": "^8.3", "doctrine/orm": "^2.4.0", - "hostnet/entity-tracker-component": "^2.3.0", + "hostnet/entity-tracker-component": "^3.0.0", "psr/log": "^1.0.0|^2.0.0|^3.0.0", "symfony/cache": "^6.4|^7.4" }, diff --git a/src/Factory/RevisionFactoryInterface.php b/src/Factory/RevisionFactoryInterface.php index 86f8072..5efc72e 100644 --- a/src/Factory/RevisionFactoryInterface.php +++ b/src/Factory/RevisionFactoryInterface.php @@ -6,13 +6,12 @@ namespace Hostnet\Component\EntityRevision\Factory; +use Hostnet\Component\EntityRevision\RevisionInterface; + interface RevisionFactoryInterface { /** * Create a revision - * - * @param \DateTime $created_at - * @return \Hostnet\Component\EntityRevision\RevisionInterface */ - public function createRevision(\DateTime $created_at); + public function createRevision(\DateTime $created_at): RevisionInterface; } diff --git a/src/Listener/RevisionListener.php b/src/Listener/RevisionListener.php index 003c881..b5fec3d 100644 --- a/src/Listener/RevisionListener.php +++ b/src/Listener/RevisionListener.php @@ -6,8 +6,8 @@ namespace Hostnet\Component\EntityRevision\Listener; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\PostFlushEventArgs; -use Doctrine\ORM\Event\PreFlushEventArgs; use Hostnet\Component\EntityRevision\Factory\RevisionFactoryInterface; use Hostnet\Component\EntityRevision\Resolver\RevisionResolverInterface; use Hostnet\Component\EntityRevision\RevisionableInterface; @@ -26,32 +26,16 @@ class RevisionListener public function __construct( private RevisionResolverInterface $resolver, private RevisionFactoryInterface $factory, - private ?LoggerInterface $logger = new NullLogger(), - private ?CacheItemPoolInterface $is_revision_cache = new ArrayAdapter() + private LoggerInterface $logger = new NullLogger(), + private CacheItemPoolInterface $is_revision_cache = new ArrayAdapter() ) { } - /** - * Event is used to create a new revision - * - * Used to group all entities to the same revision - * in the same flush if they use @Revision - * - * @deprecated functionality was moved to entityChanged and postFlush, will be removed when removing - * doctrine/annotations. - * @param PreFlushEventArgs $event - */ - public function preFlush(PreFlushEventArgs $event): void - { - trigger_error(__METHOD__ . ' is deprecated, please remove it from your event listener.', E_USER_DEPRECATED); - $this->revision = $this->factory->createRevision(new \DateTime()); - } - /** * Event is used to remove the previous revision * * Used to group all entities to the same revision - * in the same flush if they use @Revision. This method + * in the same flush if they use #[Revision]. This method * can safely be overwritten if you prefer a Revision * per Request. */ @@ -60,9 +44,6 @@ public function postFlush(PostFlushEventArgs $event): void $this->revision = null; } - /** - * @param EntityChangedEvent $event - */ public function entityChanged(EntityChangedEvent $event): void { if (!$this->shouldBePersisted($event)) { @@ -73,22 +54,16 @@ public function entityChanged(EntityChangedEvent $event): void $this->revision = $this->factory->createRevision(new \DateTime()); } - if (null === $this->revision) { - throw new \RuntimeException('No Revision set for current flush.'); - } - $event->getEntityManager()->persist($this->revision); $entity = $event->getCurrentEntity(); $entity->setRevision($this->revision); - $this->logger->info(sprintf('Added revision for entity', ['entity' => get_class($entity)])); + $this->logger->info('Added revision for entity {entity}', ['entity' => $entity::class]); } /** * Checks if the current entity is eligable for a revision. - * - * @param EntityChangedEvent $event */ private function shouldBePersisted(EntityChangedEvent $event): bool { @@ -102,16 +77,16 @@ private function shouldBePersisted(EntityChangedEvent $event): bool $fields = $this->resolver->getRevisionableFields($em, $entity); // only create a revision if the mutated fields are tracked - if (count(array_intersect($fields, $event->getMutatedFields())) == 0) { + if (count(array_intersect($fields, $event->getMutatedFields())) === 0) { return false; } return true; } - private function isRevision($em, $entity): bool + private function isRevision(EntityManagerInterface $em, object $entity): bool { - $cache_key = base64_encode('REVISION-' . get_class($entity)); + $cache_key = base64_encode('REVISION-' . $entity::class); $cached_item = $this->is_revision_cache->getItem($cache_key); if ($cached_item->isHit()) { @@ -126,10 +101,6 @@ private function isRevision($em, $entity): bool return $this->save($cached_item, true); } - if (null !== $this->resolver->getRevisionAnnotation($em, $entity)) { - return $this->save($cached_item, true); - } - return $this->save($cached_item, false); } diff --git a/src/Resolver/RevisionResolver.php b/src/Resolver/RevisionResolver.php index d14bcef..2bc7c10 100644 --- a/src/Resolver/RevisionResolver.php +++ b/src/Resolver/RevisionResolver.php @@ -8,7 +8,6 @@ use Doctrine\ORM\EntityManagerInterface; use Hostnet\Component\EntityRevision\Attributes\Revision; -use Hostnet\Component\EntityRevision\Revision as RevisionAnnotation; use Hostnet\Component\EntityTracker\Provider\EntityMetadataProvider; class RevisionResolver implements RevisionResolverInterface @@ -17,27 +16,16 @@ public function __construct(private EntityMetadataProvider $provider) { } - /** - * @see \Hostnet\Component\EntityRevision\Resolver\RevisionResolverInterface::getRevisionAnnotation() - * - * @deprecated Please use the Revision attribute instead - */ - public function getRevisionAnnotation(EntityManagerInterface $em, $entity): ?RevisionAnnotation - { - return $this->provider->getAnnotationFromEntity($em, $entity, RevisionAnnotation::class); - } - - public function getRevisionAttribute(EntityManagerInterface $em, $entity): ?Revision + #[\Override] + public function getRevisionAttribute(EntityManagerInterface $em, object $entity): ?Revision { return $this->provider->getAttributeFromEntity(Revision::class, $em, $entity); } - /** - * @see \Hostnet\Component\EntityRevision\Resolver\RevisionResolverInterface::getRevisionableFields() - */ - public function getRevisionableFields(EntityManagerInterface $em, $entity): array + #[\Override] + public function getRevisionableFields(EntityManagerInterface $em, object $entity): array { - $metadata = $em->getClassMetadata(get_class($entity)); + $metadata = $em->getClassMetadata($entity::class); return array_merge($metadata->getFieldNames(), $metadata->getAssociationNames()); } } diff --git a/src/Resolver/RevisionResolverInterface.php b/src/Resolver/RevisionResolverInterface.php index 2b29250..b4aea68 100644 --- a/src/Resolver/RevisionResolverInterface.php +++ b/src/Resolver/RevisionResolverInterface.php @@ -8,7 +8,6 @@ use Doctrine\ORM\EntityManagerInterface; use Hostnet\Component\EntityRevision\Attributes\Revision; -use Hostnet\Component\EntityRevision\Revision as RevisionAnnotation; interface RevisionResolverInterface { @@ -17,17 +16,10 @@ interface RevisionResolverInterface * * @return string[] */ - public function getRevisionableFields(EntityManagerInterface $em, $entity): array; - - /** - * Return the revision annotation or null - * - * @deprecated - */ - public function getRevisionAnnotation(EntityManagerInterface $em, $entity): ?RevisionAnnotation; + public function getRevisionableFields(EntityManagerInterface $em, object $entity): array; /** * Return the revision attribute or null */ - public function getRevisionAttribute(EntityManagerInterface $em, $entity): ?Revision; + public function getRevisionAttribute(EntityManagerInterface $em, object $entity): ?Revision; } diff --git a/src/Revision.php b/src/Revision.php deleted file mode 100644 index 7cba5f1..0000000 --- a/src/Revision.php +++ /dev/null @@ -1,19 +0,0 @@ -logger = $this->createMock(LoggerInterface::class); } - public function testPreFlush(): void - { - $this->factory - ->expects($this->once()) - ->method('createRevision'); - - $doctrine_event = $this - ->getMockBuilder(PreFlushEventArgs::class) - ->disableOriginalConstructor() - ->getMock(); - - $listener = new RevisionListener($this->resolver, $this->factory, $this->logger); - $listener->preFlush($doctrine_event); - } - public function testOnEntityChangedNoInterface(): void { $this->resolver @@ -64,7 +48,7 @@ public function testOnEntityChangedNoInterface(): void $listener->entityChanged($event); } - public function testOnEntityChangedNoAnnotation(): void + public function testOnEntityChangedNoAttribute(): void { $event = new EntityChangedEvent($this->em, $this->entity, $this->entity, []); $listener = new RevisionListener($this->resolver, $this->factory, $this->logger); @@ -78,8 +62,8 @@ public function testOnEntityChangedNoRevisionFields(): void { $this->resolver ->expects($this->once()) - ->method('getRevisionAnnotation') - ->willReturn(new RevisionAnnotation()); + ->method('getRevisionAttribute') + ->willReturn(new Revision()); $this->resolver ->expects($this->once()) @@ -99,8 +83,8 @@ public function testOnEntityChangedNoTrackedMutations(): void { $this->resolver ->expects($this->exactly(2)) - ->method('getRevisionAnnotation') - ->willReturn(new RevisionAnnotation()); + ->method('getRevisionAttribute') + ->willReturn(new Revision()); $this->resolver ->expects($this->exactly(2)) @@ -120,84 +104,8 @@ public function testOnEntityChangedNoTrackedMutations(): void $listener->entityChanged($event); } - public function testOnEntityChangedNoRevisionPresentOnFlush(): void - { - $history = new RevisionAnnotation(); - - $this->resolver - ->expects($this->once()) - ->method('getRevisionAnnotation') - ->willReturn($history); - - $this->resolver - ->expects($this->once()) - ->method('getRevisionableFields') - ->willReturn(['something']); - - $this->factory - ->expects($this->once()) - ->method('createRevision') - ->willReturn(null); - - $event = new EntityChangedEvent($this->em, $this->entity, $this->entity, ['something']); - $listener = new RevisionListener($this->resolver, $this->factory, $this->logger); - - $this->expectException(\RuntimeException::class); - - $listener->entityChanged($event); - } - - public function testOnEntityChanged(): void - { - $r1 = $this->createMock('Hostnet\Component\EntityRevision\RevisionInterface'); - $r2 = $this->createMock('Hostnet\Component\EntityRevision\RevisionInterface'); - - $history = new RevisionAnnotation(); - $this->resolver - ->expects($this->any()) - ->method('getRevisionAnnotation') - ->willReturn($history); - - $this->resolver - ->expects($this->any()) - ->method('getRevisionableFields') - ->willReturn(['something']); - - $this->factory - ->expects($this->exactly(2)) - ->method('createRevision') - ->willReturnOnConsecutiveCalls($r1, $r2); - - $this->em - ->expects($this->any()) - ->method('persist') - ->withConsecutive([$this->identicalTo($r1)], [$this->identicalTo($r2)]); - - $this->entity - ->expects($this->exactly(3)) - ->method('setRevision') - ->withConsecutive([$this->identicalTo($r1)], [$this->identicalTo($r2)], [$this->identicalTo($r2)]); - - $event = new EntityChangedEvent($this->em, $this->entity, $this->entity, ['something']); - $doctrine_event = $this - ->getMockBuilder('Doctrine\ORM\Event\PostFlushEventArgs') - ->disableOriginalConstructor() - ->getMock(); - - $listener = new RevisionListener($this->resolver, $this->factory); - $listener->entityChanged($event); - $listener->postFlush($doctrine_event); - $listener->entityChanged($event); - $listener->entityChanged($event); - } - - public function testOnEntityChangedInterfaceOnlyNoAnnotationNoAttribute(): void + public function testOnEntityChangedInterfaceOnlyNoAttribute(): void { - $this->resolver - ->expects($this->any()) - ->method('getRevisionAnnotation') - ->willReturn(null); - $this->resolver ->expects($this->never()) ->method('getRevisionableFields'); @@ -230,11 +138,6 @@ public function testOnEntityChangedAttribute(): void $r1 = $this->createMock('Hostnet\Component\EntityRevision\RevisionInterface'); $r2 = $this->createMock('Hostnet\Component\EntityRevision\RevisionInterface'); - $this->resolver - ->expects($this->any()) - ->method('getRevisionAnnotation') - ->willReturn(null); - $this->resolver ->expects($this->once()) ->method('getRevisionAttribute') @@ -250,15 +153,23 @@ public function testOnEntityChangedAttribute(): void ->method('createRevision') ->willReturnOnConsecutiveCalls($r1, $r2); + $persisted = []; $this->em - ->expects($this->any()) + ->expects($this->exactly(3)) ->method('persist') - ->withConsecutive([$this->identicalTo($r1)], [$this->identicalTo($r2)]); + ->willReturnCallback(function ($revision) use (&$persisted): void { + $persisted[] = $revision; + }); + $set_revisions = []; $this->entity ->expects($this->exactly(3)) ->method('setRevision') - ->withConsecutive([$this->identicalTo($r1)], [$this->identicalTo($r2)], [$this->identicalTo($r2)]); + ->with($this->callback(function ($revision) use (&$set_revisions): bool { + $set_revisions[] = $revision; + + return true; + })); $event = new EntityChangedEvent($this->em, $this->entity, $this->entity, ['something']); $doctrine_event = $this @@ -271,5 +182,8 @@ public function testOnEntityChangedAttribute(): void $listener->postFlush($doctrine_event); $listener->entityChanged($event); $listener->entityChanged($event); + + self::assertSame([$r1, $r2, $r2], $persisted); + self::assertSame([$r1, $r2, $r2], $set_revisions); } } diff --git a/test/Resolver/RevisionResolverTest.php b/test/Resolver/RevisionResolverTest.php index 9c3a04e..fad5a95 100644 --- a/test/Resolver/RevisionResolverTest.php +++ b/test/Resolver/RevisionResolverTest.php @@ -9,8 +9,8 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Hostnet\Component\EntityRevision\Attributes\Revision; -use Hostnet\Component\EntityRevision\Revision as RevisionAnnotation; -use Hostnet\Component\EntityTracker\Provider\EntityAnnotationMetadataProvider; +use Hostnet\Component\EntityTracker\Provider\EntityMetadataProvider; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; /** @@ -18,37 +18,17 @@ */ class RevisionResolverTest extends TestCase { - private $provider; - private $resolver; - private $em; + private EntityMetadataProvider&MockObject $provider; + private EntityManagerInterface&MockObject $em; + private RevisionResolver $resolver; public function setUp(): void { - $this->provider = $this - ->getMockBuilder(EntityAnnotationMetadataProvider::class) - ->disableOriginalConstructor() - ->getMock(); - - $this->em = $this - ->getMockBuilder(EntityManagerInterface::class) - ->disableOriginalConstructor() - ->getMock(); - + $this->provider = $this->createMock(EntityMetadataProvider::class); + $this->em = $this->createMock(EntityManagerInterface::class); $this->resolver = new RevisionResolver($this->provider); } - public function testGetRevisionAnnotation(): void - { - $entity = new \stdClass(); - - $this->provider - ->expects($this->once()) - ->method('getAnnotationFromEntity') - ->with($this->em, $entity, RevisionAnnotation::class); - - $this->resolver->getRevisionAnnotation($this->em, $entity); - } - public function testGetRevisionableFields(): void { $entity = new \stdClass(); @@ -59,7 +39,7 @@ public function testGetRevisionableFields(): void $this->em ->expects($this->once()) ->method('getClassMetadata') - ->with(get_class($entity)) + ->with($entity::class) ->willReturn($metadata); $this->assertEquals(['id', 'test'], $this->resolver->getRevisionableFields($this->em, $entity));