Skip to content

Commit 11073d1

Browse files
committed
[DI] allow service subscribers to return SubscribedService[]
1 parent 43c09ab commit 11073d1

File tree

8 files changed

+105
-19
lines changed

8 files changed

+105
-19
lines changed

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\Component\DependencyInjection\Reference;
2121
use Symfony\Component\DependencyInjection\TypedReference;
2222
use Symfony\Component\HttpFoundation\Session\SessionInterface;
23+
use Symfony\Contracts\Service\Attribute\SubscribedService;
2324
use Symfony\Contracts\Service\ServiceProviderInterface;
2425
use Symfony\Contracts\Service\ServiceSubscriberInterface;
2526

@@ -73,6 +74,14 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed
7374
$subscriberMap = [];
7475

7576
foreach ($class::getSubscribedServices() as $key => $type) {
77+
$attributes = [];
78+
79+
if ($type instanceof SubscribedService) {
80+
$key = $type->key;
81+
$attributes = $type->attributes;
82+
$type = ($type->nullable ? '?' : '').($type->type ?? throw new InvalidArgumentException(sprintf('When "%s::getSubscribedServices()" returns %s\'s, a type must be set.', $class, SubscribedService::class)));
83+
}
84+
7685
if (!\is_string($type) || !preg_match('/(?(DEFINE)(?<cn>[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*+))(?(DEFINE)(?<fqcn>(?&cn)(?:\\\\(?&cn))*+))^\??(?&fqcn)(?:(?:\|(?&fqcn))*+|(?:&(?&fqcn))*+)$/', $type)) {
7786
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)));
7887
}
@@ -109,7 +118,7 @@ protected function processValue(mixed $value, bool $isRoot = false): mixed
109118
$name = $this->container->has($type.' $'.$camelCaseName) ? $camelCaseName : $name;
110119
}
111120

112-
$subscriberMap[$key] = new TypedReference((string) $serviceMap[$key], $type, $optionalBehavior ?: ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $name);
121+
$subscriberMap[$key] = new TypedReference((string) $serviceMap[$key], $type, $optionalBehavior ?: ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $name, $attributes);
113122
unset($serviceMap[$key]);
114123
}
115124

src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
use PHPUnit\Framework\TestCase;
1515
use Psr\Container\ContainerInterface as PsrContainerInterface;
1616
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
17+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
18+
use Symfony\Component\DependencyInjection\Attribute\MapDecorated;
19+
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
20+
use Symfony\Component\DependencyInjection\Attribute\TaggedLocator;
21+
use Symfony\Component\DependencyInjection\Attribute\Target;
1722
use Symfony\Component\DependencyInjection\Compiler\AutowirePass;
1823
use Symfony\Component\DependencyInjection\Compiler\RegisterServiceSubscribersPass;
1924
use Symfony\Component\DependencyInjection\Compiler\ResolveBindingsPass;
@@ -402,6 +407,44 @@ public static function getSubscribedServices(): array
402407
$this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0));
403408
}
404409

410+
public function testSubscribedServiceWithAttributes()
411+
{
412+
$container = new ContainerBuilder();
413+
414+
$subscriber = new class() implements ServiceSubscriberInterface {
415+
public static function getSubscribedServices(): array
416+
{
417+
return [
418+
new SubscribedService('tagged.iterator', 'iterable', attributes: new TaggedIterator('tag')),
419+
new SubscribedService('tagged.locator', PsrContainerInterface::class, attributes: new TaggedLocator('tag')),
420+
new SubscribedService('autowired', 'stdClass', attributes: new Autowire(service: 'service.id')),
421+
new SubscribedService('mapped.decorated', \stdClass::class, attributes: new MapDecorated()),
422+
new SubscribedService('target', \stdClass::class, attributes: new Target('someTarget')),
423+
];
424+
}
425+
};
426+
427+
$container->register('service.id', 'stdClass');
428+
$container->register('foo', get_class($subscriber))
429+
->addMethodCall('setContainer', [new Reference(PsrContainerInterface::class)])
430+
->addTag('container.service_subscriber');
431+
432+
(new RegisterServiceSubscribersPass())->process($container);
433+
(new ResolveServiceSubscribersPass())->process($container);
434+
435+
$foo = $container->getDefinition('foo');
436+
$locator = $container->getDefinition((string) $foo->getMethodCalls()[0][1][0]);
437+
438+
$expected = [
439+
'tagged.iterator' => new ServiceClosureArgument(new TypedReference('iterable', 'iterable', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'tagged.iterator', [new TaggedIterator('tag')])),
440+
'tagged.locator' => new ServiceClosureArgument(new TypedReference(PsrContainerInterface::class, PsrContainerInterface::class, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'tagged.locator', [new TaggedLocator('tag')])),
441+
'autowired' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'autowired', [new Autowire(service: 'service.id')])),
442+
'mapped.decorated' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'mapped.decorated', [new MapDecorated()])),
443+
'target' => new ServiceClosureArgument(new TypedReference('stdClass', 'stdClass', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'target', [new Target('someTarget')])),
444+
];
445+
$this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0));
446+
}
447+
405448
public function testBinding()
406449
{
407450
$container = new ContainerBuilder();

src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/services_subscriber.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ public function isCompiled(): bool
4444
public function getRemovedIds(): array
4545
{
4646
return [
47-
'.service_locator.JmEob1b' => true,
48-
'.service_locator.KIgkoLM' => true,
49-
'.service_locator.qUb.lJI' => true,
50-
'.service_locator.qUb.lJI.foo_service' => true,
47+
'.service_locator.0H1ht0q' => true,
48+
'.service_locator.0H1ht0q.foo_service' => true,
49+
'.service_locator.2hyyc9y' => true,
50+
'.service_locator.KGUGnmw' => true,
5151
'Symfony\\Component\\DependencyInjection\\Tests\\Fixtures\\CustomDefinition' => true,
5252
];
5353
}

src/Symfony/Component/DependencyInjection/TypedReference.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,21 @@ class TypedReference extends Reference
2020
{
2121
private string $type;
2222
private ?string $name;
23+
private array $attributes;
2324

2425
/**
2526
* @param string $id The service identifier
2627
* @param string $type The PHP type of the identified service
2728
* @param int $invalidBehavior The behavior when the service does not exist
2829
* @param string|null $name The name of the argument targeting the service
30+
* @param array $attributes The attributes to be used
2931
*/
30-
public function __construct(string $id, string $type, int $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, string $name = null)
32+
public function __construct(string $id, string $type, int $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, string $name = null, array $attributes = [])
3133
{
3234
$this->name = $type === $id ? $name : null;
3335
parent::__construct($id, $invalidBehavior);
3436
$this->type = $type;
37+
$this->attributes = $attributes;
3538
}
3639

3740
public function getType()
@@ -43,4 +46,9 @@ public function getName(): ?string
4346
{
4447
return $this->name;
4548
}
49+
50+
public function getAttributes(): array
51+
{
52+
return $this->attributes;
53+
}
4654
}

src/Symfony/Contracts/Service/Attribute/SubscribedService.php

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@
1111

1212
namespace Symfony\Contracts\Service\Attribute;
1313

14+
use Symfony\Contracts\Service\ServiceSubscriberInterface;
1415
use Symfony\Contracts\Service\ServiceSubscriberTrait;
1516

1617
/**
18+
* For use as the return value for {@see ServiceSubscriberInterface}.
19+
*
20+
* @example new SubscribedService('http_client', HttpClientInterface::class, false, new Target('githubApi'))
21+
*
1722
* Use with {@see ServiceSubscriberTrait} to mark a method's return type
1823
* as a subscribed service.
1924
*
@@ -22,12 +27,21 @@
2227
#[\Attribute(\Attribute::TARGET_METHOD)]
2328
final class SubscribedService
2429
{
30+
/** @var object[] */
31+
public array $attributes;
32+
2533
/**
26-
* @param string|null $key The key to use for the service
27-
* If null, use "ClassName::methodName"
34+
* @param string|null $key The key to use for the service
35+
* @param class-string|null $type The service class
36+
* @param bool $nullable Whether the service is optional
37+
* @param object|object[] $attributes One or more dependency injection attributes to use
2838
*/
2939
public function __construct(
30-
public ?string $key = null
40+
public ?string $key = null,
41+
public ?string $type = null,
42+
public bool $nullable = false,
43+
array|object $attributes = [],
3144
) {
45+
$this->attributes = \is_array($attributes) ? $attributes : [$attributes];
3246
}
3347
}

src/Symfony/Contracts/Service/ServiceSubscriberInterface.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\Contracts\Service;
1313

14+
use Symfony\Contracts\Service\Attribute\SubscribedService;
15+
1416
/**
1517
* A ServiceSubscriber exposes its dependencies via the static {@link getSubscribedServices} method.
1618
*
@@ -29,7 +31,8 @@
2931
interface ServiceSubscriberInterface
3032
{
3133
/**
32-
* Returns an array of service types required by such instances, optionally keyed by the service names used internally.
34+
* Returns an array of service types (or {@see SubscribedService} objects) required
35+
* by such instances, optionally keyed by the service names used internally.
3336
*
3437
* For mandatory dependencies:
3538
*
@@ -47,7 +50,13 @@ interface ServiceSubscriberInterface
4750
* * ['?Psr\Log\LoggerInterface'] is a shortcut for
4851
* * ['Psr\Log\LoggerInterface' => '?Psr\Log\LoggerInterface']
4952
*
50-
* @return string[] The required service types, optionally keyed by service names
53+
* additionally, an array of {@see SubscribedService}'s can be returned:
54+
*
55+
* * [new SubscribedService('logger', Psr\Log\LoggerInterface::class)]
56+
* * [new SubscribedService(type: Psr\Log\LoggerInterface::class, nullable: true)]
57+
* * [new SubscribedService('http_client', HttpClientInterface::class, attributes: new Target('githubApi'))]
58+
*
59+
* @return string[]|SubscribedService[] The required service types, optionally keyed by service names
5160
*/
5261
public static function getSubscribedServices(): array;
5362
}

src/Symfony/Contracts/Service/ServiceSubscriberTrait.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,15 @@ public static function getSubscribedServices(): array
5050
throw new \LogicException(sprintf('Cannot use "%s" on methods without a return type in "%s::%s()".', SubscribedService::class, $method->name, self::class));
5151
}
5252

53-
$serviceId = $returnType instanceof \ReflectionNamedType ? $returnType->getName() : (string) $returnType;
53+
$attribute = $attribute->newInstance();
5454

55-
if ($returnType->allowsNull()) {
56-
$serviceId = '?'.$serviceId;
57-
}
55+
/* @var SubscribedService $attribute */
56+
57+
$attribute->key ??= self::class.'::'.$method->name;
58+
$attribute->type ??= $returnType instanceof \ReflectionNamedType ? $returnType->getName() : (string) $returnType;
59+
$attribute->nullable = $returnType->allowsNull();
5860

59-
$services[$attribute->newInstance()->key ?? self::class.'::'.$method->name] = $serviceId;
61+
$services[] = $attribute;
6062
}
6163

6264
return $services;

src/Symfony/Contracts/Tests/Service/ServiceSubscriberTraitTest.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Psr\Container\ContainerInterface;
1616
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\OtherDir\Component1\Dir1\Service1;
1717
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\OtherDir\Component1\Dir2\Service2;
18+
use Symfony\Contracts\Service\Attribute\Required;
1819
use Symfony\Contracts\Service\Attribute\SubscribedService;
1920
use Symfony\Contracts\Service\ServiceLocatorTrait;
2021
use Symfony\Contracts\Service\ServiceSubscriberInterface;
@@ -25,8 +26,8 @@ class ServiceSubscriberTraitTest extends TestCase
2526
public function testMethodsOnParentsAndChildrenAreIgnoredInGetSubscribedServices()
2627
{
2728
$expected = [
28-
TestService::class.'::aService' => Service2::class,
29-
TestService::class.'::nullableService' => '?'.Service2::class,
29+
new SubscribedService(TestService::class.'::aService', Service2::class),
30+
new SubscribedService(TestService::class.'::nullableService', Service2::class, true, new Required()),
3031
];
3132

3233
$this->assertEquals($expected, ChildTestService::getSubscribedServices());
@@ -89,7 +90,7 @@ public function aService(): Service2
8990
{
9091
}
9192

92-
#[SubscribedService]
93+
#[SubscribedService(attributes: new Required())]
9394
public function nullableService(): ?Service2
9495
{
9596
}

0 commit comments

Comments
 (0)