From bde0c09b25702a5876cfde1dc924834b38f1031c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yann=20Eugon=C3=A9?= Date: Wed, 18 Jul 2018 14:42:55 +0200 Subject: [PATCH] WIP : docs --- README.md | 46 ++++- docs/1-install.md | 13 ++ docs/2-concepts.md | 95 +++++++++ docs/3-usage.md | 104 ++++++++++ docs/4-initialize.md | 17 ++ docs/5-purge.md | 18 ++ docs/components/author-storage.md | 8 + docs/components/object-finder.md | 8 + docs/components/purger.md | 8 + docs/components/snapshot-taker.md | 8 + docs/components/update-guesser.md | 8 + docs/components/version-factory.md | 48 +++++ docs/components/version-storage.md | 8 + docs/components/version.md | 28 +++ docs/components/versionable-author.md | 49 +++++ docs/components/versionable-resource.md | 69 +++++++ docs/integration/doctrine-orm.md | 195 ++++++++++++++++++ docs/integration/symfony-console.md | 8 + docs/integration/symfony-event-dispatcher.md | 67 ++++++ docs/integration/symfony-framework.md | 8 + docs/integration/symfony-security.md | 42 ++++ docs/integration/symfony-serializer.md | 63 ++++++ docs/recipes/custom-author-storage.md | 40 ++++ docs/recipes/custom-object-finder.md | 49 +++++ docs/recipes/custom-resource-storage.md | 40 ++++ docs/recipes/custom-version-storage.md | 91 ++++++++ src/Bridge/Doctrine/ORM/Entity/Version.php | 177 ++++++++++++++++ .../EventListener/CreateVersionListener.php | 142 +++++++++++++ .../Doctrine/ORM/Initialize/EntityFinder.php | 52 +++++ .../Doctrine/ORM/Metadata/Version.orm.xml | 30 +++ .../Doctrine/ORM/Purge/ObsoletePurger.php | 112 ++++++++++ src/Bridge/Doctrine/ORM/Purge/OldPurger.php | 60 ++++++ src/Bridge/Doctrine/ORM/Purge/PurgerTrait.php | 44 ++++ .../ORM/Storage/AuthorEntityStorage.php | 32 +++ .../Doctrine/ORM/Storage/OrmRegistryTrait.php | 35 ++++ .../ORM/Storage/ResourceEntityStorage.php | 46 +++++ .../ORM/Storage/VersionEntityStorage.php | 129 ++++++++++++ .../Doctrine/ORM/VersionEntityFactory.php | 42 ++++ .../DependencyInjection/Configuration.php | 98 +++++++++ .../YokaiVersioningExtension.php | 70 +++++++ .../Bundle/Resources/config/aliases.xml | 17 ++ .../Bundle/Resources/config/console.xml | 21 ++ .../Bundle/Resources/config/doctrine-orm.xml | 44 ++++ .../Resources/config/event-dispatcher.xml | 14 ++ .../config/serializer.doctrine-orm.xml | 14 ++ .../Bundle/Resources/config/serializer.xml | 19 ++ .../Bundle/Resources/config/services.xml | 74 +++++++ .../Symfony/Bundle/YokaiVersioningBundle.php | 9 + .../Console/Command/InitializeCommand.php | 71 +++++++ .../Symfony/Console/Command/PurgeCommand.php | 47 +++++ .../EventListener/InitContextListener.php | 69 +++++++ .../Normalizer/DoctrineResourceNormalizer.php | 133 ++++++++++++ .../Serializer/NormalizerSnapshotTaker.php | 31 +++ src/Bridge/Symfony/Serializer/Serializer.php | 23 +++ .../Doctrine/ORM/VersionEntityFactoryTest.php | 58 ++++++ .../Console/Command/InitializeCommandTest.php | 102 +++++++++ .../Console/Command/PurgeCommandTest.php | 67 ++++++ .../EventListener/InitContextListenerTest.php | 169 +++++++++++++++ .../Symfony/Serializer/SnapshotTakerTest.php | 46 +++++ 59 files changed, 3234 insertions(+), 1 deletion(-) create mode 100644 docs/1-install.md create mode 100644 docs/2-concepts.md create mode 100644 docs/3-usage.md create mode 100644 docs/4-initialize.md create mode 100644 docs/5-purge.md create mode 100644 docs/components/author-storage.md create mode 100644 docs/components/object-finder.md create mode 100644 docs/components/purger.md create mode 100644 docs/components/snapshot-taker.md create mode 100644 docs/components/update-guesser.md create mode 100644 docs/components/version-factory.md create mode 100644 docs/components/version-storage.md create mode 100644 docs/components/version.md create mode 100644 docs/components/versionable-author.md create mode 100644 docs/components/versionable-resource.md create mode 100644 docs/integration/doctrine-orm.md create mode 100644 docs/integration/symfony-console.md create mode 100644 docs/integration/symfony-event-dispatcher.md create mode 100644 docs/integration/symfony-framework.md create mode 100644 docs/integration/symfony-security.md create mode 100644 docs/integration/symfony-serializer.md create mode 100644 docs/recipes/custom-author-storage.md create mode 100644 docs/recipes/custom-object-finder.md create mode 100644 docs/recipes/custom-resource-storage.md create mode 100644 docs/recipes/custom-version-storage.md create mode 100644 src/Bridge/Doctrine/ORM/Entity/Version.php create mode 100644 src/Bridge/Doctrine/ORM/EventListener/CreateVersionListener.php create mode 100644 src/Bridge/Doctrine/ORM/Initialize/EntityFinder.php create mode 100644 src/Bridge/Doctrine/ORM/Metadata/Version.orm.xml create mode 100644 src/Bridge/Doctrine/ORM/Purge/ObsoletePurger.php create mode 100644 src/Bridge/Doctrine/ORM/Purge/OldPurger.php create mode 100644 src/Bridge/Doctrine/ORM/Purge/PurgerTrait.php create mode 100644 src/Bridge/Doctrine/ORM/Storage/AuthorEntityStorage.php create mode 100644 src/Bridge/Doctrine/ORM/Storage/OrmRegistryTrait.php create mode 100644 src/Bridge/Doctrine/ORM/Storage/ResourceEntityStorage.php create mode 100644 src/Bridge/Doctrine/ORM/Storage/VersionEntityStorage.php create mode 100644 src/Bridge/Doctrine/ORM/VersionEntityFactory.php create mode 100644 src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php create mode 100644 src/Bridge/Symfony/Bundle/DependencyInjection/YokaiVersioningExtension.php create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/aliases.xml create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/console.xml create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/doctrine-orm.xml create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/event-dispatcher.xml create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/serializer.doctrine-orm.xml create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/serializer.xml create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/services.xml create mode 100644 src/Bridge/Symfony/Bundle/YokaiVersioningBundle.php create mode 100644 src/Bridge/Symfony/Console/Command/InitializeCommand.php create mode 100644 src/Bridge/Symfony/Console/Command/PurgeCommand.php create mode 100644 src/Bridge/Symfony/EventDispatcher/EventListener/InitContextListener.php create mode 100644 src/Bridge/Symfony/Serializer/Normalizer/DoctrineResourceNormalizer.php create mode 100644 src/Bridge/Symfony/Serializer/NormalizerSnapshotTaker.php create mode 100644 src/Bridge/Symfony/Serializer/Serializer.php create mode 100644 tests/Unit/Bridge/Doctrine/ORM/VersionEntityFactoryTest.php create mode 100644 tests/Unit/Bridge/Symfony/Console/Command/InitializeCommandTest.php create mode 100644 tests/Unit/Bridge/Symfony/Console/Command/PurgeCommandTest.php create mode 100644 tests/Unit/Bridge/Symfony/EventDispatcher/EventListener/InitContextListenerTest.php create mode 100644 tests/Unit/Bridge/Symfony/Serializer/SnapshotTakerTest.php diff --git a/README.md b/README.md index d5552d8..2d6e725 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,51 @@ This library aims to provide storage agnostic mechanics to add versioning capabi Documentation ------------- -todo documentation +Start reading the library documentation. + +* [Installation](docs/1-install.md) +* [Concepts](docs/2-concepts.md) (**highly recommended**) +* [Usage](docs/3-usage.md) +* [Initialize](docs/4-initialize.md) +* [Purge](docs/5-purge.md) + + +Learn more about model classes to implement. + +* [Version](docs/components/version.md) +* [Versionable Resource](docs/components/versionable-resource.md) +* [Versionable Author](docs/components/versionable-author.md) + + +Learn more about core components. + +* [Author Storage](docs/components/author-storage.md) +* [Object finder](docs/components/object-finder.md) +* [Purger](docs/components/purger.md) +* [Snapshot Taker](docs/components/snapshot-taker.md) +* [Update Guesser](docs/components/update-guesser.md) +* [Version Factory](docs/components/version-factory.md) +* [Version Storage](docs/components/version-storage.md) + + +Or jump to integrations. + +| Require | Purpose | Documentation | +| -------------------------- | ---------------------------------------------------- | ---------------------------------------------------- | +| `symfony/framework-bundle` | Framework integration | [here](docs/integration/symfony-framework.md) | +| `symfony/event-dispatcher` | Initialize context using events | [here](docs/integration/symfony-event-dispatcher.md) | +| `symfony/serializer` | Take snapshots using normalization | [here](docs/integration/symfony-serializer.md) | +| `doctrine/orm` | Store versions using ORM, track versionable entities | [here](docs/integration/doctrine-orm.md) | +| `symfony/security` | Initialize context authors using authenticated user | [here](docs/integration/symfony-security.md) | +| `symfony/console` | Add command to purge/initialize versionable models | [here](docs/integration/symfony-console.md) | + + +If you did not find what you was looking for ? Have a look to the recipes. + +* [Custom Author storage](docs/recipes/custom-author-storage.md) +* [Custom Object Finder](docs/recipes/custom-object-finder.md) +* [Custom Resource storage](docs/recipes/custom-resource-storage.md) +* [Custom Version storage](docs/recipes/custom-version-storage.md) MIT License diff --git a/docs/1-install.md b/docs/1-install.md new file mode 100644 index 0000000..1073d5d --- /dev/null +++ b/docs/1-install.md @@ -0,0 +1,13 @@ +Installation +------------ + +Require the library as composer dependency + +```bash +$ composer require yokai/versionable +``` + + +--- + +« [README](../README.md) • [Concepts](2-concepts.md) » diff --git a/docs/2-concepts.md b/docs/2-concepts.md new file mode 100644 index 0000000..227ca33 --- /dev/null +++ b/docs/2-concepts.md @@ -0,0 +1,95 @@ +Concepts +-------- + + +### Analyze models to analyze + +Whenever you are about to create/update/delete something, you should trigger an analyze ot these objects, +so the library can decide whether or not there something to do with each of these. + +> **note** deciding whether or not an object is under versioning is the job of the +[update guesser components](components/update-guesser.md) + +When to ask this library is up to you. You must call explicitly the library to analyze your objects. +Then, for each objects that you considered as created/updated/deleted, +you must call the [VersionBuilder](../src/VersionBuilder.php). + +> **integration** when using `doctrine/orm` [integration](integration/doctrine-orm.md) +you do not need to trigger the analyze explicitly : a listener is doing it for you. + + +### Find current version of model + +The first to do is to find whether or not there was a previous version for your object. +The [verson storage](components/version-storage.md) will be called to fetch it. + + +### Take a snapshot + +Then, the [snapshot taker](components/snapshot-taker.md) will be called to take a snapshot of your object. + +A snapshot is nothing more than a normalized representation of your object, ie : an array. + +> **integration** did you noticed that we talked about normalization here ? +You may use the `symfony/serializer` [integration](integration/symfony-serializer.md) to take a these snapshots. + + +### Determine changeset + +With these information, the [ChangesetBuilder](../src/ChangesetBuilder.php) will be called with 2 snapshots to compare : +the previous that was fetched (or an empty array), the new one that was just fetched. + +The snapshot will be an array, that look like to something like : + +```json +{ + "name": {"old": "Spoon", "new": "Very interesting spoon"}, + "price": {"old": 0.29, "new": 0.30} +} +``` + + +### Gather contextual information + +Before creating the version object, the [VersionBuilder](../src/VersionBuilder.php) +will ask some contextual information (stored in [Context](../src/Context.php)). + +There is three things we can extract from it : + +* An entry point. + It is a `string` that represent the place on which the changes was performed. +* Some parameters. + It is an `array` that represent the parameters of the endpoint. +* An author. + It is an `object`, instance of [VersionableAuthorInterface](../src/VersionableAuthorInterface.php) + that represent the one that introduced these changes. + + +> **integration** when using `symfony/event-dispatcher` [integration](integration/symfony-event-dispatcher.md), +the entry point and parameters may be filled using `route` and `route params` in http context, +or with `command name` and `command arguments` plus `command options` in console context. +The author may be filled using authenticated user if available (and if it implements the interface). + + +### Create a version object + +At this point, we have all required information to create a new version for our object. +The [version factory](components/version-factory.md) will be asked to create it. + +Then, the [VersionBuilder](../src/VersionBuilder.php) will return the object version. + +> **note** the version changeset may be empty, it is up to decide what to do with it. + + +### Store new version of model + +The [VersionBuilder](../src/VersionBuilder.php) just built a version for your object(s), but it is not stored yet. +The [verson storage](components/version-storage.md) should be called to store the built version(s). + +> **integration** when using `doctrine/orm` [integration](integration/doctrine-orm.md) +you do not need to store the version by yourself : a listener is doing it for you. + + +--- + +« [Install](1-install.md) • [Usage](3-usage.md) » diff --git a/docs/3-usage.md b/docs/3-usage.md new file mode 100644 index 0000000..0962b6f --- /dev/null +++ b/docs/3-usage.md @@ -0,0 +1,104 @@ +Usage +----- + +The following example assume that you (as a developer) provided an implementation for each of the following : + +* `MyVersionFactory` : a [version factory](components/version-factory.md) +* `MyVersionStorage` : a [version storage](components/version-storage.md) +* `MySnapshotTaker` : a [snapshot taker](components/snapshot-taker.md) + +> **note** keep in mind that the library already provide at least one implementation for each. + +Additionally, the example assume that the following models exists and are : + +* `Acme\Model\BlogPost` : a valid [versionable resource](components/versionable-resource.md) +* `Acme\Model\User` : a valid [versionable author](components/versionable-author.md) + + +```php + 'Acme\Model\BlogPost'], + ['blog-post' => 'Acme\Model\User'] +); + +$context = new Context(); +$context->setEntryPoint($theAction); +$context->setParameters($theActionParameters); +$context->setAuthor($theUserThatTriggeredTheChanges); + +$updateGuesser = new ChainUpdateGuesser([new VersionableUpdateGuesser(), new VersionableChildUpdateGuesser()]); + +$versionBuilder = new VersionBuilder( + $typesConfig, + $versionStorage, + $snapshotTaker, + new ChangesetBuilder(), + $context, + $versionFactory +); + +$versions = []; + +$analyzeHash = [ + UpdateGuesserInterface::ACTION_INSERT => $createdObjects, + UpdateGuesserInterface::ACTION_UPDATE => $updatedObjects, + UpdateGuesserInterface::ACTION_DELETE => $deletedObjects, +]; + +foreach ($analyzeHash as $action => $objects) { + foreach ($objects as $object) { + foreach ($updateGuesser->guessUpdates($object, $action) as $versionable) { + $version = $versionBuilder->build($object); + + if (count($version->getChangeSet()) === 0) { + continue; + } + + $versions[] = $version; + } + } +} + +$versionStorage->store($versions); +``` + +What is happening here ? + +* First, an instance of each of your components are created. +* An instance of [TypesConfig](../src/TypesConfig.php) is created. + It will store mapping of storage identifier for each versionable resource / author. +* An instance of [Context](../src/Context.php) is created. + It will store the context of the triggered changes. +* The [Context](../src/Context.php) is filled using some information (optional). +* An [update guesser](components/update-guesser.md) is created. + It will find what versionable resources are tracked for an object. +* An instance of [VersionBuilder](../src/VersionBuilder.php), with all its dependencies is created. + It will build a version for a each versionable objects. +* An analyze will be triggered for each inserted/updated/deleted objects, 3 steps : + * Find versionables resources associated, using the update guesser. + * Build a version for each of the versionable resources. + * Add the version object to an array if the changeset is not empty. +* The version storage is called with the list of built version to store through persistence. + + +--- + +« [Concepts](2-concepts.md) • [Initialize](4-initialize.md) » diff --git a/docs/4-initialize.md b/docs/4-initialize.md new file mode 100644 index 0000000..086f0ca --- /dev/null +++ b/docs/4-initialize.md @@ -0,0 +1,17 @@ +Initialize +---------- + +If you installed this library in a project that already has some versionable resource models. +You may want to initialize the first version of these objects in the version storage. + +This is the responsibility of the [Initializer](../src/Initialize/Initializer.php). + +The initializer will trigger the version analyze process for each objects +fetched by the [version finder](components/object-finder.md). + +> **note** the [Symfony Console component](integration/symfony-console.md) integration has a command to trigger it. + + +--- + +« [Usage](3-usage.md) • [Purge](5-purge.md) » diff --git a/docs/5-purge.md b/docs/5-purge.md new file mode 100644 index 0000000..b6f607d --- /dev/null +++ b/docs/5-purge.md @@ -0,0 +1,18 @@ +Purge +----- + +Sometime, you may want to remove some versions from the storage. + +There is different strategies you may want to implement : +* keep a fixed number of version for each versionable resource +* remove versions of versionable resources that was removed +* etc... + +This feature is covered by the [purger](components/purger.md). + +> **note** the [Symfony Console component](integration/symfony-console.md) integration has a command to trigger it. + + +--- + +« [Initialize](4-initialize.md) • [README](../README.md) » diff --git a/docs/components/author-storage.md b/docs/components/author-storage.md new file mode 100644 index 0000000..033bf75 --- /dev/null +++ b/docs/components/author-storage.md @@ -0,0 +1,8 @@ +Author storage +-------------- + + + +--- + +« [README](../../README.md) diff --git a/docs/components/object-finder.md b/docs/components/object-finder.md new file mode 100644 index 0000000..96f69cf --- /dev/null +++ b/docs/components/object-finder.md @@ -0,0 +1,8 @@ +Object Finder +------------- + + + +--- + +« [README](../../README.md) diff --git a/docs/components/purger.md b/docs/components/purger.md new file mode 100644 index 0000000..dc7c108 --- /dev/null +++ b/docs/components/purger.md @@ -0,0 +1,8 @@ +Purger +------ + + + +--- + +« [README](../../README.md) diff --git a/docs/components/snapshot-taker.md b/docs/components/snapshot-taker.md new file mode 100644 index 0000000..f831d44 --- /dev/null +++ b/docs/components/snapshot-taker.md @@ -0,0 +1,8 @@ +Snapshot taker +-------------- + + + +--- + +« [README](../../README.md) diff --git a/docs/components/update-guesser.md b/docs/components/update-guesser.md new file mode 100644 index 0000000..49f90de --- /dev/null +++ b/docs/components/update-guesser.md @@ -0,0 +1,8 @@ +Update guesser +-------------- + + + +--- + +« [README](../../README.md) diff --git a/docs/components/version-factory.md b/docs/components/version-factory.md new file mode 100644 index 0000000..1e9c44f --- /dev/null +++ b/docs/components/version-factory.md @@ -0,0 +1,48 @@ +Version Factory +--------------- + +The [VersionFactoryInterface](../../src/VersionFactoryInterface.php) +is the interface for version factories. + +The purpose of a version factory is to return a concrete [version](version.md) instance. + +```php + **note** each [version storage](version-storage.md) could implement its own version final class. + + +--- + +« [README](../../README.md) diff --git a/docs/components/versionable-author.md b/docs/components/versionable-author.md new file mode 100644 index 0000000..87248db --- /dev/null +++ b/docs/components/versionable-author.md @@ -0,0 +1,49 @@ +Versionable Author +------------------ + +In the [version](version.md) object, a versionable author is identified by a type and an id. + +The [VersionableAuthorInterface](../../src/VersionableAuthorInterface.php) +is the interface for all authors may introduce changes. +The interface has a single method that must return the object id, **as a string**. + +The object type is stored in the [TypesConfig](../../src/TypesConfig.php) authors map. + +For example : + +```php +id; + } +} +``` + +```php + 'User'], []); +``` + +> **note** types config versionable map config is a hash array with storage type as key and model FQCN as value. + + +--- + +« [README](../../README.md) diff --git a/docs/components/versionable-resource.md b/docs/components/versionable-resource.md new file mode 100644 index 0000000..262cee9 --- /dev/null +++ b/docs/components/versionable-resource.md @@ -0,0 +1,69 @@ +Versionable Resource +-------------------- + +In the [version](version.md) object, a versionable resource is identified by a type and an id. + + +### The Resource interface + +The [VersionableResourceInterface](../../src/VersionableResourceInterface.php) +is the interface for all resources under versioning. +The interface has a single method that must return the object id, **as a string**. + +The object type is stored in the [TypesConfig](../../src/TypesConfig.php) resources map. + +For example : + +```php +id; + } +} +``` + +```php + 'Product']); +``` + +> **note** types config versionable map config is a hash array with storage type as key and model FQCN as value. + + +### The Resource Parent interface + +```php +``` + + +### The Resource Child interface + +```php +``` + + +--- + +« [README](../../README.md) diff --git a/docs/integration/doctrine-orm.md b/docs/integration/doctrine-orm.md new file mode 100644 index 0000000..98589b5 --- /dev/null +++ b/docs/integration/doctrine-orm.md @@ -0,0 +1,195 @@ +Doctrine ORM integration +------------------------ + +> **integration** when using `symfony/framework-bundle` [integration](../integration/symfony-framework.md) +services are registered for you according to the configuration you provided. + + +### Automatically analyze doctrine entities + +If your application contains doctrine entities you whish to automatically analyze whenever +doctrine is applying changes on it. +You can configure the listener that will do it for you : + +```php +addEventSubscriber( + new CreateVersionListener( + $updateGuesser, + $versionBuilder, + $versionStorage + ) +); +``` + + +### Version as an ORM entity + +If you wish to store your versions using an entity. + +```php + 'Yokai\\Versioning\\Bridge\\Doctrine\\ORM\\Entity'] +); +$config->setMetadataDriverImpl($driver); +``` + +Then you will need to provide concrete implementations of : + +* [version storage](../components/version-storage.md) : + [VersionEntityStorage](../../src/Bridge/Doctrine/ORM/Storage/VersionEntityStorage.php) +* [version factory](../components/version-factory.md) : + [VersionEntityFactory](../../src/Bridge/Doctrine/ORM/VersionEntityFactory.php) + +```php +store($versions); +``` + + +### Resources ORM entities + +If your versions are related to doctrine entities and you which to extract the entity from certain version. + +```php +findForVersion($version); +``` + + +### Author ORM entities + +If your versions are authored by doctrine entities and you which to extract the entity from certain version. + +```php +findForVersion($version); +``` + + +### ORM Entity Initializer + +If the objects you are analysing are ORM entities +and you which to trigger version [initialization](../4-initialize.md) for these entities. +You can use the object finder provided by this integration. + +```php +initialize('Acme\\Model'); +``` + + +### ORM Purge strategies + +If you decided to store your versions as ORM entities and you which to [purge](../5-purge.md) these entities. +You can use the purge strategies provided by this integration. + +```php +purge(); +``` + +* [ObsoletePurger](../../src/Bridge/Doctrine/ORM/Purge/ObsoletePurger.php) : +remove versions that are related to resources that do not exists anymore. +* [OldPurger](../../src/Bridge/Doctrine/ORM/Purge/OldPurger.php) : +remove versions that are older than some date (see [date modifier](http://php.net/manual/en/datetime.modify.php)). + + +--- + +« [README](../../README.md) diff --git a/docs/integration/symfony-console.md b/docs/integration/symfony-console.md new file mode 100644 index 0000000..0eb1b96 --- /dev/null +++ b/docs/integration/symfony-console.md @@ -0,0 +1,8 @@ +Symfony console integration +--------------------------- + + + +--- + +« [README](../../README.md) diff --git a/docs/integration/symfony-event-dispatcher.md b/docs/integration/symfony-event-dispatcher.md new file mode 100644 index 0000000..9e35bf3 --- /dev/null +++ b/docs/integration/symfony-event-dispatcher.md @@ -0,0 +1,67 @@ +Symfony event dispatcher integration +------------------------------------ + +This integration is about how to populate context using events. + +It declare a listener that could subscribes to 2 events : + +* `kernel.request` (from [symfony/http-kernel](https://symfony.com/doc/current/components/http_kernel.html#the-kernel-request-event)) +* `console.command` (from [symfony/console](https://symfony.com/doc/current/components/console/events.html#the-consoleevents-command-event)) + +> **integration** for both events, you may use the additional [symfony security](symfony-security.md) integration +to populate author from security. + + +### Subscribing to `kernel.request` + +When subscribing to this event, context with be populated with : + +* **entry point** : the `_route` attribute of the `Request` object +* **parameters** : the `_route_params` attribute of the `Request` object + +```php +addListener(KernelEvents::REQUEST, [$listener, 'onRequest']); + +$eventDispatcher->dispatch(KernelEvents::REQUEST, /* ...*/); +``` + + +### Subscribing to `console.command` + +When subscribing to this event, context with be populated with : + +* **entry point** : the command name +* **parameters** : the command arguments & parameters + +```php +addListener(ConsoleEvents::COMMAND, [$listener, 'onCommand']); + +$eventDispatcher->dispatch(ConsoleEvents::COMMAND, /* ...*/); +``` + + +--- + +« [README](../../README.md) diff --git a/docs/integration/symfony-framework.md b/docs/integration/symfony-framework.md new file mode 100644 index 0000000..318da06 --- /dev/null +++ b/docs/integration/symfony-framework.md @@ -0,0 +1,8 @@ +Symfony framework integration +----------------------------- + + + +--- + +« [README](../../README.md) diff --git a/docs/integration/symfony-security.md b/docs/integration/symfony-security.md new file mode 100644 index 0000000..eebe8f5 --- /dev/null +++ b/docs/integration/symfony-security.md @@ -0,0 +1,42 @@ +Symfony security integration +---------------------------- + +> **integration** when using `symfony/framework-bundle` [integration](../integration/symfony-framework.md) +services are registered for you according to the configuration you provided. + +> **note** this integration is highly coupled to the [event dispatcher](symfony-event-dispatcher.md) integration. + +If you wish to extract the version author from security token. + +When creating the [InitContextListener](../../src/Bridge/Symfony/EventDispatcher/EventListener/InitContextListener.php) +you need to provide an instance of token storage. + +Then your security user needs to implements the +[VersionAuthorInterface](../../src/VersionableAuthorInterface.php) interface. + +```php +id; + } +} +``` + + +--- + +« [README](../../README.md) diff --git a/docs/integration/symfony-serializer.md b/docs/integration/symfony-serializer.md new file mode 100644 index 0000000..50f6e41 --- /dev/null +++ b/docs/integration/symfony-serializer.md @@ -0,0 +1,63 @@ +Symfony serializer integration +------------------------------ + +> **integration** when using `symfony/framework-bundle` [integration](../integration/symfony-framework.md) +services are registered for you according to the configuration you provided. + + +### Taking snapshots + +If you wish to take objects snapshots using the serializer component. +Use this integration. + +```php +take($resource); +``` + + +--- + +« [README](../../README.md) diff --git a/docs/recipes/custom-author-storage.md b/docs/recipes/custom-author-storage.md new file mode 100644 index 0000000..dc0382e --- /dev/null +++ b/docs/recipes/custom-author-storage.md @@ -0,0 +1,40 @@ +Custom author storage +--------------------- + +Writing a custom author storage is as easy as +creating a class that implements the `Yokai\Versioning\Storage\AuthorStorageInterface` interface. + +```php +storage[$class][$id])) { + return null; + } + + return $this->storage[$class][$id]; + } +} +``` + +> **note** you may noticed that this class is implementing `Yokai\Versioning\Storage\ChainableAuthorStorageInterface` +which is a super interface that contains a `support`. +This interface is very convenient if your authors belongs to different storage. + + +--- + +« [README](../../README.md) diff --git a/docs/recipes/custom-object-finder.md b/docs/recipes/custom-object-finder.md new file mode 100644 index 0000000..61f6646 --- /dev/null +++ b/docs/recipes/custom-object-finder.md @@ -0,0 +1,49 @@ +Custom object finder +-------------------- + +Writing a custom object finder is as easy as +creating a class that implements the `Yokai\Versioning\Initialize\ObjectFinderInterface` interface. + +```php +storage[get_class($object)])) { + $this->storage[get_class($object)] = []; + } + + $this->storage[get_class($object)][] = $object; + } + + public function supports(string $class): bool + { + return isset($this->storage[$class]); + } + + public function find(string $class): iterable + { + return $this->storage[$class] ?? []; + } +} +``` + +> **note** you may noticed that this class is implementing `Yokai\Versioning\Storage\ChainableObjectFinderInterface` +which is a super interface that contains a `support`. +This interface is very convenient if your objects belongs to different storage. + +> **note** the `add` method is not part of the interface, it was added for as a convenient way to fill the storage. + + +--- + +« [README](../../README.md) diff --git a/docs/recipes/custom-resource-storage.md b/docs/recipes/custom-resource-storage.md new file mode 100644 index 0000000..22dcabc --- /dev/null +++ b/docs/recipes/custom-resource-storage.md @@ -0,0 +1,40 @@ +Custom author storage +--------------------- + +Writing a custom resource storage is as easy as +creating a class that implements the `Yokai\Versioning\Storage\ResourceStorageInterface` interface. + +```php +storage[$class][$id])) { + return null; + } + + return $this->storage[$class][$id]; + } +} +``` + +> **note** you may noticed that this class is implementing `Yokai\Versioning\Storage\ChainableResourceStorageInterface` +which is a super interface that contains a `support`. +This interface is very convenient if your resources belongs to different storage. + + +--- + +« [README](../../README.md) diff --git a/docs/recipes/custom-version-storage.md b/docs/recipes/custom-version-storage.md new file mode 100644 index 0000000..017919d --- /dev/null +++ b/docs/recipes/custom-version-storage.md @@ -0,0 +1,91 @@ +Custom version storage +---------------------- + +Writing a custom version storage is as easy as +creating a class that implements the `Yokai\Versioning\Storage\VersionStorageInterface` interface. + +```php +storage[] = $version; + } + } + + public function currentForResource(string $type, string $id): ?VersionInterface + { + $versions = $this->listForResource($type, $id); + if (count($versions) === 0) { + return null; + } + + $sort = function (VersionInterface $versionA, VersionInterface $versionB) { + return $versionA->getVersion() <=> $versionB->getVersion(); + }; + usort($versions, $sort); + + return end($versions); + } + + public function listForResource(string $type, string $id): iterable + { + $filter = function (VersionInterface $version) use ($type, $id) { + return $version->getResourceType() === $type && $version->getResourceId() === $id; + }; + + return array_filter($this->storage, $filter); + } + + public function listForResourceList(array ...$resources): iterable + { + $versions = []; + + foreach ($resources as list($resourceType, $resourceId)) { + foreach ($this->listForResource($resourceType, $resourceId) as $version) { + $versions[] = $version; + } + } + + return $versions; + } + + public function listForAuthor(string $type, string $id): iterable + { + $filter = function (VersionInterface $version) use ($type, $id) { + return $version->getAuthorType() === $type && $version->getAuthorId() === $id; + }; + + return array_filter($this->storage, $filter); + } + + public function listForAuthorList(array ...$authors): iterable + { + $versions = []; + + foreach ($authors as list($authorType, $authorId)) { + foreach ($this->listForAuthor($authorType, $authorId) as $version) { + $versions[] = $version; + } + } + + return $versions; + } +} +``` + + +--- + +« [README](../../README.md) diff --git a/src/Bridge/Doctrine/ORM/Entity/Version.php b/src/Bridge/Doctrine/ORM/Entity/Version.php new file mode 100644 index 0000000..539526f --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Entity/Version.php @@ -0,0 +1,177 @@ +resourceType = $resourceType; + $this->resourceId = $resourceId; + $this->version = $version; + $this->snapshot = $snapshot; + $this->changeset = $changeSet; + $this->authorType = $authorType; + $this->authorId = $authorId; + $this->contextEntryPoint = $contextEntryPoint; + $this->contextParameters = $contextParameters; + $this->loggedAt = $loggedAt; + } + + /** + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * @return string + */ + public function getResourceType(): string + { + return $this->resourceType; + } + + /** + * @return string + */ + public function getResourceId(): string + { + return $this->resourceId; + } + + /** + * @return int + */ + public function getVersion(): int + { + return $this->version; + } + + /** + * @return array + */ + public function getSnapshot(): array + { + return $this->snapshot; + } + + /** + * @return array + */ + public function getChangeset(): array + { + return $this->changeset; + } + + /** + * @return string|null + */ + public function getAuthorType(): ?string + { + return $this->authorType; + } + + /** + * @return string|null + */ + public function getAuthorId(): ?string + { + return $this->authorId; + } + + /** + * @return DateTimeImmutable + */ + public function getLoggedAt(): DateTimeInterface + { + return $this->loggedAt; + } + + /** + * @return null|string + */ + public function getContextEntryPoint(): ?string + { + return $this->contextEntryPoint; + } + + /** + * @return array + */ + public function getContextParameters(): array + { + return $this->contextParameters; + } +} diff --git a/src/Bridge/Doctrine/ORM/EventListener/CreateVersionListener.php b/src/Bridge/Doctrine/ORM/EventListener/CreateVersionListener.php new file mode 100644 index 0000000..30fb8da --- /dev/null +++ b/src/Bridge/Doctrine/ORM/EventListener/CreateVersionListener.php @@ -0,0 +1,142 @@ +updateGuesser = $updateGuesser; + $this->versionBuilder = $versionBuilder; + $this->versionStorage = $versionStorage; + } + + /** + * @inheritDoc + */ + public function getSubscribedEvents(): array + { + return [Events::onFlush, Events::postFlush]; + } + + public function onFlush(OnFlushEventArgs $event): void + { + $manager = $event->getEntityManager(); + $uow = $manager->getUnitOfWork(); + + foreach ($uow->getScheduledEntityInsertions() as $entity) { + $this->scheduledInsert($entity); + } + + foreach ($uow->getScheduledEntityUpdates() as $entity) { + $this->scheduledUpdate($entity); + } + + foreach ($uow->getScheduledEntityDeletions() as $entity) { + $this->scheduledDelete($entity); + } + } + + public function postFlush(): void + { + $versions = []; + + foreach ($this->versionableEntities as $oid => $versionable) { + // if the object has no id, it has been removed, just skip + if (!$versionable->getVersionableId()) { + continue; + } + + $this->versionedEntities[$oid] = true; + + $version = $this->versionBuilder->build($versionable); + + if (empty($version->getChangeset())) { + continue; + } + + $versions[] = $version; + } + + $this->versionableEntities = []; + + if (count($versions) === 0) { + return; + } + + $this->versionStorage->store($versions); + } + + private function scheduledInsert(object $entity): void + { + $this->addVersionableEntities( + $this->updateGuesser->guessUpdates($entity, UpdateGuesserInterface::ACTION_INSERT) + ); + } + + private function scheduledUpdate(object $entity): void + { + $this->addVersionableEntities( + $this->updateGuesser->guessUpdates($entity, UpdateGuesserInterface::ACTION_UPDATE) + ); + } + + private function scheduledDelete(object $entity): void + { + $this->addVersionableEntities( + $this->updateGuesser->guessUpdates($entity, UpdateGuesserInterface::ACTION_DELETE) + ); + } + + private function addVersionableEntities(iterable $resources): void + { + foreach ($resources as $versionable) { + $oid = spl_object_hash($versionable); + if (!isset($this->versionableEntities[$oid]) && !isset($this->versionedEntities[$oid])) { + $this->versionableEntities[$oid] = $versionable; + } + } + } +} diff --git a/src/Bridge/Doctrine/ORM/Initialize/EntityFinder.php b/src/Bridge/Doctrine/ORM/Initialize/EntityFinder.php new file mode 100644 index 0000000..9e92110 --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Initialize/EntityFinder.php @@ -0,0 +1,52 @@ +doctrine = $doctrine; + } + + /** + * @inheritDoc + */ + public function supports(string $class): bool + { + return $this->doctrine->getManagerForClass($class) !== null; + } + + /** + * @inheritdoc + */ + public function find(string $class): iterable + { + $query = $this->getManager()->createQueryBuilder() + ->select('e') + ->from($class, 'e'); + + foreach ($query->getQuery()->iterate() as $row) { + yield $row[0]; + } + } + + private function getManager(): EntityManagerInterface + { + $manager = $this->doctrine->getManager(); + if (!$manager instanceof EntityManagerInterface) { + throw new \LogicException('Expecting ORM manager.'); + } + + return $manager; + } +} diff --git a/src/Bridge/Doctrine/ORM/Metadata/Version.orm.xml b/src/Bridge/Doctrine/ORM/Metadata/Version.orm.xml new file mode 100644 index 0000000..58c4680 --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Metadata/Version.orm.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bridge/Doctrine/ORM/Purge/ObsoletePurger.php b/src/Bridge/Doctrine/ORM/Purge/ObsoletePurger.php new file mode 100644 index 0000000..3191648 --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Purge/ObsoletePurger.php @@ -0,0 +1,112 @@ +doctrine = $doctrine; + } + + /** + * @inheritDoc + */ + public function purge(): int + { + $manager = $this->getManager(); + + $removals = $this->findResourcesRemovals(); + + $manager->beginTransaction(); + + $count = 0; + + try { + foreach ($removals as $resourceClass => $resourceIds) { + $count += $this->deleteResourceVersions($resourceClass, $resourceIds); + } + $manager->commit(); + } catch (Throwable $exception) { + $manager->rollback(); + + throw $exception; + } + + return $count; + } + + private function findResourcesRemovals(): array + { + $removals = []; + + foreach ($this->findGroupedResourcesIds() as $resourceClass => $resourceIds) { + $query = $this->queryBuilder($resourceClass, 'resource'); + $query + ->select('resource.id') + ->where('resource.id IN (:ids)') + ->setParameter('ids', $resourceIds); + $foundIds = array_column( + $query->getQuery()->getArrayResult(), + 'id' + ); + + sort($resourceIds); + sort($foundIds); + + $removals[$resourceClass] = array_diff($resourceIds, $foundIds); + } + + return $removals; + } + + private function deleteResourceVersions(string $resourceClass, array $resourceIds): int + { + if (count($resourceIds) === 0) { + return 0; + } + + $query = $this->deleteVersionQueryBuilder() + ->where('version.resourceClass = :class') + ->andWhere('version.resourceId IN(:ids)') + ->setParameters(['class' => $resourceClass, 'ids' => $resourceIds]) + ->getQuery(); + + return intval($query->execute()); + } + + private function findGroupedResourcesIds(): array + { + $classesQuery = $this->versionQueryBuilder(); + $classesQuery->select('DISTINCT version.resourceClass'); + $classes = array_column( + $classesQuery->getQuery()->getArrayResult(), + 'resourceClass' + ); + + $idsByClass = []; + + foreach ($classes as $class) { + $idsQuery = $this->versionQueryBuilder(); + $idsQuery + ->select('DISTINCT version.resourceId') + ->where('version.resourceClass = :class') + ->setParameter('class', $class); + $idsByClass[$class] = array_column( + $idsQuery->getQuery()->getArrayResult(), + 'resourceId' + ); + } + + return $idsByClass; + } +} diff --git a/src/Bridge/Doctrine/ORM/Purge/OldPurger.php b/src/Bridge/Doctrine/ORM/Purge/OldPurger.php new file mode 100644 index 0000000..7e5764a --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Purge/OldPurger.php @@ -0,0 +1,60 @@ +doctrine = $doctrine; + $this->keepModifier = $keepModifier; + } + + /** + * @inheritDoc + */ + public function purge(): int + { + if (0 === count($ids = $this->findVersionIds())) { + return 0; + } + + $query = $this->deleteVersionQueryBuilder() + ->where('version.id IN (:ids)') + ->setParameter('ids', $ids) + ->getQuery(); + + return intval($query->execute()); + } + + /** + * @return array + */ + private function findVersionIds() + { + $limit = new DateTime(); + $limit->modify(sprintf('-%s', $this->keepModifier)); + + ($query = $this->versionQueryBuilder()) + ->select('version.id') + ->where('version.loggedAt < :date') + ->setParameter('date', $limit) + ; + + return array_column( + $query->getQuery()->getArrayResult(), + 'id' + ); + } +} diff --git a/src/Bridge/Doctrine/ORM/Purge/PurgerTrait.php b/src/Bridge/Doctrine/ORM/Purge/PurgerTrait.php new file mode 100644 index 0000000..ed2763d --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Purge/PurgerTrait.php @@ -0,0 +1,44 @@ +getManager()->createQueryBuilder() + ->delete(Version::class, $alias); + } + + private function versionQueryBuilder(string $alias = 'version'): QueryBuilder + { + return $this->queryBuilder(Version::class, $alias); + } + + private function queryBuilder(string $class, string $alias): QueryBuilder + { + return $this->getManager()->createQueryBuilder() + ->select($alias) + ->from($class, $alias); + } + + private function getManager(): EntityManagerInterface + { + $manager = $this->doctrine->getManager(); + if (!$manager instanceof EntityManagerInterface) { + throw new \LogicException('Expecting ORM manager.'); + } + + return $manager; + } +} diff --git a/src/Bridge/Doctrine/ORM/Storage/AuthorEntityStorage.php b/src/Bridge/Doctrine/ORM/Storage/AuthorEntityStorage.php new file mode 100644 index 0000000..482f767 --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Storage/AuthorEntityStorage.php @@ -0,0 +1,32 @@ +doctrine->getManagerForClass($class) !== null; + } + + /** + * @inheritDoc + */ + public function get(string $class, string $id): ?VersionableAuthorInterface + { + $author = $this->getManager($class)->find($class, $id); + if (!$author instanceof VersionableAuthorInterface) { + return null; + } + + return $author; + } +} diff --git a/src/Bridge/Doctrine/ORM/Storage/OrmRegistryTrait.php b/src/Bridge/Doctrine/ORM/Storage/OrmRegistryTrait.php new file mode 100644 index 0000000..f31ef1c --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Storage/OrmRegistryTrait.php @@ -0,0 +1,35 @@ +doctrine = $doctrine; + } + + private function getRepository(string $class): EntityRepository + { + return $this->getManager($class)->getRepository($class); + } + + private function getManager(string $class): EntityManager + { + $manager = $this->doctrine->getManagerForClass($class); + if (!$manager instanceof EntityManager) { + throw new \LogicException('Expecting ORM manager.'); + } + + return $manager; + } +} diff --git a/src/Bridge/Doctrine/ORM/Storage/ResourceEntityStorage.php b/src/Bridge/Doctrine/ORM/Storage/ResourceEntityStorage.php new file mode 100644 index 0000000..9223838 --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Storage/ResourceEntityStorage.php @@ -0,0 +1,46 @@ +doctrine->getManagerForClass($class) !== null; + } + + /** + * @inheritdoc + */ + public function get(string $class, string $id): ?VersionableResourceInterface + { + $author = $this->getManager($class)->find($class, $id); + if (!$author instanceof VersionableResourceInterface) { + return null; + } + + return $author; + } + + /** + * @inheritdoc + */ + public function list(string $class): iterable + { + $query = $this->getRepository($class) + ->createQueryBuilder('e') + ->getQuery(); + + foreach ($query->iterate() as $row) { + yield $row[0]; + } + } +} diff --git a/src/Bridge/Doctrine/ORM/Storage/VersionEntityStorage.php b/src/Bridge/Doctrine/ORM/Storage/VersionEntityStorage.php new file mode 100644 index 0000000..d3d2670 --- /dev/null +++ b/src/Bridge/Doctrine/ORM/Storage/VersionEntityStorage.php @@ -0,0 +1,129 @@ +getManager(Version::class); + $uow = $manager->getUnitOfWork(); + + if ($versions instanceof VersionInterface) { + $versions = [$versions]; + } + + foreach ($versions as $version) { + $manager->persist($version); + $uow->computeChangeSet($manager->getClassMetadata(Version::class), $version); + } + + $manager->flush(); + + foreach ($versions as $version) { + $manager->detach($version); + } + } + + /** + * @inheritDoc + */ + public function currentForResource(string $type, string $id): ?VersionInterface + { + $version = $this->getRepository(Version::class)->findOneBy( + ['resourceType' => $type, 'resourceId' => $id], + ['version' => 'desc'] + ); + + if (!$version instanceof VersionInterface) { + return null; + } + + return $version; + } + + /** + * @inheritDoc + */ + public function listForResource(string $type, string $id): iterable + { + return $this->getRepository(Version::class)->findBy( + ['resourceType' => $type, 'resourceId' => $id], + ['version' => 'desc'] + ); + } + + /** + * @inheritDoc + */ + public function listForResourceList(array ...$resources): iterable + { + $query = $this->getRepository(Version::class)->createQueryBuilder('version') + ->orderBy('version.version', 'desc'); + + foreach ($resources as $idx => list($type, $id)) { + $typeParameter = sprintf('type_%d', $idx); + $idParameter = sprintf('id_%d', $idx); + + $query + ->orWhere( + $query->expr()->andX( + sprintf('version.resourceType = :%s', $typeParameter), + sprintf('version.resourceId = :%s', $idParameter) + ) + ) + ->setParameter($typeParameter, $type) + ->setParameter($idParameter, $id) + ; + } + + return $query->getQuery()->execute(); + } + + /** + * @inheritDoc + */ + public function listForAuthor(string $type, string $id): iterable + { + return $this->getRepository(Version::class)->findBy( + ['authorType' => $type, 'authorId' => $id], + ['loggedAt' => 'desc'] + ); + } + + /** + * @inheritDoc + */ + public function listForAuthorList(array ...$authors): iterable + { + $query = $this->getRepository(Version::class)->createQueryBuilder('version') + ->orderBy('version.loggedAt', 'desc'); + + foreach ($authors as $idx => list($type, $id)) { + $typeParameter = sprintf('type_%d', $idx); + $idParameter = sprintf('id_%d', $idx); + + $query + ->orWhere( + $query->expr()->andX( + sprintf('version.authorType = :%s', $typeParameter), + sprintf('version.authorId = :%s', $idParameter) + ) + ) + ->setParameter($typeParameter, $type) + ->setParameter($idParameter, $id) + ; + } + + return $query->getQuery()->execute(); + } +} diff --git a/src/Bridge/Doctrine/ORM/VersionEntityFactory.php b/src/Bridge/Doctrine/ORM/VersionEntityFactory.php new file mode 100644 index 0000000..0fe4b9d --- /dev/null +++ b/src/Bridge/Doctrine/ORM/VersionEntityFactory.php @@ -0,0 +1,42 @@ +format(DATE_ISO8601)); + } + + return new Version( + $resource[0], + $resource[1], + $version, + $snapshot, + $changeSet, + $author[0], + $author[1], + $context[0], + $context[1], + $loggedAt + ); + } +} diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php new file mode 100644 index 0000000..4baa5d1 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -0,0 +1,98 @@ +root('yokai_versioning'); + + $rootNode + ->info('') + ->children() + ->end() + ; + + $this->addTypesSection($rootNode); + $this->addStorageSection($rootNode); + $this->addSnapshotSection($rootNode); + + return $treeBuilder; + } + + private function addTypesSection(ArrayNodeDefinition $rootNode): void + { + $notUniqueValues = function ($value) { + return count($value) === count(array_unique($value)); + }; + + $rootNode + ->children() + ->arrayNode('types') + ->info('') + ->children() + ->arrayNode('resources') + ->validate() + ->ifTrue($notUniqueValues) + ->thenInvalid('') + ->end() + ->useAttributeAsKey('name') + ->prototype('scalar')->info('')->end() + ->end() + ->arrayNode('authors') + ->validate() + ->ifTrue($notUniqueValues) + ->thenInvalid('') + ->end() + ->useAttributeAsKey('name') + ->prototype('scalar')->info('')->end() + ->end() + ->end() + ->end() + ->end() + ; + } + + private function addStorageSection(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('storage') + ->info('') + ->children() + ->scalarNode('version') + ->defaultValue('') + ->info('') + ->end() + ->end() + ->end() + ->end() + ; + } + + private function addSnapshotSection(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('snapshot') + ->info('') + ->children() + ->scalarNode('taker') + ->defaultValue('') + ->info('') + ->end() + ->end() + ->end() + ->end() + ; + } +} diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/YokaiVersioningExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/YokaiVersioningExtension.php new file mode 100644 index 0000000..8f29147 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/YokaiVersioningExtension.php @@ -0,0 +1,70 @@ +registerForAutoconfiguration(ChainableObjectFinderInterface::class) + ->addTag('yokai.versioning.initialize.object_finder'); + $container->registerForAutoconfiguration(ChainablePurgerInterface::class) + ->addTag('yokai.versioning.purger'); + $container->registerForAutoconfiguration(ChainableAuthorStorageInterface::class) + ->addTag('yokai.versioning.author_storage'); + $container->registerForAutoconfiguration(ChainableUpdateGuesserInterface::class) + ->addTag('yokai.versioning.update_guesser'); + + $loader->load('services.xml'); + + if (class_exists(Application::class)) { + $loader->load('console.xml'); + } + + if (class_exists(EntityManagerInterface::class)) { + $loader->load('doctrine-orm.xml'); + } + + if (class_exists(EventDispatcherInterface::class)) { + $loader->load('event-dispatcher.xml'); + $listener = $container->getDefinition('yokai.versioning.event_listener.init_context_listener'); + + if (class_exists(KernelEvents::class)) { + $listener->addTag('kernel.event_listener', ['name' => KernelEvents::REQUEST, 'method' => 'onRequest']); + } + if (class_exists(Application::class)) { + $listener->addTag('kernel.event_listener', ['name' => ConsoleEvents::COMMAND, 'method' => 'onCommand']); + } + } + + if (class_exists(Serializer::class)) { + $loader->load('serializer.xml'); + + if (class_exists(EntityManagerInterface::class)) { + $loader->load('serializer.doctrine-orm.xml'); + } + } + + $loader->load('aliases.xml'); + } +} diff --git a/src/Bridge/Symfony/Bundle/Resources/config/aliases.xml b/src/Bridge/Symfony/Bundle/Resources/config/aliases.xml new file mode 100644 index 0000000..9db1979 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/aliases.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/console.xml b/src/Bridge/Symfony/Bundle/Resources/config/console.xml new file mode 100644 index 0000000..482b720 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/console.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine-orm.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine-orm.xml new file mode 100644 index 0000000..819d693 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine-orm.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/event-dispatcher.xml b/src/Bridge/Symfony/Bundle/Resources/config/event-dispatcher.xml new file mode 100644 index 0000000..4a3cde8 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/event-dispatcher.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/serializer.doctrine-orm.xml b/src/Bridge/Symfony/Bundle/Resources/config/serializer.doctrine-orm.xml new file mode 100644 index 0000000..38be1b6 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/serializer.doctrine-orm.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/serializer.xml b/src/Bridge/Symfony/Bundle/Resources/config/serializer.xml new file mode 100644 index 0000000..2e0b4ca --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/serializer.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/services.xml b/src/Bridge/Symfony/Bundle/Resources/config/services.xml new file mode 100644 index 0000000..1a446ca --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/services.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/YokaiVersioningBundle.php b/src/Bridge/Symfony/Bundle/YokaiVersioningBundle.php new file mode 100644 index 0000000..b63c5ab --- /dev/null +++ b/src/Bridge/Symfony/Bundle/YokaiVersioningBundle.php @@ -0,0 +1,9 @@ +initializer = $initializer; + $this->typesConfig = $typesConfig; + } + + /** + * @inheritDoc + */ + protected function configure() + { + $this + ->setName('yokai:versioning:initialize') + ->setDescription('Initialize versions for versionable resources.') + ->addArgument('type', InputArgument::OPTIONAL, 'A resource type') + ->addOption('all', 'a', InputOption::VALUE_NONE, 'All resources') + ; + } + + /** + * @inheritDoc + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $type = $input->getArgument('type'); + $all = $input->getOption('all'); + + if (null === $type && false === $all) { + throw new RuntimeException('You must either provide "[type]" argument or "[-a|--all]" option.'); + } + + $types = []; + if (null !== $type) { + $types = [$type]; + } elseif ($all) { + $types = $this->typesConfig->listResourceTypes(); + } + + foreach ($types as $type) { + $this->initializer->initialize( + $this->typesConfig->getResourceClass($type) + ); + } + } +} diff --git a/src/Bridge/Symfony/Console/Command/PurgeCommand.php b/src/Bridge/Symfony/Console/Command/PurgeCommand.php new file mode 100644 index 0000000..66970ee --- /dev/null +++ b/src/Bridge/Symfony/Console/Command/PurgeCommand.php @@ -0,0 +1,47 @@ +purger = $purger; + } + + /** + * @inheritDoc + */ + protected function configure() + { + $this + ->setName('yokai:versioning:purge') + ->setDescription('Purge versions') + ; + } + + /** + * @inheritDoc + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $count = $this->purger->purge(); + + if ($output->isVeryVerbose()) { + $output->writeln( + sprintf('Total purged versions : %d.', $count) + ); + } + } +} diff --git a/src/Bridge/Symfony/EventDispatcher/EventListener/InitContextListener.php b/src/Bridge/Symfony/EventDispatcher/EventListener/InitContextListener.php new file mode 100644 index 0000000..0cb218a --- /dev/null +++ b/src/Bridge/Symfony/EventDispatcher/EventListener/InitContextListener.php @@ -0,0 +1,69 @@ +context = $context; + $this->tokenStorage = $tokenStorage; + } + + public function onCommand(ConsoleEvent $event): void + { + $command = $event->getCommand(); + if (null === $command) { + return; + } + + $input = $event->getInput(); + + $this->initialize( + $command->getName(), + array_merge($input->getArguments(), $input->getOptions()) + ); + } + + public function onRequest(GetResponseEvent $event): void + { + $request = $event->getRequest(); + + $this->initialize( + $request->attributes->get('_route'), + $request->attributes->get('_route_params', []) + ); + } + + private function initialize(?string $entryPoint, array $parameters): void + { + if ($entryPoint !== null) { + ksort($parameters); + $this->context->setEntryPoint($entryPoint); + $this->context->setParameters($parameters); + } + + if ($this->tokenStorage !== null + && (null !== $token = $this->tokenStorage->getToken()) + && ($author = $token->getUser()) instanceof VersionableAuthorInterface + ) { + $this->context->setAuthor($author); + } + } +} diff --git a/src/Bridge/Symfony/Serializer/Normalizer/DoctrineResourceNormalizer.php b/src/Bridge/Symfony/Serializer/Normalizer/DoctrineResourceNormalizer.php new file mode 100644 index 0000000..a8012ea --- /dev/null +++ b/src/Bridge/Symfony/Serializer/Normalizer/DoctrineResourceNormalizer.php @@ -0,0 +1,133 @@ +doctrine = $doctrine; + $this->propertyAccessor = $propertyAccessor; + } + + /** + * @inheritDoc + */ + public function supportsNormalization($data, $format = null) + { + return is_object($data) && $data instanceof VersionableResourceInterface + && $this->getClassMetadata($data) !== null; + } + + /** + * @inheritDoc + */ + public function normalize($object, $format = null, array $context = []) + { + $metadata = $this->getClassMetadata($object); + $identifiers = $metadata->getIdentifierFieldNames(); + + $normalized = []; + + foreach ($metadata->getFieldNames() as $property) { + if (in_array($property, $identifiers)) { + continue; //property is part of the identifier, not versioning it + } + + $value = $this->propertyAccessor->getValue($object, $property); + + switch ($metadata->getTypeOfField($property)) { + case Type::DATE: + if ($value instanceof DateTimeInterface) { + $value = $value->format('Y-m-d'); + } + break; + case Type::DATETIME: + if ($value instanceof DateTimeInterface) { + $value = $value->format('Y-m-d H:i:s'); + } + break; + } + + $normalized[$property] = $value; + } + + foreach ($metadata->getAssociationMappings() as $property => $mapping) { + if ($mapping['type'] & ClassMetadata::TO_MANY) { + continue; + } + + $value = $this->propertyAccessor->getValue($object, $property); + + if (is_object($value)) { + $associationMetadata = $this->getClassMetadata($value); + $identifiers = $associationMetadata->getIdentifierValues($value); + array_walk( + $identifiers, + function (&$identifier) { + $identifier = is_numeric($identifier) ? intval($identifier) : $identifier; + } + ); + + switch (count($identifiers)) { + case 0: + $value = null; + break; + case 1: + $value = reset($identifiers); + break; + default: + $value = $identifiers; + break; + } + } + + $normalized[$property] = $value; + } + + return $normalized; + } + + /** + * @param object $object + * + * @return ClassMetadata|null + */ + private function getClassMetadata(object $object): ?ClassMetadata + { + $class = ClassUtils::getClass($object); + + $manager = $this->doctrine->getManagerForClass($class); + + try { + $metadata = $manager->getMetadataFactory()->getMetadataFor($class); + if (!$metadata instanceof ClassMetadata) { + throw new \LogicException('Expecting ORM manager.'); + } + + return $metadata; + } catch (MappingException $exception) { + return null; + } + } +} diff --git a/src/Bridge/Symfony/Serializer/NormalizerSnapshotTaker.php b/src/Bridge/Symfony/Serializer/NormalizerSnapshotTaker.php new file mode 100644 index 0000000..dd6daec --- /dev/null +++ b/src/Bridge/Symfony/Serializer/NormalizerSnapshotTaker.php @@ -0,0 +1,31 @@ +normalizer = $normalizer; + } + + /** + * @inheritdoc + */ + public function take(VersionableResourceInterface $resource): array + { + $snapshot = $this->normalizer->normalize($resource); + ksort($snapshot); + + return $snapshot; + } +} diff --git a/src/Bridge/Symfony/Serializer/Serializer.php b/src/Bridge/Symfony/Serializer/Serializer.php new file mode 100644 index 0000000..6270123 --- /dev/null +++ b/src/Bridge/Symfony/Serializer/Serializer.php @@ -0,0 +1,23 @@ +create( + [Product::VERSION_TYPE, '1'], + 1, + ['id' => 1, 'name' => 'Spoon'], + ['id' => ['old' => '', 'new' => 1], 'name' => ['old' => '', 'new' => 'Spoon']], + [Admin::VERSION_TYPE, '1'], + ['route_to_update_product', ['id' => 1, 'context' => 'admin']], + Time::mutable() + ); + + self::assertInstanceOf(Version::class, $version); + /** @var $version Version */ + + self::assertNull($version->getId()); + self::assertSame(Product::VERSION_TYPE, $version->getResourceType()); + self::assertSame('1', $version->getResourceId()); + self::assertSame(1, $version->getVersion()); + self::assertSame(['id' => 1, 'name' => 'Spoon'], $version->getSnapshot()); + self::assertSame(['id' => ['old' => '', 'new' => 1], 'name' => ['old' => '', 'new' => 'Spoon']], $version->getChangeSet()); + self::assertSame(Admin::VERSION_TYPE, $version->getAuthorType()); + self::assertSame('1', $version->getAuthorId()); + self::assertInstanceOf(DateTimeImmutable::class, $version->getLoggedAt()); + self::assertSame(self::TIME, $version->getLoggedAt()->format('Y-m-d H:i:s')); + self::assertSame('route_to_update_product', $version->getContextEntryPoint()); + self::assertSame(['id' => 1, 'context' => 'admin'], $version->getContextParameters()); + } +} diff --git a/tests/Unit/Bridge/Symfony/Console/Command/InitializeCommandTest.php b/tests/Unit/Bridge/Symfony/Console/Command/InitializeCommandTest.php new file mode 100644 index 0000000..dbd2426 --- /dev/null +++ b/tests/Unit/Bridge/Symfony/Console/Command/InitializeCommandTest.php @@ -0,0 +1,102 @@ +initializer = $this->prophesize(Initializer::class); + } + + protected function tearDown(): void + { + $this->initializer = null; + } + + /** + * @test + */ + public function it_is_a_command(): void + { + $command = new InitializeCommand($this->initializer->reveal(), self::$typesConfig); + self::assertNotEmpty($command->getName()); + self::assertStringStartsWith('yokai:versioning:', $command->getName()); + self::assertNotEmpty($command->getDescription()); + self::assertCount(1, $command->getDefinition()->getArguments()); + self::assertTrue($command->getDefinition()->hasArgument('type')); + self::assertCount(1, $command->getDefinition()->getOptions()); + self::assertTrue($command->getDefinition()->hasOption('all')); + } + + /** + * @test + * @expectedException \Symfony\Component\Console\Exception\RuntimeException + */ + public function it_expect_argument_or_option(): void + { + $this->initializer->initialize(Argument::any())->shouldNotBeCalled(); + + $tester = new CommandTester(new InitializeCommand($this->initializer->reveal(), TypesConfigFactory::get())); + + $tester->execute([]); + } + + /** + * @test + */ + public function it_call_initializer_with_single_type(): void + { + $this->initializer->initialize(Product::class)->shouldBeCalledTimes(1); + + $tester = new CommandTester(new InitializeCommand($this->initializer->reveal(), TypesConfigFactory::get())); + + self::assertSame(0, $tester->execute(['type' => Product::VERSION_TYPE])); + } + + /** + * @test + */ + public function it_call_initializer_with_all_types(): void + { + $this->initializer->initialize(Product::class)->shouldBeCalledTimes(1); + $this->initializer->initialize(Order::class)->shouldBeCalledTimes(1); + $this->initializer->initialize(OrderItem::class)->shouldBeCalledTimes(1); + + $tester = new CommandTester(new InitializeCommand($this->initializer->reveal(), TypesConfigFactory::get())); + + self::assertSame(0, $tester->execute(['--all' => true])); + } +} diff --git a/tests/Unit/Bridge/Symfony/Console/Command/PurgeCommandTest.php b/tests/Unit/Bridge/Symfony/Console/Command/PurgeCommandTest.php new file mode 100644 index 0000000..a400fa1 --- /dev/null +++ b/tests/Unit/Bridge/Symfony/Console/Command/PurgeCommandTest.php @@ -0,0 +1,67 @@ +purger = $this->prophesize(PurgerInterface::class); + } + + protected function tearDown(): void + { + $this->purger = null; + } + + /** + * @test + */ + public function it_is_a_command(): void + { + $command = new PurgeCommand($this->purger->reveal()); + self::assertNotEmpty($command->getName()); + self::assertStringStartsWith('yokai:versioning:', $command->getName()); + self::assertNotEmpty($command->getDescription()); + self::assertCount(0, $command->getDefinition()->getArguments()); + self::assertCount(0, $command->getDefinition()->getOptions()); + } + + /** + * @test + * @dataProvider verbosity + */ + public function it_call_purger(int $verbosity, bool $display): void + { + $this->purger->purge()->shouldBeCalledTimes(1)->willReturn(10); + + $tester = new CommandTester(new PurgeCommand($this->purger->reveal())); + + self::assertSame(0, $tester->execute([], ['verbosity' => $verbosity])); + + if ($display) { + self::assertContains('Total purged versions : 10.', $tester->getDisplay(true)); + } + } + + public function verbosity(): \Generator + { + yield [OutputInterface::VERBOSITY_QUIET, false]; + yield [OutputInterface::VERBOSITY_NORMAL, false]; + yield [OutputInterface::VERBOSITY_VERBOSE, false]; + yield [OutputInterface::VERBOSITY_VERY_VERBOSE, true]; + yield [OutputInterface::VERBOSITY_DEBUG, true]; + } +} diff --git a/tests/Unit/Bridge/Symfony/EventDispatcher/EventListener/InitContextListenerTest.php b/tests/Unit/Bridge/Symfony/EventDispatcher/EventListener/InitContextListenerTest.php new file mode 100644 index 0000000..46ec0be --- /dev/null +++ b/tests/Unit/Bridge/Symfony/EventDispatcher/EventListener/InitContextListenerTest.php @@ -0,0 +1,169 @@ +context = new Context(); + $this->tokenStorage = $this->prophesize(TokenStorageInterface::class); + } + + protected function tearDown(): void + { + $this->context = $this->tokenStorage = null; + } + + /** + * @test + * @dataProvider toggleSecurityProvider + */ + public function it_cannot_initialize_without_command(bool $security): void + { + $listener = new InitContextListener($this->context, $security ? $this->tokenStorage->reveal() : null); + + $listener->onCommand( + new ConsoleEvent( + null, + $this->prophesize(InputInterface::class)->reveal(), + $this->prophesize(OutputInterface::class)->reveal() + ) + ); + + self::assertNull($this->context->getEntryPoint()); + self::assertSame([], $this->context->getParameters()); + self::assertNull($this->context->getAuthor()); + } + + /** + * @test + * @dataProvider tokenProvider + */ + public function it_initialize_with_command($token, VersionableAuthorInterface $author = null): void + { + if ($token !== false) { + $this->tokenStorage->getToken()->willReturn($token); + } + + $listener = new InitContextListener($this->context, $token !== false ? $this->tokenStorage->reveal() : null); + + /** @var InputInterface|ObjectProphecy $input */ + $input = $this->prophesize(InputInterface::class); + $input->getOptions()->shouldBeCalledTimes(1)->willReturn(['force' => true]); + $input->getArguments()->shouldBeCalledTimes(1)->willReturn(['type' => 'daily']); + + $listener->onCommand( + new ConsoleEvent( + new Command('product:import'), + $input->reveal(), + $this->prophesize(OutputInterface::class)->reveal() + ) + ); + + self::assertSame('product:import', $this->context->getEntryPoint()); + self::assertSame(['force' => true, 'type' => 'daily'], $this->context->getParameters()); + self::assertSame($author, $this->context->getAuthor()); + } + + /** + * @test + * @dataProvider toggleSecurityProvider + */ + public function it_cannot_initialize_without_route(bool $security): void + { + $listener = new InitContextListener($this->context, $security ? $this->tokenStorage->reveal() : null); + + $kernel = $this->prophesize(HttpKernelInterface::class); + $request = new Request(); + $listener->onRequest( + new GetResponseEvent($kernel->reveal(), $request, HttpKernelInterface::MASTER_REQUEST) + ); + + self::assertNull($this->context->getEntryPoint()); + self::assertSame([], $this->context->getParameters()); + self::assertNull($this->context->getAuthor()); + } + + /** + * @test + * @dataProvider tokenProvider + */ + public function it_initialize_with_route($token, VersionableAuthorInterface $author = null): void + { + if ($token !== false) { + $this->tokenStorage->getToken()->willReturn($token); + } + + $listener = new InitContextListener($this->context, $token !== false ? $this->tokenStorage->reveal() : null); + + $kernel = $this->prophesize(HttpKernelInterface::class); + $request = new Request(); + $request->attributes->set('_route', 'route_to_update_product'); + $request->attributes->set('_route_params', ['id' => 1, 'context' => 'admin']); + $listener->onRequest( + new GetResponseEvent($kernel->reveal(), $request, HttpKernelInterface::MASTER_REQUEST) + ); + + self::assertSame('route_to_update_product', $this->context->getEntryPoint()); + self::assertSame(['context' => 'admin', 'id' => 1], $this->context->getParameters()); + self::assertSame($author, $this->context->getAuthor()); + } + + public function toggleSecurityProvider(): \Generator + { + yield [true]; + yield [false]; + } + + public function tokenProvider(): \Generator + { + yield 'no security' => [false]; + yield 'no token' => [null]; + + /** @var TokenInterface|ObjectProphecy $tokenWithoutUser */ + $tokenWithoutUser = $this->prophesize(TokenInterface::class); + + yield 'token but no user' => [$tokenWithoutUser->reveal()]; + + $user = $this->prophesize(UserInterface::class)->reveal(); + /** @var TokenInterface|ObjectProphecy $tokenWithoutAuthor */ + $tokenWithoutAuthor = $this->prophesize(TokenInterface::class); + $tokenWithoutAuthor->getUser()->shouldBeCalledTimes(1)->willReturn($user); + + yield 'token with not author user' => [$tokenWithoutAuthor->reveal()]; + + $author = $this->prophesize(VersionableAuthorInterface::class)->reveal(); + /** @var TokenInterface|ObjectProphecy $tokenWithAuthor */ + $tokenWithAuthor = $this->prophesize(TokenInterface::class); + $tokenWithAuthor->getUser()->shouldBeCalledTimes(1)->willReturn($author); + + yield 'token with author user' => [$tokenWithAuthor->reveal(), $author]; + } +} diff --git a/tests/Unit/Bridge/Symfony/Serializer/SnapshotTakerTest.php b/tests/Unit/Bridge/Symfony/Serializer/SnapshotTakerTest.php new file mode 100644 index 0000000..1072dff --- /dev/null +++ b/tests/Unit/Bridge/Symfony/Serializer/SnapshotTakerTest.php @@ -0,0 +1,46 @@ +normalizer = $this->prophesize(NormalizerInterface::class); + } + + protected function tearDown(): void + { + $this->normalizer = null; + } + + /** + * @test + */ + public function it_normalize_object_to_take_a_snapshot(): void + { + $product = new Product('1'); + + $this->normalizer->normalize($product) + ->shouldBeCalledTimes(1) + ->willReturn(['name' => 'Spoon', 'price' => 0.99, 'id' => 1]); + + $snapshotTaker = new NormalizerSnapshotTaker($this->normalizer->reveal()); + + self::assertSame( + ['id' => 1, 'name' => 'Spoon', 'price' => 0.99], + $snapshotTaker->take($product) + ); + } +}