Skip to content

Commit 10bc796

Browse files
feature #54016 [DependencyInjection] Add #[AutowireMethodOf] attribute to autowire a method of a service as a callable (nicolas-grekas)
This PR was merged into the 7.1 branch. Discussion ---------- [DependencyInjection] Add `#[AutowireMethodOf]` attribute to autowire a method of a service as a callable | Q | A | ------------- | --- | Branch? | 7.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | - | License | MIT Let's take a controller for example, with one action that has this argument: ```php #[AutowireMethodOf(CommentRepository::class)] \Closure $getCommentPaginator, ``` The proposed attribute tells Symfony to create a closure that will call `CommentRepository::getCommentPaginator()`. The name of the method to be called is inferred from the name of the parameter. This is already doable with this syntax, so that the proposed attribute is just a shortcut for this: ```php #[AutowireCallable(service: CommentRepository::class, method: 'getCommentPaginator')] \Closure $getCommentPaginator, ``` Using this style allows turning e.g. entity repositories into query functions, which are way more flexible. But because the existing syntax is quite verbose, i looked for a more concise alternative, so here we are with this proposal. Benefits: - Increased Flexibility: Allows developers to inject specific methods as Closures, providing greater control over what functionality is injected into - Improved Readability: By using the attribute, the intention behind the dependency injection is made explicit, improving code clarity. - **Enhanced Modularity: Facilitates a more modular architecture by decoupling services from direct dependencies on specific class methods, making the codebase more maintainable and testable.** Because we leverage the existing code infrastructure for AutowireCallable, if I declare this interface: ```php interface GetCommentPaginatorInterface { public function __invoke(Conference $conference, int $page): Paginator; } ``` then I can also do native types (vs a closure) without doing anything else: ```php #[AutowireMethodOf(CommentRepository::class)] GetCommentPaginatorInterface $getCommentPaginator, ``` Commits ------- df11660 [DependencyInjection] Add `#[AutowireMethodOf]` attribute to autowire a method of a service as a callable
2 parents 719803d + df11660 commit 10bc796

File tree

10 files changed

+103
-18
lines changed

10 files changed

+103
-18
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\DependencyInjection\Attribute;
13+
14+
use Symfony\Component\DependencyInjection\Definition;
15+
use Symfony\Component\DependencyInjection\Reference;
16+
17+
/**
18+
* Tells which method should be turned into a Closure based on the name of the parameter it's attached to.
19+
*/
20+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
21+
class AutowireMethodOf extends AutowireCallable
22+
{
23+
/**
24+
* @param string $service The service containing the method to autowire
25+
* @param bool|class-string $lazy Whether to use lazy-loading for this argument
26+
*/
27+
public function __construct(string $service, bool|string $lazy = false)
28+
{
29+
parent::__construct([new Reference($service)], lazy: $lazy);
30+
}
31+
32+
public function buildDefinition(mixed $value, ?string $type, \ReflectionParameter $parameter): Definition
33+
{
34+
$value[1] = $parameter->name;
35+
36+
return parent::buildDefinition($value, $type, $parameter);
37+
}
38+
}

src/Symfony/Component/DependencyInjection/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ CHANGELOG
88
* Add argument `$prepend` to `ContainerConfigurator::extension()` to prepend the configuration instead of appending it
99
* Have `ServiceLocator` implement `ServiceCollectionInterface`
1010
* Add `#[Lazy]` attribute as shortcut for `#[Autowire(lazy: [bool|string])]` and `#[Autoconfigure(lazy: [bool|string])]`
11+
* Add `#[AutowireMethodOf]` attribute to autowire a method of a service as a callable
1112

1213
7.0
1314
---

src/Symfony/Component/DependencyInjection/Compiler/CheckExceptionOnInvalidReferenceBehaviorPass.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed
4747
if (!$value instanceof Reference) {
4848
return parent::processValue($value, $isRoot);
4949
}
50-
if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE < $value->getInvalidBehavior() || $this->container->has($id = (string) $value)) {
50+
if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE < $value->getInvalidBehavior() || $this->container->has((string) $value)) {
5151
return $value;
5252
}
5353

@@ -83,7 +83,7 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed
8383
$this->throwServiceNotFoundException($value, $currentId, $value);
8484
}
8585

86-
private function throwServiceNotFoundException(Reference $ref, string $sourceId, $value): void
86+
private function throwServiceNotFoundException(Reference $ref, string $sourceId, mixed $value): void
8787
{
8888
$id = (string) $ref;
8989
$alternatives = [];
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\DependencyInjection\Tests\Attribute;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\DependencyInjection\Attribute\AutowireMethodOf;
16+
use Symfony\Component\DependencyInjection\Reference;
17+
18+
class AutowireMethodOfTest extends TestCase
19+
{
20+
public function testConstructor()
21+
{
22+
$a = new AutowireMethodOf('foo');
23+
24+
$this->assertEquals([new Reference('foo')], $a->value);
25+
}
26+
27+
public function testBuildDefinition(?\Closure $dummy = null)
28+
{
29+
$a = new AutowireMethodOf('foo');
30+
$r = new \ReflectionParameter([__CLASS__, __FUNCTION__], 0);
31+
32+
$this->assertEquals([[new Reference('foo'), 'dummy']], $a->buildDefinition($a->value, 'Closure', $r)->getArguments());
33+
}
34+
}

src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/ServiceValueResolver.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ public function resolve(Request $request, ArgumentMetadata $argument): array
5555
try {
5656
return [$this->container->get($controller)->get($argument->getName())];
5757
} catch (RuntimeException $e) {
58-
$what = sprintf('argument $%s of "%s()"', $argument->getName(), $controller);
59-
$message = preg_replace('/service "\.service_locator\.[^"]++"/', $what, $e->getMessage());
58+
$what = 'argument $'.$argument->getName();
59+
$message = str_replace(sprintf('service "%s"', $argument->getName()), $what, $e->getMessage());
60+
$what .= sprintf(' of "%s()"', $controller);
61+
$message = preg_replace('/service "\.service_locator\.[^"]++"/', $what, $message);
6062

6163
if ($e->getMessage() === $message) {
6264
$message = sprintf('Cannot resolve %s: %s', $what, $message);

src/Symfony/Component/HttpKernel/DependencyInjection/RegisterControllerArgumentLocatorsPass.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ public function process(ContainerBuilder $container): void
123123

124124
// create a per-method map of argument-names to service/type-references
125125
$args = [];
126+
$erroredIds = 0;
126127
foreach ($parameters as $p) {
127128
/** @var \ReflectionParameter $p */
128129
$type = preg_replace('/(^|[(|&])\\\\/', '\1', $target = ltrim(ProxyHelper::exportType($p) ?? '', '?'));
@@ -171,10 +172,8 @@ public function process(ContainerBuilder $container): void
171172
$value = $parameterBag->resolveValue($attribute->value);
172173

173174
if ($attribute instanceof AutowireCallable) {
174-
$value = $attribute->buildDefinition($value, $type, $p);
175-
}
176-
177-
if ($value instanceof Reference) {
175+
$args[$p->name] = $attribute->buildDefinition($value, $type, $p);
176+
} elseif ($value instanceof Reference) {
178177
$args[$p->name] = $type ? new TypedReference($value, $type, $invalidBehavior, $p->name) : new Reference($value, $invalidBehavior);
179178
} else {
180179
$args[$p->name] = new Reference('.value.'.$container->hash($value));
@@ -198,14 +197,15 @@ public function process(ContainerBuilder $container): void
198197
->addError($message);
199198

200199
$args[$p->name] = new Reference($erroredId, ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE);
200+
++$erroredIds;
201201
} else {
202202
$target = preg_replace('/(^|[(|&])\\\\/', '\1', $target);
203203
$args[$p->name] = $type ? new TypedReference($target, $type, $invalidBehavior, Target::parseName($p)) : new Reference($target, $invalidBehavior);
204204
}
205205
}
206206
// register the maps as a per-method service-locators
207207
if ($args) {
208-
$controllers[$id.'::'.$r->name] = ServiceLocatorTagPass::register($container, $args);
208+
$controllers[$id.'::'.$r->name] = ServiceLocatorTagPass::register($container, $args, \count($args) !== $erroredIds ? $id.'::'.$r->name.'()' : null);
209209

210210
foreach ($publicAliases[$id] ?? [] as $alias) {
211211
$controllers[$alias.'::'.$r->name] = clone $controllers[$id.'::'.$r->name];

src/Symfony/Component/HttpKernel/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPass.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ public function process(ContainerBuilder $container): void
2929
foreach ($controllers as $controller => $argumentRef) {
3030
$argumentLocator = $container->getDefinition((string) $argumentRef->getValues()[0]);
3131

32+
if ($argumentLocator->getFactory()) {
33+
$argumentLocator = $container->getDefinition($argumentLocator->getFactory()[0]);
34+
}
35+
3236
if (!$argumentLocator->getArgument(0)) {
3337
// remove empty argument locators
3438
$reason = sprintf('Removing service-argument resolver for controller "%s": no corresponding services exist for the referenced types.', $controller);

src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/ServiceValueResolverTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public function testControllerNameIsAnArray()
8989
public function testErrorIsTruncated()
9090
{
9191
$this->expectException(NearMissValueResolverException::class);
92-
$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.');
92+
$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.');
9393
$container = new ContainerBuilder();
9494
$container->addCompilerPass(new RegisterControllerArgumentLocatorsPass());
9595

src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RegisterControllerArgumentLocatorsPassTest.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ public function testAllActions()
143143
$this->assertInstanceof(ServiceClosureArgument::class, $locator['foo::fooAction']);
144144

145145
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
146+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
146147

147148
$this->assertSame(ServiceLocator::class, $locator->getClass());
148149
$this->assertFalse($locator->isPublic());
@@ -166,6 +167,7 @@ public function testExplicitArgument()
166167

167168
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
168169
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
170+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
169171

170172
$expected = ['bar' => new ServiceClosureArgument(new TypedReference('bar', ControllerDummy::class, ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE))];
171173
$this->assertEquals($expected, $locator->getArgument(0));
@@ -185,6 +187,7 @@ public function testOptionalArgument()
185187

186188
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
187189
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
190+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
188191

189192
$expected = ['bar' => new ServiceClosureArgument(new TypedReference('bar', ControllerDummy::class, ContainerInterface::IGNORE_ON_INVALID_REFERENCE))];
190193
$this->assertEquals($expected, $locator->getArgument(0));
@@ -306,8 +309,8 @@ public function testBindings($bindingName)
306309
$pass->process($container);
307310

308311
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
309-
310312
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
313+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
311314

312315
$expected = ['bar' => new ServiceClosureArgument(new Reference('foo'))];
313316
$this->assertEquals($expected, $locator->getArgument(0));
@@ -372,7 +375,8 @@ public function testBindingsOnChildDefinitions()
372375
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
373376
$this->assertInstanceOf(ServiceClosureArgument::class, $locator['child::fooAction']);
374377

375-
$locator = $container->getDefinition((string) $locator['child::fooAction']->getValues()[0])->getArgument(0);
378+
$locator = $container->getDefinition((string) $locator['child::fooAction']->getValues()[0]);
379+
$locator = $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0);
376380
$this->assertInstanceOf(ServiceClosureArgument::class, $locator['someArg']);
377381
$this->assertEquals(new Reference('parent'), $locator['someArg']->getValues()[0]);
378382
}
@@ -439,6 +443,7 @@ public function testBindWithTarget()
439443

440444
$locator = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
441445
$locator = $container->getDefinition((string) $locator['foo::fooAction']->getValues()[0]);
446+
$locator = $container->getDefinition((string) $locator->getFactory()[0]);
442447

443448
$expected = [
444449
'apiKey' => new ServiceClosureArgument(new Reference('the_api_key')),

src/Symfony/Component/HttpKernel/Tests/DependencyInjection/RemoveEmptyControllerArgumentLocatorsPassTest.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,23 @@ public function testProcess()
3535
$pass->process($container);
3636

3737
$controllers = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
38+
$getLocator = fn ($controllers, $k) => $container->getDefinition((string) $container->getDefinition((string) $controllers[$k]->getValues()[0])->getFactory()[0])->getArgument(0);
3839

39-
$this->assertCount(2, $container->getDefinition((string) $controllers['c1::fooAction']->getValues()[0])->getArgument(0));
40-
$this->assertCount(1, $container->getDefinition((string) $controllers['c2::setTestCase']->getValues()[0])->getArgument(0));
41-
$this->assertCount(1, $container->getDefinition((string) $controllers['c2::fooAction']->getValues()[0])->getArgument(0));
40+
$this->assertCount(2, $getLocator($controllers, 'c1::fooAction'));
41+
$this->assertCount(1, $getLocator($controllers, 'c2::setTestCase'));
42+
$this->assertCount(1, $getLocator($controllers, 'c2::fooAction'));
4243

4344
(new ResolveInvalidReferencesPass())->process($container);
4445

45-
$this->assertCount(1, $container->getDefinition((string) $controllers['c2::setTestCase']->getValues()[0])->getArgument(0));
46-
$this->assertSame([], $container->getDefinition((string) $controllers['c2::fooAction']->getValues()[0])->getArgument(0));
46+
$this->assertCount(1, $getLocator($controllers, 'c2::setTestCase'));
47+
$this->assertSame([], $getLocator($controllers, 'c2::fooAction'));
4748

4849
(new RemoveEmptyControllerArgumentLocatorsPass())->process($container);
4950

5051
$controllers = $container->getDefinition((string) $resolver->getArgument(0))->getArgument(0);
5152

5253
$this->assertSame(['c1::fooAction', 'c1:fooAction'], array_keys($controllers));
53-
$this->assertSame(['bar'], array_keys($container->getDefinition((string) $controllers['c1::fooAction']->getValues()[0])->getArgument(0)));
54+
$this->assertSame(['bar'], array_keys($getLocator($controllers, 'c1::fooAction')));
5455

5556
$expectedLog = [
5657
'Symfony\Component\HttpKernel\DependencyInjection\RemoveEmptyControllerArgumentLocatorsPass: Removing service-argument resolver for controller "c2::fooAction": no corresponding services exist for the referenced types.',

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy