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 c14519d9ed..405a267b36 100644
--- a/doc/loggable.md
+++ b/doc/loggable.md
@@ -2,8 +2,11 @@
The **Loggable** behavior adds support for logging changes to and restoring prior versions of your Doctrine objects.
+> [!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.
+
> [!NOTE]
-> The Loggable extension is NOT compatible with `doctrine/dbal` 4.0 or later
+> We recommend that new projects use the [Revisionable](./revisionable.md) extension instead of the Loggable extension.
## Index
diff --git a/doc/revisionable.md b/doc/revisionable.md
new file mode 100644
index 0000000000..8151981bd4
--- /dev/null
+++ b/doc/revisionable.md
@@ -0,0 +1,277 @@
+# Revisionable Behavior Extension for Doctrine
+
+The **Revisionable** behavior adds support for logging changes to and restoring prior versions of your Doctrine objects.
+
+## Index
+
+- [Differences Between Loggable and Revisionable](#differences-between-loggable-and-revisionable)
+- [Getting Started](#getting-started)
+- [Configuring Revisionable Objects](#configuring-revisionable-objects)
+- [Customizing The Revision Model](#customizing-the-revision-model)
+- [Object Repositories](#object-repositories)
+ - [Fetching a Model's Revisions](#fetching-a-models-revisions)
+ - [Revert a Model to a Previous Version](#revert-a-model-to-a-previous-version)
+
+## Differences Between Loggable and Revisionable
+
+The revisionable extension is a modern implementation of the loggable extension, and while largely similar, there are
+some underlying differences in the out-of-the-box features in each extension.
+
+### JSON Field Storage
+
+When using the revisionable extension with the Doctrine DBAL and ORM, the default `Revision` entity stores its data in
+a JSON column, whereas the loggable extension uses an array column (which under the hood is transformed to a serialized
+array). The array column type was deprecated in DBAL 3.x and removed in 4.0 in favor of the JSON column type, which requires
+a data migration since the two field types are not directly compatible.
+
+For those using the Doctrine MongoDB ODM, there is no change in the underlying mapping configuration.
+
+### Normalized Data Array
+
+The loggable extension would store the changes as provided by the underlying model. This would result in PHP objects
+(such as the core `DateTimeImmutable` class) being saved in the serialized payload. The revisionable extension saves
+normalized values using the `Type::convertToDatabaseValue()` APIs from each supported object manager. As a side effect,
+this also means that when reverting models to an older state, the `Type::convertToPHPValue()` APIs are used to restore
+values.
+
+As a practical example, this is the payload saved to a `LogEntry` when using the DBAL:
+
+```shell
+a:4:{s:5:"title";s:5:"Title";s:9:"publishAt";O:17:"DateTimeImmutable":3:{s:4:"date";s:26:"2024-06-24 23:00:00.000000";s:13:"timezone_type";i:3;s:8:"timezone";s:3:"UTC";}s:11:"author.name";s:8:"John Doe";s:12:"author.email";s:12:"john@doe.com";}
+```
+
+And this is the equivalent value when saving a `Revision`:
+
+```json
+{
+ "title": "Title",
+ "publishAt": "2024-06-24 23:00:00",
+ "author.name": "John Doe",
+ "author.email": "john@doe.com"
+}
+```
+
+## Getting Started
+
+The revisionable behavior can be added to a supported Doctrine object manager by registering its event subscriber
+when creating the manager.
+
+```php
+use Gedmo\Revisionable\RevisionableListener;
+
+$listener = new RevisionableListener();
+
+// The $om is either an instance of the ORM's entity manager or the MongoDB ODM's document manager
+$om->getEventManager()->addEventSubscriber($listener);
+```
+
+Then, once your application has it available (i.e. after validating the authentication for your user during an HTTP request),
+you can set a reference to the user who performed actions on a revisionable model by calling the listener's `setUsername` method.
+
+```php
+// The $user can be either an object or a string
+$listener->setUsername($user);
+```
+
+## Configuring Revisionable Objects
+
+The revisionable extension can be configured with [annotations](./annotations.md#revisionable-extension),
+[attributes](./attributes.md#revisionable-extension), or XML configuration (matching the mapping of
+your domain models). The full configuration for annotations and attributes can be reviewed in
+the linked documentation.
+
+The below examples show the simplest and default configuration for the extension, logging changes for defined fields.
+
+### Attribute Configuration
+
+```php
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Annotation Configuration
+
+> [!NOTE]
+> Support for annotations is deprecated and will be removed in 4.0.
+
+```php
+
+
+
+
+
+
+
+
+
+
+
+```
+
+A custom model must implement `Gedmo\Revisionable\RevisionInterface`. For convenience, we recommend extending from
+`Gedmo\Revisionable\Entity\MappedSuperClass\AbstractRevision` for Doctrine ORM users or
+`Gedmo\Revisionable\Document\MappedSuperClass\AbstractRevision` for Doctrine MongoDB ODM users, which provides a default
+mapping configuration for each object manager.
+
+## Object Repositories
+
+The revisionable extension includes a `Doctrine\Persistence\ObjectRepository` implementation for each supported object manager
+that provides out-of-the-box features for all revision models. When creating custom models, you are welcome to extend
+from either `Gedmo\Revisionable\Entity\Repository\RevisionRepository` for Doctrine ORM users or
+`Gedmo\Revisionable\Document\Repository\RevisionRepository` for Doctrine MongoDB ODM users to provide these features.
+
+### Fetching a Model's Revisions
+
+The repository classes provide a `getRevisions` method which allows fetching the list of revisions for a given model.
+
+```php
+use App\Entity\Article;
+use Doctrine\ORM\EntityManagerInterface;
+use Gedmo\Revisionable\Entity\Revision;
+use Gedmo\Revisionable\Entity\Repository\RevisionRepository;
+use Gedmo\Revisionable\RevisionableListener;
+
+/** @var EntityManagerInterface $em */
+
+// Load our revisionable model
+$article = $em->find(Article::class, 1);
+
+// Next, get the Revision repository
+/** @var RevisionRepository $repo */
+$repo = $em->getRepository(Revision::class);
+
+// Lastly, get the article's revisions
+$revisions = $repo->getRevisions($article);
+```
+
+### Revert a Model to a Previous Version
+
+The repository classes provide a `revert` method which allows reverting a model to a previous version. The repository
+will incrementally revert back to the version specified (for example, a model is currently on version 5, and you want to
+revert to version 2, it will restore the state of version 4, then version 3, and finally, version 2).
+
+```php
+use App\Entity\Article;
+use Doctrine\ORM\EntityManagerInterface;
+use Gedmo\Revisionable\Entity\Revision;
+use Gedmo\Revisionable\Entity\Repository\RevisionRepository;
+use Gedmo\Revisionable\RevisionableListener;
+
+/** @var EntityManagerInterface $em */
+
+// Load our revisionable model
+$article = $em->find(Article::class, 1);
+
+// Next, get the Revision repository
+/** @var RevisionRepository $repo */
+$repo = $em->getRepository(Revision::class);
+
+// We are now able to revert to an older version
+$repo->revert($article, 2);
+```
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 2bb87cb51e..3edc4a5c41 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -342,6 +342,66 @@ parameters:
count: 1
path: src/References/ReferencesListener.php
+ -
+ message: '#^Cannot unset offset non\-empty\-string on non\-empty\-list\\.$#'
+ identifier: unset.offset
+ count: 1
+ path: src/Revisionable/Document/Repository/RevisionRepository.php
+
+ -
+ message: '#^Method Gedmo\\Revisionable\\Document\\Revision\:\:createRevision\(\) should return Gedmo\\Revisionable\\Document\\Revision\ but returns Gedmo\\Revisionable\\Document\\Revision\\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Revisionable/Document/Revision.php
+
+ -
+ message: '#^Unable to resolve the template type T in call to method Doctrine\\ORM\\EntityManagerInterface\:\:getReference\(\)$#'
+ identifier: argument.templateType
+ 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\\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Revisionable/Entity/Revision.php
+
+ -
+ message: '#^Access to offset ''isOwningSide'' on an unknown class Doctrine\\ODM\\MongoDB\\Mapping\\AssociationFieldMapping\.$#'
+ identifier: class.notFound
+ count: 1
+ path: src/Revisionable/Mapping/Driver/Attribute.php
+
+ -
+ message: '#^Access to an undefined property Doctrine\\Persistence\\Mapping\\ClassMetadata\\:\:\$associationMappings\.$#'
+ identifier: property.notFound
+ count: 1
+ path: src/Revisionable/Mapping/Driver/Xml.php
+
+ -
+ message: '#^Call to an undefined method Doctrine\\Persistence\\Mapping\\ClassMetadata\\>\:\:setFieldValue\(\)\.$#'
+ identifier: method.notFound
+ count: 1
+ path: src/Revisionable/RevisionableListener.php
+
+ -
+ message: '#^Call to an undefined method Doctrine\\Persistence\\ObjectManager\:\:getUnitOfWork\(\)\.$#'
+ identifier: method.notFound
+ count: 4
+ path: src/Revisionable/RevisionableListener.php
+
+ -
+ message: '#^Method Gedmo\\Revisionable\\RevisionableListener\:\:getRevisionClass\(\) should return class\-string\\> but returns class\-string\\>\.$#'
+ identifier: return.type
+ count: 1
+ path: src/Revisionable/RevisionableListener.php
+
+ -
+ message: '#^Method Gedmo\\Tool\\WrapperInterface\,object,Doctrine\\Persistence\\ObjectManager\>\:\:getIdentifier\(\) invoked with 2 parameters, 0\-1 required\.$#'
+ identifier: arguments.count
+ count: 2
+ path: src/Revisionable/RevisionableListener.php
+
-
message: '#^Call to an undefined method Doctrine\\Persistence\\Mapping\\ClassMetadata\\:\:getReflectionProperty\(\)\.$#'
identifier: method.notFound
@@ -1026,6 +1086,150 @@ parameters:
count: 1
path: tests/Gedmo/Mapping/Fixture/Category.php
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Document\\EmbeddedRevisionable\:\:\$subtitle \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Document/EmbeddedRevisionable.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Document\\Revisionable\:\:\$id \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Document/Revisionable.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Document\\RevisionableWithEmbedded\:\:\$embedded \(Gedmo\\Tests\\Mapping\\Fixture\\Document\\EmbeddedRevisionable\|null\) is never assigned Gedmo\\Tests\\Mapping\\Fixture\\Document\\EmbeddedRevisionable so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Document/RevisionableWithEmbedded.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Document\\RevisionableWithEmbedded\:\:\$id \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Document/RevisionableWithEmbedded.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Document\\RevisionableWithEmbedded\:\:\$title \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Document/RevisionableWithEmbedded.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Entity\\EmbeddedRevisionable\:\:\$subtitle \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Entity/EmbeddedRevisionable.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Xml\\EmbeddedRevisionable\:\:\$subtitle \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Xml/EmbeddedRevisionable.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Xml\\Revisionable\:\:\$id \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Xml/Revisionable.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Xml\\RevisionableComposite\:\:\$one \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Xml/RevisionableComposite.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Xml\\RevisionableComposite\:\:\$two \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Xml/RevisionableComposite.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Xml\\RevisionableCompositeRelation\:\:\$one \(Gedmo\\Tests\\Mapping\\Fixture\\Xml\\Revisionable\|null\) is never assigned Gedmo\\Tests\\Mapping\\Fixture\\Xml\\Revisionable so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Xml/RevisionableCompositeRelation.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Xml\\RevisionableCompositeRelation\:\:\$two \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Xml/RevisionableCompositeRelation.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Xml\\RevisionableWithEmbedded\:\:\$embedded \(Gedmo\\Tests\\Mapping\\Fixture\\Xml\\Embedded\|null\) is never assigned Gedmo\\Tests\\Mapping\\Fixture\\Xml\\Embedded so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Xml/RevisionableWithEmbedded.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Xml\\RevisionableWithEmbedded\:\:\$id \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Xml/RevisionableWithEmbedded.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Xml\\RevisionableWithEmbedded\:\:\$title \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Xml/RevisionableWithEmbedded.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\EmbeddedRevisionable\:\:\$subtitle \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Yaml/EmbeddedRevisionable.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\Revisionable\:\:\$id \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Yaml/Revisionable.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\RevisionableComposite\:\:\$one \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Yaml/RevisionableComposite.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\RevisionableComposite\:\:\$two \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Yaml/RevisionableComposite.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\RevisionableCompositeRelation\:\:\$one \(Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\Revisionable\|null\) is never assigned Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\Revisionable so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Yaml/RevisionableCompositeRelation.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\RevisionableCompositeRelation\:\:\$two \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Yaml/RevisionableCompositeRelation.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\RevisionableWithEmbedded\:\:\$embedded \(Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\Embedded\|null\) is never assigned Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\Embedded so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Yaml/RevisionableWithEmbedded.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\RevisionableWithEmbedded\:\:\$id \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Yaml/RevisionableWithEmbedded.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Mapping\\Fixture\\Yaml\\RevisionableWithEmbedded\:\:\$title \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Mapping/Fixture/Yaml/RevisionableWithEmbedded.php
+
-
message: '#^Instantiated class Doctrine\\ORM\\Mapping\\Driver\\AnnotationDriver not found\.$#'
identifier: class.notFound
@@ -1176,6 +1380,66 @@ parameters:
count: 1
path: tests/Gedmo/Mapping/Xml/TranslatableMappingTest.php
+ -
+ message: '#^Property Gedmo\\Tests\\Revisionable\\Fixture\\Document\\Address\:\:\$id \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Revisionable/Fixture/Document/Address.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Revisionable\\Fixture\\Document\\Article\:\:\$id \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Revisionable/Fixture/Document/Article.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Revisionable\\Fixture\\Document\\Comment\:\:\$id \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Revisionable/Fixture/Document/Comment.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\\.$#'
+ identifier: return.type
+ count: 1
+ path: tests/Gedmo/Revisionable/Fixture/Document/CommentRevision.php
+
+ -
+ message: '#^Property Gedmo\\Tests\\Revisionable\\Fixture\\Document\\RelatedArticle\:\:\$id \(string\|null\) is never assigned string so it can be removed from the property type\.$#'
+ identifier: property.unusedType
+ count: 1
+ path: tests/Gedmo/Revisionable/Fixture/Document/RelatedArticle.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\\.$#'
+ identifier: return.type
+ count: 1
+ path: tests/Gedmo/Revisionable/Fixture/Entity/CommentRevision.php
+
+ -
+ message: '#^Call to an undefined method Doctrine\\ORM\\EntityRepository\\:\:getRevisions\(\)\.$#'
+ identifier: method.notFound
+ 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\.$#'
+ identifier: argument.type
+ 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\.$#'
+ identifier: argument.type
+ 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\.$#'
+ identifier: argument.type
+ count: 1
+ path: tests/Gedmo/Revisionable/RevisionableEntityTest.php
+
-
message: '#^Method Gedmo\\Tests\\Sluggable\\Fixture\\Doctrine\\FakeFilter\:\:addFilterConstraint\(\) has parameter \$targetTableAlias with no type specified\.$#'
identifier: missingType.parameter
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..db821c879d
--- /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.
+ *
+ * @template T of Revisionable|object
+ *
+ * @template-implements RevisionInterface
+ *
+ * @ODM\MappedSuperclass
+ */
+#[ODM\MappedSuperclass]
+abstract class AbstractRevision implements RevisionInterface
+{
+ /**
+ * @ODM\Id(name="id")
+ */
+ #[ODM\Id(name: 'id')]
+ protected ?string $id = null;
+
+ /**
+ * @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;
+
+ /**
+ * @var positive-int
+ *
+ * @ODM\Field(name="version", type="int")
+ */
+ #[ODM\Field(name: 'version', type: Type::INT)]
+ protected int $version = 1;
+
+ /**
+ * @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;
+
+ /**
+ * @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;
+
+ /**
+ * @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;
+ }
+
+ /**
+ * @param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action
+ */
+ public function setAction(string $action): void
+ {
+ $this->action = $action;
+ }
+
+ /**
+ * @return self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE
+ */
+ public function getAction(): string
+ {
+ return $this->action;
+ }
+
+ /**
+ * @param positive-int $version
+ */
+ public function setVersion(int $version): void
+ {
+ $this->version = $version;
+ }
+
+ /**
+ * @return positive-int
+ */
+ public function getVersion(): int
+ {
+ return $this->version;
+ }
+
+ /**
+ * @param non-empty-string $revisionableId
+ */
+ public function setRevisionableId(string $revisionableId): void
+ {
+ $this->revisionableId = $revisionableId;
+ }
+
+ /**
+ * @return non-empty-string|null
+ */
+ public function getRevisionableId(): ?string
+ {
+ return $this->revisionableId;
+ }
+
+ /**
+ * @param class-string $revisionableClass
+ */
+ public function setRevisionableClass(string $revisionableClass): void
+ {
+ $this->revisionableClass = $revisionableClass;
+ }
+
+ /**
+ * @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;
+ }
+
+ /**
+ * @param non-empty-string|null $username
+ */
+ public function setUsername(?string $username): void
+ {
+ $this->username = $username;
+ }
+
+ /**
+ * @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..a62c04ee5c
--- /dev/null
+++ b/src/Revisionable/Document/Repository/RevisionRepository.php
@@ -0,0 +1,182 @@
+ 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
+ *
+ * @template T of Revisionable|object
+ *
+ * @template-extends DocumentRepository>
+ */
+class RevisionRepository extends DocumentRepository
+{
+ /**
+ * The revisionable listener associated with the document manager for the {@see AbstractRevision} document.
+ *
+ * @var RevisionableListener|null|false
+ */
+ private $listener = false;
+
+ /**
+ * Loads all revisions for the given document
+ *
+ * @param T $document
+ *
+ * @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.
+ *
+ * @param T $document
+ * @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 T $document
+ * @param array $data
+ */
+ 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.
+ *
+ * @return RevisionableListener
+ *
+ * @throws RuntimeException if the listener is not found
+ */
+ 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..59ecb4e326
--- /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"})
+ *
+ * @template T of Revisionable|object
+ *
+ * @template-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.
+ *
+ * @param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action
+ *
+ * @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..acae45fe38
--- /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.
+ *
+ * @template T of Revisionable|object
+ *
+ * @template-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;
+
+ /**
+ * @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;
+
+ /**
+ * @var positive-int
+ *
+ * @ORM\Column(type="integer")
+ */
+ #[ORM\Column(name: 'version', type: Types::INTEGER)]
+ protected int $version = 1;
+
+ /**
+ * @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;
+
+ /**
+ * @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;
+
+ /**
+ * @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;
+ }
+
+ /**
+ * @param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action
+ */
+ public function setAction(string $action): void
+ {
+ $this->action = $action;
+ }
+
+ /**
+ * @return self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE
+ */
+ public function getAction(): string
+ {
+ return $this->action;
+ }
+
+ /**
+ * @param positive-int $version
+ */
+ public function setVersion(int $version): void
+ {
+ $this->version = $version;
+ }
+
+ /**
+ * @return positive-int
+ */
+ public function getVersion(): int
+ {
+ return $this->version;
+ }
+
+ /**
+ * @param non-empty-string $revisionableId
+ */
+ public function setRevisionableId(string $revisionableId): void
+ {
+ $this->revisionableId = $revisionableId;
+ }
+
+ /**
+ * @return non-empty-string|null
+ */
+ public function getRevisionableId(): ?string
+ {
+ return $this->revisionableId;
+ }
+
+ /**
+ * @param class-string $revisionableClass
+ */
+ public function setRevisionableClass(string $revisionableClass): void
+ {
+ $this->revisionableClass = $revisionableClass;
+ }
+
+ /**
+ * @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;
+ }
+
+ /**
+ * @param non-empty-string|null $username
+ */
+ public function setUsername(?string $username): void
+ {
+ $this->username = $username;
+ }
+
+ /**
+ * @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..b61a54423c
--- /dev/null
+++ b/src/Revisionable/Entity/Repository/RevisionRepository.php
@@ -0,0 +1,169 @@
+ 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
+ *
+ * @template T of Revisionable|object
+ *
+ * @template-extends EntityRepository>
+ */
+class RevisionRepository extends EntityRepository
+{
+ /**
+ * The revisionable listener associated with the entity manager for the {@see AbstractRevision} entity.
+ *
+ * @var RevisionableListener|null|false
+ */
+ private $listener = false;
+
+ /**
+ * Loads all revisions for the given entity
+ *
+ * @param T $entity
+ *
+ * @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.
+ *
+ * @param T $entity
+ * @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 ClassMetadata $objectMeta
+ * @param mixed $value
+ *
+ * @return void
+ */
+ 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 ?? $mapping['targetEntity'], $value) : null;
+ }
+
+ /**
+ * Get the revisionable listener associated with the entity manager for the {@see AbstractRevision} entity.
+ *
+ * @return RevisionableListener
+ *
+ * @throws RuntimeException if the listener is not found
+ */
+ 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..e96b8d9c49
--- /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")
+ *
+ * @template T of Revisionable|object
+ *
+ * @template-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.
+ *
+ * @param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action
+ *
+ * @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..f8aa570b65
--- /dev/null
+++ b/src/Revisionable/Mapping/Driver/Attribute.php
@@ -0,0 +1,172 @@
+ 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
+ * @param ORMClassMetadata $meta
+ *
+ * @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;
+ }
+
+ /**
+ * @param ClassMetadata $meta
+ * @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;
+ }
+
+ /**
+ * @param ClassMetadata $meta
+ */
+ 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..5589b5b207
--- /dev/null
+++ b/src/Revisionable/Mapping/Driver/Xml.php
@@ -0,0 +1,215 @@
+ 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
+ * @param ClassMetadata $meta
+ *
+ * @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
+ * @param ClassMetadata $meta
+ *
+ * @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
+ * @param ORMClassMetadata $meta
+ *
+ * @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);
+ }
+
+ /**
+ * @param ClassMetadata $meta
+ */
+ 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..12286aa19f
--- /dev/null
+++ b/src/Revisionable/Mapping/Driver/Yaml.php
@@ -0,0 +1,178 @@
+ 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']) && !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
+ * @param ORMClassMetadata $meta
+ *
+ * @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
+ * @param ORMClassMetadata $meta
+ *
+ * @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
+ * @param ORMClassMetadata $meta
+ *
+ * @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..0ca1eea058
--- /dev/null
+++ b/src/Revisionable/Mapping/Event/Adapter/ODM.php
@@ -0,0 +1,86 @@
+ 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.
+ *
+ * @return class-string>
+ */
+ public function getDefaultRevisionClass(): string
+ {
+ return Revision::class;
+ }
+
+ /**
+ * Checks whether an identifier should be generated post insert.
+ *
+ * @param ClassMetadata $meta
+ */
+ 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.
+ *
+ * @param ClassMetadata $meta
+ *
+ * @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..09f1b7cbf8
--- /dev/null
+++ b/src/Revisionable/Mapping/Event/Adapter/ORM.php
@@ -0,0 +1,81 @@
+ 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.
+ *
+ * @return class-string>
+ */
+ public function getDefaultRevisionClass(): string
+ {
+ return Revision::class;
+ }
+
+ /**
+ * Checks whether an identifier should be generated post insert.
+ *
+ * @param ClassMetadata $meta
+ */
+ public function isPostInsertGenerator(ClassMetadata $meta): bool
+ {
+ assert($meta instanceof ORMClassMetadata);
+
+ return $meta->idGenerator->isPostInsertGenerator();
+ }
+
+ /**
+ * Get the new version number for an object.
+ *
+ * @param ClassMetadata $meta
+ *
+ * @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..35b422b11b
--- /dev/null
+++ b/src/Revisionable/Mapping/Event/RevisionableAdapter.php
@@ -0,0 +1,46 @@
+ 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.
+ *
+ * @return class-string>
+ */
+ public function getDefaultRevisionClass(): string;
+
+ /**
+ * Checks whether an identifier should be generated post insert.
+ *
+ * @param ClassMetadata $meta
+ */
+ public function isPostInsertGenerator(ClassMetadata $meta): bool;
+
+ /**
+ * Get the new version number for an object.
+ *
+ * @param ClassMetadata $meta
+ *
+ * @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..c0ef679ced
--- /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.
+ *
+ * @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.
+ *
+ * @param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action
+ *
+ * @return RevisionInterface
+ */
+ public static function createRevision(string $action): self;
+
+ /**
+ * @param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action
+ */
+ public function setAction(string $action): void;
+
+ /**
+ * @return self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE
+ */
+ public function getAction(): string;
+
+ /**
+ * @param positive-int $version
+ */
+ public function setVersion(int $version): void;
+
+ /**
+ * @return positive-int
+ */
+ public function getVersion(): int;
+
+ /**
+ * @param non-empty-string $revisionableId
+ */
+ public function setRevisionableId(string $revisionableId): void;
+
+ /**
+ * @return non-empty-string|null
+ */
+ public function getRevisionableId(): ?string;
+
+ /**
+ * @param class-string $revisionableClass
+ */
+ public function setRevisionableClass(string $revisionableClass): void;
+
+ /**
+ * @return class-string|null
+ */
+ public function getRevisionableClass(): ?string;
+
+ public function setLoggedAt(\DateTimeImmutable $loggedAt): void;
+
+ public function getLoggedAt(): \DateTimeImmutable;
+
+ /**
+ * @param non-empty-string|null $username
+ */
+ public function setUsername(?string $username): void;
+
+ /**
+ * @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..875c49e80f
--- /dev/null
+++ b/src/Revisionable/RevisionableListener.php
@@ -0,0 +1,339 @@
+ 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,
+ * }
+ *
+ * @template T of Revisionable|object
+ *
+ * @phpstan-extends MappedEventSubscriber
+ */
+final class RevisionableListener extends MappedEventSubscriber
+{
+ /**
+ * Username for identification
+ *
+ * @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>
+ */
+ 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, field: string}>>
+ */
+ private array $pendingRelatedObjects = [];
+
+ /**
+ * Set the username to be used when logging revisions.
+ *
+ * @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, 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
+ */
+ 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
+ */
+ 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.
+ *
+ * @param class-string $class
+ *
+ * @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.
+ *
+ * @param T $object
+ * @param RevisionInterface $revision
+ *
+ * @return array
+ */
+ 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
+ *
+ * @param RevisionInterface::ACTION_CREATE|RevisionInterface::ACTION_UPDATE|RevisionInterface::ACTION_REMOVE $action
+ * @param T $object
+ *
+ * @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/EntityWrapper.php b/src/Tool/Wrapper/EntityWrapper.php
index 34e700445d..149be4a1d9 100644
--- a/src/Tool/Wrapper/EntityWrapper.php
+++ b/src/Tool/Wrapper/EntityWrapper.php
@@ -116,6 +116,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 18e3a54ce6..217abc28e0 100644
--- a/src/Tool/Wrapper/MongoDocumentWrapper.php
+++ b/src/Tool/Wrapper/MongoDocumentWrapper.php
@@ -11,6 +11,7 @@
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
+use Doctrine\ODM\MongoDB\Types\Type;
use ProxyManager\Proxy\GhostObjectInterface;
/**
@@ -103,6 +104,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 27551df27c..91a4e2ef36 100644
--- a/src/Tool/WrapperInterface.php
+++ b/src/Tool/WrapperInterface.php
@@ -20,6 +20,9 @@
* @template-covariant TObjectManager of ObjectManager
*
* @author Gediminas Morkevicius
+ *
+ * @method mixed convertToDatabaseValue(mixed $value, string $type)
+ * @method mixed convertToPHPValue(mixed $value, string $type)
*/
interface WrapperInterface
{
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..3691a70ab8
--- /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"
+ * )
+ *
+ * @template T of Comment
+ *
+ * @template-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.
+ *
+ * @param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action
+ *
+ * @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..9a7f458b9e
--- /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")
+ *
+ * @template T of Comment
+ *
+ * @template-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.
+ *
+ * @param self::ACTION_CREATE|self::ACTION_UPDATE|self::ACTION_REMOVE $action
+ *
+ * @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 192ea84a1f..7fa9ce847a 100644
--- a/tests/Gedmo/Timestampable/ChangeTest.php
+++ b/tests/Gedmo/Timestampable/ChangeTest.php
@@ -132,9 +132,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 7fe53d6ac8..8a76f813ef 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());