From 591d0021d0a799c66f6447c13ba554eec0193f3b Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Mon, 24 Jun 2024 20:26:59 -0400 Subject: [PATCH] Introduce the Revisionable extension --- doc/annotations.md | 74 +++ doc/attributes.md | 65 +++ doc/frameworks/laminas.md | 24 +- doc/frameworks/symfony.md | 67 ++- doc/loggable.md | 3 + phpstan-baseline.neon | 90 +++- phpunit.xml.dist | 3 + .../orm/doctrine-extensions-mapping-2-2.xsd | 5 + src/DoctrineExtensions.php | 4 + src/Mapping/Annotation/Revisionable.php | 64 +++ src/Mapping/Annotation/Versioned.php | 2 +- .../MappedSuperclass/AbstractRevision.php | 199 +++++++++ .../Repository/RevisionRepository.php | 187 ++++++++ src/Revisionable/Document/Revision.php | 54 +++ .../MappedSuperclass/AbstractRevision.php | 203 +++++++++ .../Entity/Repository/RevisionRepository.php | 174 ++++++++ src/Revisionable/Entity/Revision.php | 61 +++ .../Mapping/Driver/Annotation.php | 23 + src/Revisionable/Mapping/Driver/Attribute.php | 167 +++++++ src/Revisionable/Mapping/Driver/Xml.php | 209 +++++++++ src/Revisionable/Mapping/Driver/Yaml.php | 175 ++++++++ .../Mapping/Event/Adapter/ODM.php | 82 ++++ .../Mapping/Event/Adapter/ORM.php | 77 ++++ .../Mapping/Event/RevisionableAdapter.php | 42 ++ src/Revisionable/RevisionInterface.php | 101 +++++ src/Revisionable/Revisionable.php | 17 + src/Revisionable/RevisionableListener.php | 351 +++++++++++++++ src/Tool/Wrapper/AbstractWrapper.php | 8 + src/Tool/Wrapper/EntityWrapper.php | 26 +- src/Tool/Wrapper/MongoDocumentWrapper.php | 27 +- src/Tool/WrapperInterface.php | 3 + .../Mapping/Event/TranslatableAdapter.php | 5 +- ...g.Fixture.Xml.EmbeddedRevisionable.dcm.xml | 8 + ...s.Mapping.Fixture.Xml.Revisionable.dcm.xml | 12 + ....Fixture.Xml.RevisionableComposite.dcm.xml | 11 + ....Xml.RevisionableCompositeRelation.dcm.xml | 12 + ...xture.Xml.RevisionableWithEmbedded.dcm.xml | 15 + ....Fixture.Yaml.EmbeddedRevisionable.dcm.yml | 8 + ....Mapping.Fixture.Yaml.Revisionable.dcm.yml | 17 + ...Fixture.Yaml.RevisionableComposite.dcm.yml | 18 + ...Yaml.RevisionableCompositeRelation.dcm.yml | 20 + ...ture.Yaml.RevisionableWithEmbedded.dcm.yml | 23 + .../Fixture/Document/EmbeddedRevisionable.php | 34 ++ .../Mapping/Fixture/Document/Revisionable.php | 54 +++ .../Document/RevisionableWithEmbedded.php | 51 +++ .../Fixture/Entity/EmbeddedRevisionable.php | 34 ++ .../Mapping/Fixture/Entity/Revisionable.php | 58 +++ .../Fixture/Entity/RevisionableComposite.php | 70 +++ .../Entity/RevisionableCompositeRelation.php | 69 +++ .../Entity/RevisionableWithEmbedded.php | 55 +++ .../Fixture/Xml/EmbeddedRevisionable.php | 20 + .../Mapping/Fixture/Xml/Revisionable.php | 34 ++ .../Fixture/Xml/RevisionableComposite.php | 39 ++ .../Xml/RevisionableCompositeRelation.php | 39 ++ .../Fixture/Xml/RevisionableWithEmbedded.php | 21 + .../Fixture/Yaml/EmbeddedRevisionable.php | 20 + .../Mapping/Fixture/Yaml/Revisionable.php | 34 ++ .../Fixture/Yaml/RevisionableComposite.php | 39 ++ .../Yaml/RevisionableCompositeRelation.php | 39 ++ .../Fixture/Yaml/RevisionableWithEmbedded.php | 21 + .../Mock/EventSubscriberCustomMock.php | 4 +- .../Mapping/Mock/EventSubscriberMock.php | 4 +- .../Extension/Encoder/EncoderListener.php | 5 +- .../Mapping/MongoDBODMMappingTestCase.php | 84 ++++ .../RevisionableMongoDBODMMappingTest.php | 127 ++++++ .../Mapping/RevisionableORMMappingTest.php | 217 +++++++++ .../Revisionable/Fixture/Document/Address.php | 103 +++++ .../Revisionable/Fixture/Document/Article.php | 100 +++++ .../Revisionable/Fixture/Document/Author.php | 67 +++ .../Revisionable/Fixture/Document/Comment.php | 133 ++++++ .../Fixture/Document/CommentRevision.php | 48 ++ .../Revisionable/Fixture/Document/Geo.php | 91 ++++ .../Fixture/Document/GeoLocation.php | 49 ++ .../Fixture/Document/RelatedArticle.php | 107 +++++ .../Revisionable/Fixture/Entity/Address.php | 107 +++++ .../Revisionable/Fixture/Entity/Article.php | 104 +++++ .../Revisionable/Fixture/Entity/Author.php | 67 +++ .../Revisionable/Fixture/Entity/Comment.php | 137 ++++++ .../Fixture/Entity/CommentRevision.php | 45 ++ .../Revisionable/Fixture/Entity/Composite.php | 75 ++++ .../Fixture/Entity/CompositeRelation.php | 75 ++++ .../Gedmo/Revisionable/Fixture/Entity/Geo.php | 103 +++++ .../Fixture/Entity/GeoLocation.php | 49 ++ .../Fixture/Entity/RelatedArticle.php | 111 +++++ .../Revisionable/RevisionableDocumentTest.php | 247 ++++++++++ .../Revisionable/RevisionableEntityTest.php | 421 ++++++++++++++++++ tests/Gedmo/Timestampable/ChangeTest.php | 4 +- tests/Gedmo/Tool/BaseTestCaseMongoODM.php | 2 + tests/Gedmo/Tool/BaseTestCaseOM.php | 2 + tests/Gedmo/Tool/BaseTestCaseORM.php | 2 + 90 files changed, 6113 insertions(+), 44 deletions(-) create mode 100644 src/Mapping/Annotation/Revisionable.php create mode 100644 src/Revisionable/Document/MappedSuperclass/AbstractRevision.php create mode 100644 src/Revisionable/Document/Repository/RevisionRepository.php create mode 100644 src/Revisionable/Document/Revision.php create mode 100644 src/Revisionable/Entity/MappedSuperclass/AbstractRevision.php create mode 100644 src/Revisionable/Entity/Repository/RevisionRepository.php create mode 100644 src/Revisionable/Entity/Revision.php create mode 100644 src/Revisionable/Mapping/Driver/Annotation.php create mode 100644 src/Revisionable/Mapping/Driver/Attribute.php create mode 100644 src/Revisionable/Mapping/Driver/Xml.php create mode 100644 src/Revisionable/Mapping/Driver/Yaml.php create mode 100644 src/Revisionable/Mapping/Event/Adapter/ODM.php create mode 100644 src/Revisionable/Mapping/Event/Adapter/ORM.php create mode 100644 src/Revisionable/Mapping/Event/RevisionableAdapter.php create mode 100644 src/Revisionable/RevisionInterface.php create mode 100644 src/Revisionable/Revisionable.php create mode 100644 src/Revisionable/RevisionableListener.php create mode 100644 tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.EmbeddedRevisionable.dcm.xml create mode 100644 tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.Revisionable.dcm.xml create mode 100644 tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableComposite.dcm.xml create mode 100644 tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableCompositeRelation.dcm.xml create mode 100644 tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableWithEmbedded.dcm.xml create mode 100644 tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.EmbeddedRevisionable.dcm.yml create mode 100644 tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.Revisionable.dcm.yml create mode 100644 tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableComposite.dcm.yml create mode 100644 tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableCompositeRelation.dcm.yml create mode 100644 tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableWithEmbedded.dcm.yml create mode 100644 tests/Gedmo/Mapping/Fixture/Document/EmbeddedRevisionable.php create mode 100644 tests/Gedmo/Mapping/Fixture/Document/Revisionable.php create mode 100644 tests/Gedmo/Mapping/Fixture/Document/RevisionableWithEmbedded.php create mode 100644 tests/Gedmo/Mapping/Fixture/Entity/EmbeddedRevisionable.php create mode 100644 tests/Gedmo/Mapping/Fixture/Entity/Revisionable.php create mode 100644 tests/Gedmo/Mapping/Fixture/Entity/RevisionableComposite.php create mode 100644 tests/Gedmo/Mapping/Fixture/Entity/RevisionableCompositeRelation.php create mode 100644 tests/Gedmo/Mapping/Fixture/Entity/RevisionableWithEmbedded.php create mode 100644 tests/Gedmo/Mapping/Fixture/Xml/EmbeddedRevisionable.php create mode 100644 tests/Gedmo/Mapping/Fixture/Xml/Revisionable.php create mode 100644 tests/Gedmo/Mapping/Fixture/Xml/RevisionableComposite.php create mode 100644 tests/Gedmo/Mapping/Fixture/Xml/RevisionableCompositeRelation.php create mode 100644 tests/Gedmo/Mapping/Fixture/Xml/RevisionableWithEmbedded.php create mode 100644 tests/Gedmo/Mapping/Fixture/Yaml/EmbeddedRevisionable.php create mode 100644 tests/Gedmo/Mapping/Fixture/Yaml/Revisionable.php create mode 100644 tests/Gedmo/Mapping/Fixture/Yaml/RevisionableComposite.php create mode 100644 tests/Gedmo/Mapping/Fixture/Yaml/RevisionableCompositeRelation.php create mode 100644 tests/Gedmo/Mapping/Fixture/Yaml/RevisionableWithEmbedded.php create mode 100644 tests/Gedmo/Mapping/MongoDBODMMappingTestCase.php create mode 100644 tests/Gedmo/Mapping/RevisionableMongoDBODMMappingTest.php create mode 100644 tests/Gedmo/Mapping/RevisionableORMMappingTest.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Document/Address.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Document/Article.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Document/Author.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Document/Comment.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Document/CommentRevision.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Document/Geo.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Document/GeoLocation.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Document/RelatedArticle.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/Address.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/Article.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/Author.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/Comment.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/CommentRevision.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/Composite.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/CompositeRelation.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/Geo.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/GeoLocation.php create mode 100644 tests/Gedmo/Revisionable/Fixture/Entity/RelatedArticle.php create mode 100644 tests/Gedmo/Revisionable/RevisionableDocumentTest.php create mode 100644 tests/Gedmo/Revisionable/RevisionableEntityTest.php diff --git a/doc/annotations.md b/doc/annotations.md index f77ae52d5f..1a20a65cdd 100644 --- a/doc/annotations.md +++ b/doc/annotations.md @@ -14,6 +14,7 @@ extension, refer to the extension's documentation page. - [Loggable Extension](#loggable-extension) - [Reference Integrity Extension](#reference-integrity-extension) - [References Extension](#references-extension) +- [Revisionable Extension](#revisionable-extension) - [Sluggable Extension](#sluggable-extension) - [Soft Deleteable Extension](#soft-deleteable-extension) - [Sortable Extension](#sortable-extension) @@ -495,6 +496,79 @@ class Article } ``` +### Revisionable Extension + +The below annotations are used to configure the [Revisionable extension](./revisionable.md). + +#### `@Gedmo\Mapping\Annotation\Revisionable` + +The `Revisionable` annotation is a class annotation used to identify objects which can have changes logged, +all revisionable objects **MUST** have this annotation. + +Required Attributes: + +- **revisionClass** - A custom model class implementing `Gedmo\Revisionable\RevisionInterface` to use for logging changes; + defaults to `Gedmo\Revisionable\Entity\Revision` for Doctrine ORM users or + `Gedmo\Revisionable\Document\Revision` for Doctrine MongoDB ODM users + +Example: + +```php + function (ContainerInterface $container, string $requestedName): RevisionableListener { + $listener = new RevisionableListener(); + + // This call configures the listener to use the attribute driver service created above; if using annotations, you will need to provide the appropriate service instead + $listener->setAnnotationReader($container->get('gedmo.mapping.driver.attribute')); + + return $listener; + }, 'gedmo.listener.sluggable' => function (ContainerInterface $container, string $requestedName): SluggableListener { $listener = new SluggableListener(); @@ -141,6 +150,7 @@ return [ 'gedmo.listener.blameable', 'gedmo.listener.ip_traceable', 'gedmo.listener.loggable', + 'gedmo.listener.revisionable', 'gedmo.listener.sluggable', 'gedmo.listener.soft_deleteable', 'gedmo.listener.sortable', @@ -232,8 +242,8 @@ return [ ## Registering Mapping Configuration -When using the [Loggable](../loggable.md), [Translatable](../translatable.md), or [Tree](../tree.md) extensions, you will -need to register the mappings for these extensions to your object managers. +When using the [Loggable](../loggable.md), [Revisionable](../revisionable.md), [Translatable](../translatable.md), +or [Tree](../tree.md) extensions, you will need to register the mappings for these extensions to your object managers. > [!NOTE] > These extensions only provide mappings through annotations or attributes, with support for annotations being deprecated. If using annotations, you will need to ensure the [`doctrine/annotations`](https://www.doctrine-project.org/projects/annotations.html) library is installed and configured. @@ -258,6 +268,7 @@ return [ 'class' => AttributeDriver::class, // If your application is using annotations, use the AnnotationDriver class instead 'paths' => [ '/path/to/vendor/gedmo/doctrine-extensions/src/Loggable/Document', + '/path/to/vendor/gedmo/doctrine-extensions/src/Revisionable/Document', '/path/to/vendor/gedmo/doctrine-extensions/src/Translatable/Document', ], ], @@ -288,6 +299,7 @@ return [ 'class' => AttributeDriver::class, // If your application is using annotations, use the AnnotationDriver class instead 'paths' => [ '/path/to/vendor/gedmo/doctrine-extensions/src/Loggable/Entity', + '/path/to/vendor/gedmo/doctrine-extensions/src/Revisionable/Entity', '/path/to/vendor/gedmo/doctrine-extensions/src/Translatable/Entity', '/path/to/vendor/gedmo/doctrine-extensions/src/Tree/Entity', ], @@ -310,6 +322,8 @@ $ vendor/bin/doctrine-module orm:info [OK] Gedmo\Loggable\Entity\LogEntry [OK] Gedmo\Loggable\Entity\MappedSuperclass\AbstractLogEntry + [OK] Gedmo\Revisionable\Entity\Revision + [OK] Gedmo\Revisionable\Entity\MappedSuperclass\AbstractRevision [OK] Gedmo\Translatable\Entity\Translation [OK] Gedmo\Translatable\Entity\MappedSuperclass\AbstractTranslation [OK] Gedmo\Translatable\Entity\MappedSuperclass\AbstractPersonalTranslation @@ -374,9 +388,9 @@ return [ ## Configuring Extensions via Event Listeners -When using the [Blameable](../blameable.md), [IP Traceable](../ip_traceable.md), [Loggable](../loggable.md), or -[Translatable](../translatable.md) extensions, to work correctly, they require extra information that must be set -at runtime. +When using the [Blameable](../blameable.md), [IP Traceable](../ip_traceable.md), [Loggable](../loggable.md), +[Revisionable](../revisionable.md), or [Translatable](../translatable.md) extensions, to work correctly, +they require extra information that must be set at runtime. **Help Improve This Documentation** diff --git a/doc/frameworks/symfony.md b/doc/frameworks/symfony.md index dd910318a8..cfbd63a03d 100644 --- a/doc/frameworks/symfony.md +++ b/doc/frameworks/symfony.md @@ -85,6 +85,19 @@ services: # The `annotation_reader` service was deprecated in Symfony 6.4 and removed in Symfony 7.0 - [ setAnnotationReader, [ '@annotation_reader' ] ] + # Gedmo Revisionable Extension Listener + gedmo.listener.revisionable: + class: Gedmo\Revisionable\RevisionableListener + tags: + - { name: doctrine.event_listener, event: 'onFlush' } + - { name: doctrine.event_listener, event: 'loadClassMetadata' } + - { name: doctrine.event_listener, event: 'postPersist' } + calls: + # Uncomment the below call if using attributes, and comment the call for the annotation reader + # - [ setAnnotationReader, [ '@gedmo.mapping.driver.attribute' ] ] + # The `annotation_reader` service was deprecated in Symfony 6.4 and removed in Symfony 7.0 + - [ setAnnotationReader, [ '@annotation_reader' ] ] + # Gedmo Sluggable Extension Listener gedmo.listener.sluggable: class: Gedmo\Sluggable\SluggableListener @@ -238,8 +251,8 @@ services: ## Registering Mapping Configuration -When using the [Loggable](../loggable.md), [Translatable](../translatable.md), or [Tree](../tree.md) extensions, you will -need to register the mappings for these extensions to your object managers. +When using the [Loggable](../loggable.md), [Revisionable](../revisionable.md), [Translatable](../translatable.md), +or [Tree](../tree.md) extensions, you will need to register the mappings for these extensions to your object managers. > [!NOTE] > These extensions only provide mappings through annotations or attributes, with support for annotations being deprecated. If using annotations, you will need to ensure the [`doctrine/annotations`](https://www.doctrine-project.org/projects/annotations.html) library is installed and configured. @@ -262,6 +275,12 @@ doctrine_mongodb: prefix: Gedmo\Loggable\Document dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/src/Loggable/Document" is_bundle: false + revisionable: + type: attribute # or annotation + alias: GedmoRevisionable + prefix: Gedmo\Revisionable\Document + dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/src/Revisionable/Document" + is_bundle: false translatable: type: attribute # or annotation alias: GedmoTranslatable @@ -277,6 +296,8 @@ $ bin/console doctrine:mongodb:mapping:info Found X documents mapped in document manager default: [OK] Gedmo\Loggable\Document\LogEntry [OK] Gedmo\Loggable\Document\MappedSuperclass\AbstractLogEntry + [OK] Gedmo\Revisionable\Document\Revision + [OK] Gedmo\Revisionable\Document\MappedSuperclass\AbstractRevision [OK] Gedmo\Translatable\Document\MappedSuperclass\AbstractPersonalTranslation [OK] Gedmo\Translatable\Document\MappedSuperclass\AbstractTranslation [OK] Gedmo\Translatable\Document\Translation @@ -297,6 +318,12 @@ doctrine: prefix: Gedmo\Loggable\Entity dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/src/Loggable/Entity" is_bundle: false + revisionable: + type: attribute # or annotation + alias: GedmoRevisionable + prefix: Gedmo\Revisionable\Entity + dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/src/Revisionable/Entity" + is_bundle: false translatable: type: attribute # or annotation alias: GedmoTranslatable @@ -318,6 +345,8 @@ $ bin/console doctrine:mapping:info Found X mapped entities: [OK] Gedmo\Loggable\Entity\LogEntry [OK] Gedmo\Loggable\Entity\MappedSuperclass\AbstractLogEntry + [OK] Gedmo\Revisionable\Entity\Revision + [OK] Gedmo\Revisionable\Entity\MappedSuperclass\AbstractRevision [OK] Gedmo\Translatable\Entity\MappedSuperclass\AbstractPersonalTranslation [OK] Gedmo\Translatable\Entity\MappedSuperclass\AbstractTranslation [OK] Gedmo\Translatable\Entity\Translation @@ -364,10 +393,10 @@ doctrine: ## Configuring Extensions via Event Subscribers -When using the [Blameable](../blameable.md), [IP Traceable](../ip_traceable.md), [Loggable](../loggable.md), or -[Translatable](../translatable.md) extensions, to work correctly, they require extra information that must be set -at runtime, typically during the `kernel.request` event. The below example is an event subscriber class which configures -all of these extensions. +When using the [Blameable](../blameable.md), [IP Traceable](../ip_traceable.md), [Loggable](../loggable.md), +[Revisionable](../revisionable.md), or [Translatable](../translatable.md) extensions, to work correctly, +they require extra information that must be set at runtime, typically during the `kernel.request` event. +The below example is an event subscriber class which configures all of these extensions. ```php isMainRequest()) { + return; + } + + // If the required security component services weren't provided, there's nothing we can do + if (null === $this->authorizationChecker || null === $this->tokenStorage) { + return; + } + + $token = $this->tokenStorage->getToken(); + + // Only set the user information if there is a token in storage and it represents an authenticated user + if (null !== $token && $this->authorizationChecker->isGranted('IS_AUTHENTICATED')) { + $this->revisionableListener->setUsername($token->getUser()); + } + } + /** * Configures the translatable listener using the request locale */ diff --git a/doc/loggable.md b/doc/loggable.md index 806d4cf6ba..f054e43ad7 100644 --- a/doc/loggable.md +++ b/doc/loggable.md @@ -1,5 +1,8 @@ # Loggable behavioral extension for Doctrine +> [!IMPORTANT] +> The Loggable extension is **NOT** compatible with `doctrine/dbal` >=4.0. If your project needs this extension, you will need to use the latest `doctrine/dbal` 3.x release. + **Loggable** behavior tracks your record changes and is able to manage versions. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 83d8455f98..e342ab2b25 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -280,6 +280,51 @@ parameters: count: 1 path: src/References/ReferencesListener.php + - + message: "#^Method Gedmo\\\\Revisionable\\\\Document\\\\Revision\\:\\:createRevision\\(\\) should return Gedmo\\\\Revisionable\\\\Document\\\\Revision\\ but returns Gedmo\\\\Revisionable\\\\Document\\\\Revision\\\\.$#" + count: 1 + path: src/Revisionable/Document/Revision.php + + - + message: "#^Unable to resolve the template type T in call to method Doctrine\\\\ORM\\\\EntityManagerInterface\\:\\:getReference\\(\\)$#" + count: 1 + path: src/Revisionable/Entity/Repository/RevisionRepository.php + + - + message: "#^Method Gedmo\\\\Revisionable\\\\Entity\\\\Revision\\:\\:createRevision\\(\\) should return Gedmo\\\\Revisionable\\\\Entity\\\\Revision\\ but returns Gedmo\\\\Revisionable\\\\Entity\\\\Revision\\\\.$#" + count: 1 + path: src/Revisionable/Entity/Revision.php + + - + message: "#^Access to offset 'isOwningSide' on an unknown class Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\AssociationFieldMapping\\.$#" + count: 1 + path: src/Revisionable/Mapping/Driver/Attribute.php + + - + message: "#^Access to an undefined property Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadata\\:\\:\\$associationMappings\\.$#" + count: 1 + path: src/Revisionable/Mapping/Driver/Xml.php + + - + message: "#^Call to an undefined method Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadata\\\\>\\:\\:setFieldValue\\(\\)\\.$#" + count: 1 + path: src/Revisionable/RevisionableListener.php + + - + message: "#^Call to an undefined method Doctrine\\\\Persistence\\\\ObjectManager\\:\\:getUnitOfWork\\(\\)\\.$#" + count: 4 + path: src/Revisionable/RevisionableListener.php + + - + message: "#^Method Gedmo\\\\Revisionable\\\\RevisionableListener\\:\\:getRevisionClass\\(\\) should return class\\-string\\\\> but returns class\\-string\\\\>\\.$#" + count: 1 + path: src/Revisionable/RevisionableListener.php + + - + message: "#^Method Gedmo\\\\Tool\\\\WrapperInterface\\\\:\\:getIdentifier\\(\\) invoked with 2 parameters, 0\\-1 required\\.$#" + count: 2 + path: src/Revisionable/RevisionableListener.php + - message: "#^Call to an undefined method Doctrine\\\\Persistence\\\\Mapping\\\\ClassMetadata\\\\:\\:getReflectionProperty\\(\\)\\.$#" count: 2 @@ -440,26 +485,11 @@ parameters: count: 1 path: src/Timestampable/Mapping/Driver/Yaml.php - - - message: "#^Call to an undefined method Doctrine\\\\Persistence\\\\ObjectManager\\:\\:getUnitOfWork\\(\\)\\.$#" - count: 1 - path: src/Tool/Wrapper/EntityWrapper.php - - - - message: "#^Parameter \\#2 \\$em of class Gedmo\\\\Tool\\\\Wrapper\\\\EntityWrapper constructor expects Doctrine\\\\ORM\\\\EntityManagerInterface, Doctrine\\\\Persistence\\\\ObjectManager given\\.$#" - count: 1 - path: src/Tool/Wrapper/EntityWrapper.php - - message: "#^Access to an undefined property ProxyManager\\\\Proxy\\\\GhostObjectInterface\\:\\:\\$identifier\\.$#" count: 1 path: src/Tool/Wrapper/MongoDocumentWrapper.php - - - message: "#^Call to an undefined method Doctrine\\\\Persistence\\\\ObjectManager\\:\\:getUnitOfWork\\(\\)\\.$#" - count: 2 - path: src/Tool/Wrapper/MongoDocumentWrapper.php - - message: "#^Call to function property_exists\\(\\) with \\$this\\(Gedmo\\\\Translatable\\\\Hydrator\\\\ORM\\\\ObjectHydrator\\) and '_em' will always evaluate to false\\.$#" count: 1 @@ -840,6 +870,36 @@ parameters: count: 1 path: tests/Gedmo/Mapping/Xml/TranslatableMappingTest.php + - + message: "#^Method Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Document\\\\CommentRevision\\:\\:createRevision\\(\\) should return Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Document\\\\CommentRevision\\ but returns Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Document\\\\CommentRevision\\\\.$#" + count: 1 + path: tests/Gedmo/Revisionable/Fixture/Document/CommentRevision.php + + - + message: "#^Method Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Entity\\\\CommentRevision\\:\\:createRevision\\(\\) should return Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Entity\\\\CommentRevision\\ but returns Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Entity\\\\CommentRevision\\\\.$#" + count: 1 + path: tests/Gedmo/Revisionable/Fixture/Entity/CommentRevision.php + + - + message: "#^Call to an undefined method Doctrine\\\\ORM\\\\EntityRepository\\\\:\\:getRevisions\\(\\)\\.$#" + count: 1 + path: tests/Gedmo/Revisionable/RevisionableDocumentTest.php + + - + message: "#^Parameter \\#1 \\$entity of method Gedmo\\\\Revisionable\\\\Entity\\\\Repository\\\\RevisionRepository\\\\:\\:getRevisions\\(\\) expects Gedmo\\\\Revisionable\\\\Entity\\\\Revision, Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Entity\\\\Address given\\.$#" + count: 1 + path: tests/Gedmo/Revisionable/RevisionableEntityTest.php + + - + message: "#^Parameter \\#1 \\$entity of method Gedmo\\\\Revisionable\\\\Entity\\\\Repository\\\\RevisionRepository\\\\:\\:getRevisions\\(\\) expects Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Entity\\\\CommentRevision, Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Entity\\\\Comment given\\.$#" + count: 1 + path: tests/Gedmo/Revisionable/RevisionableEntityTest.php + + - + message: "#^Parameter \\#1 \\$entity of method Gedmo\\\\Revisionable\\\\Entity\\\\Repository\\\\RevisionRepository\\\\:\\:revert\\(\\) expects Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Entity\\\\CommentRevision, Gedmo\\\\Tests\\\\Revisionable\\\\Fixture\\\\Entity\\\\Comment given\\.$#" + count: 1 + path: tests/Gedmo/Revisionable/RevisionableEntityTest.php + - message: "#^Method Gedmo\\\\Tests\\\\Sluggable\\\\Fixture\\\\Doctrine\\\\FakeFilter\\:\\:addFilterConstraint\\(\\) has parameter \\$targetTableAlias with no type specified\\.$#" count: 1 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6039c5d191..6ebe658f04 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -44,6 +44,9 @@ ./tests/Gedmo/Loggable/ + + ./tests/Gedmo/Revisionable/ + ./tests/Gedmo/Sortable/ diff --git a/schemas/orm/doctrine-extensions-mapping-2-2.xsd b/schemas/orm/doctrine-extensions-mapping-2-2.xsd index 71f30049a1..e45e83e3ae 100644 --- a/schemas/orm/doctrine-extensions-mapping-2-2.xsd +++ b/schemas/orm/doctrine-extensions-mapping-2-2.xsd @@ -27,6 +27,7 @@ + @@ -92,6 +93,10 @@ + + + + diff --git a/src/DoctrineExtensions.php b/src/DoctrineExtensions.php index 4ab41efa82..0a05f6f451 100644 --- a/src/DoctrineExtensions.php +++ b/src/DoctrineExtensions.php @@ -41,6 +41,7 @@ public static function registerMappingIntoDriverChainORM(MappingDriverChain $dri $paths = [ __DIR__.'/Translatable/Entity', __DIR__.'/Loggable/Entity', + __DIR__.'/Revisionable/Entity', __DIR__.'/Tree/Entity', ]; @@ -62,6 +63,7 @@ public static function registerAbstractMappingIntoDriverChainORM(MappingDriverCh $paths = [ __DIR__.'/Translatable/Entity/MappedSuperclass', __DIR__.'/Loggable/Entity/MappedSuperclass', + __DIR__.'/Revisionable/Entity/MappedSuperclass', __DIR__.'/Tree/Entity/MappedSuperclass', ]; @@ -83,6 +85,7 @@ public static function registerMappingIntoDriverChainMongodbODM(MappingDriverCha $paths = [ __DIR__.'/Translatable/Document', __DIR__.'/Loggable/Document', + __DIR__.'/Revisionable/Document', ]; if (\PHP_VERSION_ID >= 80000) { @@ -103,6 +106,7 @@ public static function registerAbstractMappingIntoDriverChainMongodbODM(MappingD $paths = [ __DIR__.'/Translatable/Document/MappedSuperclass', __DIR__.'/Loggable/Document/MappedSuperclass', + __DIR__.'/Revisionable/Document/MappedSuperclass', ]; if (\PHP_VERSION_ID >= 80000) { diff --git a/src/Mapping/Annotation/Revisionable.php b/src/Mapping/Annotation/Revisionable.php new file mode 100644 index 0000000000..ec3bae34fa --- /dev/null +++ b/src/Mapping/Annotation/Revisionable.php @@ -0,0 +1,64 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Mapping\Annotation; + +use Doctrine\Common\Annotations\Annotation; +use Doctrine\Deprecations\Deprecation; +use Gedmo\Mapping\Annotation\Annotation as GedmoAnnotation; +use Gedmo\Revisionable\RevisionInterface; + +/** + * Revisionable annotation for the revisionable behavioral extension + * + * @phpstan-template T of RevisionInterface + * + * @Annotation + * + * @NamedArgumentConstructor + * + * @Target("CLASS") + * + * @author Gediminas Morkevicius + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +final class Revisionable implements GedmoAnnotation +{ + use ForwardCompatibilityTrait; + + /** + * @phpstan-var class-string|null + */ + public ?string $revisionClass; + + /** + * @param array $data + * + * @phpstan-param class-string|null $revisionClass + */ + public function __construct(array $data = [], ?string $revisionClass = null) + { + if ([] !== $data) { + Deprecation::trigger( + 'gedmo/doctrine-extensions', + 'https://github.com/doctrine-extensions/DoctrineExtensions/pull/2357', + 'Passing an array as first argument to "%s()" is deprecated. Use named arguments instead.', + __METHOD__ + ); + + $args = func_get_args(); + + $this->revisionClass = $this->getAttributeValue($data, 'revisionClass', $args, 1, $revisionClass); + + return; + } + + $this->revisionClass = $revisionClass; + } +} diff --git a/src/Mapping/Annotation/Versioned.php b/src/Mapping/Annotation/Versioned.php index db99b7bc07..4912cd5cf6 100644 --- a/src/Mapping/Annotation/Versioned.php +++ b/src/Mapping/Annotation/Versioned.php @@ -13,7 +13,7 @@ use Gedmo\Mapping\Annotation\Annotation as GedmoAnnotation; /** - * Versioned annotation for Loggable behavioral extension + * Versioned annotation for use with the Loggable and Revisionable extensions * * @Annotation * diff --git a/src/Revisionable/Document/MappedSuperclass/AbstractRevision.php b/src/Revisionable/Document/MappedSuperclass/AbstractRevision.php new file mode 100644 index 0000000000..7b44058b55 --- /dev/null +++ b/src/Revisionable/Document/MappedSuperclass/AbstractRevision.php @@ -0,0 +1,199 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Document\MappedSuperclass; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Revisionable\Revisionable; +use Gedmo\Revisionable\RevisionInterface; + +/** + * Base class defining a revision with all mapping configuration for the Doctrine MongoDB ODM. + * + * @phpstan-template T of Revisionable|object + * + * @phpstan-implements RevisionInterface + * + * @ODM\MappedSuperclass + */ +#[ODM\MappedSuperclass] +abstract class AbstractRevision implements RevisionInterface +{ + /** + * @ODM\Id(name="id") + */ + #[ODM\Id(name: 'id')] + protected ?string $id = null; + + /** + * @phpstan-var self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE + * + * @ODM\Field(name="action", type="string") + */ + #[ODM\Field(name: 'action', type: Type::STRING)] + protected string $action = self::ACTION_CREATE; + + /** + * @phpstan-var positive-int + * + * @ODM\Field(name="version", type="int") + */ + #[ODM\Field(name: 'version', type: Type::INT)] + protected int $version = 1; + + /** + * @phpstan-var non-empty-string|null + * + * @ODM\Field(name="revisionable_id", type="string", nullable=true) + */ + #[ODM\Field(name: 'revisionable_id', type: Type::STRING, nullable: true)] + protected ?string $revisionableId = null; + + /** + * @phpstan-var class-string|null + * + * @ODM\Field(name="revisionable_class", type="string") + */ + #[ODM\Field(name: 'revisionable_class', type: Type::STRING)] + protected ?string $revisionableClass = null; + + /** + * @ODM\Field(name="logged_at", type="date_immutable") + */ + #[ODM\Field(name: 'logged_at', type: Type::DATE_IMMUTABLE)] + protected \DateTimeImmutable $loggedAt; + + /** + * @phpstan-var non-empty-string|null + * + * @ODM\Field(name="username", type="string", nullable=true) + */ + #[ODM\Field(name: 'username', type: Type::STRING, nullable: true)] + protected ?string $username = null; + + /** + * @var array + * + * @ODM\Field(name="data", type="hash") + */ + #[ODM\Field(name: 'data', type: Type::HASH)] + protected array $data = []; + + public function getId(): ?string + { + return $this->id; + } + + /** + * @phpstan-param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action + */ + public function setAction(string $action): void + { + $this->action = $action; + } + + /** + * @phpstan-return self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE + */ + public function getAction(): string + { + return $this->action; + } + + /** + * @phpstan-param positive-int $version + */ + public function setVersion(int $version): void + { + $this->version = $version; + } + + /** + * @phpstan-return positive-int + */ + public function getVersion(): int + { + return $this->version; + } + + /** + * @phpstan-param non-empty-string $revisionableId + */ + public function setRevisionableId(string $revisionableId): void + { + $this->revisionableId = $revisionableId; + } + + /** + * @phpstan-return non-empty-string|null + */ + public function getRevisionableId(): ?string + { + return $this->revisionableId; + } + + /** + * @phpstan-param class-string $revisionableClass + */ + public function setRevisionableClass(string $revisionableClass): void + { + $this->revisionableClass = $revisionableClass; + } + + /** + * @phpstan-return class-string|null + */ + public function getRevisionableClass(): ?string + { + return $this->revisionableClass; + } + + public function setLoggedAt(\DateTimeImmutable $loggedAt): void + { + $this->loggedAt = $loggedAt; + } + + public function getLoggedAt(): \DateTimeImmutable + { + return $this->loggedAt; + } + + /** + * @phpstan-param non-empty-string|null $username + */ + public function setUsername(?string $username): void + { + $this->username = $username; + } + + /** + * @phpstan-return non-empty-string|null + */ + public function getUsername(): ?string + { + return $this->username; + } + + /** + * @param array $data + */ + public function setData(array $data): void + { + $this->data = $data; + } + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } +} diff --git a/src/Revisionable/Document/Repository/RevisionRepository.php b/src/Revisionable/Document/Repository/RevisionRepository.php new file mode 100644 index 0000000000..b6dcfce2fe --- /dev/null +++ b/src/Revisionable/Document/Repository/RevisionRepository.php @@ -0,0 +1,187 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Document\Repository; + +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Gedmo\Exception\RuntimeException; +use Gedmo\Exception\UnexpectedValueException; +use Gedmo\Revisionable\Document\MappedSuperclass\AbstractRevision; +use Gedmo\Revisionable\Revisionable; +use Gedmo\Revisionable\RevisionableListener; +use Gedmo\Tool\Wrapper\MongoDocumentWrapper; + +/** + * The RevisionRepository has some useful functions to interact with revisions. + * + * @author Gediminas Morkevicius + * + * @phpstan-template T of Revisionable|object + * + * @phpstan-extends DocumentRepository> + */ +class RevisionRepository extends DocumentRepository +{ + /** + * The revisionable listener associated with the document manager for the {@see AbstractRevision} document. + * + * @var RevisionableListener|bool|null + * + * @phpstan-var RevisionableListener|null|false + */ + private $listener = false; + + /** + * Loads all revisions for the given document + * + * @return AbstractRevision[] + * + * @phpstan-param T $document + * + * @phpstan-return list> + */ + public function getRevisions(object $document): array + { + $documentWrapper = new MongoDocumentWrapper($document, $this->getDocumentManager()); + + $documentId = (string) $documentWrapper->getIdentifier(false, true); + $documentClass = $documentWrapper->getMetadata()->getName(); + + $qb = $this->createQueryBuilder(); + $qb->field('revisionableId')->equals($documentId); + $qb->field('revisionableClass')->equals($documentClass); + $qb->sort('version', 'DESC'); + + return $qb->getQuery()->getIterator()->toArray(); + } + + /** + * Reverts the given document to the requested version, restoring all versioned fields to the state of that revision. + * + * Callers to this method will need to persist and flush changes to the document. + * + * @phpstan-param T $document + * @phpstan-param positive-int $version + * + * @throws UnexpectedValueException + */ + public function revert(object $document, int $version = 1): void + { + $documentWrapper = new MongoDocumentWrapper($document, $this->getDocumentManager()); + + $documentMetadata = $documentWrapper->getMetadata(); + $documentId = (string) $documentWrapper->getIdentifier(false, true); + $documentClass = $documentMetadata->getName(); + + $qb = $this->createQueryBuilder(); + $qb->field('revisionableId')->equals($documentId); + $qb->field('revisionableClass')->equals($documentClass); + $qb->field('version')->lte($version); + $qb->sort('version', 'ASC'); + + $revisions = $qb->getQuery()->getIterator()->toArray(); + + if ([] === $revisions) { + throw new UnexpectedValueException(sprintf('Could not find any revisions for version %d of document %s.', $version, $documentClass)); + } + + $data = [[]]; + + while ($revision = array_shift($revisions)) { + $data[] = $revision->getData(); + } + + $data = array_merge(...$data); + + $this->fillDocument($document, $data); + } + + /** + * Fills a document's versioned fields with the given data + * + * @param array $data + * + * @phpstan-param T $document + */ + protected function fillDocument(object $document, array $data): void + { + $documentWrapper = new MongoDocumentWrapper($document, $this->getDocumentManager()); + + $documentMeta = $documentWrapper->getMetadata(); + + assert($documentMeta instanceof ClassMetadata); + + $config = $this->getListener()->getConfiguration($this->getDocumentManager(), $documentMeta->getName()); + $fields = $config['versioned']; + + foreach ($data as $field => $value) { + if (!in_array($field, $fields, true)) { + continue; + } + + $mapping = $documentMeta->getFieldMapping($field); + + // Fill the embedded document + if ($documentWrapper->isEmbeddedAssociation($field)) { + if (!empty($value)) { + assert(class_exists($mapping['targetDocument'])); + + $embeddedMetadata = $this->getDocumentManager()->getClassMetadata($mapping['targetDocument']); + $document = $embeddedMetadata->newInstance(); + $this->fillDocument($document, $value); + $value = $document; + } + } elseif ($documentMeta->isSingleValuedAssociation($field)) { + assert(class_exists($mapping['targetDocument'])); + + $value = $value ? $this->getDocumentManager()->getReference($mapping['targetDocument'], $value) : null; + } else { + $value = $value ? $documentWrapper->convertToPHPValue($value, $documentMeta->getTypeOfField($field)) : null; + } + + $documentWrapper->setPropertyValue($field, $value); + unset($fields[$field]); + } + + /* + if (count($fields)) { + throw new UnexpectedValueException(sprintf('Could not fully revert document %s to version %d.', $documentMetadata->getName(), $version)); + } + */ + } + + /** + * Get the revisionable listener associated with the document manager for the {@see AbstractRevision} document. + * + * @throws RuntimeException if the listener is not found + * + * @phpstan-return RevisionableListener + */ + private function getListener(): RevisionableListener + { + if ($this->listener instanceof RevisionableListener) { + return $this->listener; + } + + if (false === $this->listener) { + foreach ($this->getDocumentManager()->getEventManager()->getAllListeners() as $listeners) { + foreach ($listeners as $listener) { + if ($listener instanceof RevisionableListener) { + return $this->listener = $listener; + } + } + } + + $this->listener = null; + } + + throw new RuntimeException('The revisionable listener was not registered to the document manager.'); + } +} diff --git a/src/Revisionable/Document/Revision.php b/src/Revisionable/Document/Revision.php new file mode 100644 index 0000000000..94d46ae5fc --- /dev/null +++ b/src/Revisionable/Document/Revision.php @@ -0,0 +1,54 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Gedmo\Revisionable\Document\MappedSuperclass\AbstractRevision; +use Gedmo\Revisionable\Document\Repository\RevisionRepository; +use Gedmo\Revisionable\Revisionable; + +/** + * Default concrete revision implementation for the Doctrine MongoDB ODM. + * + * @ODM\Document(repositoryClass="Gedmo\Revisionable\Document\Repository\RevisionRepository") + * @ODM\Index(keys={"revisionableClass": "asc"}) + * @ODM\Index(keys={"loggedAt": "asc"}) + * @ODM\Index(keys={"username": "asc"}) + * @ODM\Index(keys={"revisionableId": "asc", "revisionableClass": "asc", "version": "asc"}) + * + * @phpstan-template T of Revisionable|object + * + * @phpstan-extends AbstractRevision + */ +#[ODM\Document(repositoryClass: RevisionRepository::class)] +#[ODM\Index(keys: ['revisionableClass' => 'asc'])] +#[ODM\Index(keys: ['loggedAt' => 'asc'])] +#[ODM\Index(keys: ['username' => 'asc'])] +#[ODM\Index(keys: ['revisionableId' => 'asc', 'revisionableClass' => 'asc', 'version' => 'asc'])] +class Revision extends AbstractRevision +{ + /** + * Named constructor to create a new revision. + * + * Implementations should handle setting the initial logged at time and version for new instances within this constructor. + * + * @phpstan-param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action + * + * @phpstan-return self + */ + public static function createRevision(string $action): self + { + $document = new self(); + $document->setAction($action); + $document->setLoggedAt(new \DateTimeImmutable()); + + return $document; + } +} diff --git a/src/Revisionable/Entity/MappedSuperclass/AbstractRevision.php b/src/Revisionable/Entity/MappedSuperclass/AbstractRevision.php new file mode 100644 index 0000000000..03bc05a67f --- /dev/null +++ b/src/Revisionable/Entity/MappedSuperclass/AbstractRevision.php @@ -0,0 +1,203 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Entity\MappedSuperclass; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Revisionable\Revisionable; +use Gedmo\Revisionable\RevisionInterface; + +/** + * Base class defining a revision with all mapping configuration for the Doctrine ORM. + * + * @phpstan-template T of Revisionable|object + * + * @phpstan-implements RevisionInterface + * + * @ORM\MappedSuperclass + */ +#[ORM\MappedSuperclass] +abstract class AbstractRevision implements RevisionInterface +{ + /** + * @ORM\Column(name="id", type="integer") + * @ORM\Id + * @ORM\GeneratedValue + */ + #[ORM\Column(name: 'id', type: Types::INTEGER)] + #[ORM\Id] + #[ORM\GeneratedValue] + protected ?int $id = null; + + /** + * @phpstan-var self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE + * + * @ORM\Column(name="action", type="string", length=8) + */ + #[ORM\Column(name: 'action', type: Types::STRING, length: 8)] + protected string $action = self::ACTION_CREATE; + + /** + * @phpstan-var positive-int + * + * @ORM\Column(type="integer") + */ + #[ORM\Column(name: 'version', type: Types::INTEGER)] + protected int $version = 1; + + /** + * @phpstan-var non-empty-string|null + * + * @ORM\Column(name="revisionable_id", type="string", length=64, nullable=true) + */ + #[ORM\Column(name: 'revisionable_id', type: Types::STRING, length: 64, nullable: true)] + protected ?string $revisionableId = null; + + /** + * @phpstan-var class-string|null + * + * @ORM\Column(name="revisionable_class", type="string", length=191) + */ + #[ORM\Column(name: 'revisionable_class', type: Types::STRING, length: 191)] + protected ?string $revisionableClass = null; + + /** + * @ORM\Column(name="logged_at", type="datetime_immutable") + */ + #[ORM\Column(name: 'logged_at', type: Types::DATETIME_IMMUTABLE)] + protected \DateTimeImmutable $loggedAt; + + /** + * @phpstan-var non-empty-string|null + * + * @ORM\Column(name="username", length=191, nullable=true) + */ + #[ORM\Column(name: 'username', length: 191, nullable: true)] + protected ?string $username = null; + + /** + * @var array + * + * @ORM\Column(name="data", type="json") + */ + #[ORM\Column(name: 'data', type: Types::JSON)] + protected array $data = []; + + public function getId(): ?int + { + return $this->id; + } + + /** + * @phpstan-param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action + */ + public function setAction(string $action): void + { + $this->action = $action; + } + + /** + * @phpstan-return self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE + */ + public function getAction(): string + { + return $this->action; + } + + /** + * @phpstan-param positive-int $version + */ + public function setVersion(int $version): void + { + $this->version = $version; + } + + /** + * @phpstan-return positive-int + */ + public function getVersion(): int + { + return $this->version; + } + + /** + * @phpstan-param non-empty-string $revisionableId + */ + public function setRevisionableId(string $revisionableId): void + { + $this->revisionableId = $revisionableId; + } + + /** + * @phpstan-return non-empty-string|null + */ + public function getRevisionableId(): ?string + { + return $this->revisionableId; + } + + /** + * @phpstan-param class-string $revisionableClass + */ + public function setRevisionableClass(string $revisionableClass): void + { + $this->revisionableClass = $revisionableClass; + } + + /** + * @phpstan-return class-string|null + */ + public function getRevisionableClass(): ?string + { + return $this->revisionableClass; + } + + public function setLoggedAt(\DateTimeImmutable $loggedAt): void + { + $this->loggedAt = $loggedAt; + } + + public function getLoggedAt(): \DateTimeImmutable + { + return $this->loggedAt; + } + + /** + * @phpstan-param non-empty-string|null $username + */ + public function setUsername(?string $username): void + { + $this->username = $username; + } + + /** + * @phpstan-return non-empty-string|null + */ + public function getUsername(): ?string + { + return $this->username; + } + + /** + * @param array $data + */ + public function setData(array $data): void + { + $this->data = $data; + } + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } +} diff --git a/src/Revisionable/Entity/Repository/RevisionRepository.php b/src/Revisionable/Entity/Repository/RevisionRepository.php new file mode 100644 index 0000000000..02b2500eaa --- /dev/null +++ b/src/Revisionable/Entity/Repository/RevisionRepository.php @@ -0,0 +1,174 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Entity\Repository; + +use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Mapping\ClassMetadata; +use Gedmo\Exception\RuntimeException; +use Gedmo\Exception\UnexpectedValueException; +use Gedmo\Revisionable\Entity\MappedSuperclass\AbstractRevision; +use Gedmo\Revisionable\Revisionable; +use Gedmo\Revisionable\RevisionableListener; +use Gedmo\Tool\Wrapper\EntityWrapper; + +/** + * The RevisionRepository has some useful functions to interact with revisions. + * + * @author Gediminas Morkevicius + * + * @phpstan-template T of Revisionable|object + * + * @phpstan-extends EntityRepository> + */ +class RevisionRepository extends EntityRepository +{ + /** + * The revisionable listener associated with the entity manager for the {@see AbstractRevision} entity. + * + * @var RevisionableListener|bool|null + * + * @phpstan-var RevisionableListener|null|false + */ + private $listener = false; + + /** + * Loads all revisions for the given entity + * + * @return AbstractRevision[] + * + * @phpstan-param T $entity + * + * @phpstan-return list> + */ + public function getRevisions(object $entity): array + { + $entityWrapper = new EntityWrapper($entity, $this->getEntityManager()); + + $entityId = (string) $entityWrapper->getIdentifier(false, true); + $entityClass = $entityWrapper->getMetadata()->getName(); + + return $this->createQueryBuilder('revision') + ->where('revision.revisionableId = :revisionableId') + ->andWhere('revision.revisionableClass = :revisionableClass') + ->orderBy('revision.version', 'DESC') + ->setParameter('revisionableId', $entityId) + ->setParameter('revisionableClass', $entityClass) + ->getQuery() + ->getResult(); + } + + /** + * Reverts the given entity to the requested version, restoring all versioned fields to the state of that revision. + * + * Callers to this method will need to persist and flush changes to the entity. + * + * @phpstan-param T $entity + * @phpstan-param positive-int $version + * + * @throws UnexpectedValueException + */ + public function revert(object $entity, int $version = 1): void + { + $entityWrapper = new EntityWrapper($entity, $this->getEntityManager()); + + $entityMetadata = $entityWrapper->getMetadata(); + $entityId = (string) $entityWrapper->getIdentifier(false, true); + $entityClass = $entityMetadata->getName(); + + $qb = $this->createQueryBuilder('revision') + ->where('revision.revisionableId = :revisionableId') + ->andWhere('revision.revisionableClass = :revisionableClass') + ->andWhere('revision.version <= :version') + ->orderBy('revision.version', 'DESC') + ->setParameter('revisionableId', $entityId) + ->setParameter('revisionableClass', $entityClass) + ->setParameter('version', $version); + + $config = $this->getListener()->getConfiguration($this->getEntityManager(), $entityClass); + $fields = $config['versioned']; + $filled = false; + $revisionsFound = false; + + $revisions = $qb->getQuery()->toIterable(); + + assert($revisions instanceof \Generator); + + while ((null !== $revision = $revisions->current()) && !$filled) { + $revisionsFound = true; + $revisions->next(); + if ($data = $revision->getData()) { + foreach ($data as $field => $value) { + if (in_array($field, $fields, true)) { + $this->mapValue($entityMetadata, $field, $value); + $entityWrapper->setPropertyValue($field, $value); + unset($fields[array_search($field, $fields, true)]); + } + } + } + + $filled = [] === $fields; + } + + if (!$revisionsFound) { + throw new UnexpectedValueException(sprintf('Could not find any revisions for version %d of entity %s.', $version, $entityClass)); + } + + if (count($fields)) { + throw new UnexpectedValueException(sprintf('Could not fully revert entity %s to version %d.', $entityClass, $version)); + } + } + + /** + * @param mixed $value + * + * @return void + * + * @phpstan-param ClassMetadata $objectMeta + */ + protected function mapValue(ClassMetadata $objectMeta, string $field, &$value) + { + if (!$objectMeta->isSingleValuedAssociation($field)) { + $value = $this->getEntityManager()->getConnection()->convertToPHPValue($value, $objectMeta->getTypeOfField($field)); + + return; + } + + $mapping = $objectMeta->getAssociationMapping($field); + $value = $value ? $this->getEntityManager()->getReference($mapping['targetEntity'], $value) : null; + } + + /** + * Get the revisionable listener associated with the entity manager for the {@see AbstractRevision} entity. + * + * @throws RuntimeException if the listener is not found + * + * @phpstan-return RevisionableListener + */ + private function getListener(): RevisionableListener + { + if ($this->listener instanceof RevisionableListener) { + return $this->listener; + } + + if (false === $this->listener) { + foreach ($this->getEntityManager()->getEventManager()->getAllListeners() as $listeners) { + foreach ($listeners as $listener) { + if ($listener instanceof RevisionableListener) { + return $this->listener = $listener; + } + } + } + + $this->listener = null; + } + + throw new RuntimeException('The revisionable listener was not registered to the entity manager.'); + } +} diff --git a/src/Revisionable/Entity/Revision.php b/src/Revisionable/Entity/Revision.php new file mode 100644 index 0000000000..780686c64a --- /dev/null +++ b/src/Revisionable/Entity/Revision.php @@ -0,0 +1,61 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Revisionable\Entity\MappedSuperclass\AbstractRevision; +use Gedmo\Revisionable\Entity\Repository\RevisionRepository; +use Gedmo\Revisionable\Revisionable; + +/** + * Default concrete revision implementation for the Doctrine ORM. + * + * @ORM\Table( + * name="revisions", + * options={"row_format": "DYNAMIC"}, + * indexes={ + * @ORM\Index(name="revision_class_lookup_idx", columns={"revisionable_class"}), + * @ORM\Index(name="revision_date_lookup_idx", columns={"logged_at"}), + * @ORM\Index(name="revision_user_lookup_idx", columns={"username"}), + * @ORM\Index(name="revision_version_lookup_idx", columns={"revisionable_id", "revisionable_class", "version"}) + * } + * ) + * @ORM\Entity(repositoryClass="Gedmo\Revisionable\Entity\Repository\RevisionRepository") + * + * @phpstan-template T of Revisionable|object + * + * @phpstan-extends AbstractRevision + */ +#[ORM\Entity(repositoryClass: RevisionRepository::class)] +#[ORM\Table(name: 'revisions', options: ['row_format' => 'DYNAMIC'])] +#[ORM\Index(name: 'revision_class_lookup_idx', columns: ['revisionable_class'])] +#[ORM\Index(name: 'revision_date_lookup_idx', columns: ['logged_at'])] +#[ORM\Index(name: 'revision_user_lookup_idx', columns: ['username'])] +#[ORM\Index(name: 'revision_version_lookup_idx', columns: ['revisionable_id', 'revisionable_class', 'version'])] +class Revision extends AbstractRevision +{ + /** + * Named constructor to create a new revision. + * + * Implementations should handle setting the initial logged at time and version for new instances within this constructor. + * + * @phpstan-param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action + * + * @phpstan-return self + */ + public static function createRevision(string $action): self + { + $entity = new self(); + $entity->setAction($action); + $entity->setLoggedAt(new \DateTimeImmutable()); + + return $entity; + } +} diff --git a/src/Revisionable/Mapping/Driver/Annotation.php b/src/Revisionable/Mapping/Driver/Annotation.php new file mode 100644 index 0000000000..ab764c4db9 --- /dev/null +++ b/src/Revisionable/Mapping/Driver/Annotation.php @@ -0,0 +1,23 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Mapping\Driver; + +use Gedmo\Mapping\Driver\AnnotationDriverInterface; + +/** + * Mapping driver for the revisionable extension which reads extended metadata from annotations on a revisionable class. + * + * @deprecated since gedmo/doctrine-extensions 3.x, will be removed in version 4.0. + * + * @internal + */ +class Annotation extends Attribute implements AnnotationDriverInterface +{ +} diff --git a/src/Revisionable/Mapping/Driver/Attribute.php b/src/Revisionable/Mapping/Driver/Attribute.php new file mode 100644 index 0000000000..5c199e2c17 --- /dev/null +++ b/src/Revisionable/Mapping/Driver/Attribute.php @@ -0,0 +1,167 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Mapping\Driver; + +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDBDOMClassMetadata; +use Doctrine\ORM\Mapping\ClassMetadata as ORMClassMetadata; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Gedmo\Exception\InvalidMappingException; +use Gedmo\Mapping\Annotation\Revisionable; +use Gedmo\Mapping\Annotation\Versioned; +use Gedmo\Mapping\Driver\AbstractAnnotationDriver; + +/** + * Mapping driver for the revisionable extension which reads extended metadata from attributes on a revisionable class. + * + * @author Boussekeyt Jules + * @author Gediminas Morkevicius + * + * @internal + */ +class Attribute extends AbstractAnnotationDriver +{ + public function readExtendedMetadata($meta, array &$config) + { + // Skip embedded classes for the ORM, they will be handled inline while processing classes using embeds + if ($meta instanceof ORMClassMetadata && $meta->isEmbeddedClass) { + return $config; + } + + $class = $this->getMetaReflectionClass($meta); + + // Determine if the object is revisionable by inspecting the class attributes + if ($annot = $this->reader->getClassAnnotation($class, Revisionable::class)) { + \assert($annot instanceof Revisionable); + + $config['revisionable'] = true; + + if ($annot->revisionClass) { + // Embedded models cannot have a revision class defined, their data is logged to the owning model + if ($this->isEmbed($meta)) { + throw new InvalidMappingException(sprintf("Class '%s' is mapped as an embedded object and cannot specify the revision class property.", $meta->getName())); + } + + if (!$cl = $this->getRelatedClassName($meta, $annot->revisionClass)) { + throw new InvalidMappingException(sprintf("The revision class '%s' configured for '%s' does not exist.", $class, $meta->getName())); + } + + $config['revisionClass'] = $cl; + } + } + + // Inspect properties for versioned fields + foreach ($class->getProperties() as $property) { + $field = $property->getName(); + + if ($this->reader->getPropertyAnnotation($property, Versioned::class)) { + if ($meta->isCollectionValuedAssociation($field)) { + throw new InvalidMappingException(sprintf('Cannot version field %s::$%s, collection valued associations are not supported.', $meta->getName(), $field)); + } + + // The MongoDB ODM's @EmbedMany is not supported + if ($meta instanceof MongoDBDOMClassMetadata && $meta->isCollectionValuedEmbed($field)) { + throw new InvalidMappingException(sprintf('Cannot version field %s::$%s, an embedded many collection is not supported.', $meta->getName(), $field)); + } + + // To version a field with a relationship, it must be the owning side + if ($this->isRelationship($meta, $field)) { + $associationMapping = $meta->associationMappings[$field]; + + if (!$associationMapping['isOwningSide']) { + throw new InvalidMappingException(sprintf('Cannot version field %s::$%s, it is not the owning side of the relationship.', $meta->getName(), $field)); + } + } + + /* + * Due to differences in the UoW's for each object manager, embedded models need to be handled differently. + * + * The MongoDB ODM tracks embedded documents within the UoW and the listener can recursively inspect the change set + * for changes to these objects. + * + * The ORM inlines embedded field mappings to the root entity, so the list of versioned fields needs to be added to + * the extension metadata now. + */ + + if ($meta instanceof ORMClassMetadata && isset($meta->embeddedClasses[$field])) { + $config = $this->inspectEmbeddedForVersioned($field, $config, $meta); + + continue; + } + + $config['versioned'][] = $field; + } + } + + // Validate configuration + if (!$meta->isMappedSuperclass && $config) { + // The revisionable flag must be set, except for embedded models, and the versioned config should be a non-empty array + if (isset($config['versioned']) && (!$this->isEmbed($meta) && !isset($config['revisionable']))) { + throw new InvalidMappingException(sprintf("Class '%s' has '%s' annotated fields but is missing the '%s' class annotation.", $meta->getName(), Versioned::class, Revisionable::class)); + } + } + + return $config; + } + + /** + * Recursively searches properties of an embedded object for versioned fields. + * + * @param array $config + * + * @return array + */ + private function inspectEmbeddedForVersioned(string $field, array $config, ORMClassMetadata $meta): array + { + foreach ((new \ReflectionClass($meta->embeddedClasses[$field]['class']))->getProperties() as $property) { + if ($this->reader->getPropertyAnnotation($property, Versioned::class)) { + $embeddedField = $field.'.'.$property->getName(); + + if (isset($meta->embeddedClasses[$embeddedField])) { + $config = $this->inspectEmbeddedForVersioned($embeddedField, $config, $meta); + + continue; + } + + $config['versioned'][] = $embeddedField; + } + } + + return $config; + } + + /** + * @phpstan-param non-empty-string $field + */ + private function isRelationship(ClassMetadata $meta, string $field): bool + { + if ($meta instanceof MongoDBDOMClassMetadata) { + return $meta->hasReference($field); + } + + if ($meta instanceof ORMClassMetadata) { + return $meta->hasAssociation($field); + } + + return false; + } + + private function isEmbed(ClassMetadata $meta): bool + { + if ($meta instanceof MongoDBDOMClassMetadata) { + return $meta->isEmbeddedDocument; + } + + if ($meta instanceof ORMClassMetadata) { + return $meta->isEmbeddedClass; + } + + return false; + } +} diff --git a/src/Revisionable/Mapping/Driver/Xml.php b/src/Revisionable/Mapping/Driver/Xml.php new file mode 100644 index 0000000000..1e10fa0211 --- /dev/null +++ b/src/Revisionable/Mapping/Driver/Xml.php @@ -0,0 +1,209 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Mapping\Driver; + +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDBDOMClassMetadata; +use Doctrine\ORM\Mapping\ClassMetadata as ORMClassMetadata; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Gedmo\Exception\InvalidMappingException; +use Gedmo\Mapping\Driver\Xml as BaseXml; + +/** + * Mapping driver for the revisionable extension which reads extended metadata from an XML mapping document for a revisionable class. + * + * @author Boussekeyt Jules + * @author Gediminas Morkevicius + * @author Miha Vrhovnik + * + * @internal + */ +class Xml extends BaseXml +{ + public function readExtendedMetadata($meta, array &$config) + { + // Skip embedded classes for the ORM, they will be handled inline while processing classes using embeds + if ($meta instanceof ORMClassMetadata && $meta->isEmbeddedClass) { + return $config; + } + + $xmlDoctrine = $this->_getMapping($meta->getName()); + + assert($xmlDoctrine instanceof \SimpleXMLElement); + + $xml = $xmlDoctrine->children(self::GEDMO_NAMESPACE_URI); + + $isEmbed = $this->isEmbed($meta); + + // Determine if the object is revisionable by inspecting the revisionable element if present + if (isset($xml->revisionable)) { + $data = $xml->revisionable; + $config['revisionable'] = true; + + if ($this->_isAttributeSet($data, 'revision-class')) { + // Embedded models cannot have a revision class defined, their data is logged to the owning model + if ($isEmbed) { + throw new InvalidMappingException(sprintf("Class '%s' is mapped as an embedded object and cannot specify a revision-class attribute.", $meta->getName())); + } + + $class = $this->_getAttribute($data, 'revision-class'); + + if (!$cl = $this->getRelatedClassName($meta, $class)) { + throw new InvalidMappingException(sprintf("The revision class '%s' configured for '%s' does not exist.", $class, $meta->getName())); + } + + $config['revisionClass'] = $cl; + } + } + + $config = $this->inspectDocument($xmlDoctrine, $config, $meta); + + // Validate configuration + if (!$meta->isMappedSuperclass && $config) { + // The revisionable flag must be set, except for embedded models, and the versioned config should be a non-empty array + if (isset($config['versioned']) && (!$this->isEmbed($meta) && !isset($config['revisionable']))) { + throw new InvalidMappingException(sprintf("Class '%s' has fields with the 'gedmo:versioned' element but the class does not have the 'gedmo:revisionable' element.", $meta->getName())); + } + } + + return $config; + } + + /** + * Searches a document for versioned fields + * + * @param array $config + * + * @return array + */ + private function inspectDocument(\SimpleXMLElement $xmlRoot, array $config, ClassMetadata $meta, string $prepend = ''): array + { + // Inspect for versioned fields + if (isset($xmlRoot->field)) { + $config = $this->inspectElementForVersioned($xmlRoot->field, $config, $meta, $prepend); + } + + // Inspect for versioned embeds + foreach (['embedded', 'embed-one', 'embed-many'] as $embedType) { + if (isset($xmlRoot->$embedType)) { + $config = $this->inspectElementForVersioned($xmlRoot->$embedType, $config, $meta, $prepend); + } + } + + // Inspect for versioned relationships + foreach (['many-to-one', 'one-to-one', 'reference-one'] as $relationshipType) { + if (isset($xmlRoot->$relationshipType)) { + $config = $this->inspectElementForVersioned($xmlRoot->$relationshipType, $config, $meta, $prepend); + } + } + + // Inspect attribute overrides + if (isset($xmlRoot->{'attribute-overrides'})) { + foreach ($xmlRoot->{'attribute-overrides'}->{'attribute-override'} ?? [] as $overrideMapping) { + $config = $this->inspectElementForVersioned($overrideMapping, $config, $meta, $prepend); + } + } + + return $config; + } + + /** + * Searches direct child nodes of the given element for versioned fields + * + * @param array $config + * + * @return array + */ + private function inspectElementForVersioned(\SimpleXMLElement $element, array $config, ClassMetadata $meta, string $prepend = ''): array + { + foreach ($element as $mappingDoctrine) { + $mapping = $mappingDoctrine->children(self::GEDMO_NAMESPACE_URI); + + if (!isset($mapping->versioned)) { + continue; + } + + $isRelationship = !in_array($mappingDoctrine->getName(), ['field', 'embedded', 'embed-one', 'embed-many'], true); + + $field = $this->_getAttribute( + $mappingDoctrine, + $isRelationship ? 'field' : 'name' + ); + + if ($meta->isCollectionValuedAssociation($field)) { + throw new InvalidMappingException(sprintf('Cannot version field %s::$%s, collection valued associations are not supported.', $meta->getName(), $field)); + } + + // The MongoDB ODM's @EmbedMany is not supported + if ('embed-many' === $mappingDoctrine->getName()) { + throw new InvalidMappingException(sprintf('Cannot version field %s::$%s, an embedded many collection is not supported.', $meta->getName(), $field)); + } + + // To version a field with a relationship, it must be the owning side + if ($isRelationship) { + $associationMapping = $meta->associationMappings[$field]; + + if (!$associationMapping['isOwningSide']) { + throw new InvalidMappingException(sprintf('Cannot version field %s::$%s, it is not the owning side of the relationship.', $meta->getName(), $field)); + } + } + + /* + * Due to differences in the UoW's for each object manager, embedded models need to be handled differently. + * + * The MongoDB ODM tracks embedded documents within the UoW and the listener can recursively inspect the change set + * for changes to these objects. + * + * The ORM inlines embedded field mappings to the root entity, so the list of versioned fields needs to be added to + * the extension metadata now. + */ + + if ($meta instanceof ORMClassMetadata && isset($meta->embeddedClasses[$field])) { + $config = $this->inspectEmbeddedForVersioned($field, $config, $meta); + + continue; + } + + $config['versioned'][] = $prepend + ? $prepend.'.'.$field + : $field; + } + + return $config; + } + + /** + * Recursively searches properties of an embedded object for versioned fields. + * + * @param array $config + * + * @return array + */ + private function inspectEmbeddedForVersioned(string $field, array $config, ORMClassMetadata $meta): array + { + $xmlDoctrine = $this->_getMapping($meta->embeddedClasses[$field]['class']); + + assert($xmlDoctrine instanceof \SimpleXMLElement); + + return $this->inspectDocument($xmlDoctrine, $config, $meta, $field); + } + + private function isEmbed(ClassMetadata $meta): bool + { + if ($meta instanceof MongoDBDOMClassMetadata) { + return $meta->isEmbeddedDocument; + } + + if ($meta instanceof ORMClassMetadata) { + return $meta->isEmbeddedClass; + } + + return false; + } +} diff --git a/src/Revisionable/Mapping/Driver/Yaml.php b/src/Revisionable/Mapping/Driver/Yaml.php new file mode 100644 index 0000000000..9c37679b9a --- /dev/null +++ b/src/Revisionable/Mapping/Driver/Yaml.php @@ -0,0 +1,175 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Mapping\Driver; + +use Doctrine\ORM\Mapping\ClassMetadata as ORMClassMetadata; +use Gedmo\Exception\InvalidMappingException; +use Gedmo\Mapping\Driver\File; +use Symfony\Component\Yaml\Yaml as YamlParser; + +/** + * Mapping driver for the revisionable extension which reads extended metadata from a YAML mapping document for a revisionable class. + * + * @author Boussekeyt Jules + * @author Gediminas Morkevicius + * + * @deprecated since gedmo/doctrine-extensions 3.x, will be removed in version 4.0. + * + * @internal + */ +class Yaml extends File +{ + /** + * File extension + * + * @var string + */ + protected $_extension = '.dcm.yml'; + + public function readExtendedMetadata($meta, array &$config) + { + \assert($meta instanceof ORMClassMetadata); + + // Skip embedded classes, they will be handled inline while processing classes using embeds + if ($meta->isEmbeddedClass) { + return $config; + } + + $mapping = $this->_getMapping($meta->getName()); + + // Determine if the object is revisionable by inspecting the object root for a Gedmo config node + if (isset($mapping['gedmo'])) { + $classMapping = $mapping['gedmo']; + + if (isset($classMapping['revisionable'])) { + $config['revisionable'] = true; + + if (isset($classMapping['revisionable']['revisionClass'])) { + if (!$cl = $this->getRelatedClassName($meta, $classMapping['revisionable']['revisionClass'])) { + throw new InvalidMappingException(sprintf("The revision class '%s' configured for '%s' does not exist.", $classMapping['revisionable']['revisionClass'], $meta->getName())); + } + + $config['revisionClass'] = $cl; + } + } + } + + $config = $this->inspectConfiguration($mapping, $config, $meta); + + // Validate configuration + if (!$meta->isMappedSuperclass && $config) { + // The revisionable flag must be set, except for embedded models, and the versioned config should be a non-empty array + if (isset($config['versioned']) && (!$meta->isEmbeddedClass && !isset($config['revisionable']))) { + throw new InvalidMappingException(sprintf("Class '%s' has fields marked as versioned but the class does not have the 'revisionable' configuration.", $meta->getName())); + } + } + + return $config; + } + + protected function _loadMappingFile($file) + { + return YamlParser::parse(file_get_contents($file)); + } + + /** + * Searches a configuration array for versioned fields + * + * @param array $mapping + * @param array $config + * + * @return array + */ + private function inspectConfiguration(array $mapping, array $config, ORMClassMetadata $meta, string $prepend = ''): array + { + // Inspect for versioned fields + if (isset($mapping['fields'])) { + $config = $this->inspectConfigurationForVersioned($mapping['fields'], $config, $meta, $prepend); + } + + // Inspect for versioned embeds + if (isset($mapping['embedded'])) { + $config = $this->inspectConfigurationForVersioned($mapping['embedded'], $config, $meta, $prepend); + } + + // Inspect for versioned relationships + foreach (['manyToOne', 'oneToOne'] as $relationshipType) { + if (isset($mapping[$relationshipType])) { + $config = $this->inspectConfigurationForVersioned($mapping[$relationshipType], $config, $meta, $prepend); + } + } + + // Inspect attribute overrides + if (isset($mapping['attributeOverride'])) { + $config = $this->inspectConfigurationForVersioned($mapping['attributeOverride'], $config, $meta, $prepend); + } + + return $config; + } + + /** + * @param array $config + * + * @return array + */ + private function inspectEmbeddedForVersioned(string $field, array $config, ORMClassMetadata $meta): array + { + return $this->inspectConfiguration($this->_getMapping($meta->embeddedClasses[$field]['class']), $config, $meta, $field); + } + + /** + * @param array>> $mapping + * @param array $config + * + * @return array + */ + private function inspectConfigurationForVersioned(array $mapping, array $config, ORMClassMetadata $meta, string $prepend = ''): array + { + foreach ($mapping as $field => $fieldMapping) { + if (!isset($fieldMapping['gedmo'])) { + continue; + } + + if (in_array('versioned', $fieldMapping['gedmo'], true)) { + if ($meta->isCollectionValuedAssociation($field)) { + throw new InvalidMappingException(sprintf('Cannot version field %s::$%s, collection valued associations are not supported.', $meta->getName(), $field)); + } + + // To version a field with a relationship, it must be the owning side + if ($meta->hasAssociation($field)) { + $associationMapping = $meta->associationMappings[$field]; + + if (!$associationMapping['isOwningSide']) { + throw new InvalidMappingException(sprintf('Cannot version field %s::$%s, it is not the owning side of the relationship.', $meta->getName(), $field)); + } + } + + /* + * Due to differences in the UoW's for each object manager, embedded models need to be handled differently. + * + * The ORM inlines embedded field mappings to the root entity, so the list of versioned fields needs to be added to + * the extension metadata now. + */ + + if (isset($meta->embeddedClasses[$field])) { + $config = $this->inspectEmbeddedForVersioned($field, $config, $meta); + + continue; + } + + $config['versioned'][] = $prepend + ? $prepend.'.'.$field + : $field; + } + } + + return $config; + } +} diff --git a/src/Revisionable/Mapping/Event/Adapter/ODM.php b/src/Revisionable/Mapping/Event/Adapter/ODM.php new file mode 100644 index 0000000000..f70e1d6f59 --- /dev/null +++ b/src/Revisionable/Mapping/Event/Adapter/ODM.php @@ -0,0 +1,82 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Mapping\Event\Adapter; + +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDBODMClassMetadata; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Gedmo\Mapping\Event\Adapter\ODM as BaseAdapterODM; +use Gedmo\Revisionable\Document\Revision; +use Gedmo\Revisionable\Mapping\Event\RevisionableAdapter; +use Gedmo\Revisionable\Revisionable; +use Gedmo\Revisionable\RevisionInterface; +use Gedmo\Tool\Wrapper\MongoDocumentWrapper; + +/** + * Doctrine event adapter for the Revisionable extension when using the Doctrine MongoDB ORM. + * + * @author Gediminas Morkevicius + */ +final class ODM extends BaseAdapterODM implements RevisionableAdapter +{ + /** + * Get the default object class name used to store revisions. + * + * @phpstan-return class-string> + */ + public function getDefaultRevisionClass(): string + { + return Revision::class; + } + + /** + * Checks whether an identifier should be generated post insert. + */ + public function isPostInsertGenerator(ClassMetadata $meta): bool + { + // The MongoDB ODM does not support post insert generated identifiers + return false; + } + + /** + * Get the new version number for an object. + * + * @phpstan-return positive-int + */ + public function getNewVersion(ClassMetadata $meta, object $object): int + { + assert($meta instanceof MongoDBODMClassMetadata); + + $dm = $this->getObjectManager(); + + $documentWrapper = new MongoDocumentWrapper($object, $dm); + + $documentMetadata = $documentWrapper->getMetadata(); + $documentId = (string) $documentWrapper->getIdentifier(false, true); + $documentClass = $documentMetadata->getName(); + + $qb = $dm->createQueryBuilder($meta->getName()); + $qb->select('version'); + $qb->field('revisionableId')->equals($documentId); + $qb->field('revisionableClass')->equals($documentClass); + $qb->sort('version', 'DESC'); + $qb->limit(1); + + $q = $qb->getQuery(); + $q->setHydrate(false); + + $result = $q->getSingleResult(); + + if ($result) { + $result = (int) $result['version'] + 1; + } + + return (int) $result; + } +} diff --git a/src/Revisionable/Mapping/Event/Adapter/ORM.php b/src/Revisionable/Mapping/Event/Adapter/ORM.php new file mode 100644 index 0000000000..6cb5be81fa --- /dev/null +++ b/src/Revisionable/Mapping/Event/Adapter/ORM.php @@ -0,0 +1,77 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Mapping\Event\Adapter; + +use Doctrine\ORM\Mapping\ClassMetadata as ORMClassMetadata; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Gedmo\Mapping\Event\Adapter\ORM as BaseAdapterORM; +use Gedmo\Revisionable\Entity\Revision; +use Gedmo\Revisionable\Mapping\Event\RevisionableAdapter; +use Gedmo\Revisionable\Revisionable; +use Gedmo\Revisionable\RevisionInterface; +use Gedmo\Tool\Wrapper\EntityWrapper; + +/** + * Doctrine event adapter for the Revisionable extension when using the Doctrine ORM. + * + * @author Gediminas Morkevicius + */ +final class ORM extends BaseAdapterORM implements RevisionableAdapter +{ + /** + * Get the default object class name used to store revisions. + * + * @phpstan-return class-string> + */ + public function getDefaultRevisionClass(): string + { + return Revision::class; + } + + /** + * Checks whether an identifier should be generated post insert. + */ + public function isPostInsertGenerator(ClassMetadata $meta): bool + { + assert($meta instanceof ORMClassMetadata); + + return $meta->idGenerator->isPostInsertGenerator(); + } + + /** + * Get the new version number for an object. + * + * @phpstan-return positive-int + */ + public function getNewVersion(ClassMetadata $meta, object $object): int + { + assert($meta instanceof ORMClassMetadata); + + $em = $this->getObjectManager(); + + $entityWrapper = new EntityWrapper($object, $em); + + $entityMetadata = $entityWrapper->getMetadata(); + $entityId = (string) $entityWrapper->getIdentifier(false, true); + $entityClass = $entityMetadata->getName(); + + $qb = $em->createQueryBuilder() + ->select('MAX(revision.version)') + ->from($meta->getName(), 'revision') + ->where('revision.revisionableId = :revisionableId') + ->andWhere('revision.revisionableClass = :revisionableClass') + ->setParameter('revisionableId', $entityId) + ->setParameter('revisionableClass', $entityClass); + + $version = (int) $qb->getQuery()->getSingleScalarResult(); + + return $version + 1; + } +} diff --git a/src/Revisionable/Mapping/Event/RevisionableAdapter.php b/src/Revisionable/Mapping/Event/RevisionableAdapter.php new file mode 100644 index 0000000000..ece6d0fb14 --- /dev/null +++ b/src/Revisionable/Mapping/Event/RevisionableAdapter.php @@ -0,0 +1,42 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable\Mapping\Event; + +use Doctrine\Persistence\Mapping\ClassMetadata; +use Gedmo\Mapping\Event\AdapterInterface; +use Gedmo\Revisionable\Revisionable; +use Gedmo\Revisionable\RevisionInterface; + +/** + * Doctrine event adapter for the Revisionable extension. + * + * @author Gediminas Morkevicius + */ +interface RevisionableAdapter extends AdapterInterface +{ + /** + * Get the default object class name used to store revisions. + * + * @phpstan-return class-string> + */ + public function getDefaultRevisionClass(): string; + + /** + * Checks whether an identifier should be generated post insert. + */ + public function isPostInsertGenerator(ClassMetadata $meta): bool; + + /** + * Get the new version number for an object. + * + * @phpstan-return positive-int + */ + public function getNewVersion(ClassMetadata $meta, object $object): int; +} diff --git a/src/Revisionable/RevisionInterface.php b/src/Revisionable/RevisionInterface.php new file mode 100644 index 0000000000..474223b0c4 --- /dev/null +++ b/src/Revisionable/RevisionInterface.php @@ -0,0 +1,101 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable; + +/** + * Interface defining a revision model. + * + * @phpstan-template T of Revisionable|object + * + * @author Javier Spagnoletti + */ +interface RevisionInterface +{ + public const ACTION_CREATE = 'create'; + + public const ACTION_UPDATE = 'update'; + + public const ACTION_REMOVE = 'remove'; + + /** + * Named constructor to create a new revision. + * + * Implementations should handle setting the initial logged at time and version for new instances within this constructor. + * + * @phpstan-param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action + * + * @phpstan-return RevisionInterface + */ + public static function createRevision(string $action): self; + + /** + * @phpstan-param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action + */ + public function setAction(string $action): void; + + /** + * @phpstan-return self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE + */ + public function getAction(): string; + + /** + * @phpstan-param positive-int $version + */ + public function setVersion(int $version): void; + + /** + * @phpstan-return positive-int + */ + public function getVersion(): int; + + /** + * @phpstan-param non-empty-string $revisionableId + */ + public function setRevisionableId(string $revisionableId): void; + + /** + * @phpstan-return non-empty-string|null + */ + public function getRevisionableId(): ?string; + + /** + * @phpstan-param class-string $revisionableClass + */ + public function setRevisionableClass(string $revisionableClass): void; + + /** + * @phpstan-return class-string|null + */ + public function getRevisionableClass(): ?string; + + public function setLoggedAt(\DateTimeImmutable $loggedAt): void; + + public function getLoggedAt(): \DateTimeImmutable; + + /** + * @phpstan-param non-empty-string|null $username + */ + public function setUsername(?string $username): void; + + /** + * @phpstan-return non-empty-string|null + */ + public function getUsername(): ?string; + + /** + * @param array $data + */ + public function setData(array $data): void; + + /** + * @return array + */ + public function getData(): array; +} diff --git a/src/Revisionable/Revisionable.php b/src/Revisionable/Revisionable.php new file mode 100644 index 0000000000..fa4fcb4c74 --- /dev/null +++ b/src/Revisionable/Revisionable.php @@ -0,0 +1,17 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable; + +/** + * Marker interface for objects which can be identified as revisionable. + */ +interface Revisionable +{ +} diff --git a/src/Revisionable/RevisionableListener.php b/src/Revisionable/RevisionableListener.php new file mode 100644 index 0000000000..a12359a895 --- /dev/null +++ b/src/Revisionable/RevisionableListener.php @@ -0,0 +1,351 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Revisionable; + +use Doctrine\Common\EventArgs; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata as MongoDBODMClassMetadata; +use Doctrine\ORM\Mapping\ClassMetadata as ORMClassMetadata; +use Doctrine\Persistence\Event\LifecycleEventArgs; +use Doctrine\Persistence\Event\LoadClassMetadataEventArgs; +use Doctrine\Persistence\Event\ManagerEventArgs; +use Doctrine\Persistence\Mapping\ClassMetadata; +use Doctrine\Persistence\ObjectManager; +use Gedmo\Exception\InvalidArgumentException; +use Gedmo\Mapping\MappedEventSubscriber; +use Gedmo\Revisionable\Mapping\Event\RevisionableAdapter; +use Gedmo\Tool\Wrapper\AbstractWrapper; + +/** + * Revisionable listener + * + * @author Boussekeyt Jules + * @author Gediminas Morkevicius + * + * @phpstan-type RevisionableConfiguration = array{ + * revisionable?: bool, + * revisionClass?: class-string>, + * versioned?: list, + * } + * + * @phpstan-template T of Revisionable|object + * + * @phpstan-extends MappedEventSubscriber + */ +final class RevisionableListener extends MappedEventSubscriber +{ + /** + * Username for identification + * + * @phpstan-var non-empty-string|null + */ + private ?string $username = null; + + /** + * List of revisions which do not have the foreign key generated yet - MySQL case. + * + * These entries will be updated with new keys on postPersist event + * + * @var array + * + * @phpstan-var array> + */ + private array $pendingRevisionInserts = []; + + /** + * For log of changed relations we use its identifiers to avoid storing serialized Proxies. + * + * These are pending relations in case it does not have an identifier yet. + * + * @var array>> + * + * @phpstan-var array, field: string}>> + */ + private array $pendingRelatedObjects = []; + + /** + * Set the username to be used when logging revisions. + * + * @param string|object $username + * + * @phpstan-param non-empty-string|object $username + * + * @throws InvalidArgumentException Invalid username + */ + public function setUsername($username): void + { + if (is_string($username)) { + $this->username = $username; + + return; + } + + if (!is_object($username)) { + throw new InvalidArgumentException('The username must be a string or an object implementing Stringable or with a getUserIdentifier or getUsername method.'); + } + + if (method_exists($username, 'getUserIdentifier')) { + $this->username = (string) $username->getUserIdentifier(); + + return; + } + + if (method_exists($username, 'getUsername')) { + $this->username = (string) $username->getUsername(); + + return; + } + + if (method_exists($username, '__toString')) { + $this->username = $username->__toString(); + + return; + } + + throw new InvalidArgumentException('The username must be a string or an object implementing Stringable or with a getUserIdentifier or getUsername method.'); + } + + /** + * @return list + */ + public function getSubscribedEvents(): array + { + return [ + 'onFlush', + 'loadClassMetadata', + 'postPersist', + ]; + } + + /** + * Maps additional metadata for revisionable objects. + * + * @param LoadClassMetadataEventArgs $eventArgs + * + * @phpstan-param LoadClassMetadataEventArgs, ObjectManager> $eventArgs + */ + public function loadClassMetadata(EventArgs $eventArgs): void + { + $this->loadMetadataForObjectClass($eventArgs->getObjectManager(), $eventArgs->getClassMetadata()); + } + + /** + * Checks for inserted objects to update the revision's foreign key. + * + * @param LifecycleEventArgs $args + * + * @phpstan-param LifecycleEventArgs $args + */ + public function postPersist(EventArgs $args): void + { + $ea = $this->getEventAdapter($args); + $object = $ea->getObject(); + $om = $ea->getObjectManager(); + $oid = spl_object_id($object); + $uow = $om->getUnitOfWork(); + + if ($this->pendingRevisionInserts && array_key_exists($oid, $this->pendingRevisionInserts)) { + $wrapped = AbstractWrapper::wrap($object, $om); + + $revision = $this->pendingRevisionInserts[$oid]; + $revisionMeta = $om->getClassMetadata(get_class($revision)); + + $id = $wrapped->getIdentifier(false, true); + $revisionMeta->setFieldValue($revision, 'revisionableId', $id); + $uow->scheduleExtraUpdate($revision, [ + 'revisionableId' => [null, $id], + ]); + $ea->setOriginalObjectProperty($uow, $revision, 'revisionableId', $id); + unset($this->pendingRevisionInserts[$oid]); + } + + if ($this->pendingRelatedObjects && array_key_exists($oid, $this->pendingRelatedObjects)) { + $wrapped = AbstractWrapper::wrap($object, $om); + $identifiers = $wrapped->getIdentifier(false); + + foreach ($this->pendingRelatedObjects[$oid] as $props) { + $revision = $props['revision']; + + $oldData = $data = $revision->getData(); + $data[$props['field']] = $identifiers; + + $revision->setData($data); + + $uow->scheduleExtraUpdate($revision, [ + 'data' => [$oldData, $data], + ]); + $ea->setOriginalObjectProperty($uow, $revision, 'data', $data); + } + unset($this->pendingRelatedObjects[$oid]); + } + } + + /** + * Creates revisions for revisionable objects. + * + * @param ManagerEventArgs $eventArgs + * + * @phpstan-param ManagerEventArgs $eventArgs + */ + public function onFlush(EventArgs $eventArgs): void + { + $ea = $this->getEventAdapter($eventArgs); + $om = $ea->getObjectManager(); + $uow = $om->getUnitOfWork(); + + foreach ($ea->getScheduledObjectInsertions($uow) as $object) { + $this->createRevision(RevisionInterface::ACTION_CREATE, $object, $ea); + } + + foreach ($ea->getScheduledObjectUpdates($uow) as $object) { + $this->createRevision(RevisionInterface::ACTION_UPDATE, $object, $ea); + } + + foreach ($ea->getScheduledObjectDeletions($uow) as $object) { + $this->createRevision(RevisionInterface::ACTION_REMOVE, $object, $ea); + } + } + + protected function getNamespace(): string + { + return __NAMESPACE__; + } + + /** + * Get the {@see RevisionInterface} class name to use when creating revisions for the provided class. + * + * @phpstan-param class-string $class + * + * @phpstan-return class-string> + */ + private function getRevisionClass(RevisionableAdapter $ea, string $class): string + { + return $this->getConfiguration($ea->getObjectManager(), $class)['revisionClass'] ?? $ea->getDefaultRevisionClass(); + } + + /** + * Provides the changed data for an object to store in a revision. + * + * @return array + * + * @phpstan-param T $object + * @phpstan-param RevisionInterface $revision + */ + private function getObjectChangeSetData(RevisionableAdapter $ea, object $object, RevisionInterface $revision): array + { + $om = $ea->getObjectManager(); + $wrapped = AbstractWrapper::wrap($object, $om); + $meta = $wrapped->getMetadata(); + $config = $this->getConfiguration($om, $meta->getName()); + $uow = $om->getUnitOfWork(); + $newValues = []; + + foreach ($ea->getObjectChangeSet($uow, $object) as $field => $changes) { + if (empty($config['versioned']) || !in_array($field, $config['versioned'], true)) { + continue; + } + + $value = $changes[1]; + + if ($meta instanceof MongoDBODMClassMetadata && $meta->hasEmbed($field)) { + $value = $this->getObjectChangeSetData($ea, $value, $revision); + } elseif ($meta->isSingleValuedAssociation($field)) { + if ($value) { + $oid = spl_object_id($value); + $wrappedAssoc = AbstractWrapper::wrap($value, $om); + $value = $wrappedAssoc->getIdentifier(false); + + if (!is_array($value) && !$value) { + $this->pendingRelatedObjects[$oid][] = [ + 'revision' => $revision, + 'field' => $field, + ]; + } + } + } else { + $value = $wrapped->convertToDatabaseValue($value, $meta->getTypeOfField($field)); + } + + $newValues[$field] = $value; + } + + return $newValues; + } + + /** + * Create a new {@see RevisionInterface} instance + * + * @phpstan-param RevisionInterface::ACTION_CREATE|RevisionInterface::ACTION_UPDATE|RevisionInterface::ACTION_REMOVE $action + * @phpstan-param T $object + * + * @phpstan-return RevisionInterface|null + */ + private function createRevision(string $action, object $object, RevisionableAdapter $ea): ?RevisionInterface + { + $om = $ea->getObjectManager(); + $wrapped = AbstractWrapper::wrap($object, $om); + $meta = $wrapped->getMetadata(); + + // Exclude embedded documents + if ($meta instanceof MongoDBODMClassMetadata && $meta->isEmbeddedDocument) { + return null; + } + + $config = $this->getConfiguration($om, $meta->getName()); + + if ([] === $config) { + return null; + } + + $revisionClass = $this->getRevisionClass($ea, $meta->getName()); + $revisionMeta = $om->getClassMetadata($revisionClass); + + $revision = $revisionClass::createRevision($action); + $revision->setUsername($this->username); + $revision->setRevisionableClass($meta->getName()); + + // check for the availability of the primary key + if (RevisionInterface::ACTION_CREATE === $action && ($ea->isPostInsertGenerator($meta) || ($meta instanceof ORMClassMetadata && $meta->isIdentifierComposite))) { + $this->pendingRevisionInserts[spl_object_id($object)] = $revision; + } else { + $revision->setRevisionableId($wrapped->getIdentifier(false, true)); + } + + $newValues = []; + + if (RevisionInterface::ACTION_REMOVE !== $action && isset($config['versioned'])) { + $newValues = $this->getObjectChangeSetData($ea, $object, $revision); + $revision->setData($newValues); + } + + // Don't create a revision if there's nothing to log on update + if (RevisionInterface::ACTION_UPDATE === $action && [] === $newValues) { + return null; + } + + $version = 1; + + if (RevisionInterface::ACTION_CREATE !== $action) { + $version = $ea->getNewVersion($revisionMeta, $object); + + if (empty($version)) { + // was versioned later + $version = 1; + } + } + + $revision->setVersion($version); + + $om->persist($revision); + + $om->getUnitOfWork()->computeChangeSet($revisionMeta, $revision); + + return $revision; + } +} diff --git a/src/Tool/Wrapper/AbstractWrapper.php b/src/Tool/Wrapper/AbstractWrapper.php index 8055069b93..aeac57fc37 100644 --- a/src/Tool/Wrapper/AbstractWrapper.php +++ b/src/Tool/Wrapper/AbstractWrapper.php @@ -24,6 +24,7 @@ * manipulation * * @phpstan-template TClassMetadata of ClassMetadata + * @phpstan-template TObjectManager of ObjectManager * * @phpstan-implements WrapperInterface * @@ -51,6 +52,8 @@ abstract class AbstractWrapper implements WrapperInterface * Object manager instance * * @var ObjectManager + * + * @phpstan-var TObjectManager */ protected $om; @@ -93,6 +96,11 @@ public function getObject() return $this->object; } + /** + * @return ClassMetadata + * + * @phpstan-return TClassMetadata + */ public function getMetadata() { return $this->meta; diff --git a/src/Tool/Wrapper/EntityWrapper.php b/src/Tool/Wrapper/EntityWrapper.php index 63bbe6187f..06d515e7df 100644 --- a/src/Tool/Wrapper/EntityWrapper.php +++ b/src/Tool/Wrapper/EntityWrapper.php @@ -18,7 +18,7 @@ * Wraps entity or proxy for more convenient * manipulation * - * @phpstan-extends AbstractWrapper + * @phpstan-extends AbstractWrapper * * @author Gediminas Morkevicius * @@ -114,6 +114,30 @@ public function isEmbeddedAssociation($field) return false; } + /** + * Converts a given value to its database representation. + * + * @param mixed $value + * + * @return mixed + */ + public function convertToDatabaseValue($value, string $type) + { + return $this->om->getConnection()->convertToDatabaseValue($value, $type); + } + + /** + * Converts a given value to its PHP representation. + * + * @param mixed $value + * + * @return mixed + */ + public function convertToPHPValue($value, string $type) + { + return $this->om->getConnection()->convertToPHPValue($value, $type); + } + /** * Initialize the entity if it is proxy * required when is detached or not initialized diff --git a/src/Tool/Wrapper/MongoDocumentWrapper.php b/src/Tool/Wrapper/MongoDocumentWrapper.php index 6744fc8222..4a0baa8354 100644 --- a/src/Tool/Wrapper/MongoDocumentWrapper.php +++ b/src/Tool/Wrapper/MongoDocumentWrapper.php @@ -11,13 +11,14 @@ use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; +use Doctrine\ODM\MongoDB\Types\Type; use ProxyManager\Proxy\GhostObjectInterface; /** * Wraps document or proxy for more convenient * manipulation * - * @phpstan-extends AbstractWrapper + * @phpstan-extends AbstractWrapper * * @author Gediminas Morkevicius * @@ -101,6 +102,30 @@ public function isEmbeddedAssociation($field) return $this->getMetadata()->isSingleValuedEmbed($field); } + /** + * Converts a given value to its database representation. + * + * @param mixed $value + * + * @return mixed + */ + public function convertToDatabaseValue($value, string $type) + { + return Type::getType($type)->convertToDatabaseValue($value); + } + + /** + * Converts a given value to its PHP representation. + * + * @param mixed $value + * + * @return mixed + */ + public function convertToPHPValue($value, string $type) + { + return Type::getType($type)->convertToPHPValue($value); + } + /** * Initialize the document if it is proxy * required when is detached or not initialized diff --git a/src/Tool/WrapperInterface.php b/src/Tool/WrapperInterface.php index efa2eb6971..cf5a62f8e0 100644 --- a/src/Tool/WrapperInterface.php +++ b/src/Tool/WrapperInterface.php @@ -17,6 +17,9 @@ * @phpstan-template-covariant TClassMetadata of ClassMetadata * * @author Gediminas Morkevicius + * + * @method mixed convertToDatabaseValue(mixed $value, string $type) + * @method mixed convertToPHPValue(mixed $value, string $type) */ interface WrapperInterface { diff --git a/src/Translatable/Mapping/Event/TranslatableAdapter.php b/src/Translatable/Mapping/Event/TranslatableAdapter.php index 555b87718b..a3e247eb3d 100644 --- a/src/Translatable/Mapping/Event/TranslatableAdapter.php +++ b/src/Translatable/Mapping/Event/TranslatableAdapter.php @@ -10,6 +10,7 @@ namespace Gedmo\Translatable\Mapping\Event; use Doctrine\Persistence\Mapping\ClassMetadata; +use Doctrine\Persistence\ObjectManager; use Gedmo\Mapping\Event\AdapterInterface; use Gedmo\Tool\Wrapper\AbstractWrapper; @@ -63,7 +64,7 @@ public function loadTranslations($object, $translationClass, $locale, $objectCla * @param string $translationClass * @param string $objectClass * - * @phpstan-param AbstractWrapper> $wrapped + * @phpstan-param AbstractWrapper, ObjectManager> $wrapped * @phpstan-param class-string $translationClass * @phpstan-param class-string $objectClass * @@ -77,7 +78,7 @@ public function findTranslation(AbstractWrapper $wrapped, $locale, $field, $tran * @param string $transClass * @param string $objectClass * - * @phpstan-param AbstractWrapper> $wrapped + * @phpstan-param AbstractWrapper, ObjectManager> $wrapped * @phpstan-param class-string $transClass * @phpstan-param class-string $objectClass * diff --git a/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.EmbeddedRevisionable.dcm.xml b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.EmbeddedRevisionable.dcm.xml new file mode 100644 index 0000000000..373e970a0e --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.EmbeddedRevisionable.dcm.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.Revisionable.dcm.xml b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.Revisionable.dcm.xml new file mode 100644 index 0000000000..04316fa9b9 --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.Revisionable.dcm.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableComposite.dcm.xml b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableComposite.dcm.xml new file mode 100644 index 0000000000..5b46503db4 --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableComposite.dcm.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableCompositeRelation.dcm.xml b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableCompositeRelation.dcm.xml new file mode 100644 index 0000000000..956ecc1e61 --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableCompositeRelation.dcm.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableWithEmbedded.dcm.xml b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableWithEmbedded.dcm.xml new file mode 100644 index 0000000000..a7fbb85568 --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Xml/Gedmo.Tests.Mapping.Fixture.Xml.RevisionableWithEmbedded.dcm.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.EmbeddedRevisionable.dcm.yml b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.EmbeddedRevisionable.dcm.yml new file mode 100644 index 0000000000..991161e75a --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.EmbeddedRevisionable.dcm.yml @@ -0,0 +1,8 @@ +--- +Gedmo\Tests\Mapping\Fixture\Yaml\EmbeddedRevisionable: + type: embeddable + fields: + subtitle: + type: string + gedmo: + - versioned diff --git a/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.Revisionable.dcm.yml b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.Revisionable.dcm.yml new file mode 100644 index 0000000000..7621fcb641 --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.Revisionable.dcm.yml @@ -0,0 +1,17 @@ +--- +Gedmo\Tests\Mapping\Fixture\Yaml\Revisionable: + type: entity + table: revisionable + gedmo: + revisionable: true + id: + id: + type: integer + generator: + strategy: AUTO + fields: + title: + type: string + length: 64 + gedmo: + - versioned diff --git a/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableComposite.dcm.yml b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableComposite.dcm.yml new file mode 100644 index 0000000000..caca7aec55 --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableComposite.dcm.yml @@ -0,0 +1,18 @@ +--- +Gedmo\Tests\Mapping\Fixture\Yaml\RevisionableComposite: + type: entity + table: revisionable_with_composite + gedmo: + revisionable: + revisionClass: Gedmo\Revisionable\Entity\Revision + id: + one: + type: integer + two: + type: integer + fields: + title: + type: string + length: 64 + gedmo: + - versioned diff --git a/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableCompositeRelation.dcm.yml b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableCompositeRelation.dcm.yml new file mode 100644 index 0000000000..0db7133de9 --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableCompositeRelation.dcm.yml @@ -0,0 +1,20 @@ +--- +Gedmo\Tests\Mapping\Fixture\Yaml\RevisionableCompositeRelation: + type: entity + table: revisionable_with_composite_relation + gedmo: + revisionable: true + id: + one: + associationKey: true + two: + type: integer + fields: + title: + type: string + length: 64 + gedmo: + - versioned + manyToOne: + one: + targetEntity: Revisionable diff --git a/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableWithEmbedded.dcm.yml b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableWithEmbedded.dcm.yml new file mode 100644 index 0000000000..0053f46425 --- /dev/null +++ b/tests/Gedmo/Mapping/Driver/Yaml/Gedmo.Tests.Mapping.Fixture.Yaml.RevisionableWithEmbedded.dcm.yml @@ -0,0 +1,23 @@ +--- +Gedmo\Tests\Mapping\Fixture\Yaml\RevisionableWithEmbedded: + type: entity + table: revisionable_with_embedded + gedmo: + revisionable: + revisionClass: Gedmo\Revisionable\Entity\Revision + id: + id: + type: integer + generator: + strategy: AUTO + fields: + title: + type: string + length: 64 + gedmo: + - versioned + embedded: + embedded: + class: Gedmo\Tests\Mapping\Fixture\Yaml\EmbeddedRevisionable + gedmo: + - versioned diff --git a/tests/Gedmo/Mapping/Fixture/Document/EmbeddedRevisionable.php b/tests/Gedmo/Mapping/Fixture/Document/EmbeddedRevisionable.php new file mode 100644 index 0000000000..0b9ad72f96 --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Document/EmbeddedRevisionable.php @@ -0,0 +1,34 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @author Fabian Sabau + * + * @ODM\EmbeddedDocument + */ +#[ODM\EmbeddedDocument] +class EmbeddedRevisionable +{ + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $subtitle = null; +} diff --git a/tests/Gedmo/Mapping/Fixture/Document/Revisionable.php b/tests/Gedmo/Mapping/Fixture/Document/Revisionable.php new file mode 100644 index 0000000000..33f11c4603 --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Document/Revisionable.php @@ -0,0 +1,54 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @ODM\Document(collection="revisionables") + * + * @Gedmo\Revisionable + */ +#[ODM\Document(collection: 'revisionables')] +#[Gedmo\Revisionable] +class Revisionable +{ + /** + * @ODM\Id + */ + #[ODM\Id] + private ?string $id = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $title = null; + + public function getId(): ?string + { + return $this->id; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Document/RevisionableWithEmbedded.php b/tests/Gedmo/Mapping/Fixture/Document/RevisionableWithEmbedded.php new file mode 100644 index 0000000000..c03e69b05d --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Document/RevisionableWithEmbedded.php @@ -0,0 +1,51 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Document\Revision; + +/** + * @ODM\Document(collection="revisionables_with_embedded") + * + * @Gedmo\Revisionable(revisionClass="Gedmo\Revisionable\Document\Revision") + */ +#[ODM\Document(collection: 'revisionables_with_embedded')] +#[Gedmo\Revisionable(revisionClass: Revision::class)] +class RevisionableWithEmbedded +{ + /** + * @ODM\Id + */ + #[ODM\Id] + private ?string $id = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $title = null; + + /** + * @ODM\EmbedOne(targetDocument="Gedmo\Tests\Mapping\Fixture\Document\EmbeddedRevisionable") + * + * @Gedmo\Versioned + */ + #[ODM\EmbedOne(targetDocument: EmbeddedRevisionable::class)] + #[Gedmo\Versioned] + private ?EmbeddedRevisionable $embedded = null; +} diff --git a/tests/Gedmo/Mapping/Fixture/Entity/EmbeddedRevisionable.php b/tests/Gedmo/Mapping/Fixture/Entity/EmbeddedRevisionable.php new file mode 100644 index 0000000000..33e29830b4 --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Entity/EmbeddedRevisionable.php @@ -0,0 +1,34 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @author Fabian Sabau + * + * @ORM\Embeddable + */ +#[ORM\Embeddable] +class EmbeddedRevisionable +{ + /** + * @ORM\Column(type="string") + * + * @Gedmo\Versioned + */ + #[ORM\Column(type: Types::STRING)] + #[Gedmo\Versioned] + private ?string $subtitle = null; +} diff --git a/tests/Gedmo/Mapping/Fixture/Entity/Revisionable.php b/tests/Gedmo/Mapping/Fixture/Entity/Revisionable.php new file mode 100644 index 0000000000..39042e2d1f --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Entity/Revisionable.php @@ -0,0 +1,58 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @ORM\Entity + * + * @Gedmo\Revisionable + */ +#[ORM\Entity] +#[Gedmo\Revisionable] +class Revisionable +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: Types::INTEGER)] + private ?int $id = null; + + /** + * @ORM\Column(name="title", type="string", length=64) + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'title', type: Types::STRING, length: 64)] + #[Gedmo\Versioned] + private ?string $title = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Entity/RevisionableComposite.php b/tests/Gedmo/Mapping/Fixture/Entity/RevisionableComposite.php new file mode 100644 index 0000000000..2b6ad50d68 --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Entity/RevisionableComposite.php @@ -0,0 +1,70 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Entity\Revision; + +/** + * @ORM\Entity + * + * @Gedmo\Revisionable(revisionClass="Gedmo\Revisionable\Entity\Revision") + */ +#[ORM\Entity] +#[Gedmo\Revisionable(revisionClass: Revision::class)] +class RevisionableComposite +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + */ + #[ORM\Id] + #[ORM\Column(type: Types::INTEGER)] + private ?int $one = null; + + /** + * @ORM\Id + * @ORM\Column(type="integer") + */ + #[ORM\Id] + #[ORM\Column(type: Types::INTEGER)] + private ?int $two = null; + + /** + * @ORM\Column(name="title", type="string", length=64) + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'title', type: Types::STRING, length: 64)] + #[Gedmo\Versioned] + private ?string $title = null; + + public function getOne(): ?int + { + return $this->one; + } + + public function getTwo(): ?int + { + return $this->two; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Entity/RevisionableCompositeRelation.php b/tests/Gedmo/Mapping/Fixture/Entity/RevisionableCompositeRelation.php new file mode 100644 index 0000000000..34059aefc3 --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Entity/RevisionableCompositeRelation.php @@ -0,0 +1,69 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @ORM\Entity + * + * @Gedmo\Revisionable + */ +#[ORM\Entity] +#[Gedmo\Revisionable] +class RevisionableCompositeRelation +{ + /** + * @ORM\Id + * @ORM\ManyToOne(targetEntity="Revisionable") + */ + #[ORM\Id] + #[ORM\ManyToOne(targetEntity: Revisionable::class)] + private ?Revisionable $one = null; + + /** + * @ORM\Id + * @ORM\Column(type="integer") + */ + #[ORM\Id] + #[ORM\Column(type: Types::INTEGER)] + private ?int $two = null; + + /** + * @ORM\Column(name="title", type="string", length=64) + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'title', type: Types::STRING, length: 64)] + #[Gedmo\Versioned] + private ?string $title = null; + + public function getOne(): ?Revisionable + { + return $this->one; + } + + public function getTwo(): ?int + { + return $this->two; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Entity/RevisionableWithEmbedded.php b/tests/Gedmo/Mapping/Fixture/Entity/RevisionableWithEmbedded.php new file mode 100644 index 0000000000..82d71d7b9a --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Entity/RevisionableWithEmbedded.php @@ -0,0 +1,55 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Entity\Revision; + +/** + * @ORM\Entity + * + * @Gedmo\Revisionable(revisionClass="Gedmo\Revisionable\Entity\Revision") + */ +#[ORM\Entity] +#[Gedmo\Revisionable(revisionClass: Revision::class)] +class RevisionableWithEmbedded +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: Types::INTEGER)] + private ?int $id = null; + + /** + * @ORM\Column(name="title", type="string", length=64) + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'title', type: Types::STRING, length: 64)] + #[Gedmo\Versioned] + private ?string $title = null; + + /** + * @ORM\Embedded(class="Gedmo\Tests\Mapping\Fixture\Entity\EmbeddedRevisionable") + * + * @Gedmo\Versioned + */ + #[ORM\Embedded(class: EmbeddedRevisionable::class)] + #[Gedmo\Versioned] + private ?EmbeddedRevisionable $embedded = null; +} diff --git a/tests/Gedmo/Mapping/Fixture/Xml/EmbeddedRevisionable.php b/tests/Gedmo/Mapping/Fixture/Xml/EmbeddedRevisionable.php new file mode 100644 index 0000000000..11ff118aff --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Xml/EmbeddedRevisionable.php @@ -0,0 +1,20 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Xml; + +/** + * @author Fabian Sabau + */ +class EmbeddedRevisionable +{ + private ?string $subtitle = null; +} diff --git a/tests/Gedmo/Mapping/Fixture/Xml/Revisionable.php b/tests/Gedmo/Mapping/Fixture/Xml/Revisionable.php new file mode 100644 index 0000000000..3cf2bb7aeb --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Xml/Revisionable.php @@ -0,0 +1,34 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Xml; + +class Revisionable +{ + private ?int $id = null; + + private ?string $title = null; + + public function getId(): int + { + return $this->id; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Xml/RevisionableComposite.php b/tests/Gedmo/Mapping/Fixture/Xml/RevisionableComposite.php new file mode 100644 index 0000000000..79e8e5f703 --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Xml/RevisionableComposite.php @@ -0,0 +1,39 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Xml; + +class RevisionableComposite +{ + private ?int $one = null; + + private ?int $two = null; + + private ?string $title = null; + + public function getOne(): int + { + return $this->one; + } + + public function getTwo(): int + { + return $this->two; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Xml/RevisionableCompositeRelation.php b/tests/Gedmo/Mapping/Fixture/Xml/RevisionableCompositeRelation.php new file mode 100644 index 0000000000..924f198dfd --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Xml/RevisionableCompositeRelation.php @@ -0,0 +1,39 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Xml; + +class RevisionableCompositeRelation +{ + private ?Revisionable $one = null; + + private ?int $two = null; + + private ?string $title = null; + + public function getOne(): ?Revisionable + { + return $this->one; + } + + public function getTwo(): ?int + { + return $this->two; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Xml/RevisionableWithEmbedded.php b/tests/Gedmo/Mapping/Fixture/Xml/RevisionableWithEmbedded.php new file mode 100644 index 0000000000..cb685c25ae --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Xml/RevisionableWithEmbedded.php @@ -0,0 +1,21 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Xml; + +class RevisionableWithEmbedded +{ + private ?int $id = null; + + private ?string $title = null; + + private ?Embedded $embedded = null; +} diff --git a/tests/Gedmo/Mapping/Fixture/Yaml/EmbeddedRevisionable.php b/tests/Gedmo/Mapping/Fixture/Yaml/EmbeddedRevisionable.php new file mode 100644 index 0000000000..f6eba8e2d8 --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Yaml/EmbeddedRevisionable.php @@ -0,0 +1,20 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Yaml; + +/** + * @author Fabian Sabau + */ +class EmbeddedRevisionable +{ + private ?string $subtitle = null; +} diff --git a/tests/Gedmo/Mapping/Fixture/Yaml/Revisionable.php b/tests/Gedmo/Mapping/Fixture/Yaml/Revisionable.php new file mode 100644 index 0000000000..dac8d33e6f --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Yaml/Revisionable.php @@ -0,0 +1,34 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Yaml; + +class Revisionable +{ + private ?int $id = null; + + private ?string $title = null; + + public function getId(): int + { + return $this->id; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Yaml/RevisionableComposite.php b/tests/Gedmo/Mapping/Fixture/Yaml/RevisionableComposite.php new file mode 100644 index 0000000000..6cce08dacf --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Yaml/RevisionableComposite.php @@ -0,0 +1,39 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Yaml; + +class RevisionableComposite +{ + private ?int $one = null; + + private ?int $two = null; + + private ?string $title = null; + + public function getOne(): int + { + return $this->one; + } + + public function getTwo(): int + { + return $this->two; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Yaml/RevisionableCompositeRelation.php b/tests/Gedmo/Mapping/Fixture/Yaml/RevisionableCompositeRelation.php new file mode 100644 index 0000000000..fb45fca48e --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Yaml/RevisionableCompositeRelation.php @@ -0,0 +1,39 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Yaml; + +class RevisionableCompositeRelation +{ + private ?Revisionable $one = null; + + private ?int $two = null; + + private ?string $title = null; + + public function getOne(): ?Revisionable + { + return $this->one; + } + + public function getTwo(): ?int + { + return $this->two; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Mapping/Fixture/Yaml/RevisionableWithEmbedded.php b/tests/Gedmo/Mapping/Fixture/Yaml/RevisionableWithEmbedded.php new file mode 100644 index 0000000000..026ebe1e03 --- /dev/null +++ b/tests/Gedmo/Mapping/Fixture/Yaml/RevisionableWithEmbedded.php @@ -0,0 +1,21 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping\Fixture\Yaml; + +class RevisionableWithEmbedded +{ + private ?int $id = null; + + private ?string $title = null; + + private ?Embedded $embedded = null; +} diff --git a/tests/Gedmo/Mapping/Mock/EventSubscriberCustomMock.php b/tests/Gedmo/Mapping/Mock/EventSubscriberCustomMock.php index e8448a671e..18052b232f 100644 --- a/tests/Gedmo/Mapping/Mock/EventSubscriberCustomMock.php +++ b/tests/Gedmo/Mapping/Mock/EventSubscriberCustomMock.php @@ -15,9 +15,7 @@ use Gedmo\Mapping\Event\AdapterInterface; use Gedmo\Mapping\MappedEventSubscriber; -/** - * @phpstan-extends MappedEventSubscriber - */ +/** @phpstan-extends MappedEventSubscriber */ final class EventSubscriberCustomMock extends MappedEventSubscriber { public function getAdapter(EventArgs $args): AdapterInterface diff --git a/tests/Gedmo/Mapping/Mock/EventSubscriberMock.php b/tests/Gedmo/Mapping/Mock/EventSubscriberMock.php index 94b88ce524..42e160ac02 100644 --- a/tests/Gedmo/Mapping/Mock/EventSubscriberMock.php +++ b/tests/Gedmo/Mapping/Mock/EventSubscriberMock.php @@ -15,9 +15,7 @@ use Gedmo\Mapping\Event\AdapterInterface; use Gedmo\Mapping\MappedEventSubscriber; -/** - * @phpstan-extends MappedEventSubscriber - */ +/** @phpstan-extends MappedEventSubscriber */ final class EventSubscriberMock extends MappedEventSubscriber { public function getAdapter(EventArgs $args): AdapterInterface diff --git a/tests/Gedmo/Mapping/Mock/Extension/Encoder/EncoderListener.php b/tests/Gedmo/Mapping/Mock/Extension/Encoder/EncoderListener.php index 2ad5e3be44..ea2785236e 100644 --- a/tests/Gedmo/Mapping/Mock/Extension/Encoder/EncoderListener.php +++ b/tests/Gedmo/Mapping/Mock/Extension/Encoder/EncoderListener.php @@ -15,13 +15,10 @@ use Doctrine\Persistence\Event\LoadClassMetadataEventArgs; use Doctrine\Persistence\Mapping\ClassMetadata; use Doctrine\Persistence\ObjectManager; -use Gedmo\Mapping\Event\AdapterInterface; use Gedmo\Mapping\Event\AdapterInterface as EventAdapterInterface; use Gedmo\Mapping\MappedEventSubscriber; -/** - * @phpstan-extends MappedEventSubscriber - */ +/** @phpstan-extends MappedEventSubscriber */ class EncoderListener extends MappedEventSubscriber { public function getSubscribedEvents(): array diff --git a/tests/Gedmo/Mapping/MongoDBODMMappingTestCase.php b/tests/Gedmo/Mapping/MongoDBODMMappingTestCase.php new file mode 100644 index 0000000000..29f3875265 --- /dev/null +++ b/tests/Gedmo/Mapping/MongoDBODMMappingTestCase.php @@ -0,0 +1,84 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping; + +use Doctrine\Common\Annotations\AnnotationReader; +use Doctrine\Common\EventManager; +use Doctrine\ODM\MongoDB\Configuration; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Mapping\Driver\AnnotationDriver; +use Doctrine\ODM\MongoDB\Mapping\Driver\AttributeDriver; +use Doctrine\Persistence\Mapping\Driver\MappingDriverChain; +use MongoDB\Client; +use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; + +abstract class MongoDBODMMappingTestCase extends TestCase +{ + protected CacheItemPoolInterface $cache; + + protected DocumentManager $dm; + + protected function setUp(): void + { + $this->cache = new ArrayAdapter(); + $this->dm = $this->getBasicDocumentManager(); + } + + protected function tearDown(): void + { + foreach ($this->dm->getDocumentDatabases() as $documentDatabase) { + $documentDatabase->drop(); + } + } + + final protected function getBasicConfiguration(): Configuration + { + $config = new Configuration(); + $config->setProxyDir(TESTS_TEMP_DIR); + $config->setHydratorDir(TESTS_TEMP_DIR); + $config->setProxyNamespace('Proxy'); + $config->setHydratorNamespace('Hydrator'); + $config->setDefaultDB('gedmo_extensions_test'); + $config->setAutoGenerateProxyClasses(Configuration::AUTOGENERATE_EVAL); + $config->setAutoGenerateHydratorClasses(Configuration::AUTOGENERATE_EVAL); + $config->setMetadataCache(new ArrayAdapter()); + + return $config; + } + + final protected function getBasicDocumentManager(?Configuration $config = null, ?Client $client = null, ?EventManager $evm = null): DocumentManager + { + if (null === $config) { + $config = $this->getBasicConfiguration(); + $config->setMetadataDriverImpl($this->createChainedMappingDriver()); + } + + $client = new Client($_ENV['MONGODB_SERVER'], [], ['typeMap' => DocumentManager::CLIENT_TYPEMAP]); + + return DocumentManager::create($client, $config, $evm); + } + + final protected function createChainedMappingDriver(): MappingDriverChain + { + $chain = new MappingDriverChain(); + + if (PHP_VERSION_ID >= 80000 && class_exists(AttributeDriver::class)) { + $chain->addDriver(new AttributeDriver([]), 'Gedmo\Tests\Mapping\Fixture'); + } elseif (class_exists(AnnotationDriver::class) && class_exists(AnnotationReader::class)) { + $chain->addDriver(new AnnotationDriver(new AnnotationReader()), 'Gedmo\Tests\Mapping\Fixture'); + } + + return $chain; + } +} diff --git a/tests/Gedmo/Mapping/RevisionableMongoDBODMMappingTest.php b/tests/Gedmo/Mapping/RevisionableMongoDBODMMappingTest.php new file mode 100644 index 0000000000..d5dff68775 --- /dev/null +++ b/tests/Gedmo/Mapping/RevisionableMongoDBODMMappingTest.php @@ -0,0 +1,127 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping; + +use Doctrine\ODM\MongoDB\Mapping\Driver\AnnotationDriver; +use Doctrine\ODM\MongoDB\Mapping\Driver\AttributeDriver; +use Gedmo\Mapping\ExtensionMetadataFactory; +use Gedmo\Revisionable\Document\Revision; +use Gedmo\Revisionable\RevisionableListener; +use Gedmo\Tests\Mapping\Fixture\Document\EmbeddedRevisionable as AnnotatedEmbeddedRevisionable; +use Gedmo\Tests\Mapping\Fixture\Document\Revisionable as AnnotatedRevisionable; +use Gedmo\Tests\Mapping\Fixture\Document\RevisionableWithEmbedded as AnnotatedRevisionableWithEmbedded; + +/** + * These are mapping tests for the revisionable extension + * + * @author Gediminas Morkevicius + * + * @requires extension mongodb + */ +final class RevisionableMongoDBODMMappingTest extends MongoDBODMMappingTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + $listener = new RevisionableListener(); + $listener->setCacheItemPool($this->cache); + + $this->dm->getEventManager()->addEventSubscriber($listener); + } + + /** + * @return \Generator + */ + public static function dataRevisionableObject(): \Generator + { + if (PHP_VERSION_ID >= 80000 && class_exists(AttributeDriver::class)) { + yield 'Model with attributes' => [AnnotatedRevisionable::class]; + } elseif (class_exists(AnnotationDriver::class)) { + yield 'Model with annotations' => [AnnotatedRevisionable::class]; + } + } + + /** + * @param class-string $className + * + * @dataProvider dataRevisionableObject + */ + public function testRevisionableMapping(string $className): void + { + // Force metadata class loading. + $this->dm->getClassMetadata($className); + $cacheId = ExtensionMetadataFactory::getCacheId($className, 'Gedmo\Revisionable'); + $config = $this->cache->getItem($cacheId)->get(); + + static::assertArrayNotHasKey('revisionableClass', $config); + static::assertArrayHasKey('revisionable', $config); + static::assertTrue($config['revisionable']); + + static::assertArrayHasKey('versioned', $config); + static::assertCount(1, $config['versioned']); + static::assertContains('title', $config['versioned']); + } + + /** + * @return \Generator + */ + public static function dataRevisionableObjectWithEmbedded(): \Generator + { + if (PHP_VERSION_ID >= 80000 && class_exists(AttributeDriver::class)) { + yield 'Model with attributes' => [AnnotatedRevisionableWithEmbedded::class, AnnotatedEmbeddedRevisionable::class]; + } elseif (class_exists(AnnotationDriver::class)) { + yield 'Model with annotations' => [AnnotatedRevisionableWithEmbedded::class, AnnotatedEmbeddedRevisionable::class]; + } + } + + /** + * @param class-string $className + * @param class-string $embeddedClassName + * + * @dataProvider dataRevisionableObjectWithEmbedded + */ + public function testRevisionableWithEmbedded(string $className, string $embeddedClassName): void + { + // Force metadata class loading. + $this->dm->getClassMetadata($className); + $this->dm->getClassMetadata($embeddedClassName); + + /* + * Inspect the base class + */ + + $cacheId = ExtensionMetadataFactory::getCacheId($className, 'Gedmo\Revisionable'); + $config = $this->cache->getItem($cacheId)->get(); + + static::assertArrayHasKey('revisionable', $config); + static::assertTrue($config['revisionable']); + static::assertArrayHasKey('revisionClass', $config); + static::assertSame(Revision::class, $config['revisionClass']); + + static::assertArrayHasKey('versioned', $config); + static::assertCount(2, $config['versioned']); + static::assertContains('title', $config['versioned']); + static::assertContains('embedded', $config['versioned']); + + /* + * Inspect the embedded class + */ + + $cacheId = ExtensionMetadataFactory::getCacheId($embeddedClassName, 'Gedmo\Revisionable'); + $config = $this->cache->getItem($cacheId)->get(); + + static::assertArrayHasKey('versioned', $config); + static::assertCount(1, $config['versioned']); + static::assertContains('subtitle', $config['versioned']); + } +} diff --git a/tests/Gedmo/Mapping/RevisionableORMMappingTest.php b/tests/Gedmo/Mapping/RevisionableORMMappingTest.php new file mode 100644 index 0000000000..4fb6bc59a0 --- /dev/null +++ b/tests/Gedmo/Mapping/RevisionableORMMappingTest.php @@ -0,0 +1,217 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Mapping; + +use Doctrine\ORM\EntityManager; +use Doctrine\ORM\Mapping\Driver\AnnotationDriver; +use Doctrine\ORM\Mapping\Driver\YamlDriver; +use Gedmo\Mapping\ExtensionMetadataFactory; +use Gedmo\Revisionable\Entity\Revision; +use Gedmo\Revisionable\RevisionableListener; +use Gedmo\Tests\Mapping\Fixture\Entity\Revisionable as AnnotatedRevisionable; +use Gedmo\Tests\Mapping\Fixture\Entity\RevisionableComposite as AnnotatedRevisionableComposite; +use Gedmo\Tests\Mapping\Fixture\Entity\RevisionableCompositeRelation as AnnotatedRevisionableCompositeRelation; +use Gedmo\Tests\Mapping\Fixture\Entity\RevisionableWithEmbedded as AnnotatedRevisionableWithEmbedded; +use Gedmo\Tests\Mapping\Fixture\Xml\Revisionable as XmlRevisionable; +use Gedmo\Tests\Mapping\Fixture\Xml\RevisionableComposite as XmlRevisionableComposite; +use Gedmo\Tests\Mapping\Fixture\Xml\RevisionableCompositeRelation as XmlRevisionableCompositeRelation; +use Gedmo\Tests\Mapping\Fixture\Xml\RevisionableWithEmbedded as XmlRevisionableWithEmbedded; +use Gedmo\Tests\Mapping\Fixture\Yaml\Revisionable as YamlRevisionable; +use Gedmo\Tests\Mapping\Fixture\Yaml\RevisionableComposite as YamlRevisionableComposite; +use Gedmo\Tests\Mapping\Fixture\Yaml\RevisionableCompositeRelation as YamlRevisionableCompositeRelation; +use Gedmo\Tests\Mapping\Fixture\Yaml\RevisionableWithEmbedded as YamlRevisionableWithEmbedded; + +/** + * These are mapping tests for the revisionable extension + * + * @author Gediminas Morkevicius + */ +final class RevisionableORMMappingTest extends ORMMappingTestCase +{ + private EntityManager $em; + + protected function setUp(): void + { + parent::setUp(); + + $listener = new RevisionableListener(); + $listener->setCacheItemPool($this->cache); + + $this->em = $this->getBasicEntityManager(); + $this->em->getEventManager()->addEventSubscriber($listener); + } + + /** + * @return \Generator + */ + public static function dataRevisionableObject(): \Generator + { + yield 'Model with XML mapping' => [XmlRevisionable::class]; + + if (PHP_VERSION_ID >= 80000) { + yield 'Model with attributes' => [AnnotatedRevisionable::class]; + } elseif (class_exists(AnnotationDriver::class)) { + yield 'Model with annotations' => [AnnotatedRevisionable::class]; + } + + if (class_exists(YamlDriver::class)) { + yield 'Model with YAML mapping' => [YamlRevisionable::class]; + } + } + + /** + * @param class-string $className + * + * @dataProvider dataRevisionableObject + */ + public function testRevisionableMapping(string $className): void + { + // Force metadata class loading. + $this->em->getClassMetadata($className); + $cacheId = ExtensionMetadataFactory::getCacheId($className, 'Gedmo\Revisionable'); + $config = $this->cache->getItem($cacheId)->get(); + + static::assertArrayNotHasKey('revisionableClass', $config); + static::assertArrayHasKey('revisionable', $config); + static::assertTrue($config['revisionable']); + + static::assertArrayHasKey('versioned', $config); + static::assertCount(1, $config['versioned']); + static::assertContains('title', $config['versioned']); + } + + /** + * @return \Generator + */ + public static function dataRevisionableObjectWithCompositeKey(): \Generator + { + yield 'Model with XML mapping' => [XmlRevisionableComposite::class]; + + if (PHP_VERSION_ID >= 80000) { + yield 'Model with attributes' => [AnnotatedRevisionableComposite::class]; + } elseif (class_exists(AnnotationDriver::class)) { + yield 'Model with annotations' => [AnnotatedRevisionableComposite::class]; + } + + if (class_exists(YamlDriver::class)) { + yield 'Model with YAML mapping' => [YamlRevisionableComposite::class]; + } + } + + /** + * @param class-string $className + * + * @dataProvider dataRevisionableObjectWithCompositeKey + */ + public function testRevisionableCompositeMapping(string $className): void + { + $meta = $this->em->getClassMetadata($className); + + static::assertIsArray($meta->identifier); + static::assertCount(2, $meta->identifier); + + $cacheId = ExtensionMetadataFactory::getCacheId($className, 'Gedmo\Revisionable'); + $config = $this->cache->getItem($cacheId)->get(); + + static::assertArrayHasKey('revisionable', $config); + static::assertTrue($config['revisionable']); + static::assertArrayHasKey('revisionClass', $config); + static::assertSame(Revision::class, $config['revisionClass']); + + static::assertArrayHasKey('versioned', $config); + static::assertCount(1, $config['versioned']); + static::assertContains('title', $config['versioned']); + } + + /** + * @return \Generator + */ + public static function dataRevisionableObjectWithCompositeKeyAndRelation(): \Generator + { + yield 'Model with XML mapping' => [XmlRevisionableCompositeRelation::class]; + + if (PHP_VERSION_ID >= 80000) { + yield 'Model with attributes' => [AnnotatedRevisionableCompositeRelation::class]; + } elseif (class_exists(AnnotationDriver::class)) { + yield 'Model with annotations' => [AnnotatedRevisionableCompositeRelation::class]; + } + + if (class_exists(YamlDriver::class)) { + yield 'Model with YAML mapping' => [YamlRevisionableCompositeRelation::class]; + } + } + + /** + * @param class-string $className + * + * @dataProvider dataRevisionableObjectWithCompositeKeyAndRelation + */ + public function testRevisionableCompositeRelationMapping(string $className): void + { + $meta = $this->em->getClassMetadata($className); + + static::assertIsArray($meta->identifier); + static::assertCount(2, $meta->identifier); + + $cacheId = ExtensionMetadataFactory::getCacheId($className, 'Gedmo\Revisionable'); + $config = $this->cache->getItem($cacheId)->get(); + + static::assertArrayHasKey('revisionable', $config); + static::assertTrue($config['revisionable']); + static::assertArrayNotHasKey('revisionClass', $config); + + static::assertArrayHasKey('versioned', $config); + static::assertCount(1, $config['versioned']); + static::assertContains('title', $config['versioned']); + } + + /** + * @return \Generator + */ + public static function dataRevisionableObjectWithEmbedded(): \Generator + { + yield 'Model with XML mapping' => [XmlRevisionableWithEmbedded::class]; + + if (PHP_VERSION_ID >= 80000) { + yield 'Model with attributes' => [AnnotatedRevisionableWithEmbedded::class]; + } elseif (class_exists(AnnotationDriver::class)) { + yield 'Model with annotations' => [AnnotatedRevisionableWithEmbedded::class]; + } + + if (class_exists(YamlDriver::class)) { + yield 'Model with YAML mapping' => [YamlRevisionableWithEmbedded::class]; + } + } + + /** + * @param class-string $className + * + * @dataProvider dataRevisionableObjectWithEmbedded + */ + public function testRevisionableWithEmbedded(string $className): void + { + // Force metadata class loading. + $this->em->getClassMetadata($className); + $cacheId = ExtensionMetadataFactory::getCacheId($className, 'Gedmo\Revisionable'); + $config = $this->cache->getItem($cacheId)->get(); + + static::assertArrayHasKey('revisionable', $config); + static::assertTrue($config['revisionable']); + static::assertArrayHasKey('revisionClass', $config); + static::assertSame(Revision::class, $config['revisionClass']); + + static::assertArrayHasKey('versioned', $config); + static::assertCount(2, $config['versioned']); + static::assertContains('title', $config['versioned']); + static::assertContains('embedded.subtitle', $config['versioned']); + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Document/Address.php b/tests/Gedmo/Revisionable/Fixture/Document/Address.php new file mode 100644 index 0000000000..d76f4fd786 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Document/Address.php @@ -0,0 +1,103 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @author Fabian Sabau + * + * @ODM\Document(collection="addresses") + * + * @Gedmo\Revisionable + */ +#[ODM\Document(collection: 'addresses')] +#[Gedmo\Revisionable] +class Address implements Revisionable +{ + /** + * @ODM\Id + */ + #[ODM\Id] + private ?string $id = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $street = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $city = null; + + /** + * @ODM\EmbedOne(targetDocument="Gedmo\Tests\Revisionable\Fixture\Document\Geo") + * + * @Gedmo\Versioned + */ + #[ODM\EmbedOne(targetDocument: Geo::class)] + #[Gedmo\Versioned] + private ?Geo $geo = null; + + public function getId(): ?string + { + return $this->id; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): self + { + $this->street = $street; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): self + { + $this->city = $city; + + return $this; + } + + public function getGeo(): ?Geo + { + return $this->geo; + } + + public function setGeo(?Geo $geo): self + { + $this->geo = $geo; + + return $this; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Document/Article.php b/tests/Gedmo/Revisionable/Fixture/Document/Article.php new file mode 100644 index 0000000000..a8fa080e71 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Document/Article.php @@ -0,0 +1,100 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @ODM\Document(collection="articles") + * + * @Gedmo\Revisionable + */ +#[ODM\Document(collection: 'articles')] +#[Gedmo\Revisionable] +class Article implements Revisionable +{ + /** + * @ODM\Id + */ + #[ODM\Id] + private ?string $id = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $title = null; + + /** + * @ODM\Field(type="date_immutable") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::DATE_IMMUTABLE)] + #[Gedmo\Versioned] + private ?\DateTimeImmutable $publishAt = null; + + /** + * @ODM\EmbedOne(targetDocument="Gedmo\Tests\Revisionable\Fixture\Document\Author") + * + * @Gedmo\Versioned + */ + #[ODM\EmbedOne(targetDocument: Author::class)] + #[Gedmo\Versioned] + private ?Author $author = null; + + public function __toString() + { + return $this->title; + } + + public function getId(): ?string + { + return $this->id; + } + + public function setTitle(?string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setPublishAt(?\DateTimeImmutable $publishAt): void + { + $this->publishAt = $publishAt; + } + + public function getPublishAt(): ?\DateTimeImmutable + { + return $this->publishAt; + } + + public function setAuthor(?Author $author): void + { + $this->author = $author; + } + + public function getAuthor(): ?Author + { + return $this->author; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Document/Author.php b/tests/Gedmo/Revisionable/Fixture/Document/Author.php new file mode 100644 index 0000000000..6739569819 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Document/Author.php @@ -0,0 +1,67 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @ODM\EmbeddedDocument + */ +#[ODM\EmbeddedDocument] +class Author implements Revisionable +{ + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $name = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $email = null; + + public function __toString() + { + return (string) $this->getName(); + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setEmail(?string $email): void + { + $this->email = $email; + } + + public function getEmail(): ?string + { + return $this->email; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Document/Comment.php b/tests/Gedmo/Revisionable/Fixture/Document/Comment.php new file mode 100644 index 0000000000..d4de3dce19 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Document/Comment.php @@ -0,0 +1,133 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @ODM\Document + * + * @Gedmo\Revisionable(revisionClass="Gedmo\Tests\Revisionable\Fixture\Document\CommentRevision") + */ +#[ODM\Document] +#[Gedmo\Revisionable(revisionClass: CommentRevision::class)] +class Comment implements Revisionable +{ + /** + * @ODM\Id + */ + #[ODM\Id] + private ?string $id = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $subject = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $message = null; + + /** + * @ODM\Field(type="date_immutable") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::DATE_IMMUTABLE)] + #[Gedmo\Versioned] + private ?\DateTimeImmutable $writtenAt = null; + + /** + * @ODM\ReferenceOne(targetDocument="Gedmo\Tests\Revisionable\Fixture\Document\RelatedArticle", inversedBy="comments") + * + * @Gedmo\Versioned + */ + #[ODM\ReferenceOne(targetDocument: RelatedArticle::class, inversedBy: 'comments')] + #[Gedmo\Versioned] + private ?RelatedArticle $article = null; + + /** + * @ODM\EmbedOne(targetDocument="Gedmo\Tests\Revisionable\Fixture\Document\Author") + * + * @Gedmo\Versioned + */ + #[ODM\EmbedOne(targetDocument: Author::class)] + #[Gedmo\Versioned] + private ?Author $author = null; + + public function getId(): ?string + { + return $this->id; + } + + public function setSubject(?string $subject): void + { + $this->subject = $subject; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setMessage(?string $message): void + { + $this->message = $message; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setWrittenAt(?\DateTimeImmutable $writtenAt): void + { + $this->writtenAt = $writtenAt; + } + + public function getWrittenAt(): ?\DateTimeImmutable + { + return $this->writtenAt; + } + + public function setArticle(?RelatedArticle $article): void + { + $this->article = $article; + } + + public function getArticle(): ?RelatedArticle + { + return $this->article; + } + + public function setAuthor(?Author $author): void + { + $this->author = $author; + } + + public function getAuthor(): ?Author + { + return $this->author; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Document/CommentRevision.php b/tests/Gedmo/Revisionable/Fixture/Document/CommentRevision.php new file mode 100644 index 0000000000..c39545cb5a --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Document/CommentRevision.php @@ -0,0 +1,48 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Gedmo\Revisionable\Document\MappedSuperclass\AbstractRevision; +use Gedmo\Revisionable\Document\Repository\RevisionRepository; + +/** + * @ODM\Document( + * collection="comment_revisions", + * repositoryClass="Gedmo\Revisionable\Document\Repository\RevisionRepository" + * ) + * + * @phpstan-template T of Comment + * + * @phpstan-extends AbstractRevision + */ +#[ODM\Document(collection: 'comment_revisions', repositoryClass: RevisionRepository::class)] +class CommentRevision extends AbstractRevision +{ + /** + * Named constructor to create a new revision. + * + * Implementations should handle setting the initial logged at time and version for new instances within this constructor. + * + * @phpstan-param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action + * + * @phpstan-return self + */ + public static function createRevision(string $action): self + { + $document = new self(); + $document->setAction($action); + $document->setLoggedAt(new \DateTimeImmutable()); + + return $document; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Document/Geo.php b/tests/Gedmo/Revisionable/Fixture/Document/Geo.php new file mode 100644 index 0000000000..1eebdd67d0 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Document/Geo.php @@ -0,0 +1,91 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @author Fabian Sabau + * + * @ODM\EmbeddedDocument + */ +#[ODM\EmbeddedDocument] +class Geo +{ + /** + * @ODM\Field(type="float") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::FLOAT)] + #[Gedmo\Versioned] + private float $latitude; + + /** + * @ODM\Field(type="float") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::FLOAT)] + #[Gedmo\Versioned] + private float $longitude; + + /** + * @var GeoLocation + * + * @ODM\EmbedOne(targetDocument="Gedmo\Tests\Revisionable\Fixture\Document\GeoLocation") + * + * @Gedmo\Versioned + */ + #[ODM\EmbedOne(targetDocument: GeoLocation::class)] + #[Gedmo\Versioned] + private GeoLocation $geoLocation; + + public function __construct(float $latitude, float $longitude, GeoLocation $geoLocation) + { + $this->latitude = $latitude; + $this->longitude = $longitude; + $this->geoLocation = $geoLocation; + } + + public function getLatitude(): float + { + return $this->latitude; + } + + public function setLatitude(float $latitude): void + { + $this->latitude = $latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } + + public function setLongitude(float $longitude): void + { + $this->longitude = $longitude; + } + + public function getGeoLocation(): GeoLocation + { + return $this->geoLocation; + } + + public function setGeoLocation(GeoLocation $geoLocation): void + { + $this->geoLocation = $geoLocation; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Document/GeoLocation.php b/tests/Gedmo/Revisionable/Fixture/Document/GeoLocation.php new file mode 100644 index 0000000000..d922869383 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Document/GeoLocation.php @@ -0,0 +1,49 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @author Fabian Sabau + * + * @ODM\EmbeddedDocument + */ +#[ODM\EmbeddedDocument] +class GeoLocation +{ + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private string $location; + + public function __construct(string $location) + { + $this->location = $location; + } + + public function getLocation(): string + { + return $this->location; + } + + public function setLocation(string $location): void + { + $this->location = $location; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Document/RelatedArticle.php b/tests/Gedmo/Revisionable/Fixture/Document/RelatedArticle.php new file mode 100644 index 0000000000..879b15df9f --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Document/RelatedArticle.php @@ -0,0 +1,107 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Document; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Doctrine\ODM\MongoDB\Types\Type; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @ODM\Document + * + * @Gedmo\Revisionable + */ +#[ODM\Document] +#[Gedmo\Revisionable] +class RelatedArticle implements Revisionable +{ + /** + * @ODM\Id + */ + #[ODM\Id] + private ?string $id = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $title = null; + + /** + * @ODM\Field(type="string") + * + * @Gedmo\Versioned + */ + #[ODM\Field(type: Type::STRING)] + #[Gedmo\Versioned] + private ?string $content = null; + + /** + * @var Collection + * + * @ODM\ReferenceMany(targetDocument="Gedmo\Tests\Revisionable\Fixture\Document\Comment", mappedBy="article") + */ + #[ODM\ReferenceMany(targetDocument: Comment::class, mappedBy: 'article')] + private Collection $comments; + + public function __construct() + { + $this->comments = new ArrayCollection(); + } + + public function getId(): ?string + { + return $this->id; + } + + public function setTitle(?string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setContent(?string $content): void + { + $this->content = $content; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function addComment(Comment $comment): void + { + if (!$this->comments->contains($comment)) { + $this->comments[] = $comment; + $comment->setArticle($this); + } + } + + /** + * @return Collection + */ + public function getComments(): Collection + { + return $this->comments; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/Address.php b/tests/Gedmo/Revisionable/Fixture/Entity/Address.php new file mode 100644 index 0000000000..26dd1845cc --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/Address.php @@ -0,0 +1,107 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @author Fabian Sabau + * + * @ORM\Entity + * + * @Gedmo\Revisionable + */ +#[ORM\Entity] +#[Gedmo\Revisionable] +class Address implements Revisionable +{ + /** + * @ORM\Id + * @ORM\Column(name="id", type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + */ + #[ORM\Id] + #[ORM\Column(name: 'id', type: Types::INTEGER)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + /** + * @ORM\Column(type="string", length=191) + * + * @Gedmo\Versioned + */ + #[ORM\Column(type: Types::STRING, length: 191)] + #[Gedmo\Versioned] + private ?string $street = null; + + /** + * @ORM\Column(type="string", length=191) + * + * @Gedmo\Versioned + */ + #[ORM\Column(type: Types::STRING, length: 191)] + #[Gedmo\Versioned] + private ?string $city = null; + + /** + * @ORM\Embedded(class="Gedmo\Tests\Revisionable\Fixture\Entity\Geo") + * + * @Gedmo\Versioned + */ + #[ORM\Embedded(class: Geo::class)] + #[Gedmo\Versioned] + private ?Geo $geo = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getStreet(): ?string + { + return $this->street; + } + + public function setStreet(?string $street): self + { + $this->street = $street; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(?string $city): self + { + $this->city = $city; + + return $this; + } + + public function getGeo(): ?Geo + { + return $this->geo; + } + + public function setGeo(?Geo $geo): self + { + $this->geo = $geo; + + return $this; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/Article.php b/tests/Gedmo/Revisionable/Fixture/Entity/Article.php new file mode 100644 index 0000000000..b29def171c --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/Article.php @@ -0,0 +1,104 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @ORM\Entity + * + * @Gedmo\Revisionable + */ +#[ORM\Entity] +#[Gedmo\Revisionable] +class Article implements Revisionable +{ + /** + * @ORM\Column(name="id", type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="IDENTITY") + */ + #[ORM\Id] + #[ORM\Column(name: 'id', type: Types::INTEGER)] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private ?int $id = null; + + /** + * @ORM\Column(name="title", type="string", length=8) + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'title', type: Types::STRING, length: 8)] + #[Gedmo\Versioned] + private ?string $title = null; + + /** + * @ORM\Column(name="publish_at", type="datetime_immutable") + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'publish_at', type: Types::DATETIME_IMMUTABLE)] + #[Gedmo\Versioned] + private ?\DateTimeImmutable $publishAt = null; + + /** + * @ORM\Embedded(class="Gedmo\Tests\Revisionable\Fixture\Entity\Author") + * + * @Gedmo\Versioned + */ + #[ORM\Embedded(class: Author::class)] + #[Gedmo\Versioned] + private ?Author $author = null; + + public function __toString() + { + return $this->title; + } + + public function getId(): ?int + { + return $this->id; + } + + public function setTitle(?string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setPublishAt(?\DateTimeImmutable $publishAt): void + { + $this->publishAt = $publishAt; + } + + public function getPublishAt(): ?\DateTimeImmutable + { + return $this->publishAt; + } + + public function setAuthor(?Author $author): void + { + $this->author = $author; + } + + public function getAuthor(): ?Author + { + return $this->author; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/Author.php b/tests/Gedmo/Revisionable/Fixture/Entity/Author.php new file mode 100644 index 0000000000..6aa2c971c8 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/Author.php @@ -0,0 +1,67 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @ORM\Embeddable + */ +#[ORM\Embeddable] +class Author implements Revisionable +{ + /** + * @ORM\Column(name="name", type="string") + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'name', type: Types::STRING)] + #[Gedmo\Versioned] + private ?string $name = null; + + /** + * @ORM\Column(name="email", type="string") + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'email', type: Types::STRING)] + #[Gedmo\Versioned] + private ?string $email = null; + + public function __toString() + { + return (string) $this->getName(); + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setEmail(?string $email): void + { + $this->email = $email; + } + + public function getEmail(): ?string + { + return $this->email; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/Comment.php b/tests/Gedmo/Revisionable/Fixture/Entity/Comment.php new file mode 100644 index 0000000000..6aede9c89b --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/Comment.php @@ -0,0 +1,137 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @ORM\Entity + * + * @Gedmo\Revisionable(revisionClass="Gedmo\Tests\Revisionable\Fixture\Entity\CommentRevision") + */ +#[ORM\Entity] +#[Gedmo\Revisionable(revisionClass: CommentRevision::class)] +class Comment implements Revisionable +{ + /** + * @ORM\Column(name="id", type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="IDENTITY") + */ + #[ORM\Id] + #[ORM\Column(name: 'id', type: Types::INTEGER)] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private ?int $id = null; + + /** + * @ORM\Column(name="subject", type="string", length=128) + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'subject', type: Types::STRING, length: 128)] + #[Gedmo\Versioned] + private ?string $subject = null; + + /** + * @ORM\Column(name="message", type="text") + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'message', type: Types::TEXT)] + #[Gedmo\Versioned] + private ?string $message = null; + + /** + * @ORM\Column(name="written_at", type="datetime_immutable") + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'written_at', type: Types::DATETIME_IMMUTABLE)] + #[Gedmo\Versioned] + private ?\DateTimeImmutable $writtenAt = null; + + /** + * @ORM\ManyToOne(targetEntity="Gedmo\Tests\Revisionable\Fixture\Entity\RelatedArticle", inversedBy="comments") + * + * @Gedmo\Versioned + */ + #[ORM\ManyToOne(targetEntity: RelatedArticle::class, inversedBy: 'comments')] + #[Gedmo\Versioned] + private ?RelatedArticle $article = null; + + /** + * @ORM\Embedded(class="Gedmo\Tests\Revisionable\Fixture\Entity\Author") + * + * @Gedmo\Versioned + */ + #[ORM\Embedded(class: Author::class)] + #[Gedmo\Versioned] + private ?Author $author = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setSubject(?string $subject): void + { + $this->subject = $subject; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setMessage(?string $message): void + { + $this->message = $message; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setWrittenAt(?\DateTimeImmutable $writtenAt): void + { + $this->writtenAt = $writtenAt; + } + + public function getWrittenAt(): ?\DateTimeImmutable + { + return $this->writtenAt; + } + + public function setArticle(?RelatedArticle $article): void + { + $this->article = $article; + } + + public function getArticle(): ?RelatedArticle + { + return $this->article; + } + + public function setAuthor(?Author $author): void + { + $this->author = $author; + } + + public function getAuthor(): ?Author + { + return $this->author; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/CommentRevision.php b/tests/Gedmo/Revisionable/Fixture/Entity/CommentRevision.php new file mode 100644 index 0000000000..8270c7e468 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/CommentRevision.php @@ -0,0 +1,45 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Revisionable\Entity\MappedSuperclass\AbstractRevision; +use Gedmo\Revisionable\Entity\Repository\RevisionRepository; + +/** + * @ORM\Entity(repositoryClass="Gedmo\Revisionable\Entity\Repository\RevisionRepository") + * + * @phpstan-template T of Comment + * + * @phpstan-extends AbstractRevision + */ +#[ORM\Entity(repositoryClass: RevisionRepository::class)] +class CommentRevision extends AbstractRevision +{ + /** + * Named constructor to create a new revision. + * + * Implementations should handle setting the initial logged at time and version for new instances within this constructor. + * + * @phpstan-param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action + * + * @phpstan-return self + */ + public static function createRevision(string $action): self + { + $entity = new self(); + $entity->setAction($action); + $entity->setLoggedAt(new \DateTimeImmutable()); + + return $entity; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/Composite.php b/tests/Gedmo/Revisionable/Fixture/Entity/Composite.php new file mode 100644 index 0000000000..f102c52622 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/Composite.php @@ -0,0 +1,75 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @ORM\Entity + * + * @Gedmo\Revisionable + */ +#[ORM\Entity] +#[Gedmo\Revisionable] +class Composite +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + */ + #[ORM\Id] + #[ORM\Column(name: 'one', type: Types::INTEGER)] + private int $one; + + /** + * @ORM\Id + * @ORM\Column(type="integer") + */ + #[ORM\Id] + #[ORM\Column(name: 'two', type: Types::INTEGER)] + private int $two; + + /** + * @ORM\Column(length=8) + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'title', type: Types::STRING, length: 8)] + #[Gedmo\Versioned] + private ?string $title = null; + + public function __construct(int $one, int $two) + { + $this->one = $one; + $this->two = $two; + } + + public function getOne(): int + { + return $this->one; + } + + public function getTwo(): int + { + return $this->two; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/CompositeRelation.php b/tests/Gedmo/Revisionable/Fixture/Entity/CompositeRelation.php new file mode 100644 index 0000000000..258120460b --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/CompositeRelation.php @@ -0,0 +1,75 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @ORM\Entity + * + * @Gedmo\Revisionable + */ +#[ORM\Entity] +#[Gedmo\Revisionable] +class CompositeRelation +{ + /** + * @ORM\Id + * @ORM\ManyToOne(targetEntity="Article") + */ + #[ORM\Id] + #[ORM\ManyToOne(targetEntity: Article::class)] + private Article $articleOne; + + /** + * @ORM\Id + * @ORM\ManyToOne(targetEntity="Article") + */ + #[ORM\Id] + #[ORM\ManyToOne(targetEntity: Article::class)] + private Article $articleTwo; + + /** + * @ORM\Column(length=8) + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'title', type: Types::STRING, length: 8)] + #[Gedmo\Versioned] + private ?string $title = null; + + public function __construct(Article $articleOne, Article $articleTwo) + { + $this->articleOne = $articleOne; + $this->articleTwo = $articleTwo; + } + + public function getArticleOne(): Article + { + return $this->articleOne; + } + + public function getArticleTwo(): Article + { + return $this->articleTwo; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } + + public function getTitle(): string + { + return $this->title; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/Geo.php b/tests/Gedmo/Revisionable/Fixture/Entity/Geo.php new file mode 100644 index 0000000000..7ee3992ad2 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/Geo.php @@ -0,0 +1,103 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @author Fabian Sabau + * + * @ORM\Embeddable + */ +#[ORM\Embeddable] +class Geo +{ + /** + * @phpstan-var numeric-string + * + * @ORM\Column(type="decimal", precision=9, scale=6) + * + * @Gedmo\Versioned + */ + #[ORM\Column(type: Types::DECIMAL, precision: 9, scale: 6)] + #[Gedmo\Versioned] + private string $latitude; + + /** + * @phpstan-var numeric-string + * + * @ORM\Column(type="decimal", precision=9, scale=6) + * + * @Gedmo\Versioned + */ + #[ORM\Column(type: Types::DECIMAL, precision: 9, scale: 6)] + #[Gedmo\Versioned] + private string $longitude; + + /** + * @var GeoLocation + * + * @ORM\Embedded(class="Gedmo\Tests\Revisionable\Fixture\Entity\GeoLocation") + * + * @Gedmo\Versioned + */ + #[ORM\Embedded(class: GeoLocation::class)] + #[Gedmo\Versioned] + private GeoLocation $geoLocation; + + public function __construct(float $latitude, float $longitude, GeoLocation $geoLocation) + { + $this->latitude = $this->parseFloatToString($latitude); + $this->longitude = $this->parseFloatToString($longitude); + $this->geoLocation = $geoLocation; + } + + public function getLatitude(): float + { + return (float) $this->latitude; + } + + public function setLatitude(float $latitude): void + { + $this->latitude = $this->parseFloatToString($latitude); + } + + public function getLongitude(): float + { + return (float) $this->longitude; + } + + public function setLongitude(float $longitude): void + { + $this->longitude = $this->parseFloatToString($longitude); + } + + public function getGeoLocation(): GeoLocation + { + return $this->geoLocation; + } + + public function setGeoLocation(GeoLocation $geoLocation): void + { + $this->geoLocation = $geoLocation; + } + + /** + * @phpstan-return numeric-string + */ + private function parseFloatToString(float $number): string + { + return sprintf('%.6f', $number); + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/GeoLocation.php b/tests/Gedmo/Revisionable/Fixture/Entity/GeoLocation.php new file mode 100644 index 0000000000..cb343d1d76 --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/GeoLocation.php @@ -0,0 +1,49 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; + +/** + * @author Fabian Sabau + * + * @ORM\Embeddable + */ +#[ORM\Embeddable] +class GeoLocation +{ + /** + * @ORM\Column(type="string") + * + * @Gedmo\Versioned + */ + #[ORM\Column(type: Types::STRING)] + #[Gedmo\Versioned] + private string $location; + + public function __construct(string $location) + { + $this->location = $location; + } + + public function getLocation(): string + { + return $this->location; + } + + public function setLocation(string $location): void + { + $this->location = $location; + } +} diff --git a/tests/Gedmo/Revisionable/Fixture/Entity/RelatedArticle.php b/tests/Gedmo/Revisionable/Fixture/Entity/RelatedArticle.php new file mode 100644 index 0000000000..07e89bd25e --- /dev/null +++ b/tests/Gedmo/Revisionable/Fixture/Entity/RelatedArticle.php @@ -0,0 +1,111 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable\Fixture\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Gedmo\Mapping\Annotation as Gedmo; +use Gedmo\Revisionable\Revisionable; + +/** + * @ORM\Entity + * + * @Gedmo\Revisionable + */ +#[ORM\Entity] +#[Gedmo\Revisionable] +class RelatedArticle implements Revisionable +{ + /** + * @ORM\Column(name="id", type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="IDENTITY") + */ + #[ORM\Id] + #[ORM\Column(name: 'id', type: Types::INTEGER)] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private ?int $id = null; + + /** + * @ORM\Column(name="title", type="string", length=8) + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'title', type: Types::STRING, length: 8)] + #[Gedmo\Versioned] + private ?string $title = null; + + /** + * @ORM\Column(name="content", type="text") + * + * @Gedmo\Versioned + */ + #[ORM\Column(name: 'content', type: Types::TEXT)] + #[Gedmo\Versioned] + private ?string $content = null; + + /** + * @var Collection + * + * @ORM\OneToMany(targetEntity="Gedmo\Tests\Revisionable\Fixture\Entity\Comment", mappedBy="article") + */ + #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article')] + private Collection $comments; + + public function __construct() + { + $this->comments = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setTitle(?string $title): void + { + $this->title = $title; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setContent(?string $content): void + { + $this->content = $content; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function addComment(Comment $comment): void + { + if (!$this->comments->contains($comment)) { + $this->comments[] = $comment; + $comment->setArticle($this); + } + } + + /** + * @return Collection + */ + public function getComments(): Collection + { + return $this->comments; + } +} diff --git a/tests/Gedmo/Revisionable/RevisionableDocumentTest.php b/tests/Gedmo/Revisionable/RevisionableDocumentTest.php new file mode 100644 index 0000000000..c10c87e1f8 --- /dev/null +++ b/tests/Gedmo/Revisionable/RevisionableDocumentTest.php @@ -0,0 +1,247 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable; + +use Doctrine\Common\EventManager; +use Gedmo\Revisionable\Document\Repository\RevisionRepository; +use Gedmo\Revisionable\Document\Revision; +use Gedmo\Revisionable\RevisionableListener; +use Gedmo\Revisionable\RevisionInterface; +use Gedmo\Tests\Revisionable\Fixture\Document\Address; +use Gedmo\Tests\Revisionable\Fixture\Document\Article; +use Gedmo\Tests\Revisionable\Fixture\Document\Author; +use Gedmo\Tests\Revisionable\Fixture\Document\Comment; +use Gedmo\Tests\Revisionable\Fixture\Document\CommentRevision; +use Gedmo\Tests\Revisionable\Fixture\Document\Geo; +use Gedmo\Tests\Revisionable\Fixture\Document\GeoLocation; +use Gedmo\Tests\Revisionable\Fixture\Document\RelatedArticle; +use Gedmo\Tests\Tool\BaseTestCaseMongoODM; +use MongoDB\BSON\UTCDateTime; + +/** + * Functional tests for the revsionable extension with the Doctrine MongoDB ODM + * + * @author Boussekeyt Jules + * @author Gediminas Morkevicius + */ +final class RevisionableDocumentTest extends BaseTestCaseMongoODM +{ + protected function setUp(): void + { + parent::setUp(); + + $evm = new EventManager(); + + $listener = new RevisionableListener(); + $listener->setUsername('jules'); + + $evm->addEventSubscriber($listener); + + $this->getDefaultDocumentManager($evm); + } + + public function testRevisionableLifecycle(): void + { + $revisionRepository = $this->dm->getRepository(Revision::class); + + static::assertCount(0, $revisionRepository->findAll()); + + $articleRepository = $this->dm->getRepository(Article::class); + + $art0 = new Article(); + $art0->setTitle('Title'); + $art0->setPublishAt(new \DateTimeImmutable('2024-06-24 23:00:00', new \DateTimeZone('UTC'))); + + $author = new Author(); + $author->setName('John Doe'); + $author->setEmail('john@doe.com'); + + $art0->setAuthor($author); + + $this->dm->persist($art0); + $this->dm->flush(); + + $articleId = $art0->getId(); + + $revision = $revisionRepository->findOneBy(['revisionableId' => $articleId]); + + static::assertNotNull($revision); + static::assertSame(RevisionInterface::ACTION_CREATE, $revision->getAction()); + static::assertSame(get_class($art0), $revision->getRevisionableClass()); + static::assertSame('jules', $revision->getUsername()); + static::assertSame(1, $revision->getVersion()); + + $data = $revision->getData(); + + static::assertCount(3, $data); + static::assertArrayHasKey('title', $data); + static::assertSame('Title', $data['title']); + static::assertArrayHasKey('publishAt', $data); + static::assertInstanceOf(UTCDateTime::class, $data['publishAt']); + static::assertArrayHasKey('author', $data); + static::assertSame(['name' => 'John Doe', 'email' => 'john@doe.com'], $data['author']); + + // test update + $article = $articleRepository->findOneBy(['title' => 'Title']); + $article->setTitle('New'); + $this->dm->persist($article); + $this->dm->flush(); + $this->dm->clear(); + + $revision = $revisionRepository->findOneBy(['version' => 2, 'revisionableId' => $articleId]); + + static::assertSame(RevisionInterface::ACTION_UPDATE, $revision->getAction()); + + // test delete + $article = $articleRepository->findOneBy(['title' => 'New']); + $this->dm->remove($article); + $this->dm->flush(); + $this->dm->clear(); + + $revision = $revisionRepository->findOneBy(['version' => 3, 'revisionableId' => $articleId]); + + static::assertSame(RevisionInterface::ACTION_REMOVE, $revision->getAction()); + static::assertEmpty($revision->getData()); + } + + public function testVersionLifecycle(): void + { + $this->populate(); + + $commentRevisionRepository = $this->dm->getRepository(CommentRevision::class); + + $commentRepository = $this->dm->getRepository(Comment::class); + + static::assertInstanceOf(RevisionRepository::class, $commentRevisionRepository); + + $comment = $commentRepository->findOneBy(['message' => 'm-v5']); + + static::assertSame('m-v5', $comment->getMessage()); + static::assertSame('s-v3', $comment->getSubject()); + static::assertSame('2024-06-24 23:30:00', $comment->getWrittenAt()->format('Y-m-d H:i:s')); + static::assertSame('a2-t-v1', $comment->getArticle()->getTitle()); + static::assertSame('Jane Doe', $comment->getAuthor()->getName()); + static::assertSame('jane@doe.com', $comment->getAuthor()->getEmail()); + + // test revert + $commentRevisionRepository->revert($comment, 3); + + static::assertSame('s-v3', $comment->getSubject()); + static::assertSame('m-v2', $comment->getMessage()); + static::assertSame('2024-06-24 23:30:00', $comment->getWrittenAt()->format('Y-m-d H:i:s')); + static::assertSame('a1-t-v1', $comment->getArticle()->getTitle()); + static::assertSame('John Doe', $comment->getAuthor()->getName()); + static::assertSame('john@doe.com', $comment->getAuthor()->getEmail()); + + $this->dm->persist($comment); + $this->dm->flush(); + + // test fetch revisions + $revisions = $commentRevisionRepository->getRevisions($comment); + + static::assertCount(6, $revisions); + + $latest = array_shift($revisions); + + static::assertSame(RevisionInterface::ACTION_UPDATE, $latest->getAction()); + } + + public function testLogsRevisionsOfEmbeddedDocuments(): void + { + $address = new Address(); + $address->setCity('city-v1'); + $address->setStreet('street-v1'); + $address->setGeo(new Geo(1.0000, 1.0000, new GeoLocation('Online'))); + + $this->dm->persist($address); + $this->dm->flush(); + + $address->setGeo(new Geo(2.0000, 2.0000, new GeoLocation('Offline'))); + + $this->dm->persist($address); + $this->dm->flush(); + + $address->getGeo()->setLatitude(3.0000); + $address->getGeo()->setLongitude(3.0000); + + $this->dm->persist($address); + $this->dm->flush(); + + $address->setStreet('street-v2'); + + $this->dm->persist($address); + $this->dm->flush(); + + $revisionRepository = $this->dm->getRepository(Revision::class); + + $revisions = $revisionRepository->getRevisions($address); + + static::assertCount(4, $revisions); + static::assertCount(1, $revisions[0]->getData()); + static::assertCount(1, $revisions[1]->getData()); + static::assertCount(1, $revisions[2]->getData()); + static::assertCount(3, $revisions[3]->getData()); + } + + private function populate(): void + { + $article = new RelatedArticle(); + $article->setTitle('a1-t-v1'); + $article->setContent('a1-c-v1'); + + $author = new Author(); + $author->setName('John Doe'); + $author->setEmail('john@doe.com'); + + $comment = new Comment(); + $comment->setArticle($article); + $comment->setAuthor($author); + $comment->setMessage('m-v1'); + $comment->setSubject('s-v1'); + $comment->setWrittenAt(new \DateTimeImmutable('2024-06-24 23:30:00', new \DateTimeZone('UTC'))); + + $this->dm->persist($article); + $this->dm->persist($comment); + $this->dm->flush(); + + $comment->setMessage('m-v2'); + + $this->dm->persist($comment); + $this->dm->flush(); + + $comment->setSubject('s-v3'); + + $this->dm->persist($comment); + $this->dm->flush(); + + $article2 = new RelatedArticle(); + $article2->setTitle('a2-t-v1'); + $article2->setContent('a2-c-v1'); + + $author2 = new Author(); + $author2->setName('Jane Doe'); + $author2->setEmail('jane@doe.com'); + + $comment->setAuthor($author2); + $comment->setArticle($article2); + + $this->dm->persist($article2); + $this->dm->persist($comment); + $this->dm->flush(); + + $comment->setMessage('m-v5'); + + $this->dm->persist($comment); + $this->dm->flush(); + $this->dm->clear(); + } +} diff --git a/tests/Gedmo/Revisionable/RevisionableEntityTest.php b/tests/Gedmo/Revisionable/RevisionableEntityTest.php new file mode 100644 index 0000000000..38385313be --- /dev/null +++ b/tests/Gedmo/Revisionable/RevisionableEntityTest.php @@ -0,0 +1,421 @@ + http://www.gediminasm.org + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gedmo\Tests\Revisionable; + +use Doctrine\Common\EventManager; +use Gedmo\Revisionable\Entity\Repository\RevisionRepository; +use Gedmo\Revisionable\Entity\Revision; +use Gedmo\Revisionable\RevisionableListener; +use Gedmo\Revisionable\RevisionInterface; +use Gedmo\Tests\Revisionable\Fixture\Entity\Address; +use Gedmo\Tests\Revisionable\Fixture\Entity\Article; +use Gedmo\Tests\Revisionable\Fixture\Entity\Author; +use Gedmo\Tests\Revisionable\Fixture\Entity\Comment; +use Gedmo\Tests\Revisionable\Fixture\Entity\CommentRevision; +use Gedmo\Tests\Revisionable\Fixture\Entity\Composite; +use Gedmo\Tests\Revisionable\Fixture\Entity\CompositeRelation; +use Gedmo\Tests\Revisionable\Fixture\Entity\Geo; +use Gedmo\Tests\Revisionable\Fixture\Entity\GeoLocation; +use Gedmo\Tests\Revisionable\Fixture\Entity\RelatedArticle; +use Gedmo\Tests\Tool\BaseTestCaseORM; + +/** + * Functional tests for the revsionable extension with the Doctrine ORM + * + * @author Gediminas Morkevicius + */ +final class RevisionableEntityTest extends BaseTestCaseORM +{ + protected function setUp(): void + { + parent::setUp(); + + $evm = new EventManager(); + + $listener = new RevisionableListener(); + $listener->setUsername('jules'); + + $evm->addEventSubscriber($listener); + + $this->em = $this->getDefaultMockSqliteEntityManager($evm); + } + + public function testRevisionableLifecycle(): void + { + $revisionRepository = $this->em->getRepository(Revision::class); + + static::assertCount(0, $revisionRepository->findAll()); + + $articleRepository = $this->em->getRepository(Article::class); + + $art0 = new Article(); + $art0->setTitle('Title'); + $art0->setPublishAt(new \DateTimeImmutable('2024-06-24 23:00:00', new \DateTimeZone('UTC'))); + + $author = new Author(); + $author->setName('John Doe'); + $author->setEmail('john@doe.com'); + + $art0->setAuthor($author); + + $this->em->persist($art0); + $this->em->flush(); + + $articleId = $art0->getId(); + + $revision = $revisionRepository->findOneBy(['revisionableId' => $articleId]); + + static::assertNotNull($revision); + static::assertSame(RevisionInterface::ACTION_CREATE, $revision->getAction()); + static::assertSame(get_class($art0), $revision->getRevisionableClass()); + static::assertSame('jules', $revision->getUsername()); + static::assertSame(1, $revision->getVersion()); + + $data = $revision->getData(); + + static::assertCount(4, $data); + static::assertArrayHasKey('title', $data); + static::assertSame('Title', $data['title']); + static::assertArrayHasKey('publishAt', $data); + static::assertSame('2024-06-24 23:00:00', $data['publishAt']); + static::assertArrayHasKey('author.name', $data); + static::assertSame('John Doe', $data['author.name']); + static::assertArrayHasKey('author.email', $data); + static::assertSame('john@doe.com', $data['author.email']); + + // test update + $article = $articleRepository->findOneBy(['title' => 'Title']); + $article->setTitle('New'); + $this->em->persist($article); + $this->em->flush(); + $this->em->clear(); + + $revision = $revisionRepository->findOneBy(['version' => 2, 'revisionableId' => $articleId]); + + static::assertSame(RevisionInterface::ACTION_UPDATE, $revision->getAction()); + + // test delete + $article = $articleRepository->findOneBy(['title' => 'New']); + $this->em->remove($article); + $this->em->flush(); + $this->em->clear(); + + $revision = $revisionRepository->findOneBy(['version' => 3, 'revisionableId' => $articleId]); + + static::assertSame(RevisionInterface::ACTION_REMOVE, $revision->getAction()); + static::assertEmpty($revision->getData()); + } + + public function testVersionLifecycle(): void + { + $this->populate(); + + $commentRevisionRepository = $this->em->getRepository(CommentRevision::class); + + $commentRepository = $this->em->getRepository(Comment::class); + + static::assertInstanceOf(RevisionRepository::class, $commentRevisionRepository); + + $comment = $commentRepository->findOneBy(['message' => 'm-v5']); + + static::assertNotNull($comment); + + static::assertSame('m-v5', $comment->getMessage()); + static::assertSame('s-v3', $comment->getSubject()); + static::assertSame('2024-06-24 23:30:00', $comment->getWrittenAt()->format('Y-m-d H:i:s')); + static::assertSame('a2-t-v1', $comment->getArticle()->getTitle()); + static::assertSame('Jane Doe', $comment->getAuthor()->getName()); + static::assertSame('jane@doe.com', $comment->getAuthor()->getEmail()); + + // test revert + $commentRevisionRepository->revert($comment, 3); + + static::assertSame('s-v3', $comment->getSubject()); + static::assertSame('m-v2', $comment->getMessage()); + static::assertSame('2024-06-24 23:30:00', $comment->getWrittenAt()->format('Y-m-d H:i:s')); + static::assertSame('a1-t-v1', $comment->getArticle()->getTitle()); + static::assertSame('John Doe', $comment->getAuthor()->getName()); + static::assertSame('john@doe.com', $comment->getAuthor()->getEmail()); + + $this->em->persist($comment); + $this->em->flush(); + + // test fetch revisions + $revisions = $commentRevisionRepository->getRevisions($comment); + + static::assertCount(6, $revisions); + + $latest = array_shift($revisions); + + static::assertSame(RevisionInterface::ACTION_UPDATE, $latest->getAction()); + } + + public function testSupportsClonedEntities(): void + { + $art0 = new Article(); + $art0->setTitle('Title'); + $art0->setPublishAt(new \DateTimeImmutable('2024-06-24 23:00:00', new \DateTimeZone('UTC'))); + + $author = new Author(); + $author->setName('John Doe'); + $author->setEmail('john@doe.com'); + + $art0->setAuthor($author); + + $this->em->persist($art0); + $this->em->flush(); + + $art1 = clone $art0; + $art1->setTitle('Cloned'); + + $this->em->persist($art1); + $this->em->flush(); + + $revisionRepository = $this->em->getRepository(Revision::class); + + $revisions = $revisionRepository->findAll(); + + static::assertCount(2, $revisions); + static::assertSame(RevisionInterface::ACTION_CREATE, $revisions[0]->getAction()); + static::assertSame(RevisionInterface::ACTION_CREATE, $revisions[1]->getAction()); + static::assertNotSame($revisions[0]->getRevisionableId(), $revisions[1]->getRevisionableId()); + } + + public function testLogsRevisionsOfEmbeddedEntities(): void + { + $address = new Address(); + $address->setCity('city-v1'); + $address->setStreet('street-v1'); + $address->setGeo(new Geo(1.0000, 1.0000, new GeoLocation('Online'))); + + $this->em->persist($address); + $this->em->flush(); + + $address->setGeo(new Geo(2.0000, 2.0000, new GeoLocation('Offline'))); + + $this->em->persist($address); + $this->em->flush(); + + $address->getGeo()->setLatitude(3.0000); + $address->getGeo()->setLongitude(3.0000); + + $this->em->persist($address); + $this->em->flush(); + + $address->setStreet('street-v2'); + + $this->em->persist($address); + $this->em->flush(); + + $revisionRepository = $this->em->getRepository(Revision::class); + + $revisions = $revisionRepository->getRevisions($address); + + static::assertCount(4, $revisions); + static::assertCount(1, $revisions[0]->getData()); + static::assertCount(2, $revisions[1]->getData()); + static::assertCount(3, $revisions[2]->getData()); + static::assertCount(5, $revisions[3]->getData()); + } + + public function testLogsRevisionsOfEntitiesWithCompositeIds(): void + { + $compositeIds = [1, 2]; + + $composite = new Composite(...$compositeIds); + $composite->setTitle('Title2'); + + $this->em->persist($composite); + $this->em->flush(); + + $compositeId = sprintf('%s %s', ...$compositeIds); + + $revisionRepository = $this->em->getRepository(Revision::class); + + $revision = $revisionRepository->findOneBy(['revisionableId' => $compositeId]); + + static::assertNotNull($revision); + static::assertSame(RevisionInterface::ACTION_CREATE, $revision->getAction()); + static::assertSame(get_class($composite), $revision->getRevisionableClass()); + static::assertSame('jules', $revision->getUsername()); + static::assertSame(1, $revision->getVersion()); + + $data = $revision->getData(); + + static::assertCount(1, $data); + static::assertArrayHasKey('title', $data); + static::assertSame($data['title'], 'Title2'); + + $compositeRepository = $this->em->getRepository(Composite::class); + + // test update + $composite = $compositeRepository->findOneBy(['title' => 'Title2']); + $composite->setTitle('New'); + + $this->em->persist($composite); + $this->em->flush(); + $this->em->clear(); + + $revision = $revisionRepository->findOneBy(['revisionableId' => $compositeId, 'version' => 2]); + + static::assertNotNull($revision); + static::assertSame(RevisionInterface::ACTION_UPDATE, $revision->getAction()); + + // test delete + $composite = $compositeRepository->findOneBy(['title' => 'New']); + $this->em->remove($composite); + $this->em->flush(); + $this->em->clear(); + + $revision = $revisionRepository->findOneBy(['revisionableId' => $compositeId, 'version' => 3]); + static::assertSame(RevisionInterface::ACTION_REMOVE, $revision->getAction()); + static::assertEmpty($revision->getData()); + } + + public function testLogsRevisionsOfEntitiesWithCompositeIdsBasedOnRelations(): void + { + $author = new Author(); + $author->setName('John Doe'); + $author->setEmail('john@doe.com'); + + $art0 = new Article(); + $art0->setTitle('Title0'); + $art0->setPublishAt(new \DateTimeImmutable('2024-06-24 23:00:00', new \DateTimeZone('UTC'))); + $art0->setAuthor($author); + + $art1 = new Article(); + $art1->setTitle('Title1'); + $art1->setPublishAt(new \DateTimeImmutable('2024-06-24 23:00:00', new \DateTimeZone('UTC'))); + $art1->setAuthor($author); + + $composite = new CompositeRelation($art0, $art1); + $composite->setTitle('Title2'); + + $this->em->persist($art0); + $this->em->persist($art1); + $this->em->persist($composite); + $this->em->flush(); + + $compositeId = sprintf('%s %s', $art0->getId(), $art1->getId()); + + $revisionRepository = $this->em->getRepository(Revision::class); + + $revision = $revisionRepository->findOneBy(['revisionableId' => $compositeId]); + + static::assertNotNull($revision); + static::assertSame(RevisionInterface::ACTION_CREATE, $revision->getAction()); + static::assertSame(get_class($composite), $revision->getRevisionableClass()); + static::assertSame('jules', $revision->getUsername()); + static::assertSame(1, $revision->getVersion()); + + $data = $revision->getData(); + + static::assertCount(1, $data); + static::assertArrayHasKey('title', $data); + static::assertSame($data['title'], 'Title2'); + + $compositeRepository = $this->em->getRepository(CompositeRelation::class); + + // test update + $composite = $compositeRepository->findOneBy(['title' => 'Title2']); + $composite->setTitle('New'); + + $this->em->persist($composite); + $this->em->flush(); + $this->em->clear(); + + $revision = $revisionRepository->findOneBy(['revisionableId' => $compositeId, 'version' => 2]); + + static::assertNotNull($revision); + static::assertSame(RevisionInterface::ACTION_UPDATE, $revision->getAction()); + + // test delete + $composite = $compositeRepository->findOneBy(['title' => 'New']); + $this->em->remove($composite); + $this->em->flush(); + $this->em->clear(); + + $revision = $revisionRepository->findOneBy(['revisionableId' => $compositeId, 'version' => 3]); + static::assertSame(RevisionInterface::ACTION_REMOVE, $revision->getAction()); + static::assertEmpty($revision->getData()); + } + + protected function getUsedEntityFixtures(): array + { + return [ + Address::class, + Article::class, + Author::class, + Comment::class, + CommentRevision::class, + Composite::class, + CompositeRelation::class, + Geo::class, + GeoLocation::class, + RelatedArticle::class, + Revision::class, + ]; + } + + private function populate(): void + { + $article = new RelatedArticle(); + $article->setTitle('a1-t-v1'); + $article->setContent('a1-c-v1'); + + $author = new Author(); + $author->setName('John Doe'); + $author->setEmail('john@doe.com'); + + $comment = new Comment(); + $comment->setArticle($article); + $comment->setAuthor($author); + $comment->setMessage('m-v1'); + $comment->setSubject('s-v1'); + $comment->setWrittenAt(new \DateTimeImmutable('2024-06-24 23:30:00', new \DateTimeZone('UTC'))); + + $this->em->persist($article); + $this->em->persist($comment); + $this->em->flush(); + + $comment->setMessage('m-v2'); + + $this->em->persist($comment); + $this->em->flush(); + + $comment->setSubject('s-v3'); + + $this->em->persist($comment); + $this->em->flush(); + + $article2 = new RelatedArticle(); + $article2->setTitle('a2-t-v1'); + $article2->setContent('a2-c-v1'); + + $author2 = new Author(); + $author2->setName('Jane Doe'); + $author2->setEmail('jane@doe.com'); + + $comment->setAuthor($author2); + $comment->setArticle($article2); + + $this->em->persist($article2); + $this->em->persist($comment); + $this->em->flush(); + + $comment->setMessage('m-v5'); + + $this->em->persist($comment); + $this->em->flush(); + $this->em->clear(); + } +} diff --git a/tests/Gedmo/Timestampable/ChangeTest.php b/tests/Gedmo/Timestampable/ChangeTest.php index 0d6f03bdff..b677d1b8f6 100644 --- a/tests/Gedmo/Timestampable/ChangeTest.php +++ b/tests/Gedmo/Timestampable/ChangeTest.php @@ -128,9 +128,7 @@ public function getDateValue($meta, $field): ?\DateTime } } -/** - * @phpstan-extends AbstractTrackingListener - */ +/** @phpstan-extends AbstractTrackingListener */ final class TimestampableListenerStub extends AbstractTrackingListener { /** diff --git a/tests/Gedmo/Tool/BaseTestCaseMongoODM.php b/tests/Gedmo/Tool/BaseTestCaseMongoODM.php index 0d31c5d59f..b65addf84d 100644 --- a/tests/Gedmo/Tool/BaseTestCaseMongoODM.php +++ b/tests/Gedmo/Tool/BaseTestCaseMongoODM.php @@ -18,6 +18,7 @@ use Doctrine\ODM\MongoDB\Mapping\Driver\AttributeDriver; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Gedmo\Loggable\LoggableListener; +use Gedmo\Revisionable\RevisionableListener; use Gedmo\Sluggable\SluggableListener; use Gedmo\SoftDeleteable\Filter\ODM\SoftDeleteableFilter; use Gedmo\SoftDeleteable\SoftDeleteableListener; @@ -160,6 +161,7 @@ private function getEventManager(): EventManager $evm = new EventManager(); $evm->addEventSubscriber(new SluggableListener()); $evm->addEventSubscriber(new LoggableListener()); + $evm->addEventSubscriber(new RevisionableListener()); $evm->addEventSubscriber(new TranslatableListener()); $evm->addEventSubscriber(new TimestampableListener()); $evm->addEventSubscriber(new SoftDeleteableListener()); diff --git a/tests/Gedmo/Tool/BaseTestCaseOM.php b/tests/Gedmo/Tool/BaseTestCaseOM.php index 73b2e18129..296588ca5d 100644 --- a/tests/Gedmo/Tool/BaseTestCaseOM.php +++ b/tests/Gedmo/Tool/BaseTestCaseOM.php @@ -29,6 +29,7 @@ use Doctrine\ORM\Tools\SchemaTool; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Gedmo\Loggable\LoggableListener; +use Gedmo\Revisionable\RevisionableListener; use Gedmo\Sluggable\SluggableListener; use Gedmo\SoftDeleteable\Filter\ODM\SoftDeleteableFilter; use Gedmo\Timestampable\TimestampableListener; @@ -155,6 +156,7 @@ private function getEventManager(): EventManager $this->evm->addEventSubscriber(new TreeListener()); $this->evm->addEventSubscriber(new SluggableListener()); $this->evm->addEventSubscriber(new LoggableListener()); + $this->evm->addEventSubscriber(new RevisionableListener()); $this->evm->addEventSubscriber(new TranslatableListener()); $this->evm->addEventSubscriber(new TimestampableListener()); } diff --git a/tests/Gedmo/Tool/BaseTestCaseORM.php b/tests/Gedmo/Tool/BaseTestCaseORM.php index 3e77c9d360..d43ded766d 100644 --- a/tests/Gedmo/Tool/BaseTestCaseORM.php +++ b/tests/Gedmo/Tool/BaseTestCaseORM.php @@ -22,6 +22,7 @@ use Doctrine\ORM\Tools\SchemaTool; use Doctrine\Persistence\Mapping\Driver\MappingDriver; use Gedmo\Loggable\LoggableListener; +use Gedmo\Revisionable\RevisionableListener; use Gedmo\Sluggable\SluggableListener; use Gedmo\SoftDeleteable\SoftDeleteableListener; use Gedmo\Timestampable\TimestampableListener; @@ -115,6 +116,7 @@ private function getEventManager(): EventManager $evm->addEventSubscriber(new TreeListener()); $evm->addEventSubscriber(new SluggableListener()); $evm->addEventSubscriber(new LoggableListener()); + $evm->addEventSubscriber(new RevisionableListener()); $evm->addEventSubscriber(new TranslatableListener()); $evm->addEventSubscriber(new TimestampableListener()); $evm->addEventSubscriber(new SoftDeleteableListener());