diff --git a/CHANGELOG.md b/CHANGELOG.md index 8594dc1..8a4ad5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Latest ------ ### Changes +* [#53](https://github.com/cleverage/cache-process-bundle/issues/53) AdapterRegistry: the adapters are loaded lazily from a service locator indexed by code: an adapter is only instantiated when a task uses it (an error in an adapter that a process does not use no longer makes it fail). The code is given by the new `code` attribute of the `cleverage.cache.adapter` tag, or by the new `#[AsCacheAdapter(code: '...')]` attribute (autoconfiguration); it must be the one returned by `getCode()`. Duplicate or empty codes fail the container compilation. An adapter tagged without `code` is still registered eagerly, with `AdapterRegistry::addAdapter()`, which is deprecated (the `code` will be required in 3.0). New optional `AdapterRegistry` constructor argument (the locator), new `hasAdapter()` and `getAdapters()` methods. Require `symfony/service-contracts`. Update documentation, add tests. * [#51](https://github.com/cleverage/cache-process-bundle/issues/51) Use the PHP 8.3 to 8.5 functions through the Symfony polyfills ([cleverage/process-bundle#299](https://github.com/cleverage/process-bundle/issues/299)): require `symfony/polyfill-php83`, `symfony/polyfill-php84` and `symfony/polyfill-php85`. Rector is configured with the minimum supported PHP version (`withPhpVersion(PhpVersion::PHP_82)`, it used PHP 8.5 features) and `withPhpSets()` without explicit version, which applies the rules using a newer function when its polyfill is required (no change on the current code). Document it in `CONTRIBUTING.md`. * [#49](https://github.com/cleverage/cache-process-bundle/issues/49) Add a `CleverAge\CacheProcessBundle\Registry\AdapterRegistry` alias of the `cleverage_cache_process.registry.adapter` service: the registry can be autowired, e.g. in a custom cache task extending `AbstractCacheTask`. Update documentation, add tests. * [#43](https://github.com/cleverage/cache-process-bundle/issues/43) Give a code to the tasks ([cleverage/process-bundle#91](https://github.com/cleverage/process-bundle/issues/91)): `cache_get` and `cache_set` (`task: cache_get`, with `cleverage/process-bundle` 5.4 or later, referencing them by `service` being deprecated there). The tasks have their class name as service id; fetching them directly from the service container is deprecated (`container.private`), they will be private in 3.0. The former ids `cleverage_cache_process.task.get` / `cleverage_cache_process.task.set` are deprecated aliases, removed in 3.0. Update documentation, add tests. diff --git a/composer.json b/composer.json index 4da4ee6..94a69a1 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,8 @@ "symfony/options-resolver": "^6.4 || ^7.4 || ^8", "symfony/polyfill-php83": "^1.33", "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33" + "symfony/polyfill-php85": "^1.33", + "symfony/service-contracts": "^2.5 || ^3" }, "require-dev": { "friendsofphp/php-cs-fixer": "*", diff --git a/config/services/registry.yaml b/config/services/registry.yaml index 84ea97c..6dfcbad 100644 --- a/config/services/registry.yaml +++ b/config/services/registry.yaml @@ -2,6 +2,8 @@ services: cleverage_cache_process.registry.adapter: class: CleverAge\CacheProcessBundle\Registry\AdapterRegistry public: false + arguments: + - ~ # Service locator of the adapters by code, see RegisterAdaptersPass CleverAge\CacheProcessBundle\Registry\AdapterRegistry: alias: cleverage_cache_process.registry.adapter diff --git a/docs/cookbooks/cache_warmup.md b/docs/cookbooks/cache_warmup.md index 00ecb3f..11bfbd0 100644 --- a/docs/cookbooks/cache_warmup.md +++ b/docs/cookbooks/cache_warmup.md @@ -22,7 +22,7 @@ services: class: CleverAge\CacheProcessBundle\Adapter\Adapter arguments: ['@app.cache.catalog', 'catalog'] tags: - - { name: cleverage.cache.adapter } + - { name: cleverage.cache.adapter, code: catalog } ``` Then define a process storing each line of the file, and another one reading a line by key: diff --git a/docs/cookbooks/share_data_between_branches.md b/docs/cookbooks/share_data_between_branches.md index 47a53c9..b3747b6 100644 --- a/docs/cookbooks/share_data_between_branches.md +++ b/docs/cookbooks/share_data_between_branches.md @@ -32,7 +32,7 @@ services: app.cleverage_cache_process.adapter.memory: class: App\Adapter\MemoryAdapter tags: - - { name: cleverage.cache.adapter } + - { name: cleverage.cache.adapter, code: memory } ``` Then define the process: diff --git a/docs/index.md b/docs/index.md index efe1f06..44d378e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,8 @@ CleverAge\CacheProcessBundle\CleverAgeCacheProcessBundle::class => ['all' => tru The bundle has no semantic configuration. To use the cache tasks, declare at least one cache [adapter](reference/adapter.md): a service implementing `CleverAge\CacheProcessBundle\Adapter\AdapterInterface` (usually `CleverAge\CacheProcessBundle\Adapter\Adapter`, wrapping any Symfony cache pool), tagged -`cleverage.cache.adapter`. Its code is then used in the `adapter` option of the tasks. +`cleverage.cache.adapter` with its code (or with the `#[AsCacheAdapter]` attribute). This code is then used in the +`adapter` option of the tasks. The tasks are registered as services (non-shared) and are used in your processes by their code, `cache_get` and `cache_set` (e.g. `task: cache_get`, requires `cleverage/process-bundle` 5.4 or later, see its @@ -43,7 +44,7 @@ services: class: CleverAge\CacheProcessBundle\Adapter\Adapter arguments: ['@cache.app', 'app'] tags: - - { name: cleverage.cache.adapter } + - { name: cleverage.cache.adapter, code: app } ``` ## Custom cache tasks diff --git a/docs/reference/adapter.md b/docs/reference/adapter.md index 3e9851d..5b65a81 100644 --- a/docs/reference/adapter.md +++ b/docs/reference/adapter.md @@ -13,8 +13,11 @@ Adapter reference option of the tasks) * **Base class**: `CleverAge\CacheProcessBundle\Adapter\Adapter`, decorates any `Symfony\Component\Cache\Adapter\AdapterInterface` (every PSR-6 method is forwarded to it) -* **Service tag**: `cleverage.cache.adapter`, every tagged service is registered in the - `cleverage_cache_process.registry.adapter` registry (`CleverAge\CacheProcessBundle\Registry\AdapterRegistry`) +* **Service tag**: `cleverage.cache.adapter`, with a `code` attribute (`- { name: cleverage.cache.adapter, code: memory }`): + every tagged service is registered in the `cleverage_cache_process.registry.adapter` registry + (`CleverAge\CacheProcessBundle\Registry\AdapterRegistry`, autowirable by its class name) +* **Attribute**: `CleverAge\CacheProcessBundle\Attribute\AsCacheAdapter` (`#[AsCacheAdapter(code: 'memory')]`, with + autoconfiguration), equivalent to the tag Constructor arguments --------------------- @@ -56,7 +59,7 @@ services: app.cleverage_cache_process.adapter.memory: class: App\Adapter\MemoryAdapter tags: - - { name: cleverage.cache.adapter } + - { name: cleverage.cache.adapter, code: memory } ``` * Persistent adapter, wrapping a cache pool of the FrameworkBundle, without any PHP class @@ -80,15 +83,23 @@ services: - '@app.cache.catalog' - 'catalog' tags: - - { name: cleverage.cache.adapter } + - { name: cleverage.cache.adapter, code: catalog } ``` Notes ----- -* Codes must be unique: registering two adapters with the same code throws an `\UnexpectedValueException` giving the - ids of both services (`Adapter is already defined by service "", cannot register service ""`) when - the registry is instantiated, i.e. the first time a cache task is used. +* The adapters are registered by the `code` attribute of their tag, in a service locator built when the container is + compiled: an adapter is only instantiated when a task uses it (an error in an adapter that a process does not use + does not make it fail). The `code` must be the one returned by `getCode()` (an `\UnexpectedValueException` is thrown + otherwise when it is fetched). Two adapters with the same code, or an empty code, fail the container compilation. + `AdapterRegistry::hasAdapter()` and `getAdapters()` (which instantiates every adapter) are available, e.g. in a + custom cache task. +* **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 + (`Adapter is already defined by service "", cannot register service ""`) when the registry is + instantiated, i.e. the first time a cache task is used. * Using a code that is not registered throws a `CleverAge\CacheProcessBundle\Exception\MissingAdapterException` (`Adapter is missing`) when the task is executed. * The lifetime of the items is the default lifetime of the decorated pool (`default_lifetime` of a FrameworkBundle diff --git a/src/Attribute/AsCacheAdapter.php b/src/Attribute/AsCacheAdapter.php new file mode 100644 index 0000000..1d2242c --- /dev/null +++ b/src/Attribute/AsCacheAdapter.php @@ -0,0 +1,29 @@ +findServices($container, __DIR__.'/../../config/services'); + + $container->registerAttributeForAutoconfiguration( + AsCacheAdapter::class, + static function (ChildDefinition $definition, AsCacheAdapter $attribute): void { + $definition->addTag('cleverage.cache.adapter', ['code' => $attribute->code]); + } + ); } /** diff --git a/src/DependencyInjection/Compiler/RegisterAdaptersPass.php b/src/DependencyInjection/Compiler/RegisterAdaptersPass.php index b478e96..f808995 100644 --- a/src/DependencyInjection/Compiler/RegisterAdaptersPass.php +++ b/src/DependencyInjection/Compiler/RegisterAdaptersPass.php @@ -14,11 +14,14 @@ namespace CleverAge\CacheProcessBundle\DependencyInjection\Compiler; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Reference; /** - * Adds the tagged cache adapters to the adapter registry, with their service id. + * 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). */ class RegisterAdaptersPass implements CompilerPassInterface { @@ -27,10 +30,35 @@ public function process(ContainerBuilder $container): void if (!$container->has('cleverage_cache_process.registry.adapter')) { return; } - $definition = $container->findDefinition('cleverage_cache_process.registry.adapter'); - foreach (array_keys($container->findTaggedServiceIds('cleverage.cache.adapter')) as $id) { - $definition->addMethodCall('addAdapter', [new Reference($id), $id]); + + $services = []; + $ids = []; + foreach ($container->findTaggedServiceIds('cleverage.cache.adapter') as $id => $tags) { + $codes = []; + foreach ($tags as $attributes) { + if (!\is_array($attributes) || !isset($attributes['code'])) { + continue; + } + if (!\is_string($attributes['code']) || '' === $attributes['code']) { + throw new InvalidArgumentException(\sprintf('The "code" attribute of the "cleverage.cache.adapter" tag of the "%s" service must be a non-empty string.', $id)); + } + $codes[] = $attributes['code']; + } + if ([] === $codes) { + // Deprecated: instantiated with the registry, addAdapter() triggers the deprecation + $definition->addMethodCall('addAdapter', [new Reference($id), $id]); + continue; + } + 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); + } } + + $definition->setArgument(0, ServiceLocatorTagPass::register($container, $services)); } } diff --git a/src/Registry/AdapterRegistry.php b/src/Registry/AdapterRegistry.php index 3df66df..82a3ca7 100644 --- a/src/Registry/AdapterRegistry.php +++ b/src/Registry/AdapterRegistry.php @@ -15,28 +15,57 @@ use CleverAge\CacheProcessBundle\Adapter\AdapterInterface; use CleverAge\CacheProcessBundle\Exception\MissingAdapterException; +use Symfony\Contracts\Service\ServiceProviderInterface; /** - * Holds all tagged cache adapters services. + * Holds the cache adapters, indexed by code. + * + * The adapter services are fetched lazily from a service locator indexed by code (`code` attribute of the + * `cleverage.cache.adapter` tag, or `#[AsCacheAdapter]`, see RegisterAdaptersPass): an adapter is only instantiated + * when it is used. */ class AdapterRegistry { - /** @var AdapterInterface[] */ + /** + * Adapters already fetched from the locator, and the ones registered with addAdapter() (deprecated). + * + * @var array + */ private array $adapters = []; - /** @var array Service ids of the adapters, indexed by code */ + /** @var array Service ids of the adapters registered with addAdapter(), indexed by code */ private array $serviceIds = []; + /** + * @param ServiceProviderInterface|null $locator Adapter services, checked when fetched + */ + public function __construct( + private readonly ?ServiceProviderInterface $locator = null, + ) { + } + /** * @param string|null $serviceId Id of the adapter service, used to identify the adapters with the same code + * + * @deprecated since 2.3, tag the adapter service with a "code" attribute or use #[AsCacheAdapter] instead */ public function addAdapter(AdapterInterface $adapter, ?string $serviceId = null): void { $code = $adapter->getCode(); - if (\array_key_exists($code, $this->adapters)) { + trigger_deprecation( + 'cleverage/cache-process-bundle', + '2.3', + 'Registering the cache adapter "%s" (%s) with "%s()" is deprecated, tag its service with a "code" attribute ({ name: cleverage.cache.adapter, code: %s }) or use the #[AsCacheAdapter] attribute instead, so that it is only instantiated when used. It will be required in 3.0.', + $code, + $adapter::class, + __METHOD__, + $code, + ); + if ($this->hasAdapter($code)) { $message = "Adapter {$code} is already defined"; - if (null !== $this->serviceIds[$code] && null !== $serviceId) { - $message .= " by service \"{$this->serviceIds[$code]}\", cannot register service \"{$serviceId}\""; + $definedBy = $this->serviceIds[$code] ?? null; + if (null !== $definedBy && null !== $serviceId) { + $message .= " by service \"{$definedBy}\", cannot register service \"{$serviceId}\""; } throw new \UnexpectedValueException($message); @@ -45,15 +74,45 @@ public function addAdapter(AdapterInterface $adapter, ?string $serviceId = null) $this->serviceIds[$code] = $serviceId; } + /** + * Instantiates every adapter of the locator. + * + * @return array + */ + public function getAdapters(): array + { + foreach (array_keys($this->locator?->getProvidedServices() ?? []) as $code) { + $this->getAdapter((string) $code); + } + + return $this->adapters; + } + /** * @throws MissingAdapterException */ public function getAdapter(string $code): AdapterInterface { - if (!\array_key_exists($code, $this->adapters)) { + if (isset($this->adapters[$code])) { + return $this->adapters[$code]; + } + if (!$this->locator?->has($code)) { throw new MissingAdapterException("Adapter {$code} is missing"); } - return $this->adapters[$code]; + $adapter = $this->locator->get($code); + if (!$adapter instanceof AdapterInterface) { + throw new \UnexpectedValueException(\sprintf('The service of the cache adapter "%s" must implement "%s".', $code, AdapterInterface::class)); + } + if ($code !== $adapter->getCode()) { + throw new \UnexpectedValueException(\sprintf('The cache adapter "%s" is registered with the code "%s" but its getCode() method returns "%s": both must be the same.', $adapter::class, $code, $adapter->getCode())); + } + + return $this->adapters[$code] = $adapter; + } + + public function hasAdapter(string $code): bool + { + return isset($this->adapters[$code]) || ($this->locator?->has($code) ?? false); } } diff --git a/tests/DependencyInjection/CleverAgeCacheProcessExtensionTest.php b/tests/DependencyInjection/CleverAgeCacheProcessExtensionTest.php index a9d93b2..93b25e4 100644 --- a/tests/DependencyInjection/CleverAgeCacheProcessExtensionTest.php +++ b/tests/DependencyInjection/CleverAgeCacheProcessExtensionTest.php @@ -13,19 +13,26 @@ namespace CleverAge\CacheProcessBundle\Tests\DependencyInjection; +use CleverAge\CacheProcessBundle\Attribute\AsCacheAdapter; use CleverAge\CacheProcessBundle\DependencyInjection\CleverAgeCacheProcessExtension; use CleverAge\CacheProcessBundle\Registry\AdapterRegistry; use CleverAge\CacheProcessBundle\Task\GetTask; use CleverAge\CacheProcessBundle\Task\SetTask; +use CleverAge\CacheProcessBundle\Tests\Fixtures\AttributedAdapter; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Compiler\AttributeAutoconfigurationPass; +use Symfony\Component\DependencyInjection\Compiler\ResolveChildDefinitionsPass; +use Symfony\Component\DependencyInjection\Compiler\ResolveInstanceofConditionalsPass; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; #[CoversClass(CleverAgeCacheProcessExtension::class)] #[UsesClass(AdapterRegistry::class)] +#[CoversClass(AsCacheAdapter::class)] class CleverAgeCacheProcessExtensionTest extends TestCase { public function testRegistryIsRegistered(): void @@ -38,6 +45,19 @@ public function testRegistryIsRegistered(): void self::assertTrue($definition->isShared()); } + public function testAsCacheAdapterAttributeRegistersTheCode(): void + { + $container = new ContainerBuilder(); + (new CleverAgeCacheProcessExtension())->load([], $container); + $container->setDefinition('app.attributed', (new Definition(AttributedAdapter::class))->setAutoconfigured(true)); + + (new AttributeAutoconfigurationPass())->process($container); + (new ResolveInstanceofConditionalsPass())->process($container); + (new ResolveChildDefinitionsPass())->process($container); + + self::assertSame([['code' => 'app_attributed']], $container->getDefinition('app.attributed')->getTag('cleverage.cache.adapter')); + } + public function testRegistryCanBeAutowired(): void { $container = new ContainerBuilder(); diff --git a/tests/DependencyInjection/Compiler/RegisterAdaptersPassTest.php b/tests/DependencyInjection/Compiler/RegisterAdaptersPassTest.php index af9aefd..2f403f9 100644 --- a/tests/DependencyInjection/Compiler/RegisterAdaptersPassTest.php +++ b/tests/DependencyInjection/Compiler/RegisterAdaptersPassTest.php @@ -22,31 +22,69 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; #[CoversClass(RegisterAdaptersPass::class)] #[UsesClass(AdapterRegistry::class)] #[UsesClass(Adapter::class)] class RegisterAdaptersPassTest extends TestCase { - public function testAdaptersAreRegistered(): void + public function testAdaptersWithACodeAreRegisteredInALocator(): void { - $container = $this->createContainer(['app.adapter.memory' => 'memory', 'app.adapter.other' => 'other']); + $container = $this->createContainer(['app.adapter' => 'adapter', 'app.other' => 'other'], withCode: true); $container->compile(true); /** @var AdapterRegistry $registry */ $registry = $container->get('cleverage_cache_process.registry.adapter'); - self::assertSame('memory', $registry->getAdapter('memory')->getCode()); + // Not instantiated with the registry + self::assertFalse($container->initialized('app.adapter')); + self::assertSame('adapter', $registry->getAdapter('adapter')->getCode()); + self::assertTrue($container->initialized('app.adapter')); + self::assertFalse($container->initialized('app.other')); self::assertSame('other', $registry->getAdapter('other')->getCode()); } - public function testDuplicateCodeGivesTheServiceIds(): void + public function testAdaptersWithoutCodeAreAddedToTheRegistry(): void { - $container = $this->createContainer(['app.adapter.memory' => 'memory', 'app.adapter.memory_duplicate' => 'memory']); + $container = $this->createContainer(['app.adapter' => 'adapter', 'app.other' => 'other']); $container->compile(true); - $this->expectException(\UnexpectedValueException::class); - $this->expectExceptionMessage('Adapter memory is already defined by service "app.adapter.memory", cannot register service "app.adapter.memory_duplicate"'); - $container->get('cleverage_cache_process.registry.adapter'); + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + /** @var AdapterRegistry $registry */ + $registry = $container->get('cleverage_cache_process.registry.adapter'); + } finally { + restore_error_handler(); + } + + self::assertCount(2, $deprecations); + self::assertSame('adapter', $registry->getAdapter('adapter')->getCode()); + self::assertSame('other', $registry->getAdapter('other')->getCode()); + } + + public function testDuplicateCodeFailsTheCompilation(): void + { + $container = $this->createContainer(['app.adapter' => 'adapter', 'app.adapter_duplicate' => 'adapter'], withCode: true); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The cache adapter code "adapter" is used by the "app.adapter" and "app.adapter_duplicate" services.'); + $container->compile(true); + } + + public function testEmptyCodeFailsTheCompilation(): void + { + $container = $this->createContainer([]); + $container->setDefinition('app.adapter', new Definition(Adapter::class)) + ->addTag('cleverage.cache.adapter', ['code' => '']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "code" attribute of the "cleverage.cache.adapter" tag of the "app.adapter" service must be a non-empty string.'); + $container->compile(true); } public function testWithoutRegistry(): void @@ -60,15 +98,16 @@ public function testWithoutRegistry(): void /** * @param array $adapters Codes of the adapters, indexed by service id */ - private function createContainer(array $adapters): ContainerBuilder + private function createContainer(array $adapters, bool $withCode = false): ContainerBuilder { $container = new ContainerBuilder(); $container->addCompilerPass(new RegisterAdaptersPass()); - $container->setDefinition('cleverage_cache_process.registry.adapter', new Definition(AdapterRegistry::class)) + $container->setDefinition('cleverage_cache_process.registry.adapter', new Definition(AdapterRegistry::class, [null])) ->setPublic(true); foreach ($adapters as $id => $code) { $container->setDefinition($id, new Definition(Adapter::class, [new Definition(ArrayAdapter::class), $code])) - ->addTag('cleverage.cache.adapter'); + ->setPublic(true) + ->addTag('cleverage.cache.adapter', $withCode ? ['code' => $code] : []); } return $container; diff --git a/tests/Fixtures/AttributedAdapter.php b/tests/Fixtures/AttributedAdapter.php new file mode 100644 index 0000000..4b607a4 --- /dev/null +++ b/tests/Fixtures/AttributedAdapter.php @@ -0,0 +1,22 @@ + Codes of the adapters instantiated by the locator */ + private array $instantiated = []; + + public function testAdaptersAreFetchedLazilyFromTheLocator(): void { - $registry = new AdapterRegistry(); - $memory = new Adapter(new ArrayAdapter(), 'memory'); - $other = new Adapter(new ArrayAdapter(), 'other'); - $registry->addAdapter($memory, 'app.adapter.memory'); - $registry->addAdapter($other); + $registry = new AdapterRegistry($this->createLocator(['adapter' => 'adapter', 'other' => 'other'])); - self::assertSame($memory, $registry->getAdapter('memory')); - self::assertSame($other, $registry->getAdapter('other')); + self::assertTrue($registry->hasAdapter('adapter')); + self::assertFalse($registry->hasAdapter('missing')); + self::assertSame([], $this->instantiated); + + $adapter = $registry->getAdapter('adapter'); + self::assertSame('adapter', $adapter->getCode()); + self::assertSame($adapter, $registry->getAdapter('adapter')); + self::assertSame(['adapter'], $this->instantiated); + + self::assertSame(['adapter', 'other'], array_keys($registry->getAdapters())); + self::assertSame(['adapter', 'other'], $this->instantiated); } public function testMissingAdapter(): void { $this->expectException(MissingAdapterException::class); $this->expectExceptionMessage('Adapter missing is missing'); + (new AdapterRegistry($this->createLocator([])))->getAdapter('missing'); + } + + public function testMissingAdapterWithoutLocator(): void + { + $this->expectException(MissingAdapterException::class); (new AdapterRegistry())->getAdapter('missing'); } + public function testCodeOfTheTagMustBeTheCodeOfTheAdapter(): void + { + $registry = new AdapterRegistry($this->createLocator(['adapter' => 'other'])); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/is registered with the code "adapter" but its getCode\(\) method returns "other"/'); + $registry->getAdapter('adapter'); + } + + public function testServiceMustBeAnAdapter(): void + { + $registry = new AdapterRegistry(new ServiceLocator(['adapter' => static fn (): \stdClass => new \stdClass()])); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The service of the cache adapter "adapter" must implement "'.AdapterInterface::class.'".'); + $registry->getAdapter('adapter'); + } + + public function testAddAdapterIsDeprecated(): void + { + $registry = new AdapterRegistry($this->createLocator(['adapter' => 'adapter'])); + $other = $this->createAdapter('other'); + + $deprecations = $this->collectDeprecations(static fn () => $registry->addAdapter($other, 'app.other')); + + self::assertSame( + ['Since cleverage/cache-process-bundle 2.3: Registering the cache adapter "other" ('.$other::class.') with "'.AdapterRegistry::class.'::addAdapter()" is deprecated, tag its service with a "code" attribute ({ name: cleverage.cache.adapter, code: other }) or use the #[AsCacheAdapter] attribute instead, so that it is only instantiated when used. It will be required in 3.0.'], + $deprecations + ); + self::assertSame($other, $registry->getAdapter('other')); + self::assertEqualsCanonicalizing(['adapter', 'other'], array_keys($registry->getAdapters())); + } + + public function testAddAdapterWithTheCodeOfALocatorAdapter(): void + { + $registry = new AdapterRegistry($this->createLocator(['adapter' => 'adapter'])); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessageMatches('/^Adapter adapter is already defined$/'); + $this->collectDeprecations(fn () => $registry->addAdapter($this->createAdapter('adapter'), 'app.adapter_duplicate')); + } + public function testDuplicateCodeGivesTheServiceIds(): void { $registry = new AdapterRegistry(); - $registry->addAdapter(new Adapter(new ArrayAdapter(), 'memory'), 'app.adapter.memory'); + $this->collectDeprecations(fn () => $registry->addAdapter($this->createAdapter('adapter'), 'app.adapter')); $this->expectException(\UnexpectedValueException::class); - $this->expectExceptionMessage('Adapter memory is already defined by service "app.adapter.memory", cannot register service "app.adapter.memory_duplicate"'); - $registry->addAdapter(new Adapter(new ArrayAdapter(), 'memory'), 'app.adapter.memory_duplicate'); + $this->expectExceptionMessage('Adapter adapter is already defined by service "app.adapter", cannot register service "app.adapter_duplicate"'); + $this->collectDeprecations(fn () => $registry->addAdapter($this->createAdapter('adapter'), 'app.adapter_duplicate')); } public function testDuplicateCodeWithoutServiceIds(): void { $registry = new AdapterRegistry(); - $registry->addAdapter(new Adapter(new ArrayAdapter(), 'memory')); + $this->collectDeprecations(fn () => $registry->addAdapter($this->createAdapter('adapter'))); $this->expectException(\UnexpectedValueException::class); - $this->expectExceptionMessageMatches('/^Adapter memory is already defined$/'); - $registry->addAdapter(new Adapter(new ArrayAdapter(), 'memory'), 'app.adapter.memory_duplicate'); + $this->expectExceptionMessageMatches('/^Adapter adapter is already defined$/'); + $this->collectDeprecations(fn () => $registry->addAdapter($this->createAdapter('adapter'), 'app.adapter_duplicate')); + } + + /** + * @param array $codes Code returned by each adapter, indexed by the code of the locator + * + * @return ServiceLocator + */ + private function createLocator(array $codes): ServiceLocator + { + $factories = []; + foreach ($codes as $code => $adapterCode) { + $factories[$code] = function () use ($code, $adapterCode): AdapterInterface { + $this->instantiated[] = $code; + + return $this->createAdapter($adapterCode); + }; + } + + return new ServiceLocator($factories); + } + + private function createAdapter(string $code): AdapterInterface + { + $adapter = $this->createStub(AdapterInterface::class); + $adapter->method('getCode')->willReturn($code); + + return $adapter; + } + + /** + * @return list + */ + private function collectDeprecations(callable $callable): array + { + $deprecations = []; + set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool { + $deprecations[] = $errstr; + + return true; + }, \E_USER_DEPRECATED); + try { + $callable(); + } finally { + restore_error_handler(); + } + + return $deprecations; } } diff --git a/tests/Task/AbstractCacheTaskTest.php b/tests/Task/AbstractCacheTaskTest.php index f8307d8..13c4728 100644 --- a/tests/Task/AbstractCacheTaskTest.php +++ b/tests/Task/AbstractCacheTaskTest.php @@ -21,6 +21,7 @@ use CleverAge\ProcessBundle\Model\ProcessHistory; use CleverAge\ProcessBundle\Model\ProcessState; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -29,6 +30,7 @@ * Custom cache tasks extending AbstractCacheTask get their own options from the input. */ #[CoversClass(AbstractCacheTask::class)] +#[UsesClass(AdapterRegistry::class)] class AbstractCacheTaskTest extends TestCase { public function testCustomOptionFromInput(): void diff --git a/tests/Task/GetTaskTest.php b/tests/Task/GetTaskTest.php index fc04076..16602f5 100644 --- a/tests/Task/GetTaskTest.php +++ b/tests/Task/GetTaskTest.php @@ -28,6 +28,7 @@ use PHPUnit\Framework\TestCase; use Psr\Cache\InvalidArgumentException; use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; @@ -240,8 +241,8 @@ private function createTask(array $options, array $context = []): array $state->setContext($context); $state->setTaskConfiguration(new TaskConfiguration('get', GetTask::class, $options)); - $registry = new AdapterRegistry(); - $registry->addAdapter($this->adapter); + $adapter = $this->adapter; + $registry = new AdapterRegistry(new ServiceLocator(['memory' => static fn (): Adapter => $adapter])); $task = new GetTask($registry); $task->initialize($state); diff --git a/tests/Task/SetTaskTest.php b/tests/Task/SetTaskTest.php index 858f50e..b059c3f 100644 --- a/tests/Task/SetTaskTest.php +++ b/tests/Task/SetTaskTest.php @@ -31,6 +31,7 @@ use Symfony\Component\Cache\Adapter\AdapterInterface as SymfonyAdapterInterface; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\CacheItem; +use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; @@ -259,8 +260,8 @@ private function createTask(array $options, array $context = []): array $state->setContext($context); $state->setTaskConfiguration(new TaskConfiguration('set', SetTask::class, $options)); - $registry = new AdapterRegistry(); - $registry->addAdapter($this->adapter); + $adapter = $this->adapter; + $registry = new AdapterRegistry(new ServiceLocator(['memory' => static fn (): Adapter => $adapter])); $task = new SetTask($registry); $task->initialize($state);