diff --git a/.github/workflows/auto_assign_owner.yml b/.github/workflows/auto_assign_owner.yml new file mode 100644 index 0000000..df3daed --- /dev/null +++ b/.github/workflows/auto_assign_owner.yml @@ -0,0 +1,13 @@ +name: Auto assign owner when opening PRs +on: + pull_request: + types: [ opened ] +jobs: + auto-assign-owner: + if: startsWith(github.event.ref, 'dependabot/') == false + runs-on: ubuntu-latest + steps: + - name: Auto assign owner + uses: danielswensson/auto-assign-owner-action@v1.0.2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 4afa4c7..241ce91 100644 --- a/README.md +++ b/README.md @@ -14,212 +14,551 @@ It also includes an implementation of the projection over the Symfony's Tag Awar ## Installation -1. Add this repository to your project composer.json. Copy the following configuration - ``` - "repositories": [ - ... - { - "type": "vcs", - "url": "https://github.com/kununu/projections.git", - "no-api": true - } - ] - ``` - -2. Require this library to your project - ``` - composer require kununu/projections - ``` - -3. If you wish to have projections implemented via Symfony's Tag Aware Cache Pool, you must also request the required packages for that implementation - ``` - composer require symfony/cache - composer require jms/serializer - ``` - If you want to use this library on a Symfony App you may want to require the `jms/serializer-bundle` instead of `jms/serializer` - ``` - composer require jms/serializer-bundle - ``` +### Add custom private repositories to composer.json + +```json +"repositories": [ + ... + { + "type": "vcs", + "url": "https://github.com/kununu/projections.git", + "no-api": true + } +] +``` + +### Require this library to your project + +```bash +composer require kununu/projections +``` + +### If you wish to have projections implemented via Symfony's Tag Aware Cache Pool, you must also request the required packages for that implementation + +```bash +composer require symfony/cache +composer require jms/serializer +``` + +If you want to use this library on a Symfony App you may want to require the `jms/serializer-bundle` instead of `jms/serializer` + +```bash +composer require jms/serializer-bundle +``` ## Usage -1. A projection is represented by an object that implements `ProjectionItem` interface. This object is called **projection item** and holds on its properties the data to be projected. +### `ProjectionItem` + +A projection is represented by an object that implements `ProjectionItem` interface. This object is called **projection item** and holds on its properties the data to be projected. - Here's an example of projection item: +Here's an example of projection item: - ``` - namespace Kununu\Example; +```php +namespace Kununu\Example; - class ExampleProjectionItem implements ProjectionItem +class ExampleProjectionItem implements ProjectionItem +{ + private $id; + private $someValue; + + public function __construct(string $id, string $someValue) { - private $id; - private $someValue; - - public function __construct(string $id, string $someValue) - { - $this->id = $id; - $this->someValue = $someValue; - } - - public function getKey(): string - { - return sprintf('example_projection_item_%s', $this->id); - } - - public function getTags(): Tags - { - return new Tags(new Tag('example_tag'), new Tag($this->id)); - } + $this->id = $id; + $this->someValue = $someValue; } - ``` - The `getKey()` and `getTags()` methods must be implemented. - - The `getKey()` method is the unique identifier of the projection. If projections are stored with the same key then they will be overridden. - - The `getTags()` method serves to mark the projection item with a tag. These can be used later for bulk operations on projections, like delete all projections with a certain tag. - -2. The package also offers an extension of `ProjectionItem` designed to store generic data (the data itself will be any PHP iterable, like an array). - - The interface is `ProjectionItemIterable` and must implement the following methods: - ``` - interface ProjectionItemIterable extends ProjectionItem + public function getKey(): string { - public function storeData(iterable $data): ProjectionItemArrayData; + return sprintf('example_projection_item_%s', $this->id); + } - public function data(): iterable; + public function getTags(): Tags + { + return new Tags(new Tag('example_tag'), new Tag($this->id)); } - ``` - A trait called `ProjectionItemIterableTrait` is provided with those methods already implemented and with the data stored as an array, so just use it your projection item classes and you're good to go. +} +``` + +The `getKey()` and `getTags()` methods must be implemented. + +The `getKey()` method is the unique identifier of the projection. If projections are stored with the same key then they will be overridden. + +The `getTags()` method serves to mark the projection item with a tag. These can be used later for bulk operations on projections, like delete all projections with a certain tag. + +### `ProjectionItemIterable` - Just bear in mind that the trait is only implementing the methods defined in `ProjectionItemIterable` and not those of `ProjectionItem` so it is still responsibility of your projection item class to implement them! +The package also offers an extension of `ProjectionItem` designed to store generic data (the data itself will be any PHP iterable, like an array). -3. The **projection item** is projected through a repository which implements `ProjectionRepository` interface. +The interface is `ProjectionItemIterable` and must implement the following methods: - This holds methods to get, add and delete the projections. The methods are used by passing a **projection item** object. +```php + interface ProjectionItemIterable extends ProjectionItem + { + public function storeData(iterable $data): ProjectionItemArrayData; - ``` - interface ProjectionRepository - { - public function add(ProjectionItem $item): void; + public function data(): iterable; + } +``` - public function addDeferred(ProjectionItem $item): void; +A trait called `ProjectionItemIterableTrait` is provided with those methods already implemented and with the data stored as an array, so just use it your projection item classes and you're good to go. - public function flush(): void; +Just bear in mind that the trait is only implementing the methods defined in `ProjectionItemIterable` and not those of `ProjectionItem` so it is still responsibility of your projection item class to implement them! - public function get(ProjectionItem $item): ?ProjectionItem; +### `ProjectionRepository` - public function delete(ProjectionItem $item): void; +The **projection item** is projected through a repository which implements `ProjectionRepository` interface. - public function deleteByTags(Tags $tags): void; - } - ``` +This holds methods to get, add and delete the projections. The methods are used by passing a **projection item** object. + + ```php + interface ProjectionRepository + { + public function add(ProjectionItem $item): void; + + public function addDeferred(ProjectionItem $item): void; + + public function flush(): void; + + public function get(ProjectionItem $item): ?ProjectionItem; - * `add()` method immediately projects the item. - * `addDeferred()` method sets items to be projected, but they are only projected when `flush()` is called. - * `get()` method gets a projected item. If it not projected, then `null` is returned. - * `delete()` method deletes item from projection - * `deleteByTags()` method deletes all projected items that have at least one of the tags passed as argument + public function delete(ProjectionItem $item): void; - Right now there is only an implementation of a repository, which projects the items using Symfony's Tag Aware Cache Pool component. - This repository is called `CachePoolProjectionRepository` + public function deleteByTags(Tags $tags): void; + } + ``` + + * `add()` method immediately projects the item. + * `addDeferred()` method sets items to be projected, but they are only projected when `flush()` is called. + * `get()` method gets a projected item. If it not projected, then `null` is returned. + * `delete()` method deletes item from projection + * `deleteByTags()` method deletes all projected items that have at least one of the tags passed as argument + +Right now there is only an implementation of a repository, which projects the items using Symfony's Tag Aware Cache Pool component. + +This repository is called `CachePoolProjectionRepository`. ### Using `CachePoolProjectionRepository` -1. Besides the Symfony's Tag Aware Cache Pool interface, this repository uses the JMS Serializer. The following snippet is the repository's constructor. - ``` + +#### Serialization + +Besides the Symfony's Tag Aware Cache Pool interface, this repository uses the JMS Serializer. The following snippet is the repository's constructor: + +```php public function __construct(TagAwareAdapterInterface $cachePool, SerializerInterface $serializer) { $this->cachePool = $cachePool; $this->serializer = $serializer; } - ``` - - So there is the need to define the serialization config for the Projection items. For instance, for the previous `ExampleProjectionItem` example, here is an example of the JMS Serializer XML config for this class: - ``` - - - - - - - - ``` - This should be saved in a `ExampleProjectionItem.xml` file. - - The data that you want projected needs exist on the serializer config in order to be actually projected. In this example you can see that the two properties of the projection item are on the config. - - This configuration needs to be loaded into the JMS Serializer and the repository needs to be instantiated in order to be used. - -2. Usage with Symfony ^4.0 - - Create a cache pool with Symfony config. Here's an example for the cache pool to use Memcached: - ``` - framework: - cache: - prefix_seed: "example" - default_memcached_provider: "memcached://172.0.0.1:1121" - pools: - example.cache.projections: - adapter: cache.adapter.memcached - default_lifetime: 3600 +``` - ``` - This automatically creates a `example.cache.projections` service. In this case the lifetime for the projections is 3600 seconds = 1 hour. +So there is the need to define the serialization config for the Projection items. For instance, for the previous `ExampleProjectionItem` example, here is an example of the JMS Serializer XML config for this class: + +```xml + + + + + + + +``` + +This should be saved in a `ExampleProjectionItem.xml` file. - Here is assumed that the `jms/serializer-bundle` was required. The minimum configuration you need for the JMS Serializer Bundle is: - ``` - jms_serializer: - metadata: - directories: - projections: - namespace_prefix: "Kununu\Example" - path: "%kernel.root_dir%/Repository/Resources/config/serializer" - ``` +The data that you want projected needs exist on the serializer config in order to be actually projected. In this example you can see that the two properties of the projection item are on the config. - where `%kernel.root_dir%/Repository/Resources/config/serializer` is the directory where is the JMS Serializer configuration files for the projection items, which means the previous `ExampleProjectionItem.xml` file is inside. +This configuration needs to be loaded into the JMS Serializer and the repository needs to be instantiated in order to be used. + +#### Usage with Symfony >= 4.0 + +Create a cache pool with Symfony config. Here's an example for the cache pool to use Memcached: + +```yaml +framework: + cache: + prefix_seed: "example" + default_memcached_provider: "memcached://172.0.0.1:1121" + pools: + example.cache.projections: + adapter: cache.adapter.memcached + default_lifetime: 3600 - Please notice that the namespace prefix of the projection item class is also defined in here. +``` + +This automatically creates a `example.cache.projections` service. In this case the lifetime for the projections is 3600 seconds = 1 hour. - Next define the `CachePoolProjectionRepository` as a Symfony service: - ``` - services: - _defaults: - autowire: true - autoconfigure: true +Here is assumed that the `jms/serializer-bundle` was required. The minimum configuration you need for the JMS Serializer Bundle is: + +```yaml +jms_serializer: + metadata: + directories: + projections: + namespace_prefix: "Kununu\Example" + path: "%kernel.root_dir%/Repository/Resources/config/serializer" +``` - Kununu/Projections/Repository/CachePoolProjectionRepository: - class: Kununu\Projections\Repository\CachePoolProjectionRepository - arguments: - - '@example.cache.projections' - - '@jms_serializer' +where `%kernel.root_dir%/Repository/Resources/config/serializer` is the directory where is the JMS Serializer configuration files for the projection items, which means the previous `ExampleProjectionItem.xml` file is inside. - example.cache.projections.tagged: - class: Symfony\Component\Cache\Adapter\TagAwareAdapter - decorates: 'example.cache.projections' - ``` - Note that the `TagAwareAdapter` is added as a decorator for the cache pool service. +Please notice that the namespace prefix of the projection item class is also defined in here. - Now you can inject the repository's service. Example: - ``` - App\Infrastructure\UseCase\Query\GetProfileCommonByUuid\DataProvider\ProjectionDataProvider: - arguments: - - '@Kununu/Projections/Repository/CachePoolProjectionRepository' +Next define the `CachePoolProjectionRepository` as a Symfony service: + +```yaml +services: + _defaults: + autowire: true + autoconfigure: true + + Kununu/Projections/Repository/CachePoolProjectionRepository: + class: Kununu\Projections\Repository\CachePoolProjectionRepository + arguments: + - '@example.cache.projections' + - '@jms_serializer' + + example.cache.projections.tagged: + class: Symfony\Component\Cache\Adapter\TagAwareAdapter + decorates: 'example.cache.projections' +``` + +Note that the `TagAwareAdapter` is added as a decorator for the cache pool service. - ``` +Now you can inject the repository's service. Example: + +```yaml +App\Infrastructure\UseCase\Query\GetProfileCommonByUuid\DataProvider\ProjectionDataProvider: + arguments: + - '@Kununu/Projections/Repository/CachePoolProjectionRepository' +``` - And inside the respective class we should depend only on the `ProjectionRepository` interface. - ``` - class ProjectionDataProvider +And inside the respective class we should depend only on the `ProjectionRepository` interface. + +```php +class ProjectionDataProvider +{ + private $projectionRepository; + + public function __construct(ProjectionRepository $projectionRepository) { - private $projectionRepository; + $this->projectionRepository = $projectionRepository; + } + + ... +} +``` - public function __construct(ProjectionRepository $projectionRepository) - { - $this->projectionRepository = $projectionRepository; - } +Now we can start reading, setting and deleting from the cache pool :) + + +### `CacheCleaner` + +Sometimes we need to force the cleaning of caches. In order to do this the library offers an interface called `CacheCleaner`: + +```php +interface CacheCleaner +{ + public function clear(): void; +} +``` + +It only has one method called `clear` which should as the name says clear the data on the cache. + +#### AbstractCacheCleanerByTags + +The interface `CacheCleaner` by itself is not really useful. One of the most common cases when cleaning/invalidating caches is to delete a series of data. + +The `AbstractCacheCleanerByTags` provides a base class that will allow you to invalidate cache items by **Tags**. + +As we already have seen, the `ProjectionRepository` already has a method called `deleteByTags`, so this class will combine that usage and abstract it. + +So your cache cleaner class by tags should be instantiated with a `ProjectionRepository` instance (and also with a PSR logger instance), and simply implement the `getTags` method which must return the `Tags` collection that will be passed to the `deleteByTags` on the repository instance. + + +```php +public function __construct(ProjectionRepository $projectionRepository, LoggerInterface $logger); + + +abstract protected function getTags(): Tags; +``` + +Example: + +```php + +use Kununu\Projections\CacheCleaner\CacheCleaner; +use Kununu\Projections\CacheCleaner\AbstractCacheCleanerByTags; + +class MyCacheCleaner extends AbstractCacheCleanerByTags +{ + protected function getTags(): Tags + { + return new Tags( + new Tag('my-tag1'), + new Tag('my-tag2') + ); + } +}; + +class MyClass +{ + private $cacheCleaner; + + public function __construct(CacheCleaner $cacheCleaner) + { + $this->cacheCleaner = $cacheCleaner; + } + + + public function myMethod(...$myArguments): void + { + $this->cacheCleaner->clear(); + } +} + +$cacheCleaner = new MyCacheCleaner($myProjectionRepo, $myLogger); +$myClass = new MyClass($cacheCleaner); + +// When I call `myMethod` it will call `MyCacheCleaner` and delete all cache entries that +// are tagged with 'my-tag1' and 'my-tag2' +$myClass->myMethod(); + +``` + +##### AbstractCacheCleanerTestCase + +In order to help you unit testing your cache cleaners implementations the `AbstractCacheCleanerTestCase` exists for that purpose. + +Just make you test class extend it and override the `TAGS` constant and implement the `getCacheCleaner` method. + +Example: + +```php +final class MyCacheCleanerTest extends AbstractCacheCleanerTestCase +{ + protected const TAGS = ['my-tag1', 'my-tag2']; + + protected function getCacheCleaner(ProjectionRepository $projectionRepository, LoggerInterface $logger): CacheCleaner + { + // Return a new instance of your cache cleaner implementation + return new MyCacheCleaner($projectionRepository, $logger); + } +} +``` + +The `TAGS` constant must be the tags that you expect that your cache cleaner class will use. + +For the example above we are expecting that `MyCacheCleaner::getTags` will return a `Tags` collection with the same tags defined in the constant, e.g.: + +```php +class MyCacheCleaner extends AbstractCacheCleanerByTags +{ + protected function getTags(): Tags + { + return new Tags(new Tag('my-tag1'), new Tag('my-tag2')); + } +} +``` + +### `CacheCleanerChain` + +What if you want to clear more than one cache/more than one set of tags? Easy, you create a `CacheCleanerChain`. + +This class is constructed by passing the desired instances of your classes that implement the `CacheCleaner` interface (which could be sub-classes of `AbstractCacheCleanerByTags`) and then (as itself also implements the `CacheCleaner` interface) just call the `clear` method. + +Example: + +```php + +use Kununu\Projections\CacheCleaner\CacheCleaner; +use Kununu\Projections\CacheCleaner\AbstractCacheCleanerByTags; + + +// Continuing our example, let's add more cache cleaners... + +class MySecondCacheCleaner extends AbstractCacheCleanerByTags +{ + protected function getTags(): Tags + { + return new Tags(new Tag('my-tag3')); + } +}; + + +class MyThirdCacheCleaner implements CacheCleaner +{ + public function clear(): void + { + // Here I am deleting my cache by using some other process... + } +} + +$cacheCleaner1 = new MyCacheCleaner($myProjectionRepo, $myLogger); +$cacheCleaner2 = new MySecondCacheCleaner($myProjectionRepo, $myLogger); +$cacheCleaner3 = new MyThirdCacheCleaner(); + +$cacheCleaner = new CacheCleanerChain( + $cacheCleaner1, + $cacheCleaner2, + $cacheCleaner3 +); + +$myClass = new MyClass($cacheCleaner); + +// When I call `myMethod` it will clear all the caches as defined on each cleaner injected into the chain $cacheCleaner +$myClass->myMethod(); + +``` + +### `AbstractCachedProvider` + +As projections are being used to project data to a cache provider we might end up having the need to create a "provider" for data that will check if the data is already on cache and if not try to fetch from the "real" source and then store it on cache (e.g. create a projection). + +We can even use the **Decorator** pattern to achieve this. + +Usually the flow is always the same. + +- Get item from the cache +- Cache was hit? Return the data retrieved from cache +- Cache was miss? Call the original provider to fetch the data + - Data was found? + - Store it on the cache + - Return the data +- Rinse and repeat... + +So the `AbstractCachedProvider` will help you in reducing the boiler plate for those scenarios. + +Your "provider" class should extend it and for each method where you need to use the flow described above you just need to call the `getAndCacheData` method: + +```php +protected function getAndCacheData(ProjectionItemIterable $item, callable $dataGetter): ?iterable; +``` + +- The `$item` parameter is a projection item that will be used to build the cache key +- The `$dataGetter` is your custom function that should return an `iterable` with you data or null if no data was found + +An example: + +```php +interface MyProviderInterface +{ + public function getCustomerData(int $customerId): ?iterable; +} + +class MyProvider implements MyProviderInterface +{ + public function getCustomerData(int $customerId): ?iterable + { + // Let's grab the data from someplace (e.g. a database)... + $result = ... + + return $result; + } +} + +class MyCachedProvider extends AbstractCachedProvider implements MyProviderInterface +{ + private $myProvider; - ... + public function __construct(MyProviderInterface $myProvider, ProjectionRepository $projectionRepository, LoggerInterface $logger) + { + parent::__construct($projectionRepository, $logger); + $this->myProvider = $myProvider; } - ``` - Now we can start reading, setting and deleting from the cache pool :) + public function getCustomerData(int $customerId): ?iterable + { + return $this->getAndCacheData( + new CustomerByIdProjectionItem($customerId), + function() use ($customerId): ?iterable { + return $this->myProvider->getCustomerData($customerId); + } + ); + } +} + +$projectionRepository = // Get/build your projection/repository +$logger = // Get/build your logger +$myProvider = // Get/build your "original" provider + +$cachedProvider = new MyCachedProvider($myProvider, $projectionRepository, $logger); + +$data = $cachedProvider->getCustomerData(152); +``` + +#### CachedProviderTestCase + +In order to help you unit testing your cached providers implementations the `CachedProviderTestCase` exists for that purpose. + +Just make you test class extend it and override the `METHODS` constant and implement the `getProvider` method. + +The `getProvider` is were you should create the "decorated" cached provider you want to test. E.g: + +```php +protected function getProvider($originalProvider): AbstractCachedProvider +{ + return new MyCachedProvider($originalProvider, $this->getProjectionRepository(), $this->getLogger()); +} +``` + +You don't need to mock the projection repository neither the logger. Just create an instance of your cached provider. + +The `$originalProvider` will be an instance/mock of your original provider. + +The `METHODS` constant should contain the methods of your provider class. + +For our example above to test the `getCustomerData` method: + +```php + protected const METHODS = [ + 'getCustomerData', + ]; + +``` + +Now, for each method defined in the `METHODS` constant you need to create a PHPUnit data provider method. + +So in this case you would have to create a method called `getCustomerDataDataProvider`: + +```php +public function getCustomerDataDataProvider(): array +{ + return [ + 'my_test_case_1' => [ + $originalProvider, // An instance/mock of your original provider + $method, // Should be 'getCustomerData' as this is a test case for that method + $args, // Arguments to your method (int this case: [123 <- $customerId]) + $item, // Projection item to search in cache (e.g. new CustomerByIdProjectionItem(123)) + $projectedItem, // Projected item to be return by the projection repository (null to simulate a cache miss) + $expectedProviderData, // Expected result + ] + ]; +} +``` + +If you want to mock the original provider you can do it with the `createExternalProvider`: + +```php +protected function createExternalProvider(string $providerClass, string $method, array $args, bool $expected, ?iterable $data); +``` + +- `$providerClass` - The class/interface of your original provider +- `$method` - The method you are mocking +- `$args` - The expected arguments to the method +- `$expected` - If the call is expected to return data +- `$data` - The data to return + +Example: + +```php +$originalProvider = $this->createExternalProvider( + MyProviderInterface::class, + 'getCustomerData', + [123], + true, + [ + 'id' => 123, + 'name' => 'My Customer Name' + ] +); +``` diff --git a/composer.json b/composer.json index 0f2055e..2f23a9f 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "symfony/browser-kit": "^4.4", "symfony/cache": "^4.4", "jms/serializer-bundle": "^2.0", - "kununu/testing-bundle": "^13.0", + "kununu/testing-bundle": "^14.0", "kununu/scripts": "*" }, "suggest": { diff --git a/phpunit.xml b/phpunit.xml.dist similarity index 71% rename from phpunit.xml rename to phpunit.xml.dist index d6a62c9..c0566ff 100644 --- a/phpunit.xml +++ b/phpunit.xml.dist @@ -1,7 +1,8 @@ - + @@ -19,8 +20,11 @@ - + src/ + + src/TestCase + diff --git a/src/CacheCleaner/AbstractCacheCleanerByTags.php b/src/CacheCleaner/AbstractCacheCleanerByTags.php new file mode 100644 index 0000000..28d9462 --- /dev/null +++ b/src/CacheCleaner/AbstractCacheCleanerByTags.php @@ -0,0 +1,30 @@ +projectionRepository = $projectionRepository; + $this->logger = $logger; + } + + public function clear(): void + { + $tags = $this->getTags(); + + $this->logger->info('Deleting tagged cache items', ['tags' => $tags->raw()]); + $this->projectionRepository->deleteByTags($tags); + } + + abstract protected function getTags(): Tags; +} diff --git a/src/CacheCleaner/CacheCleaner.php b/src/CacheCleaner/CacheCleaner.php new file mode 100644 index 0000000..ab61225 --- /dev/null +++ b/src/CacheCleaner/CacheCleaner.php @@ -0,0 +1,9 @@ +cacheCleaners = $cacheCleaners; + } + + public function clear(): void + { + foreach ($this->cacheCleaners as $cacheCleaner) { + $cacheCleaner->clear(); + } + } +} diff --git a/src/Provider/AbstractCachedProvider.php b/src/Provider/AbstractCachedProvider.php new file mode 100644 index 0000000..fc29e92 --- /dev/null +++ b/src/Provider/AbstractCachedProvider.php @@ -0,0 +1,54 @@ +projectionRepository = $projectionRepository; + $this->logger = $logger; + } + + protected function getAndCacheData(ProjectionItemIterable $item, callable $dataGetter): ?iterable + { + $this->logger->info('Getting data from cache', [self::CACHE_KEY => $item->getKey()]); + + $projectedItem = $this->projectionRepository->get($item); + if ($projectedItem instanceof ProjectionItemIterable) { + $this->logger->info( + 'Item hit! Returning data from the cache', + [ + self::CACHE_KEY => $item->getKey(), + self::DATA => $projectedItem->data(), + ] + ); + + return $projectedItem->data(); + } + + $this->logger->info('Item not hit! Fetching data...', [self::CACHE_KEY => $item->getKey()]); + $data = $dataGetter(); + if (is_iterable($data)) { + $this->projectionRepository->add($item->storeData($data)); + $this->logger->info('Item saved into cache and returned', [self::CACHE_KEY => $item->getKey(), self::DATA => $data]); + + return $data; + } + + $this->logger->info('No data fetched and stored into cache!', [self::CACHE_KEY => $item->getKey()]); + + return null; + } +} diff --git a/src/TestCase/CacheCleaner/AbstractCacheCleanerTestCase.php b/src/TestCase/CacheCleaner/AbstractCacheCleanerTestCase.php new file mode 100644 index 0000000..0edb715 --- /dev/null +++ b/src/TestCase/CacheCleaner/AbstractCacheCleanerTestCase.php @@ -0,0 +1,69 @@ +cachePool + ->expects($this->once()) + ->method('invalidateTags') + ->with(static::TAGS) + ->willReturn(true); + + $this->logger + ->expects($this->once()) + ->method('info') + ->with('Deleting tagged cache items', ['tags' => static::TAGS]); + + $this->cacheCleaner->clear(); + } + + public function testCacheCleanerFail(): void + { + $this->cachePool + ->expects($this->once()) + ->method('invalidateTags') + ->with(static::TAGS) + ->willReturn(false); + + $this->logger + ->expects($this->once()) + ->method('info') + ->with('Deleting tagged cache items', ['tags' => static::TAGS]); + + $this->expectException(ProjectionException::class); + $this->expectExceptionMessage('Not possible to delete projection items on cache pool based on tag'); + + $this->cacheCleaner->clear(); + } + + abstract protected function getCacheCleaner(ProjectionRepository $projectionRepository, LoggerInterface $logger): CacheCleaner; + + protected function setUp(): void + { + $this->cachePool = $this->createMock(TagAwareAdapterInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->cacheCleaner = $this->getCacheCleaner( + new CachePoolProjectionRepository($this->cachePool, $this->createMock(SerializerInterface::class)), + $this->logger + ); + } +} diff --git a/src/TestCase/Provider/CachedProviderTestCase.php b/src/TestCase/Provider/CachedProviderTestCase.php new file mode 100644 index 0000000..ced4a2c --- /dev/null +++ b/src/TestCase/Provider/CachedProviderTestCase.php @@ -0,0 +1,122 @@ +getProjectionRepository()) + ->expects($this->once()) + ->method('get') + ->with($item) + ->willReturn($projectedItem); + + // Cache miss + if (null === $projectedItem && is_iterable($providerData)) { + // Get data from provider + $repository + ->expects($this->once()) + ->method('add') + ->with((clone $item)->storeData($providerData)); + } + + $result = call_user_func_array([$this->getProvider($originalProvider), $method], $args); + + $this->assertEquals($providerData, $result); + } + + public function getAndCacheDataDataProvider(): array + { + $data = []; + foreach (static::METHODS as $method) { + $methodDataProvider = sprintf('%sDataProvider', $method); + $methodData = call_user_func_array([$this, $methodDataProvider], []); + foreach ($methodData as $dataName => $values) { + $data[sprintf('%s_%s', $method, $dataName)] = $values; + } + } + + return $data; + } + + abstract protected function getProvider($originalProvider): AbstractCachedProvider; + + /** + * @param string $providerClass + * @param string $method + * @param array $args + * @param bool $expected + * @param iterable|null $data + * + * @return mixed|MockObject + */ + protected function createExternalProvider(string $providerClass, string $method, array $args, bool $expected, ?iterable $data) + { + $provider = $this->createMock($providerClass); + $invocationMocker = $provider + ->expects($expected ? $this->once() : $this->never()) + ->method($method) + ->with(...$args); + + if ($expected) { + $invocationMocker->willReturn($data); + } + + return $provider; + } + + /** + * @return ProjectionRepository|MockObject + */ + protected function getProjectionRepository(): ProjectionRepository + { + if (null === $this->projectionRepository) { + $this->projectionRepository = $this->createMock(ProjectionRepository::class); + } + + return $this->projectionRepository; + } + + /** + * @return LoggerInterface|MockObject + */ + protected function getLogger(): LoggerInterface + { + if (null === $this->logger) { + $this->logger = $this->createMock(LoggerInterface::class); + } + + return $this->logger; + } +} diff --git a/tests/Functional/Repository/CachePoolProjectionRepositoryTest.php b/tests/Functional/Repository/CachePoolProjectionRepositoryTest.php index 42dccea..87757a1 100644 --- a/tests/Functional/Repository/CachePoolProjectionRepositoryTest.php +++ b/tests/Functional/Repository/CachePoolProjectionRepositoryTest.php @@ -1,4 +1,5 @@ -loadCachePoolFixtures('app.cache.projections', []); - $this->projectionRepository = self::getContainer()->get(CachePoolProjectionRepository::class); + $this->projectionRepository = $this->getFixturesContainer()->get(CachePoolProjectionRepository::class); $projectionItemStub = new ProjectionItemStub('an_identifier'); $this->projectionRepository->add($projectionItemStub); diff --git a/tests/Unit/CacheCleaner/AbstractCacheCleanerByTagsTest.php b/tests/Unit/CacheCleaner/AbstractCacheCleanerByTagsTest.php new file mode 100644 index 0000000..f8535c5 --- /dev/null +++ b/tests/Unit/CacheCleaner/AbstractCacheCleanerByTagsTest.php @@ -0,0 +1,27 @@ +createCacheCleaner(), + $this->createCacheCleaner(), + $this->createCacheCleaner() + ); + + $chain->clear(); + } + + private function createCacheCleaner(): CacheCleaner + { + $cleaner = $this->createMock(CacheCleaner::class); + + $cleaner + ->expects($this->once()) + ->method('clear'); + + return $cleaner; + } +} diff --git a/tests/Unit/Provider/AbstractCachedProviderTest.php b/tests/Unit/Provider/AbstractCachedProviderTest.php new file mode 100644 index 0000000..b392c5c --- /dev/null +++ b/tests/Unit/Provider/AbstractCachedProviderTest.php @@ -0,0 +1,65 @@ + 2, + 'name' => 'The Name of 2', + 'age' => 22, + ]; + + $dataCached = [ + 'id' => 2, + 'name' => 'The Name of 2 cached', + 'age' => 22, + ]; + + $originalProvider = new MyProviderStub(); + + return [ + 'cache_miss_and_data_from_external_provider' => [ + $originalProvider, + self::METHOD_GET_DATA, + [2], + new MyStubProjectionItem(2), + null, + $data, + ], + 'cache_miss_and_no_data_from_external_provider' => [ + $originalProvider, + self::METHOD_GET_DATA, + [1], + new MyStubProjectionItem(1), + null, + null, + ], + 'cache_hit' => [ + $originalProvider, + self::METHOD_GET_DATA, + [2], + new MyStubProjectionItem(2), + (new MyStubProjectionItem(2))->storeData($dataCached), + $dataCached, + ], + ]; + } + + protected function getProvider($originalProvider): AbstractCachedProvider + { + return new MyCachedProviderStub($originalProvider, $this->getProjectionRepository(), $this->getLogger()); + } +} diff --git a/tests/Unit/Provider/MyCachedProviderStub.php b/tests/Unit/Provider/MyCachedProviderStub.php new file mode 100644 index 0000000..46db90d --- /dev/null +++ b/tests/Unit/Provider/MyCachedProviderStub.php @@ -0,0 +1,29 @@ +provider = $provider; + } + + public function getData(int $id): ?array + { + return $this->getAndCacheData( + new MyStubProjectionItem($id), + function() use ($id): ?array { + return $this->provider->getData($id); + } + ); + } +} diff --git a/tests/Unit/Provider/MyProviderStub.php b/tests/Unit/Provider/MyProviderStub.php new file mode 100644 index 0000000..a74860c --- /dev/null +++ b/tests/Unit/Provider/MyProviderStub.php @@ -0,0 +1,15 @@ + $id, 'name' => sprintf('The Name of %d', $id), 'age' => 20 + $id]; + } +} diff --git a/tests/Unit/Provider/MyProviderStubInterface.php b/tests/Unit/Provider/MyProviderStubInterface.php new file mode 100644 index 0000000..bf00d38 --- /dev/null +++ b/tests/Unit/Provider/MyProviderStubInterface.php @@ -0,0 +1,9 @@ +id = $id; + } + + public function getKey(): string + { + return sprintf('my_data_%d', $this->id); + } + + public function getTags(): Tags + { + return self::createTagsFromArray('my_tag'); + } +}