From 9869327ab20451ec430420497caae5f5697c6482 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 7 Mar 2023 11:36:11 +0100 Subject: [PATCH] [DependencyInjection] Add support for autowiring services as closures using attributes --- .../Attribute/Autowire.php | 15 ++-- .../Attribute/AutowireCallable.php | 38 +++++++++ .../Attribute/AutowireServiceClosure.php | 27 +++++++ .../Attribute/TaggedIterator.php | 5 +- .../Attribute/TaggedLocator.php | 6 +- .../DependencyInjection/CHANGELOG.md | 1 + .../Compiler/AutowirePass.php | 42 +++++----- .../RegisterServiceSubscribersPassTest.php | 2 +- .../Tests/Dumper/PhpDumperTest.php | 48 ++++++++++++ .../Tests/Fixtures/php/autowire_closure.php | 78 +++++++++++++++++++ 10 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 src/Symfony/Component/DependencyInjection/Attribute/AutowireCallable.php create mode 100644 src/Symfony/Component/DependencyInjection/Attribute/AutowireServiceClosure.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/autowire_closure.php diff --git a/src/Symfony/Component/DependencyInjection/Attribute/Autowire.php b/src/Symfony/Component/DependencyInjection/Attribute/Autowire.php index 73e6f455d8a1b..44f76f04742cb 100644 --- a/src/Symfony/Component/DependencyInjection/Attribute/Autowire.php +++ b/src/Symfony/Component/DependencyInjection/Attribute/Autowire.php @@ -11,6 +11,7 @@ namespace Symfony\Component\DependencyInjection\Attribute; +use Symfony\Component\DependencyInjection\Argument\ArgumentInterface; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\ExpressionLanguage\Expression; @@ -23,19 +24,19 @@ #[\Attribute(\Attribute::TARGET_PARAMETER)] class Autowire { - public readonly string|array|Expression|Reference $value; + public readonly string|array|Expression|Reference|ArgumentInterface $value; /** * Use only ONE of the following. * - * @param string|array|null $value Parameter value (ie "%kernel.project_dir%/some/path") - * @param string|null $service Service ID (ie "some.service") - * @param string|null $expression Expression (ie 'service("some.service").someMethod()') - * @param string|null $env Environment variable name (ie 'SOME_ENV_VARIABLE') - * @param string|null $param Parameter name (ie 'some.parameter.name') + * @param string|array|ArgumentInterface|null $value Value to inject (ie "%kernel.project_dir%/some/path") + * @param string|null $service Service ID (ie "some.service") + * @param string|null $expression Expression (ie 'service("some.service").someMethod()') + * @param string|null $env Environment variable name (ie 'SOME_ENV_VARIABLE') + * @param string|null $param Parameter name (ie 'some.parameter.name') */ public function __construct( - string|array $value = null, + string|array|ArgumentInterface $value = null, string $service = null, string $expression = null, string $env = null, diff --git a/src/Symfony/Component/DependencyInjection/Attribute/AutowireCallable.php b/src/Symfony/Component/DependencyInjection/Attribute/AutowireCallable.php new file mode 100644 index 0000000000000..a48040935a7f8 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Attribute/AutowireCallable.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Attribute; + +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Attribute to tell which callable to give to an argument of type Closure. + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class AutowireCallable extends Autowire +{ + public function __construct( + string|array $callable = null, + string $service = null, + string $method = null, + public bool $lazy = false, + ) { + if (!(null !== $callable xor null !== $service)) { + throw new LogicException('#[AutowireCallable] attribute must declare exactly one of $callable or $service.'); + } + if (!(null !== $callable xor null !== $method)) { + throw new LogicException('#[AutowireCallable] attribute must declare one of $callable or $method.'); + } + + parent::__construct($callable ?? [new Reference($service), $method ?? '__invoke']); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Attribute/AutowireServiceClosure.php b/src/Symfony/Component/DependencyInjection/Attribute/AutowireServiceClosure.php new file mode 100644 index 0000000000000..a468414a4e8c6 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Attribute/AutowireServiceClosure.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Attribute; + +use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Attribute to wrap a service in a closure that returns it. + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class AutowireServiceClosure extends Autowire +{ + public function __construct(string $service) + { + parent::__construct(new ServiceClosureArgument(new Reference($service))); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Attribute/TaggedIterator.php b/src/Symfony/Component/DependencyInjection/Attribute/TaggedIterator.php index fb33fb572942b..77c9af17fa5bd 100644 --- a/src/Symfony/Component/DependencyInjection/Attribute/TaggedIterator.php +++ b/src/Symfony/Component/DependencyInjection/Attribute/TaggedIterator.php @@ -11,8 +11,10 @@ namespace Symfony\Component\DependencyInjection\Attribute; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; + #[\Attribute(\Attribute::TARGET_PARAMETER)] -class TaggedIterator +class TaggedIterator extends Autowire { public function __construct( public string $tag, @@ -22,5 +24,6 @@ public function __construct( public string|array $exclude = [], public bool $excludeSelf = true, ) { + parent::__construct(new TaggedIteratorArgument($tag, $indexAttribute, $defaultIndexMethod, false, $defaultPriorityMethod, (array) $exclude, $excludeSelf)); } } diff --git a/src/Symfony/Component/DependencyInjection/Attribute/TaggedLocator.php b/src/Symfony/Component/DependencyInjection/Attribute/TaggedLocator.php index f05ae53bc4284..98426a01f3668 100644 --- a/src/Symfony/Component/DependencyInjection/Attribute/TaggedLocator.php +++ b/src/Symfony/Component/DependencyInjection/Attribute/TaggedLocator.php @@ -11,8 +11,11 @@ namespace Symfony\Component\DependencyInjection\Attribute; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; + #[\Attribute(\Attribute::TARGET_PARAMETER)] -class TaggedLocator +class TaggedLocator extends Autowire { public function __construct( public string $tag, @@ -22,5 +25,6 @@ public function __construct( public string|array $exclude = [], public bool $excludeSelf = true, ) { + parent::__construct(new ServiceLocatorArgument(new TaggedIteratorArgument($tag, $indexAttribute, $defaultIndexMethod, true, $defaultPriorityMethod, (array) $exclude, $excludeSelf))); } } diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index 1d2caa272249c..ccd4a45e7edfa 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -15,6 +15,7 @@ CHANGELOG * Allow to trim XML service parameters value by using `trim="true"` attribute * Allow extending the `Autowire` attribute * Add `#[Exclude]` to skip autoregistering a class + * Add support for autowiring services as closures using `#[AutowireCallable]` or `#[AutowireServiceClosure]` 6.2 --- diff --git a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php index 3bcaa812cf485..48cacd7877f20 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/AutowirePass.php @@ -12,12 +12,9 @@ namespace Symfony\Component\DependencyInjection\Compiler; use Symfony\Component\Config\Resource\ClassExistenceResource; -use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; -use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\AutowireCallable; 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\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -86,14 +83,6 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed return $this->processValue($this->container->getParameterBag()->resolveValue($value->value)); } - if ($value instanceof TaggedIterator) { - return new TaggedIteratorArgument($value->tag, $value->indexAttribute, $value->defaultIndexMethod, false, $value->defaultPriorityMethod, (array) $value->exclude, $value->excludeSelf); - } - - if ($value instanceof TaggedLocator) { - return new ServiceLocatorArgument(new TaggedIteratorArgument($value->tag, $value->indexAttribute, $value->defaultIndexMethod, true, $value->defaultPriorityMethod, (array) $value->exclude, $value->excludeSelf)); - } - if ($value instanceof MapDecorated) { $definition = $this->container->getDefinition($this->currentId); @@ -191,8 +180,6 @@ private function processAttribute(object $attribute, bool $isOptional = false): return new Reference($attribute->value, ContainerInterface::NULL_ON_INVALID_REFERENCE); } // no break - case $attribute instanceof TaggedIterator: - case $attribute instanceof TaggedLocator: case $attribute instanceof MapDecorated: return $this->processValue($attribute); } @@ -291,17 +278,32 @@ private function autowireMethod(\ReflectionFunctionAbstract $reflectionMethod, a continue; } - if ($checkAttributes) { - foreach ([TaggedIterator::class, TaggedLocator::class, Autowire::class, MapDecorated::class] as $attributeClass) { - foreach ($parameter->getAttributes($attributeClass, Autowire::class === $attributeClass ? \ReflectionAttribute::IS_INSTANCEOF : 0) as $attribute) { - $arguments[$index] = $this->processAttribute($attribute->newInstance(), $parameter->allowsNull()); + $type = ProxyHelper::exportType($parameter, true); - continue 3; + if ($checkAttributes) { + foreach ($parameter->getAttributes(Autowire::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + $attribute = $attribute->newInstance(); + $value = $this->processAttribute($attribute, $parameter->allowsNull()); + + if ($attribute instanceof AutowireCallable || 'Closure' === $type && \is_array($value)) { + $value = (new Definition('Closure')) + ->setFactory(['Closure', 'fromCallable']) + ->setArguments([$value + [1 => '__invoke']]) + ->setLazy($attribute instanceof AutowireCallable && $attribute->lazy); } + $arguments[$index] = $value; + + continue 2; + } + + foreach ($parameter->getAttributes(MapDecorated::class) as $attribute) { + $arguments[$index] = $this->processAttribute($attribute->newInstance(), $parameter->allowsNull()); + + continue 2; } } - if (!$type = ProxyHelper::exportType($parameter, true)) { + if (!$type) { if (isset($arguments[$index])) { continue; } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php index 4da06e889b715..34c62faf3b0ad 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php @@ -462,7 +462,7 @@ public static function getSubscribedServices(): array '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.LnJLtj2.inner', ContainerInterface::NULL_ON_INVALID_REFERENCE)), + 'map.decorated' => new ServiceClosureArgument(new Reference('.service_locator.EeZIdVM.inner', ContainerInterface::NULL_ON_INVALID_REFERENCE)), 'target' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'someTarget', [new Target('someTarget')])), ]; $this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0)); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php b/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php index 7d83ccb1a6eba..5e313cad403c9 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Dumper/PhpDumperTest.php @@ -21,6 +21,9 @@ use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\Argument\ServiceLocator as ArgumentServiceLocator; use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\DependencyInjection\Attribute\AutowireCallable; +use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\Container; @@ -1651,6 +1654,38 @@ public function testClosure() $this->assertStringEqualsFile(self::$fixturesPath.'/php/closure.php', $dumper->dump()); } + + public function testAutowireClosure() + { + $container = new ContainerBuilder(); + $container->register('foo', Foo::class) + ->setPublic('true'); + $container->register('baz', \Closure::class) + ->setFactory(['Closure', 'fromCallable']) + ->setArguments(['var_dump']) + ->setPublic('true'); + $container->register('bar', LazyConsumer::class) + ->setPublic('true') + ->setAutowired(true); + $container->compile(); + $dumper = new PhpDumper($container); + + $this->assertStringEqualsFile(self::$fixturesPath.'/php/autowire_closure.php', $dumper->dump(['class' => 'Symfony_DI_PhpDumper_Test_Autowire_Closure'])); + + require self::$fixturesPath.'/php/autowire_closure.php'; + + $container = new \Symfony_DI_PhpDumper_Test_Autowire_Closure(); + + $this->assertInstanceOf(Foo::class, $container->get('foo')); + $this->assertInstanceOf(LazyConsumer::class, $bar = $container->get('bar')); + $this->assertInstanceOf(\Closure::class, $bar->foo); + $this->assertInstanceOf(\Closure::class, $bar->baz); + $this->assertInstanceOf(\Closure::class, $bar->buz); + $this->assertSame($container->get('foo'), ($bar->foo)()); + $this->assertSame($container->get('baz'), $bar->baz); + $this->assertInstanceOf(Foo::class, $fooClone = ($bar->buz)()); + $this->assertNotSame($container->get('foo'), $fooClone); + } } class Rot13EnvVarProcessor implements EnvVarProcessorInterface @@ -1676,3 +1711,16 @@ public function __construct(\stdClass $a, \stdClass $b) $this->bClone = clone $b; } } + +class LazyConsumer +{ + public function __construct( + #[AutowireServiceClosure('foo')] + public \Closure $foo, + #[Autowire(service: 'baz')] + public \Closure $baz, + #[AutowireCallable(service: 'foo', method: 'cloneFoo')] + public \Closure $buz, + ) { + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/autowire_closure.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/autowire_closure.php new file mode 100644 index 0000000000000..e3ec79ab70cfd --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/autowire_closure.php @@ -0,0 +1,78 @@ +ref = \WeakReference::create($this); + $this->services = $this->privates = []; + $this->methodMap = [ + 'bar' => 'getBarService', + 'baz' => 'getBazService', + 'foo' => 'getFooService', + ]; + + $this->aliases = []; + } + + public function compile(): void + { + throw new LogicException('You cannot compile a dumped container that was already compiled.'); + } + + public function isCompiled(): bool + { + return true; + } + + /** + * Gets the public 'bar' shared autowired service. + * + * @return \Symfony\Component\DependencyInjection\Tests\Dumper\LazyConsumer + */ + protected static function getBarService($container) + { + $containerRef = $container->ref; + + return $container->services['bar'] = new \Symfony\Component\DependencyInjection\Tests\Dumper\LazyConsumer(#[\Closure(name: 'foo', class: 'Symfony\\Component\\DependencyInjection\\Tests\\Compiler\\Foo')] function () use ($containerRef) { + $container = $containerRef->get(); + + return ($container->services['foo'] ??= new \Symfony\Component\DependencyInjection\Tests\Compiler\Foo()); + }, ($container->services['baz'] ?? self::getBazService($container)), ($container->services['foo'] ??= new \Symfony\Component\DependencyInjection\Tests\Compiler\Foo())->cloneFoo(...)); + } + + /** + * Gets the public 'baz' shared service. + * + * @return \Closure + */ + protected static function getBazService($container) + { + return $container->services['baz'] = \var_dump(...); + } + + /** + * Gets the public 'foo' shared service. + * + * @return \Symfony\Component\DependencyInjection\Tests\Compiler\Foo + */ + protected static function getFooService($container) + { + return $container->services['foo'] = new \Symfony\Component\DependencyInjection\Tests\Compiler\Foo(); + } +}