From df11660015038945e33ecd679b8a053077a68c82 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 20 Feb 2024 14:38:14 -0500 Subject: [PATCH] [DependencyInjection] Add `#[AutowireMethodOf]` attribute to autowire a method of a service as a callable --- .../Attribute/AutowireMethodOf.php | 38 +++++++++++++++++++ .../DependencyInjection/CHANGELOG.md | 1 + ...xceptionOnInvalidReferenceBehaviorPass.php | 4 +- .../Tests/Attribute/AutowireMethodOfTest.php | 34 +++++++++++++++++ .../ArgumentResolver/ServiceValueResolver.php | 6 ++- ...RegisterControllerArgumentLocatorsPass.php | 10 ++--- ...oveEmptyControllerArgumentLocatorsPass.php | 4 ++ .../ServiceValueResolverTest.php | 2 +- ...sterControllerArgumentLocatorsPassTest.php | 9 ++++- ...mptyControllerArgumentLocatorsPassTest.php | 13 ++++--- 10 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 src/Symfony/Component/DependencyInjection/Attribute/AutowireMethodOf.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Attribute/AutowireMethodOfTest.php diff --git a/src/Symfony/Component/DependencyInjection/Attribute/AutowireMethodOf.php b/src/Symfony/Component/DependencyInjection/Attribute/AutowireMethodOf.php new file mode 100644 index 0000000000000..4edcb8fc74ec5 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Attribute/AutowireMethodOf.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\Definition; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Tells which method should be turned into a Closure based on the name of the parameter it's attached to. + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class AutowireMethodOf extends AutowireCallable +{ + /** + * @param string $service The service containing the method to autowire + * @param bool|class-string $lazy Whether to use lazy-loading for this argument + */ + public function __construct(string $service, bool|string $lazy = false) + { + parent::__construct([new Reference($service)], lazy: $lazy); + } + + public function buildDefinition(mixed $value, ?string $type, \ReflectionParameter $parameter): Definition + { + $value[1] = $parameter->name; + + return parent::buildDefinition($value, $type, $parameter); + } +} diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index 9e14c79fe0aec..a3f055088ec4d 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * Add argument `$prepend` to `ContainerConfigurator::extension()` to prepend the configuration instead of appending it * Have `ServiceLocator` implement `ServiceCollectionInterface` * Add `#[Lazy]` attribute as shortcut for `#[Autowire(lazy: [bool|string])]` and `#[Autoconfigure(lazy: [bool|string])]` + * Add `#[AutowireMethodOf]` attribute to autowire a method of a service as a callable 7.0 --- diff --git a/src/Symfony/Component/DependencyInjection/Compiler/CheckExceptionOnInvalidReferenceBehaviorPass.php b/src/Symfony/Component/DependencyInjection/Compiler/CheckExceptionOnInvalidReferenceBehaviorPass.php index 72e2a366817ed..e81db66e3d3bb 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/CheckExceptionOnInvalidReferenceBehaviorPass.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/CheckExceptionOnInvalidReferenceBehaviorPass.php @@ -47,7 +47,7 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed if (!$value instanceof Reference) { return parent::processValue($value, $isRoot); } - if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE < $value->getInvalidBehavior() || $this->container->has($id = (string) $value)) { + if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE < $value->getInvalidBehavior() || $this->container->has((string) $value)) { return $value; } @@ -83,7 +83,7 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed $this->throwServiceNotFoundException($value, $currentId, $value); } - private function throwServiceNotFoundException(Reference $ref, string $sourceId, $value): void + private function throwServiceNotFoundException(Reference $ref, string $sourceId, mixed $value): void { $id = (string) $ref; $alternatives = []; diff --git a/src/Symfony/Component/DependencyInjection/Tests/Attribute/AutowireMethodOfTest.php b/src/Symfony/Component/DependencyInjection/Tests/Attribute/AutowireMethodOfTest.php new file mode 100644 index 0000000000000..dc744eca1b687 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Attribute/AutowireMethodOfTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Attribute\AutowireMethodOf; +use Symfony\Component\DependencyInjection\Reference; + +class AutowireMethodOfTest extends TestCase +{ + public function testConstructor() + { + $a = new AutowireMethodOf('foo'); + + $this->assertEquals([new Reference('foo')], $a->value); + } + + public function testBuildDefinition(?\Closure $dummy = null) + { + $a = new AutowireMethodOf('foo'); + $r = new \ReflectionParameter([__CLASS__, __FUNCTION__], 0); + + $this->assertEquals([[new Reference('foo'), 'dummy']], $a->buildDefinition($a->value, 'Closure', $r)->getArguments()); + } +} diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php index 4ac10a45b0868..a7f61dbcc6581 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php @@ -55,8 +55,10 @@ public function resolve(Request $request, ArgumentMetadata $argument): array try { return [$this->container->get($controller)->get($argument->getName())]; } catch (RuntimeException $e) { - $what = sprintf('argument $%s of "%s()"', $argument->getName(), $controller); - $message = preg_replace('/service "\.service_locator\.[^"]++"/', $what, $e->getMessage()); + $what = 'argument $'.$argument->getName(); + $message = str_replace(sprintf('service "%s"', $argument->getName()), $what, $e->getMessage()); + $what .= sprintf(' of "%s()"', $controller); + $message = preg_replace('/service "\.service_locator\.[^"]++"/', $what, $message); if ($e->getMessage() === $message) { $message = sprintf('Cannot resolve %s: %s', $what, $message); diff --git a/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php b/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php index ff15502ce74fa..2d68956c59de7 100644 --- a/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php +++ b/src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php @@ -123,6 +123,7 @@ public function process(ContainerBuilder $container): void // create a per-method map of argument-names to service/type-references $args = []; + $erroredIds = 0; foreach ($parameters as $p) { /** @var \ReflectionParameter $p */ $type = preg_replace('/(^|[(|&])\\\\/', '\1', $target = ltrim(ProxyHelper::exportType($p) ?? '', '?')); @@ -171,10 +172,8 @@ public function process(ContainerBuilder $container): void $value = $parameterBag->resolveValue($attribute->value); if ($attribute instanceof AutowireCallable) { - $value = $attribute->buildDefinition($value, $type, $p); - } - - if ($value instanceof Reference) { + $args[$p->name] = $attribute->buildDefinition($value, $type, $p); + } elseif ($value instanceof Reference) { $args[$p->name] = $type ? new TypedReference($value, $type, $invalidBehavior, $p->name) : new Reference($value, $invalidBehavior); } else { $args[$p->name] = new Reference('.value.'.$container->hash($value)); @@ -198,6 +197,7 @@ public function process(ContainerBuilder $container): void ->addError($message); $args[$p->name] = new Reference($erroredId, ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE); + ++$erroredIds; } else { $target = preg_replace('/(^|[(|&])\\\\/', '\1', $target); $args[$p->name] = $type ? new TypedReference($target, $type, $invalidBehavior, Target::parseName($p)) : new Reference($target, $invalidBehavior); @@ -205,7 +205,7 @@ public function process(ContainerBuilder $container): void } // register the maps as a per-method service-locators if ($args) { - $controllers[$id.'::'.$r->name] = ServiceLocatorTagPass::register($container, $args); + $controllers[$id.'::'.$r->name] = ServiceLocatorTagPass::register($container, $args, \count($args) !== $erroredIds ? $id.'::'.$r->name.'()' : null); foreach ($publicAliases[$id] ?? [] as $alias) { $controllers[$alias.'::'.$r->name] = clone $controllers[$id.'::'.$r->name]; diff --git a/src/Symfony/Component/HttpKernel/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPass.php b/src/Symfony/Component/HttpKernel/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPass.php index f9b16befbddb2..b2e7832e6e486 100644 --- a/src/Symfony/Component/HttpKernel/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPass.php +++ b/src/Symfony/Component/HttpKernel/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPass.php @@ -29,6 +29,10 @@ public function process(ContainerBuilder $container): void foreach ($controllers as $controller => $argumentRef) { $argumentLocator = $container->getDefinition((string) $argumentRef->getValues()[0]); + if ($argumentLocator->getFactory()) { + $argumentLocator = $container->getDefinition($argumentLocator->getFactory()[0]); + } + if (!$argumentLocator->getArgument(0)) { // remove empty argument locators $reason = sprintf('Removing service-argument resolver for controller "%s": no corresponding services exist for the referenced types.', $controller); diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php index 59e81a9ae5ad0..a1a80fe82f2c2 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php @@ -89,7 +89,7 @@ public function testControllerNameIsAnArray() public function testErrorIsTruncated() { $this->expectException(NearMissValueResolverException::class); - $this->expectExceptionMessage('Cannot autowire argument $dummy of "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyController::index()": it references class "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyService" but no such service exists.'); + $this->expectExceptionMessage('Cannot autowire argument $dummy required by "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyController::index()": it references class "Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver\DummyService" but no such service exists.'); $container = new ContainerBuilder(); $container->addCompilerPass(new RegisterControllerArgumentLocatorsPass()); diff --git a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php index c74338c081a9e..e34a808625831 100644 --- a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php @@ -143,6 +143,7 @@ public function testAllActions() $this->assertInstanceof(ServiceClosureArgument::class, $locator['foo::fooAction']); $locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]); + $locator = $container->getDefinition((string) $locator->getFactory()[0]); $this->assertSame(ServiceLocator::class, $locator->getClass()); $this->assertFalse($locator->isPublic()); @@ -166,6 +167,7 @@ public function testExplicitArgument() $locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0); $locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]); + $locator = $container->getDefinition((string) $locator->getFactory()[0]); $expected = ['bar' => new ServiceClosureArgument(new TypedReference('bar', ControllerDummy::class, ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE))]; $this->assertEquals($expected, $locator->getArgument(0)); @@ -185,6 +187,7 @@ public function testOptionalArgument() $locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0); $locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]); + $locator = $container->getDefinition((string) $locator->getFactory()[0]); $expected = ['bar' => new ServiceClosureArgument(new TypedReference('bar', ControllerDummy::class, ContainerInterface::IGNORE_ON_INVALID_REFERENCE))]; $this->assertEquals($expected, $locator->getArgument(0)); @@ -306,8 +309,8 @@ public function testBindings($bindingName) $pass->process($container); $locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0); - $locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]); + $locator = $container->getDefinition((string) $locator->getFactory()[0]); $expected = ['bar' => new ServiceClosureArgument(new Reference('foo'))]; $this->assertEquals($expected, $locator->getArgument(0)); @@ -372,7 +375,8 @@ public function testBindingsOnChildDefinitions() $locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0); $this->assertInstanceOf(ServiceClosureArgument::class, $locator['child::fooAction']); - $locator = $container->getDefinition((string) $locator['child::fooAction']->getValues()[0])->getArgument(0); + $locator = $container->getDefinition((string) $locator['child::fooAction']->getValues()[0]); + $locator = $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0); $this->assertInstanceOf(ServiceClosureArgument::class, $locator['someArg']); $this->assertEquals(new Reference('parent'), $locator['someArg']->getValues()[0]); } @@ -439,6 +443,7 @@ public function testBindWithTarget() $locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0); $locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]); + $locator = $container->getDefinition((string) $locator->getFactory()[0]); $expected = [ 'apiKey' => new ServiceClosureArgument(new Reference('the_api_key')), diff --git a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPassTest.php b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPassTest.php index 8c99b882d32ca..21e0eb29ec08a 100644 --- a/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPassTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPassTest.php @@ -35,22 +35,23 @@ public function testProcess() $pass->process($container); $controllers = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0); + $getLocator = fn ($controllers, $k) => $container->getDefinition((string) $container->getDefinition((string) $controllers[$k]->getValues()[0])->getFactory()[0])->getArgument(0); - $this->assertCount(2, $container->getDefinition((string) $controllers['c1::fooAction']->getValues()[0])->getArgument(0)); - $this->assertCount(1, $container->getDefinition((string) $controllers['c2::setTestCase']->getValues()[0])->getArgument(0)); - $this->assertCount(1, $container->getDefinition((string) $controllers['c2::fooAction']->getValues()[0])->getArgument(0)); + $this->assertCount(2, $getLocator($controllers, 'c1::fooAction')); + $this->assertCount(1, $getLocator($controllers, 'c2::setTestCase')); + $this->assertCount(1, $getLocator($controllers, 'c2::fooAction')); (new ResolveInvalidReferencesPass())->process($container); - $this->assertCount(1, $container->getDefinition((string) $controllers['c2::setTestCase']->getValues()[0])->getArgument(0)); - $this->assertSame([], $container->getDefinition((string) $controllers['c2::fooAction']->getValues()[0])->getArgument(0)); + $this->assertCount(1, $getLocator($controllers, 'c2::setTestCase')); + $this->assertSame([], $getLocator($controllers, 'c2::fooAction')); (new RemoveEmptyControllerArgumentLocatorsPass())->process($container); $controllers = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0); $this->assertSame(['c1::fooAction', 'c1:fooAction'], array_keys($controllers)); - $this->assertSame(['bar'], array_keys($container->getDefinition((string) $controllers['c1::fooAction']->getValues()[0])->getArgument(0))); + $this->assertSame(['bar'], array_keys($getLocator($controllers, 'c1::fooAction'))); $expectedLog = [ 'Symfony\Component\HttpKernel\DependencyInjection\RemoveEmptyControllerArgumentLocatorsPass: Removing service-argument resolver for controller "c2::fooAction": no corresponding services exist for the referenced types.',