From e2fb640892e30d855d90d7dfdc281fa5e5e0abaf Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Tue, 7 Jun 2022 17:48:08 -0400 Subject: [PATCH] [DI] allow `ServiceSubscriberTrait` to autowire properties --- .../RegisterServiceSubscribersPassTest.php | 2 + .../Fixtures/TestServiceSubscriberChild.php | 6 ++ .../Service/Attribute/SubscribedService.php | 2 +- .../Service/ServiceSubscriberTrait.php | 58 ++++++++++++++++++- .../Service/ServiceSubscriberTraitTest.php | 22 +++++++ 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php index c2ecdf2f9f5ec..39d24fb657060 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterServiceSubscribersPassTest.php @@ -246,6 +246,8 @@ public function testServiceSubscriberTraitWithSubscribedServiceAttribute() TestServiceSubscriberChild::class.'::testDefinition4' => new ServiceClosureArgument(new TypedReference(TestDefinition3::class, TestDefinition3::class)), TestServiceSubscriberParent::class.'::testDefinition1' => new ServiceClosureArgument(new TypedReference(TestDefinition1::class, TestDefinition1::class)), 'custom_name' => new ServiceClosureArgument(new TypedReference(TestDefinition3::class, TestDefinition3::class, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'custom_name')), + 'testDefinition1' => new ServiceClosureArgument(new TypedReference(TestDefinition1::class, TestDefinition1::class, ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, 'testDefinition1')), + 'testDefinition2' => new ServiceClosureArgument(new TypedReference(TestDefinition2::class, TestDefinition2::class, ContainerInterface::IGNORE_ON_INVALID_REFERENCE, 'testDefinition2')), ]; $this->assertEquals($expected, $container->getDefinition((string) $locator->getFactory()[0])->getArgument(0)); diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TestServiceSubscriberChild.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TestServiceSubscriberChild.php index ee2df273996b6..ff7cf8fe1c68b 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TestServiceSubscriberChild.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TestServiceSubscriberChild.php @@ -10,6 +10,12 @@ class TestServiceSubscriberChild extends TestServiceSubscriberParent use ServiceSubscriberTrait; use TestServiceSubscriberTrait; + #[SubscribedService] + private TestDefinition1 $testDefinition1; + + #[SubscribedService] + private ?TestDefinition2 $testDefinition2; + #[SubscribedService] private function testDefinition2(): ?TestDefinition2 { diff --git a/src/Symfony/Contracts/Service/Attribute/SubscribedService.php b/src/Symfony/Contracts/Service/Attribute/SubscribedService.php index d98e1dfdbbeb1..26775abbe3958 100644 --- a/src/Symfony/Contracts/Service/Attribute/SubscribedService.php +++ b/src/Symfony/Contracts/Service/Attribute/SubscribedService.php @@ -24,7 +24,7 @@ * * @author Kevin Bond */ -#[\Attribute(\Attribute::TARGET_METHOD)] +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] final class SubscribedService { /** @var object[] */ diff --git a/src/Symfony/Contracts/Service/ServiceSubscriberTrait.php b/src/Symfony/Contracts/Service/ServiceSubscriberTrait.php index f7cd3a94158c1..b6629cd5a824a 100644 --- a/src/Symfony/Contracts/Service/ServiceSubscriberTrait.php +++ b/src/Symfony/Contracts/Service/ServiceSubscriberTrait.php @@ -17,7 +17,9 @@ /** * Implementation of ServiceSubscriberInterface that determines subscribed services from - * method return types. Service ids are available as "ClassName::methodName". + * method return types and property type-hints for methods/properties marked with the + * "SubscribedService" attribute. Service ids are available as "ClassName::methodName" + * for methods and "propertyName" for properties. * * @author Kevin Bond */ @@ -29,8 +31,39 @@ trait ServiceSubscriberTrait public static function getSubscribedServices(): array { $services = method_exists(get_parent_class(self::class) ?: '', __FUNCTION__) ? parent::getSubscribedServices() : []; + $refClass = new \ReflectionClass(self::class); - foreach ((new \ReflectionClass(self::class))->getMethods() as $method) { + foreach ($refClass->getProperties() as $property) { + if (self::class !== $property->getDeclaringClass()->name) { + continue; + } + + if (!$attribute = $property->getAttributes(SubscribedService::class)[0] ?? null) { + continue; + } + + if ($property->isStatic()) { + throw new \LogicException(sprintf('Cannot use "%s" on property "%s::$%s" (can only be used on non-static properties with a type).', SubscribedService::class, self::class, $property->name)); + } + + if (!$type = $property->getType()) { + throw new \LogicException(sprintf('Cannot use "%s" on properties without a type in "%s::%s()".', SubscribedService::class, $property->name, self::class)); + } + + /* @var SubscribedService $attribute */ + $attribute = $attribute->newInstance(); + $attribute->key ??= $property->name; + $attribute->type ??= $type instanceof \ReflectionNamedType ? $type->getName() : (string) $type; + $attribute->nullable = $type->allowsNull(); + + if ($attribute->attributes) { + $services[] = $attribute; + } else { + $services[$attribute->key] = ($attribute->nullable ? '?' : '').$attribute->type; + } + } + + foreach ($refClass->getMethods() as $method) { if (self::class !== $method->getDeclaringClass()->name) { continue; } @@ -68,10 +101,31 @@ public function setContainer(ContainerInterface $container): ?ContainerInterface { $this->container = $container; + foreach ((new \ReflectionClass(self::class))->getProperties() as $property) { + if (self::class !== $property->getDeclaringClass()->name) { + continue; + } + + if (!$property->getAttributes(SubscribedService::class)) { + continue; + } + + unset($this->{$property->name}); + } + if (method_exists(get_parent_class(self::class) ?: '', __FUNCTION__)) { return parent::setContainer($container); } return null; } + + public function __get(string $name): mixed + { + // TODO: ensure cannot be called from outside of the scope of the object? + // TODO: what if class has a child/parent that allows this? + // TODO: call parent::__get()? + + return $this->$name = $this->container->has($name) ? $this->container->get($name) : null; + } } diff --git a/src/Symfony/Contracts/Tests/Service/ServiceSubscriberTraitTest.php b/src/Symfony/Contracts/Tests/Service/ServiceSubscriberTraitTest.php index a4b2eccd899e9..76f3e9b8d441f 100644 --- a/src/Symfony/Contracts/Tests/Service/ServiceSubscriberTraitTest.php +++ b/src/Symfony/Contracts/Tests/Service/ServiceSubscriberTraitTest.php @@ -18,6 +18,7 @@ use Symfony\Contracts\Service\Attribute\Required; use Symfony\Contracts\Service\Attribute\SubscribedService; use Symfony\Contracts\Service\ServiceLocatorTrait; +use Symfony\Contracts\Service\ServiceProviderInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; use Symfony\Contracts\Service\ServiceSubscriberTrait; @@ -26,6 +27,8 @@ class ServiceSubscriberTraitTest extends TestCase public function testMethodsOnParentsAndChildrenAreIgnoredInGetSubscribedServices() { $expected = [ + 'service1' => Service1::class, + 'service2' => '?'.Service2::class, TestService::class.'::aService' => Service2::class, TestService::class.'::nullableService' => '?'.Service2::class, new SubscribedService(TestService::class.'::withAttribute', Service2::class, true, new Required()), @@ -68,6 +71,19 @@ public function testParentNotCalledIfNoParent() $this->assertNull($service->setContainer($container)); $this->assertSame([], $service::getSubscribedServices()); } + + public function testCanGetSubscribedServiceProperties() + { + $factories = ['service1' => fn () => new Service1(), 'somethingElse' => fn () => new Service2()]; + $container = new class($factories) implements ServiceProviderInterface { + use ServiceLocatorTrait; + }; + $service = new TestService(); + $service->setContainer($container); + + $this->assertInstanceOf(Service1::class, $service->service1); + $this->assertNull($service->service2); + } } class ParentTestService @@ -86,6 +102,12 @@ class TestService extends ParentTestService implements ServiceSubscriberInterfac { use ServiceSubscriberTrait; + #[SubscribedService] + public Service1 $service1; + + #[SubscribedService] + public ?Service2 $service2; + #[SubscribedService] public function aService(): Service2 {