From 049f15e9774098092f13dc785d556567d9fadf53 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Thu, 28 Jul 2022 12:33:29 -0400 Subject: [PATCH] [DependencyInjection] Allow service subscribers to return `SubscribedService[]` --- .../Compiler/AutowirePass.php | 69 +++++++++++-------- .../RegisterServiceSubscribersPass.php | 11 ++- .../RegisterServiceSubscribersPassTest.php | 66 ++++++++++++++++++ .../Fixtures/php/services_subscriber.php | 8 +-- .../DependencyInjection/TypedReference.php | 10 ++- src/Symfony/Contracts/CHANGELOG.md | 5 ++ .../Service/Attribute/SubscribedService.php | 20 +++++- .../Service/ServiceSubscriberInterface.php | 13 +++- .../Service/ServiceSubscriberTrait.php | 16 +++-- .../Service/ServiceSubscriberTraitTest.php | 7 ++ 10 files changed, 179 insertions(+), 46 deletions(-) diff --git a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php index 880ea41610753..a12a74294419f 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php @@ -27,6 +27,7 @@ use Symfony\Component\DependencyInjection\LazyProxy\ProxyHelper; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\TypedReference; +use Symfony\Contracts\Service\Attribute\SubscribedService; /** * Inspects existing service definitions and wires the autowired ones using the type hints of their classes. @@ -104,6 +105,19 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed private function doProcessValue(mixed $value, bool $isRoot = false): mixed { if ($value instanceof TypedReference) { + if ($attributes = $value->getAttributes()) { + $attribute = array_pop($attributes); + + if ($attributes) { + throw new AutowiringFailedException($this->currentId, sprintf('Using multiple attributes with "%s" is not supported.', SubscribedService::class)); + } + + if (!$attribute instanceof Target) { + return $this->processAttribute($attribute, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE !== $value->getInvalidBehavior()); + } + + $value = new TypedReference($value->getType(), $value->getType(), $value->getInvalidBehavior(), $attribute->name); + } if ($ref = $this->getAutowiredReference($value, true)) { return $ref; } @@ -158,6 +172,29 @@ private function doProcessValue(mixed $value, bool $isRoot = false): mixed return $value; } + private function processAttribute(object $attribute, bool $isOptional = false): mixed + { + switch (true) { + case $attribute instanceof Autowire: + $value = $this->container->getParameterBag()->resolveValue($attribute->value); + + return $value instanceof Reference && $isOptional ? new Reference($value, ContainerInterface::NULL_ON_INVALID_REFERENCE) : $value; + + case $attribute instanceof TaggedIterator: + return new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, false, $attribute->defaultPriorityMethod, (array) $attribute->exclude); + + case $attribute instanceof TaggedLocator: + return new ServiceLocatorArgument(new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, true, $attribute->defaultPriorityMethod, (array) $attribute->exclude)); + + case $attribute instanceof MapDecorated: + $definition = $this->container->getDefinition($this->currentId); + + return new Reference($definition->innerServiceId ?? $this->currentId.'.inner', $definition->decorationOnInvalid ?? ContainerInterface::NULL_ON_INVALID_REFERENCE); + } + + throw new AutowiringFailedException($this->currentId, sprintf('"%s" is an unsupported attribute.', $attribute::class)); + } + private function autowireCalls(\ReflectionClass $reflectionClass, bool $isRoot, bool $checkAttributes): array { $this->decoratedId = null; @@ -249,34 +286,8 @@ private function autowireMethod(\ReflectionFunctionAbstract $reflectionMethod, a if ($checkAttributes) { foreach ($parameter->getAttributes() as $attribute) { - if (TaggedIterator::class === $attribute->getName()) { - $attribute = $attribute->newInstance(); - $arguments[$index] = new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, false, $attribute->defaultPriorityMethod, (array) $attribute->exclude); - break; - } - - if (TaggedLocator::class === $attribute->getName()) { - $attribute = $attribute->newInstance(); - $arguments[$index] = new ServiceLocatorArgument(new TaggedIteratorArgument($attribute->tag, $attribute->indexAttribute, $attribute->defaultIndexMethod, true, $attribute->defaultPriorityMethod, (array) $attribute->exclude)); - break; - } - - if (Autowire::class === $attribute->getName()) { - $value = $attribute->newInstance()->value; - $value = $this->container->getParameterBag()->resolveValue($value); - - if ($value instanceof Reference && $parameter->allowsNull()) { - $value = new Reference($value, ContainerInterface::NULL_ON_INVALID_REFERENCE); - } - - $arguments[$index] = $value; - - break; - } - - if (MapDecorated::class === $attribute->getName()) { - $definition = $this->container->getDefinition($this->currentId); - $arguments[$index] = new Reference($definition->innerServiceId ?? $this->currentId.'.inner', $definition->decorationOnInvalid ?? ContainerInterface::NULL_ON_INVALID_REFERENCE); + if (\in_array($attribute->getName(), [TaggedIterator::class, TaggedLocator::class, Autowire::class, MapDecorated::class], true)) { + $arguments[$index] = $this->processAttribute($attribute->newInstance(), $parameter->allowsNull()); break; } @@ -315,7 +326,7 @@ private function autowireMethod(\ReflectionFunctionAbstract $reflectionMethod, a } $getValue = function () use ($type, $parameter, $class, $method) { - if (!$value = $this->getAutowiredReference($ref = new TypedReference($type, $type, ContainerBuilder::EXCEPTION_ON_INVALID_REFERENCE, Target::parseName($parameter)), true)) { + if (!$value = $this->getAutowiredReference($ref = new TypedReference($type, $type, ContainerBuilder::EXCEPTION_ON_INVALID_REFERENCE, Target::parseName($parameter)), false)) { $failureMessage = $this->createTypeNotFoundMessageCallback($ref, sprintf('argument "$%s" of method "%s()"', $parameter->name, $class !== $this->currentId ? $class.'::'.$method : $method)); if ($parameter->isDefaultValueAvailable()) { diff --git a/src/Symfony/Component/DependencyInjection/Compiler/RegisterServiceSubscribersPass.php b/src/Symfony/Component/DependencyInjection/Compiler/RegisterServiceSubscribersPass.php index 7e1a3d061e4ec..7a42a390161bc 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/RegisterServiceSubscribersPass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/RegisterServiceSubscribersPass.php @@ -20,6 +20,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\TypedReference; use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Contracts\Service\Attribute\SubscribedService; use Symfony\Contracts\Service\ServiceProviderInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -73,6 +74,14 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed $subscriberMap = []; foreach ($class::getSubscribedServices() as $key => $type) { + $attributes = []; + + if ($type instanceof SubscribedService) { + $key = $type->key; + $attributes = $type->attributes; + $type = ($type->nullable ? '?' : '').($type->type ?? throw new InvalidArgumentException(sprintf('When "%s::getSubscribedServices()" returns "%s", a type must be set.', $class, SubscribedService::class))); + } + if (!\is_string($type) || !preg_match('/(?(DEFINE)(?[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+))(?(DEFINE)(?(?&cn)(?:\\\\(?&cn))*+))^\??(?&fqcn)(?:(?:\|(?&fqcn))*+|(?:&(?&fqcn))*+)$/', $type)) { throw new InvalidArgumentException(sprintf('"%s::getSubscribedServices()" must return valid PHP types for service "%s" key "%s", "%s" returned.', $class, $this->currentId, $key, \is_string($type) ? $type : get_debug_type($type))); } @@ -109,7 +118,7 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed $name = $this->container->has($type.' $'.$camelCaseName) ? $camelCaseName : $name; } - $subscriberMap[$key] = new TypedReference((string) $serviceMap[$key], $type, $optionalBehavior ?: ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $name); + $subscriberMap[$key] = new TypedReference((string) $serviceMap[$key], $type, $optionalBehavior ?: ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $name, $attributes); unset($serviceMap[$key]); } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php index e4ca7499ff300..c2ecdf2f9f5ec 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php @@ -14,6 +14,13 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface as PsrContainerInterface; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\MapDecorated; +use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; +use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; +use Symfony\Component\DependencyInjection\Attribute\Target; use Symfony\Component\DependencyInjection\Compiler\AutowirePass; use Symfony\Component\DependencyInjection\Compiler\RegisterServiceSubscribersPass; use Symfony\Component\DependencyInjection\Compiler\ResolveBindingsPass; @@ -402,6 +409,65 @@ public static function getSubscribedServices(): array $this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0)); } + public function testSubscribedServiceWithAttributes() + { + if (!property_exists(SubscribedService::class, 'attributes')) { + $this->markTestSkipped('Requires symfony/service-contracts 3.2+'); + } + + $container = new ContainerBuilder(); + + $subscriber = new class() implements ServiceSubscriberInterface { + public static function getSubscribedServices(): array + { + return [ + new SubscribedService('tagged.iterator', 'iterable', attributes: new TaggedIterator('tag')), + new SubscribedService('tagged.locator', PsrContainerInterface::class, attributes: new TaggedLocator('tag')), + new SubscribedService('autowired', 'stdClass', attributes: new Autowire(service: 'service.id')), + new SubscribedService('autowired.nullable', 'stdClass', nullable: true, attributes: new Autowire(service: 'service.id')), + new SubscribedService('autowired.parameter', 'string', attributes: new Autowire('%parameter.1%')), + new SubscribedService('map.decorated', \stdClass::class, attributes: new MapDecorated()), + new SubscribedService('target', \stdClass::class, attributes: new Target('someTarget')), + ]; + } + }; + + $container->setParameter('parameter.1', 'foobar'); + $container->register('foo', \get_class($subscriber)) + ->addMethodCall('setContainer', [new Reference(PsrContainerInterface::class)]) + ->addTag('container.service_subscriber'); + + (new RegisterServiceSubscribersPass())->process($container); + (new ResolveServiceSubscribersPass())->process($container); + + $foo = $container->getDefinition('foo'); + $locator = $container->getDefinition((string) $foo->getMethodCalls()[0][1][0]); + + $expected = [ + 'tagged.iterator' => new ServiceClosureArgument(new TypedReference('iterable', 'iterable', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'tagged.iterator', [new TaggedIterator('tag')])), + 'tagged.locator' => new ServiceClosureArgument(new TypedReference(PsrContainerInterface::class, PsrContainerInterface::class, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'tagged.locator', [new TaggedLocator('tag')])), + 'autowired' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'autowired', [new Autowire(service: 'service.id')])), + 'autowired.nullable' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::IGNORE_ON_INVALID_REFERENCE, 'autowired.nullable', [new Autowire(service: 'service.id')])), + 'autowired.parameter' => new ServiceClosureArgument(new TypedReference('string', 'string', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'autowired.parameter', [new Autowire(service: '%parameter.1%')])), + 'map.decorated' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'map.decorated', [new MapDecorated()])), + 'target' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'target', [new Target('someTarget')])), + ]; + $this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0)); + + (new AutowirePass())->process($container); + + $expected = [ + 'tagged.iterator' => new ServiceClosureArgument(new TaggedIteratorArgument('tag')), + 'tagged.locator' => new ServiceClosureArgument(new ServiceLocatorArgument(new TaggedIteratorArgument('tag', 'tag', needsIndexes: true))), + 'autowired' => new ServiceClosureArgument(new Reference('service.id')), + 'autowired.nullable' => new ServiceClosureArgument(new Reference('service.id', ContainerInterface::NULL_ON_INVALID_REFERENCE)), + 'autowired.parameter' => new ServiceClosureArgument('foobar'), + 'map.decorated' => new ServiceClosureArgument(new Reference('.service_locator.oZHAdom.inner', ContainerInterface::NULL_ON_INVALID_REFERENCE)), + 'target' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'someTarget')), + ]; + $this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0)); + } + public function testBinding() { $container = new ContainerBuilder(); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services_subscriber.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services_subscriber.php index b0e9edfafa7fa..1928f94a41110 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services_subscriber.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services_subscriber.php @@ -44,10 +44,10 @@ public function isCompiled(): bool public function getRemovedIds(): array { return [ - '.service_locator.JmEob1b' => true, - '.service_locator.KIgkoLM' => true, - '.service_locator.qUb.lJI' => true, - '.service_locator.qUb.lJI.foo_service' => true, + '.service_locator.0H1ht0q' => true, + '.service_locator.0H1ht0q.foo_service' => true, + '.service_locator.2hyyc9y' => true, + '.service_locator.KGUGnmw' => true, 'Symfony\\Component\\DependencyInjection\\Tests\\Fixtures\\CustomDefinition' => true, ]; } diff --git a/src/Symfony/Component/DependencyInjection/TypedReference.php b/src/Symfony/Component/DependencyInjection/TypedReference.php index 946edb941d020..4c1e1c3f0c114 100644 --- a/src/Symfony/Component/DependencyInjection/TypedReference.php +++ b/src/Symfony/Component/DependencyInjection/TypedReference.php @@ -20,18 +20,21 @@ class TypedReference extends Reference { private string $type; private ?string $name; + private array $attributes; /** * @param string $id The service identifier * @param string $type The PHP type of the identified service * @param int $invalidBehavior The behavior when the service does not exist * @param string|null $name The name of the argument targeting the service + * @param array $attributes The attributes to be used */ - public function __construct(string $id, string $type, int $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, string $name = null) + public function __construct(string $id, string $type, int $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, string $name = null, array $attributes = []) { $this->name = $type === $id ? $name : null; parent::__construct($id, $invalidBehavior); $this->type = $type; + $this->attributes = $attributes; } public function getType() @@ -43,4 +46,9 @@ public function getName(): ?string { return $this->name; } + + public function getAttributes(): array + { + return $this->attributes; + } } diff --git a/src/Symfony/Contracts/CHANGELOG.md b/src/Symfony/Contracts/CHANGELOG.md index 5c9c3d08d4c9e..d8dd36863bafa 100644 --- a/src/Symfony/Contracts/CHANGELOG.md +++ b/src/Symfony/Contracts/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +3.2 +--- + + * Allow `ServiceSubscriberInterface::getSubscribedServices()` to return `SubscribedService[]` + 3.0 --- diff --git a/src/Symfony/Contracts/Service/Attribute/SubscribedService.php b/src/Symfony/Contracts/Service/Attribute/SubscribedService.php index 10d1bc38e8bf5..d98e1dfdbbeb1 100644 --- a/src/Symfony/Contracts/Service/Attribute/SubscribedService.php +++ b/src/Symfony/Contracts/Service/Attribute/SubscribedService.php @@ -11,9 +11,14 @@ namespace Symfony\Contracts\Service\Attribute; +use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\Contracts\Service\ServiceSubscriberTrait; /** + * For use as the return value for {@see ServiceSubscriberInterface}. + * + * @example new SubscribedService('http_client', HttpClientInterface::class, false, new Target('githubApi')) + * * Use with {@see ServiceSubscriberTrait} to mark a method's return type * as a subscribed service. * @@ -22,12 +27,21 @@ #[\Attribute(\Attribute::TARGET_METHOD)] final class SubscribedService { + /** @var object[] */ + public array $attributes; + /** - * @param string|null $key The key to use for the service - * If null, use "ClassName::methodName" + * @param string|null $key The key to use for the service + * @param class-string|null $type The service class + * @param bool $nullable Whether the service is optional + * @param object|object[] $attributes One or more dependency injection attributes to use */ public function __construct( - public ?string $key = null + public ?string $key = null, + public ?string $type = null, + public bool $nullable = false, + array|object $attributes = [], ) { + $this->attributes = \is_array($attributes) ? $attributes : [$attributes]; } } diff --git a/src/Symfony/Contracts/Service/ServiceSubscriberInterface.php b/src/Symfony/Contracts/Service/ServiceSubscriberInterface.php index 881ab971aa5a6..3da19169b0384 100644 --- a/src/Symfony/Contracts/Service/ServiceSubscriberInterface.php +++ b/src/Symfony/Contracts/Service/ServiceSubscriberInterface.php @@ -11,6 +11,8 @@ namespace Symfony\Contracts\Service; +use Symfony\Contracts\Service\Attribute\SubscribedService; + /** * A ServiceSubscriber exposes its dependencies via the static {@link getSubscribedServices} method. * @@ -29,7 +31,8 @@ interface ServiceSubscriberInterface { /** - * Returns an array of service types required by such instances, optionally keyed by the service names used internally. + * Returns an array of service types (or {@see SubscribedService} objects) required + * by such instances, optionally keyed by the service names used internally. * * For mandatory dependencies: * @@ -47,7 +50,13 @@ interface ServiceSubscriberInterface * * ['?Psr\Log\LoggerInterface'] is a shortcut for * * ['Psr\Log\LoggerInterface' => '?Psr\Log\LoggerInterface'] * - * @return string[] The required service types, optionally keyed by service names + * additionally, an array of {@see SubscribedService}'s can be returned: + * + * * [new SubscribedService('logger', Psr\Log\LoggerInterface::class)] + * * [new SubscribedService(type: Psr\Log\LoggerInterface::class, nullable: true)] + * * [new SubscribedService('http_client', HttpClientInterface::class, attributes: new Target('githubApi'))] + * + * @return string[]|SubscribedService[] The required service types, optionally keyed by service names */ public static function getSubscribedServices(): array; } diff --git a/src/Symfony/Contracts/Service/ServiceSubscriberTrait.php b/src/Symfony/Contracts/Service/ServiceSubscriberTrait.php index e8c9b17042bdb..53012986e5133 100644 --- a/src/Symfony/Contracts/Service/ServiceSubscriberTrait.php +++ b/src/Symfony/Contracts/Service/ServiceSubscriberTrait.php @@ -50,13 +50,17 @@ public static function getSubscribedServices(): array throw new \LogicException(sprintf('Cannot use "%s" on methods without a return type in "%s::%s()".', SubscribedService::class, $method->name, self::class)); } - $serviceId = $returnType instanceof \ReflectionNamedType ? $returnType->getName() : (string) $returnType; - - if ($returnType->allowsNull()) { - $serviceId = '?'.$serviceId; + /* @var SubscribedService $attribute */ + $attribute = $attribute->newInstance(); + $attribute->key ??= self::class.'::'.$method->name; + $attribute->type ??= $returnType instanceof \ReflectionNamedType ? $returnType->getName() : (string) $returnType; + $attribute->nullable = $returnType->allowsNull(); + + if ($attribute->attributes) { + $services[] = $attribute; + } else { + $services[$attribute->key] = ($attribute->nullable ? '?' : '').$attribute->type; } - - $services[$attribute->newInstance()->key ?? self::class.'::'.$method->name] = $serviceId; } return $services; diff --git a/src/Symfony/Contracts/Tests/Service/ServiceSubscriberTraitTest.php b/src/Symfony/Contracts/Tests/Service/ServiceSubscriberTraitTest.php index 296e9464f50f1..a4b2eccd899e9 100644 --- a/src/Symfony/Contracts/Tests/Service/ServiceSubscriberTraitTest.php +++ b/src/Symfony/Contracts/Tests/Service/ServiceSubscriberTraitTest.php @@ -15,6 +15,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\OtherDir\Component1\Dir1\Service1; use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\OtherDir\Component1\Dir2\Service2; +use Symfony\Contracts\Service\Attribute\Required; use Symfony\Contracts\Service\Attribute\SubscribedService; use Symfony\Contracts\Service\ServiceLocatorTrait; use Symfony\Contracts\Service\ServiceSubscriberInterface; @@ -27,6 +28,7 @@ public function testMethodsOnParentsAndChildrenAreIgnoredInGetSubscribedServices $expected = [ TestService::class.'::aService' => Service2::class, TestService::class.'::nullableService' => '?'.Service2::class, + new SubscribedService(TestService::class.'::withAttribute', Service2::class, true, new Required()), ]; $this->assertEquals($expected, ChildTestService::getSubscribedServices()); @@ -93,6 +95,11 @@ public function aService(): Service2 public function nullableService(): ?Service2 { } + + #[SubscribedService(attributes: new Required())] + public function withAttribute(): ?Service2 + { + } } class ChildTestService extends TestService