diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f49b9..74433b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Latest ------ ### Changes +* [#67](https://github.com/cleverage/cache-process-bundle/issues/67) A Symfony cache pool (a service implementing `Symfony\Component\Cache\Adapter\AdapterInterface`, e.g. `parent: cache.adapter.filesystem` or `class: ArrayAdapter`) can be tagged directly `cleverage.cache.adapter` with a `code`, without any adapter class: `RegisterAdaptersPass` wraps it in an `Adapter` with this code, loaded lazily. A tagged service implementing neither this interface nor the `AdapterInterface` of the bundle fails the container compilation. Update documentation, add tests. * [#65](https://github.com/cleverage/cache-process-bundle/issues/65) Configuring a key under `clever_age_cache_process` is deprecated: the bundle has no configuration and the keys were ignored without notice (new internal `Configuration`, empty; notice when the container is compiled). They will be rejected in 3.0. Require `symfony/deprecation-contracts`, used directly. Update documentation, add tests. * [#63](https://github.com/cleverage/cache-process-bundle/issues/63) The bundle throws exceptions implementing `ProcessExceptionInterface` of the process bundle instead of raw SPL exceptions (as [cleverage/process-bundle#317](https://github.com/cleverage/process-bundle/issues/317)): new `Exception\UnexpectedValueException`, extending the SPL exceptions thrown before (same messages, the existing `catch` keep working). `MissingAdapterException` implements `ProcessExceptionInterface`. Add tests. * [#61](https://github.com/cleverage/cache-process-bundle/issues/61) Composer: require PHP `^8.2` instead of `>=8.2` (PHP 9 is no longer accepted before being tested), remove the authorization of the `symfony/flex` plugin, which is not required (the CI installs it globally), as [cleverage/process-bundle#319](https://github.com/cleverage/process-bundle/issues/319). diff --git a/docs/cookbooks/share_data_between_branches.md b/docs/cookbooks/share_data_between_branches.md index b3747b6..83d561a 100644 --- a/docs/cookbooks/share_data_between_branches.md +++ b/docs/cookbooks/share_data_between_branches.md @@ -4,33 +4,14 @@ Share data between process branches This recipe uses an in-memory cache to store data in a first branch of a process, then read it in another branch of the same execution. Nothing is persisted: the values only live during the current PHP process. -First, declare an in-memory cache [adapter](../reference/adapter.md) with the `memory` code: - -```php -` service), + instantiated with the pool when a task uses it; several codes on one pool share its items. The interface is checked + from the class of the service (or of its `parent`): a tagged service implementing neither interface fails the container + compilation. A pool must be tagged with a `code`. * **Deprecated**: a service tagged without `code` is still registered, with `AdapterRegistry::addAdapter()`, instantiated with the registry (deprecated since 2.3, the `code` will be required in 3.0). Two adapters with the same code then throw an `\UnexpectedValueException` giving the ids of both services diff --git a/src/DependencyInjection/Compiler/RegisterAdaptersPass.php b/src/DependencyInjection/Compiler/RegisterAdaptersPass.php index 16d1e69..a74052a 100644 --- a/src/DependencyInjection/Compiler/RegisterAdaptersPass.php +++ b/src/DependencyInjection/Compiler/RegisterAdaptersPass.php @@ -13,9 +13,13 @@ namespace CleverAge\CacheProcessBundle\DependencyInjection\Compiler; +use CleverAge\CacheProcessBundle\Adapter\Adapter; +use CleverAge\CacheProcessBundle\Adapter\AdapterInterface; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Reference; @@ -23,6 +27,9 @@ * Registers the `cleverage.cache.adapter` tagged services in the adapter registry: in a service locator indexed by the * `code` attribute of the tag (lazy), or, without `code` (deprecated), with an addAdapter() call (eager). * + * A Symfony cache pool tagged with a `code` (a service implementing the AdapterInterface of the Cache component, but not + * the one of the bundle) is wrapped in an Adapter with this code (`.cleverage_cache_process.adapter.` service). + * * @internal */ class RegisterAdaptersPass implements CompilerPassInterface @@ -52,15 +59,49 @@ public function process(ContainerBuilder $container): void $definition->addMethodCall('addAdapter', [new Reference($id), $id]); continue; } + $isPool = $this->isPool($container, $id); foreach (array_unique($codes) as $code) { if (isset($ids[$code])) { throw new InvalidArgumentException(\sprintf('The cache adapter code "%s" is used by the "%s" and "%s" services.', $code, $ids[$code], $id)); } $ids[$code] = $id; - $services[$code] = new Reference($id); + if ($isPool) { + $container->setDefinition($adapterId = '.cleverage_cache_process.adapter.'.$code, new Definition(Adapter::class, [new Reference($id), $code])); + $services[$code] = new Reference($adapterId); + } else { + $services[$code] = new Reference($id); + } } } $definition->setArgument(0, ServiceLocatorTagPass::register($container, $services)); } + + /** + * Whether the service is a Symfony cache pool to wrap in an Adapter, from its class (the one of its parents for a + * pool defined with "parent", as the FrameworkBundle pools). A service whose class cannot be loaded is registered as + * is, and checked when it is fetched. + */ + private function isPool(ContainerBuilder $container, string $id): bool + { + $definition = $container->getDefinition($id); + $class = $definition->getClass(); + while (null === $class && $definition instanceof ChildDefinition) { + $definition = $container->findDefinition($definition->getParent()); + $class = $definition->getClass(); + } + $class = $container->getParameterBag()->resolveValue($class); + if (!\is_string($class)) { + return false; + } + $reflectionClass = $container->getReflectionClass($class, false); + if (!$reflectionClass instanceof \ReflectionClass || $reflectionClass->implementsInterface(AdapterInterface::class)) { + return false; + } + if ($reflectionClass->implementsInterface(\Symfony\Component\Cache\Adapter\AdapterInterface::class)) { + return true; + } + + throw new InvalidArgumentException(\sprintf('The "%s" service, tagged "cleverage.cache.adapter", must implement "%s" or "%s" (a Symfony cache pool), "%s" given.', $id, AdapterInterface::class, \Symfony\Component\Cache\Adapter\AdapterInterface::class, $class)); + } } diff --git a/tests/App/TestKernel.php b/tests/App/TestKernel.php index d7e4ef1..6b4d1fa 100644 --- a/tests/App/TestKernel.php +++ b/tests/App/TestKernel.php @@ -29,8 +29,8 @@ use function Symfony\Component\DependencyInjection\Loader\Configurator\inline_service; /** - * Application used by the integration tests: process-bundle and this bundle, with an adapter declared by tag and one - * by attribute, in a temporary directory of its own (per PHP process). + * Application used by the integration tests: process-bundle and this bundle, with an adapter declared by tag, one by + * attribute and two Symfony cache pools tagged directly, in a temporary directory of its own (per PHP process). */ final class TestKernel extends Kernel { @@ -80,6 +80,13 @@ protected function configureContainer(ContainerConfigurator $container): void $services->set(AttributedAdapter::class) ->autoconfigure() ->args([inline_service(ArrayAdapter::class), 'app_attributed']); + // Symfony cache pools tagged directly: wrapped in an Adapter + $services->set('app.cache_pool', ArrayAdapter::class) + ->tag('cleverage.cache.adapter', ['code' => 'app_pool']); + $services->set('app.cache_framework_pool') + ->parent('cache.adapter.array') + ->tag('cache.pool') + ->tag('cleverage.cache.adapter', ['code' => 'app_framework_pool']); // Exposed to the tests $services->alias('test.adapter_registry', AdapterRegistry::class)->public(); diff --git a/tests/DependencyInjection/Compiler/RegisterAdaptersPassTest.php b/tests/DependencyInjection/Compiler/RegisterAdaptersPassTest.php index 2f403f9..8c28a8f 100644 --- a/tests/DependencyInjection/Compiler/RegisterAdaptersPassTest.php +++ b/tests/DependencyInjection/Compiler/RegisterAdaptersPassTest.php @@ -20,6 +20,7 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; @@ -87,6 +88,90 @@ public function testEmptyCodeFailsTheCompilation(): void $container->compile(true); } + public function testPoolsAreWrappedInAnAdapter(): void + { + $container = $this->createContainer([]); + $container->setParameter('app.pool_class', ArrayAdapter::class); + $container->setDefinition('app.pool', new Definition(ArrayAdapter::class)) + ->setPublic(true) + ->addTag('cleverage.cache.adapter', ['code' => 'pool']); + // Pool defined with a parent, as the FrameworkBundle pools + $container->setDefinition('app.abstract_pool', new Definition('%app.pool_class%')) + ->setAbstract(true); + $container->setDefinition('app.child_pool', new ChildDefinition('app.abstract_pool')) + ->addTag('cleverage.cache.adapter', ['code' => 'child_pool']) + ->addTag('cleverage.cache.adapter', ['code' => 'child_pool_other']); + $container->compile(true); + + /** @var AdapterRegistry $registry */ + $registry = $container->get('cleverage_cache_process.registry.adapter'); + // Not instantiated with the registry + self::assertFalse($container->initialized('app.pool')); + $adapter = $registry->getAdapter('pool'); + self::assertInstanceOf(Adapter::class, $adapter); + self::assertSame('pool', $adapter->getCode()); + self::assertTrue($container->initialized('app.pool')); + + // The items are stored in the pool + $item = $adapter->getItem('key'); + $item->set('value'); + $adapter->save($item); + /** @var ArrayAdapter $pool */ + $pool = $container->get('app.pool'); + self::assertSame('value', $pool->getItem('key')->get()); + + $childAdapter = $registry->getAdapter('child_pool'); + self::assertInstanceOf(Adapter::class, $childAdapter); + self::assertSame('child_pool', $childAdapter->getCode()); + // One pool shared by both codes + $item = $childAdapter->getItem('key'); + $item->set('child'); + $childAdapter->save($item); + self::assertSame('child', $registry->getAdapter('child_pool_other')->getItem('key')->get()); + } + + public function testServiceWithAnUnknownClassIsRegisteredAsIs(): void + { + $container = $this->createContainer([]); + // Checked when fetched + $container->setDefinition('app.adapter', (new Definition('App\\UnknownAdapter'))->setFactory([self::class, 'createAdapter'])) + ->setPublic(true) + ->addTag('cleverage.cache.adapter', ['code' => 'factory']); + $container->compile(true); + + /** @var AdapterRegistry $registry */ + $registry = $container->get('cleverage_cache_process.registry.adapter'); + self::assertSame($container->get('app.adapter'), $registry->getAdapter('factory')); + } + + public function testServiceWithoutClassIsRegisteredAsIs(): void + { + $container = new ContainerBuilder(); + $container->setDefinition('cleverage_cache_process.registry.adapter', new Definition(AdapterRegistry::class, [null])); + // Rejected later by the container compilation + $container->setDefinition('app.adapter', new Definition()) + ->addTag('cleverage.cache.adapter', ['code' => 'adapter']); + (new RegisterAdaptersPass())->process($container); + + self::assertFalse($container->hasDefinition('.cleverage_cache_process.adapter.adapter')); + } + + public function testServiceThatIsNeitherAnAdapterNorAPoolFailsTheCompilation(): void + { + $container = $this->createContainer([]); + $container->setDefinition('app.adapter', new Definition(\stdClass::class)) + ->addTag('cleverage.cache.adapter', ['code' => 'adapter']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "app.adapter" service, tagged "cleverage.cache.adapter", must implement "CleverAge\CacheProcessBundle\Adapter\AdapterInterface" or "Symfony\Component\Cache\Adapter\AdapterInterface" (a Symfony cache pool), "stdClass" given.'); + $container->compile(true); + } + + public static function createAdapter(): Adapter + { + return new Adapter(new ArrayAdapter(), 'factory'); + } + public function testWithoutRegistry(): void { $container = new ContainerBuilder(); diff --git a/tests/Functional/KernelTest.php b/tests/Functional/KernelTest.php index 1e9e5cd..2af0af7 100644 --- a/tests/Functional/KernelTest.php +++ b/tests/Functional/KernelTest.php @@ -56,6 +56,23 @@ public function testAdaptersAreResolvedByCode(): void self::assertInstanceOf(AttributedAdapter::class, $registry->getAdapter('app_attributed')); } + public function testPoolsAreWrappedInAnAdapter(): void + { + $registry = self::getContainer()->get('test.adapter_registry'); + self::assertInstanceOf(AdapterRegistry::class, $registry); + + foreach (['app_pool', 'app_framework_pool'] as $code) { + $adapter = $registry->getAdapter($code); + self::assertInstanceOf(Adapter::class, $adapter); + self::assertSame($code, $adapter->getCode()); + + $item = $adapter->getItem('key'); + $item->set($code); + $adapter->save($item); + self::assertSame($code, $adapter->getItem('key')->get()); + } + } + public function testTasksAreResolvedByCode(): void { if (!class_exists(TaskRegistry::class)) {