From 15ef9d571fce486f8b3f69f34db3976b04bb4d27 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Mon, 28 Sep 2020 14:22:01 +0200 Subject: [PATCH] Inject via PHP 8 attributes --- couscous.yml | 3 + doc/annotations.md | 4 + doc/attributes.md | 127 ++++++++ doc/best-practices.md | 12 +- src/Attribute/Inject.php | 76 +++++ src/Attribute/Injectable.php | 38 +++ src/ContainerBuilder.php | 24 +- .../Helper/CreateDefinitionHelper.php | 1 - src/Definition/Resolver/ObjectCreator.php | 1 + .../Source/AttributeBasedAutowiring.php | 272 ++++++++++++++++++ tests/IntegrationTest/Attributes/A.php | 9 + .../Attributes/AttributesTest.php | 108 +++++++ tests/IntegrationTest/Attributes/B.php | 29 ++ tests/IntegrationTest/Attributes/C.php | 9 + tests/IntegrationTest/Attributes/Child.php | 18 ++ tests/IntegrationTest/Attributes/D.php | 9 + .../Attributes/NamedInjection.php | 16 ++ .../Definitions/AttributeTest.php | 179 ++++++++++++ .../Attributes/Fixtures/Dependency.php | 9 + .../Attributes/Fixtures/InjectFixture.php | 42 +++ .../Attributes/Fixtures/Injectable1.php | 10 + .../Attributes/Fixtures/Injectable2.php | 10 + tests/UnitTest/Attributes/InjectTest.php | 103 +++++++ tests/UnitTest/Attributes/InjectableTest.php | 54 ++++ 24 files changed, 1155 insertions(+), 8 deletions(-) create mode 100644 doc/attributes.md create mode 100644 src/Attribute/Inject.php create mode 100644 src/Attribute/Injectable.php create mode 100644 src/Definition/Source/AttributeBasedAutowiring.php create mode 100644 tests/IntegrationTest/Attributes/A.php create mode 100644 tests/IntegrationTest/Attributes/AttributesTest.php create mode 100644 tests/IntegrationTest/Attributes/B.php create mode 100644 tests/IntegrationTest/Attributes/C.php create mode 100644 tests/IntegrationTest/Attributes/Child.php create mode 100644 tests/IntegrationTest/Attributes/D.php create mode 100644 tests/IntegrationTest/Attributes/NamedInjection.php create mode 100644 tests/IntegrationTest/Definitions/AttributeTest.php create mode 100644 tests/UnitTest/Attributes/Fixtures/Dependency.php create mode 100644 tests/UnitTest/Attributes/Fixtures/InjectFixture.php create mode 100644 tests/UnitTest/Attributes/Fixtures/Injectable1.php create mode 100644 tests/UnitTest/Attributes/Fixtures/Injectable2.php create mode 100644 tests/UnitTest/Attributes/InjectTest.php create mode 100644 tests/UnitTest/Attributes/InjectableTest.php diff --git a/couscous.yml b/couscous.yml index 1ccecaa20..6b4a29941 100644 --- a/couscous.yml +++ b/couscous.yml @@ -46,6 +46,9 @@ menu: php-definitions: text: PHP definitions url: doc/php-definitions.html + attributes: + text: PHP 8 attributes + url: doc/attributes.html annotations: text: Annotations url: doc/annotations.html diff --git a/doc/annotations.md b/doc/annotations.md index 0d545c5e6..daa88824d 100644 --- a/doc/annotations.md +++ b/doc/annotations.md @@ -5,6 +5,10 @@ current_menu: annotations # Annotations +**Since PHP 8, annotations are deprecated in favor of [PHP attributes](attributes.md).** + +--- + On top of [autowiring](autowiring.md) and [PHP configuration files](php-definitions.md), you can define injections using annotations. Using annotations do not affect performances when [compiling the container](performances.md). diff --git a/doc/attributes.md b/doc/attributes.md new file mode 100644 index 000000000..8a8bbcf59 --- /dev/null +++ b/doc/attributes.md @@ -0,0 +1,127 @@ +--- +layout: documentation +current_menu: attributes +--- + +# Attributes + +On top of [autowiring](autowiring.md) and [PHP configuration files](php-definitions.md), you can define injections using PHP 8 attributes. + +Using attributes do not affect performances when [compiling the container](performances.md). For a non-compiled container, the PHP reflection is used but the overhead is minimal. + +## Setup + +Enable attributes [via the `ContainerBuilder`](container-configuration.md): + +```php +$containerBuilder->useAttributes(true); +``` + +## Inject + +`#[Inject]` lets you define where PHP-DI should inject something, and optionally what it should inject. + +It can be used on: + +- the constructor (constructor injection) +- methods (setter/method injection) +- properties (property injection) + +*Note: property injections occur after the constructor is executed, so any injectable property will be null inside `__construct`.* + +**Note: `#[Inject]` ignores types declared in phpdoc. Only types specified in PHP code are considered.** + +Here is an example of all possible uses of the `#[Inject]` attribute: + +```php +use DI\Attribute\Inject; + +class Example +{ + /** + * Attribute combined with a type on the property: + */ + #[Inject] + private Foo $property1; + + /** + * Explicit definition of the entry to inject: + */ + #[Inject('db.host')] + private $property2; + + /** + * Alternative to the above: + */ + #[Inject(name: 'db.host')] + private $property3; + + /** + * Attribute specifying exactly what to inject on the constructor: + */ + #[Inject(['db.host', 'db.name'])] + public function __construct($param1, $param2) + { + } + + /** + * Attribute combined with PHP types: + */ + #[Inject] + public function method1(Foo $param) + { + } + + /** + * Explicit definition of the entries to inject: + */ + #[Inject(['db.host', 'db.name'])] + public function method2($param1, $param2) + { + } + + /** + * Explicit definition of parameters by their name + * (types are used for the other parameters): + */ + #[Inject(['param2' => 'db.host'])] + public function method3(Foo $param1, $param2) + { + } +} +``` + +*Note: remember to import the attribute class via `use DI\Attribute\Inject;`.* + +### Troubleshooting attributes + +- remember to import the attribute class via `use DI\Attribute\Inject;` +- `#[Inject]` is not meant to be used on the method to call with [`Container::call()`](container.md#call) (it will be ignored) +- `#[Inject]` ignores types declared in phpdoc. Only types specified in PHP code are considered. + +Note that `#[Inject]` is implicit on all constructors (because constructors must be called to create an object). + +## Injectable + +The `#[Injectable]` attribute lets you set options on injectable classes: + +```php +use DI\Attribute\Injectable; + +#[Injectable(lazy: true)] +class Example +{ +} +``` + +**The `#[Injectable]` attribute is optional: by default, all classes are injectable.** + +## Limitations + +There are things that can't be defined with attributes: + +- values (instead of classes) +- mapping interfaces to implementations +- defining entries with an anonymous function + +For that, you can combine attributes with [definitions in PHP](php-definitions.md). diff --git a/doc/best-practices.md b/doc/best-practices.md index c59cd7381..26c7abf3b 100644 --- a/doc/best-practices.md +++ b/doc/best-practices.md @@ -46,11 +46,11 @@ This is the solution we recommend. Example: ```php + + class UserController { - /** - * @Inject - */ + #[Inject] private FormFactoryInterface $formFactory; public function createForm($type, $data, $options) @@ -67,7 +67,7 @@ Property injection is generally frowned upon, and for good reasons: - injecting in a private property breaks encapsulation - it is not an explicit dependency: there is no contract saying your class need the property to be set to work -- if you use PHP-DI's annotations to mark the dependency to be injected, your class is dependent on the container (see the 2nd rule above) +- if you use PHP-DI's attributes to mark the dependency to be injected, your class is dependent on the container (see the 2nd rule above) BUT @@ -82,13 +82,13 @@ So: (because most dependencies like Request, Response, templating system, etc. will have changed) This solution offers many benefits for no major drawback, so -**we recommend using annotations in controllers**. +**we recommend using attributes in controllers**. ## Writing services Given a service is intended to be reused, tested and independent of your framework, **we do not recommend -using annotations for injecting dependencies**. +using attributes for injecting dependencies**. Instead, we recommend using **constructor injection and autowiring**: diff --git a/src/Attribute/Inject.php b/src/Attribute/Inject.php new file mode 100644 index 000000000..5a6de250d --- /dev/null +++ b/src/Attribute/Inject.php @@ -0,0 +1,76 @@ + + */ +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] +final class Inject +{ + /** + * Entry name. + */ + private ?string $name = null; + + /** + * Parameters, indexed by the parameter number (index) or name. + * + * Used if the annotation is set on a method + */ + private array $parameters = []; + + /** + * @param string|array|null $name + * + * @throws InvalidAnnotation + */ + public function __construct($name = null) + { + // #[Inject('foo')] or #[Inject(name: 'foo')] + if (is_string($name)) { + $this->name = $name; + } + + // #[Inject([...])] on a method + if (is_array($name)) { + foreach ($name as $key => $value) { + if (! is_string($value)) { + throw new InvalidAnnotation(sprintf( + "#[Inject(['param' => 'value'])] expects \"value\" to be a string, %s given.", + json_encode($value, JSON_THROW_ON_ERROR) + )); + } + + $this->parameters[$key] = $value; + } + } + } + + /** + * @return string|null Name of the entry to inject + */ + public function getName() : ?string + { + return $this->name; + } + + /** + * @return array Parameters, indexed by the parameter number (index) or name + */ + public function getParameters() : array + { + return $this->parameters; + } +} diff --git a/src/Attribute/Injectable.php b/src/Attribute/Injectable.php new file mode 100644 index 000000000..bc8acd12e --- /dev/null +++ b/src/Attribute/Injectable.php @@ -0,0 +1,38 @@ + + * @author Matthieu Napoli + */ +#[Attribute(Attribute::TARGET_CLASS)] +final class Injectable +{ + /** + * Should the object be lazy-loaded. + */ + private ?bool $lazy = null; + + public function __construct(array $values) + { + if (isset($values['lazy'])) { + $this->lazy = (bool) $values['lazy']; + } + } + + public function isLazy() : ?bool + { + return $this->lazy; + } +} diff --git a/src/ContainerBuilder.php b/src/ContainerBuilder.php index 7b014e4c7..c1b695ffc 100644 --- a/src/ContainerBuilder.php +++ b/src/ContainerBuilder.php @@ -6,6 +6,7 @@ use DI\Compiler\Compiler; use DI\Definition\Source\AnnotationBasedAutowiring; +use DI\Definition\Source\AttributeBasedAutowiring; use DI\Definition\Source\DefinitionArray; use DI\Definition\Source\DefinitionFile; use DI\Definition\Source\DefinitionSource; @@ -48,6 +49,8 @@ class ContainerBuilder private bool $useAnnotations = false; + private bool $useAttributes = false; + /** * If true, write the proxies to disk to improve performances. */ @@ -104,7 +107,10 @@ public function build() { $sources = array_reverse($this->definitionSources); - if ($this->useAnnotations) { + if ($this->useAttributes) { + $autowiring = new AttributeBasedAutowiring; + $sources[] = $autowiring; + } elseif ($this->useAnnotations) { $autowiring = new AnnotationBasedAutowiring; $sources[] = $autowiring; } elseif ($this->useAutowiring) { @@ -228,6 +234,22 @@ public function useAnnotations(bool $bool) : self return $this; } + /** + * Enable or disable the use of PHP 8 attributes to configure injections. + * + * Disabled by default. + * + * @return $this + */ + public function useAttributes(bool $bool) : self + { + $this->ensureNotLocked(); + + $this->useAttributes = $bool; + + return $this; + } + /** * Configure the proxy generation. * diff --git a/src/Definition/Helper/CreateDefinitionHelper.php b/src/Definition/Helper/CreateDefinitionHelper.php index bba3a34d7..90c1b439b 100644 --- a/src/Definition/Helper/CreateDefinitionHelper.php +++ b/src/Definition/Helper/CreateDefinitionHelper.php @@ -4,7 +4,6 @@ namespace DI\Definition\Helper; -use DI\Definition\Definition; use DI\Definition\Exception\InvalidDefinition; use DI\Definition\ObjectDefinition; use DI\Definition\ObjectDefinition\MethodInjection; diff --git a/src/Definition/Resolver/ObjectCreator.php b/src/Definition/Resolver/ObjectCreator.php index 38e205dac..cefa7553f 100644 --- a/src/Definition/Resolver/ObjectCreator.php +++ b/src/Definition/Resolver/ObjectCreator.php @@ -81,6 +81,7 @@ private function createProxy(ObjectDefinition $definition, array $parameters) : function (& $wrappedObject, $proxy, $method, $params, & $initializer) use ($definition, $parameters) { $wrappedObject = $this->createInstance($definition, $parameters); $initializer = null; // turning off further lazy initialization + return true; } ); diff --git a/src/Definition/Source/AttributeBasedAutowiring.php b/src/Definition/Source/AttributeBasedAutowiring.php new file mode 100644 index 000000000..601b79291 --- /dev/null +++ b/src/Definition/Source/AttributeBasedAutowiring.php @@ -0,0 +1,272 @@ + + */ +class AttributeBasedAutowiring implements DefinitionSource, Autowiring +{ + public function __construct() + { + if (\PHP_VERSION_ID < 80000) { + throw new \Exception('Using PHP 8 attributes for autowiring is only supported with PHP 8'); + } + } + + /** + * @throws InvalidAnnotation + */ + public function autowire(string $name, ObjectDefinition $definition = null) + { + $className = $definition ? $definition->getClassName() : $name; + + if (!class_exists($className) && !interface_exists($className)) { + return $definition; + } + + $definition = $definition ?: new ObjectDefinition($name); + + $class = new ReflectionClass($className); + + $this->readInjectableAttribute($class, $definition); + + // Browse the class properties looking for annotated properties + $this->readProperties($class, $definition); + + // Browse the object's methods looking for annotated methods + $this->readMethods($class, $definition); + + return $definition; + } + + /** + * {@inheritdoc} + * @throws InvalidAnnotation + * @throws InvalidArgumentException The class doesn't exist + */ + public function getDefinition(string $name) + { + return $this->autowire($name); + } + + /** + * Autowiring cannot guess all existing definitions. + */ + public function getDefinitions() : array + { + return []; + } + + /** + * Browse the class properties looking for annotated properties. + */ + private function readProperties(ReflectionClass $class, ObjectDefinition $definition) + { + foreach ($class->getProperties() as $property) { + if ($property->isStatic()) { + continue; + } + $this->readProperty($property, $definition); + } + + // Read also the *private* properties of the parent classes + /** @noinspection PhpAssignmentInConditionInspection */ + while ($class = $class->getParentClass()) { + foreach ($class->getProperties(ReflectionProperty::IS_PRIVATE) as $property) { + if ($property->isStatic()) { + continue; + } + $this->readProperty($property, $definition, $class->getName()); + } + } + } + + /** + * @throws InvalidAnnotation + */ + private function readProperty(ReflectionProperty $property, ObjectDefinition $definition, $classname = null) : void + { + // Look for #[Inject] annotation + try { + $attribute = $property->getAttributes(Inject::class)[0] ?? null; + if (! $attribute) { + return; + } + /** @var Inject $inject */ + $inject = $attribute->newInstance(); + } catch (Throwable $e) { + throw new InvalidAnnotation(sprintf( + '#[Inject] annotation on property %s::%s is malformed. %s', + $property->getDeclaringClass()->getName(), + $property->getName(), + $e->getMessage() + ), 0, $e); + } + + // Try to #[Inject("name")] or look for the property type + $entryName = $inject->getName(); + + // Try using typed properties + $propertyType = $property->getType(); + if ($entryName === null && $propertyType instanceof ReflectionNamedType) { + if (! class_exists($propertyType->getName()) && ! interface_exists($propertyType->getName())) { + throw new InvalidAnnotation(sprintf( + '#[Inject] found on property %s::%s but unable to guess what to inject, the type of the property does not look like a valid class or interface name', + $property->getDeclaringClass()->getName(), + $property->getName() + )); + } + $entryName = $propertyType->getName(); + } + + if ($entryName === null) { + throw new InvalidAnnotation(sprintf( + '#[Inject] found on property %s::%s but unable to guess what to inject, please add a type to the property', + $property->getDeclaringClass()->getName(), + $property->getName() + )); + } + + $definition->addPropertyInjection( + new PropertyInjection($property->getName(), new Reference($entryName), $classname) + ); + } + + /** + * Browse the object's methods looking for annotated methods. + */ + private function readMethods(ReflectionClass $class, ObjectDefinition $objectDefinition) + { + // This will look in all the methods, including those of the parent classes + foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->isStatic()) { + continue; + } + + $methodInjection = $this->getMethodInjection($method); + + if (! $methodInjection) { + continue; + } + + if ($method->isConstructor()) { + $objectDefinition->completeConstructorInjection($methodInjection); + } else { + $objectDefinition->completeFirstMethodInjection($methodInjection); + } + } + } + + /** + * @throws InvalidAnnotation + */ + private function getMethodInjection(ReflectionMethod $method) : ?MethodInjection + { + // Look for #[Inject] attribute + try { + $attribute = $method->getAttributes(Inject::class)[0] ?? null; + if (! $attribute) { + return null; + } + /** @var Inject $inject */ + $inject = $attribute->newInstance(); + } catch (Throwable $e) { + throw new InvalidAnnotation(sprintf( + '#[Inject] annotation on %s::%s() is malformed. %s', + $method->getDeclaringClass()->getName(), + $method->getName(), + $e->getMessage() + ), 0, $e); + } + + $annotationParameters = $inject->getParameters(); + + $parameters = []; + foreach ($method->getParameters() as $index => $parameter) { + $entryName = $this->getMethodParameter($index, $parameter, $annotationParameters); + + if ($entryName !== null) { + $parameters[$index] = new Reference($entryName); + } + } + + if ($method->isConstructor()) { + return MethodInjection::constructor($parameters); + } + + return new MethodInjection($method->getName(), $parameters); + } + + /** + * @return string|null Entry name or null if not found. + */ + private function getMethodParameter(int $parameterIndex, ReflectionParameter $parameter, array $annotationParameters) : ?string + { + // #[Inject] has definition for this parameter (by index, or by name) + if (isset($annotationParameters[$parameterIndex])) { + return $annotationParameters[$parameterIndex]; + } + if (isset($annotationParameters[$parameter->getName()])) { + return $annotationParameters[$parameter->getName()]; + } + + // Skip optional parameters if not explicitly defined + if ($parameter->isOptional()) { + return null; + } + + // Look for the property type + $parameterType = $parameter->getType(); + if ($parameterType && !$parameterType->isBuiltin() && $parameterType instanceof ReflectionNamedType) { + return $parameterType->getName(); + } + + return null; + } + + /** + * @throws InvalidAnnotation + */ + private function readInjectableAttribute(ReflectionClass $class, ObjectDefinition $definition) : void + { + try { + $attribute = $class->getAttributes(Injectable::class)[0] ?? null; + if (! $attribute) { + return; + } + $attribute = $attribute->newInstance(); + } catch (Throwable $e) { + throw new InvalidAnnotation(sprintf( + 'Error while reading #[Injectable] on %s: %s', + $class->getName(), + $e->getMessage() + ), 0, $e); + } + + if ($attribute->isLazy() !== null) { + $definition->setLazy($attribute->isLazy()); + } + } +} diff --git a/tests/IntegrationTest/Attributes/A.php b/tests/IntegrationTest/Attributes/A.php new file mode 100644 index 000000000..1b5030a08 --- /dev/null +++ b/tests/IntegrationTest/Attributes/A.php @@ -0,0 +1,9 @@ += 8 + */ +class AttributesTest extends BaseContainerTest +{ + /** + * @test + * @dataProvider provideContainer + */ + public function inject_in_properties(ContainerBuilder $builder) + { + $builder->useAttributes(true); + + /** @var B $object */ + $object = $builder->build()->get(B::class); + + $this->assertInstanceOf(A::class, $object->public); + $this->assertInstanceOf(A::class, $object->getProtected()); + $this->assertInstanceOf(A::class, $object->getPrivate()); + } + + /** + * Inject in parent properties (public, protected and private). + * + * @test + * @dataProvider provideContainer + */ + public function inject_in_parent_properties(ContainerBuilder $builder) + { + $builder->useAttributes(true); + $container = $builder->build(); + + /** @var C $object */ + $object = $container->get(C::class); + $this->assertInstanceOf(A::class, $object->public); + $this->assertInstanceOf(A::class, $object->getProtected()); + $this->assertInstanceOf(A::class, $object->getPrivate()); + + /** @var D $object */ + $object = $container->get(D::class); + $this->assertInstanceOf(A::class, $object->public); + $this->assertInstanceOf(A::class, $object->getProtected()); + $this->assertInstanceOf(A::class, $object->getPrivate()); + } + + /** + * Inject in private parent properties even if they have the same name of child properties. + * + * @test + * @dataProvider provideContainer + */ + public function inject_in_private_parent_properties_with_same_name(ContainerBuilder $builder) + { + $builder->useAttributes(true); + $container = $builder->build(); + + /** @var Child $object */ + $object = $container->get(Child::class); + $this->assertInstanceOf(A::class, $object->public); + $this->assertInstanceOf(A::class, $object->getProtected()); + $this->assertInstanceOf(A::class, $object->getPrivate()); + $this->assertInstanceOf(A::class, $object->getChildPrivate()); + } + + /** + * @test + * @dataProvider provideContainer + */ + public function inject_by_name(ContainerBuilder $builder) + { + $builder->useAttributes(true); + + $dependency = new \stdClass(); + + $builder->addDefinitions([ + 'namedDependency' => $dependency, + ]); + $container = $builder->build(); + + /** @var NamedInjection $object */ + $object = $container->get(NamedInjection::class); + $this->assertSame($dependency, $object->dependency1); + $this->assertSame($dependency, $object->dependency2); + } + + /** + * @test + * @dataProvider provideContainer + */ + public function errors_if_dependency_by_name_not_found(ContainerBuilder $builder) + { + $this->expectException(DependencyException::class); + $builder->useAttributes(true); + $builder->build()->get(NamedInjection::class); + } +} diff --git a/tests/IntegrationTest/Attributes/B.php b/tests/IntegrationTest/Attributes/B.php new file mode 100644 index 000000000..d4bbbfc25 --- /dev/null +++ b/tests/IntegrationTest/Attributes/B.php @@ -0,0 +1,29 @@ +protected; + } + + public function getPrivate() + { + return $this->private; + } +} diff --git a/tests/IntegrationTest/Attributes/C.php b/tests/IntegrationTest/Attributes/C.php new file mode 100644 index 000000000..e4eaf52c6 --- /dev/null +++ b/tests/IntegrationTest/Attributes/C.php @@ -0,0 +1,9 @@ +private; + } +} diff --git a/tests/IntegrationTest/Attributes/D.php b/tests/IntegrationTest/Attributes/D.php new file mode 100644 index 000000000..45a577d96 --- /dev/null +++ b/tests/IntegrationTest/Attributes/D.php @@ -0,0 +1,9 @@ += 8 + */ +class AttributeTest extends BaseContainerTest +{ + /** + * @dataProvider provideContainer + */ + public function test_injectable_annotation_is_not_required(ContainerBuilder $builder) + { + $container = $builder->useAttributes(true)->build(); + self::assertInstanceOf(NonAnnotatedClass::class, $container->get(NonAnnotatedClass::class)); + } + + /** + * @dataProvider provideContainer + */ + public function test_constructor_injection(ContainerBuilder $builder) + { + $builder->useAttributes(true); + $builder->addDefinitions([ + 'foo' => 'bar', + 'lazyService' => autowire(\stdClass::class)->lazy(), + ]); + $container = $builder->build(); + + $object = $container->get(ConstructorInjection::class); + + self::assertEquals(new \stdClass, $object->typedValue); + self::assertEquals(new \stdClass, $object->typedOptionalValue); + self::assertEquals('bar', $object->value); + self::assertInstanceOf(\stdClass::class, $object->lazyService); + self::assertInstanceOf(LazyLoadingInterface::class, $object->lazyService); + self::assertFalse($object->lazyService->isProxyInitialized()); + self::assertEquals('hello', $object->optionalValue); + } + + /** + * @dataProvider provideContainer + */ + public function test_property_injection(ContainerBuilder $builder) + { + $builder->useAttributes(true); + $builder->addDefinitions([ + 'foo' => 'bar', + 'lazyService' => autowire(\stdClass::class)->lazy(), + ]); + $container = $builder->build(); + + $object = $container->get(PropertyInjection::class); + + self::assertEquals('bar', $object->value); + self::assertEquals('bar', $object->value2); + self::assertInstanceOf(\stdClass::class, $object->entry); + self::assertInstanceOf(\stdClass::class, $object->lazyService); + self::assertInstanceOf(LazyLoadingInterface::class, $object->lazyService); + self::assertFalse($object->lazyService->isProxyInitialized()); + } + + /** + * @dataProvider provideContainer + */ + public function test_method_injection(ContainerBuilder $builder) + { + $builder->useAttributes(true); + $builder->addDefinitions([ + 'foo' => 'bar', + 'lazyService' => autowire(\stdClass::class)->lazy(), + ]); + $container = $builder->build(); + + $object = $container->get(ConstructorInjection::class); + + self::assertEquals(new \stdClass, $object->typedValue); + self::assertEquals(new \stdClass, $object->typedOptionalValue); + self::assertEquals('bar', $object->value); + self::assertInstanceOf(\stdClass::class, $object->lazyService); + self::assertInstanceOf(LazyLoadingInterface::class, $object->lazyService); + self::assertFalse($object->lazyService->isProxyInitialized()); + self::assertEquals('hello', $object->optionalValue); + } +} + +namespace DI\Test\IntegrationTest\Definitions\AttributesTest; + +use DI\Attribute\Inject; +use stdClass; + +class NonAnnotatedClass +{ +} + +class NamespacedClass +{ +} + +class ConstructorInjection +{ + public $value; + public $scalarValue; + public $typedValue; + public $typedOptionalValue; + /** @var \ProxyManager\Proxy\LazyLoadingInterface */ + public $lazyService; + public $optionalValue; + + #[Inject(['value' => 'foo', 'scalarValue' => 'foo', 'lazyService' => 'lazyService'])] + public function __construct( + $value, + string $scalarValue, + \stdClass $typedValue, + \stdClass $typedOptionalValue = null, + \stdClass $lazyService, + $optionalValue = 'hello' + ) { + $this->value = $value; + $this->scalarValue = $scalarValue; + $this->typedValue = $typedValue; + $this->typedOptionalValue = $typedOptionalValue; + $this->lazyService = $lazyService; + $this->optionalValue = $optionalValue; + } +} + +class PropertyInjection +{ + #[Inject(name: 'foo')] + public $value; + #[Inject('foo')] + public $value2; + #[Inject] + public stdClass $entry; + #[Inject('lazyService')] + public $lazyService; +} + +class MethodInjection +{ + public $value; + public $scalarValue; + public $typedValue; + public $typedOptionalValue; + /** @var \ProxyManager\Proxy\LazyLoadingInterface */ + public $lazyService; + public $optionalValue; + + #[Inject(['value' => 'foo', 'scalarValue' => 'foo', 'lazyService' => 'lazyService'])] + public function method( + $value, + string $scalarValue, + $untypedValue, + \stdClass $typedOptionalValue = null, + \stdClass $lazyService, + $optionalValue = 'hello' + ) { + $this->value = $value; + $this->scalarValue = $scalarValue; + $this->untypedValue = $untypedValue; + $this->typedOptionalValue = $typedOptionalValue; + $this->lazyService = $lazyService; + $this->optionalValue = $optionalValue; + } +} diff --git a/tests/UnitTest/Attributes/Fixtures/Dependency.php b/tests/UnitTest/Attributes/Fixtures/Dependency.php new file mode 100644 index 000000000..e90d65a3e --- /dev/null +++ b/tests/UnitTest/Attributes/Fixtures/Dependency.php @@ -0,0 +1,9 @@ + 'foo'])] + public function method3($str1) + { + } + + #[Inject(['str1' => []])] + public function method4($str1) + { + } +} diff --git a/tests/UnitTest/Attributes/Fixtures/Injectable1.php b/tests/UnitTest/Attributes/Fixtures/Injectable1.php new file mode 100644 index 000000000..75c3cb172 --- /dev/null +++ b/tests/UnitTest/Attributes/Fixtures/Injectable1.php @@ -0,0 +1,10 @@ += 8 + * + * @covers \DI\Attribute\Inject + */ +class InjectTest extends TestCase +{ + private ReflectionClass $reflectionClass; + + public function setUp(): void + { + $this->reflectionClass = new ReflectionClass(InjectFixture::class); + } + + public function testProperty1() + { + $property = $this->reflectionClass->getProperty('property1'); + /** @var Inject $annotation */ + $annotation = $property->getAttributes(Inject::class)[0]->newInstance(); + + $this->assertInstanceOf(Inject::class, $annotation); + $this->assertEquals('foo', $annotation->getName()); + } + + public function testProperty2() + { + $property = $this->reflectionClass->getProperty('property2'); + /** @var Inject $annotation */ + $annotation = $property->getAttributes(Inject::class)[0]->newInstance(); + + $this->assertInstanceOf(Inject::class, $annotation); + $this->assertNull($annotation->getName()); + } + + public function testProperty3() + { + $property = $this->reflectionClass->getProperty('property3'); + /** @var Inject $annotation */ + $annotation = $property->getAttributes(Inject::class)[0]->newInstance(); + + $this->assertInstanceOf(Inject::class, $annotation); + $this->assertEquals('foo', $annotation->getName()); + } + + public function testMethod1() + { + $method = $this->reflectionClass->getMethod('method1'); + /** @var Inject $annotation */ + $annotation = $method->getAttributes(Inject::class)[0]->newInstance(); + + $this->assertInstanceOf(Inject::class, $annotation); + $this->assertEmpty($annotation->getParameters()); + } + + public function testMethod2() + { + $method = $this->reflectionClass->getMethod('method2'); + /** @var Inject $annotation */ + $annotation = $method->getAttributes(Inject::class)[0]->newInstance(); + $parameters = $annotation->getParameters(); + + $this->assertInstanceOf(Inject::class, $annotation); + $this->assertCount(2, $parameters); + $this->assertEquals('foo', $parameters[0]); + $this->assertEquals('bar', $parameters[1]); + } + + public function testMethod3() + { + $method = $this->reflectionClass->getMethod('method3'); + /** @var Inject $annotation */ + $annotation = $method->getAttributes(Inject::class)[0]->newInstance(); + $parameters = $annotation->getParameters(); + + $this->assertInstanceOf(Inject::class, $annotation); + $this->assertCount(1, $parameters); + + $this->assertArrayHasKey('str1', $parameters); + $this->assertEquals('foo', $parameters['str1']); + } + + public function testInvalidAnnotation() + { + $this->expectException(InvalidAnnotation::class); + $this->expectExceptionMessage("#[Inject(['param' => 'value'])] expects \"value\" to be a string, [] given."); + $method = $this->reflectionClass->getMethod('method4'); + $method->getAttributes(Inject::class)[0]->newInstance(); + } +} diff --git a/tests/UnitTest/Attributes/InjectableTest.php b/tests/UnitTest/Attributes/InjectableTest.php new file mode 100644 index 000000000..aa2de42e9 --- /dev/null +++ b/tests/UnitTest/Attributes/InjectableTest.php @@ -0,0 +1,54 @@ += 8 + * + * @covers \DI\Annotation\Injectable + */ +class InjectableTest extends TestCase +{ + /** + * @var DoctrineAnnotationReader + */ + private $annotationReader; + + public function setUp(): void + { + $definitionReader = new AnnotationBasedAutowiring(); + $this->annotationReader = $definitionReader->getAnnotationReader(); + } + + public function testEmptyAnnotation() + { + $class = new ReflectionClass(Injectable1::class); + /** @var $annotation Injectable */ + $annotation = $this->annotationReader->getClassAnnotation($class, Injectable::class); + + $this->assertInstanceOf(Injectable::class, $annotation); + $this->assertNull($annotation->isLazy()); + } + + public function testLazy() + { + $class = new ReflectionClass(Injectable2::class); + /** @var $annotation Injectable */ + $annotation = $this->annotationReader->getClassAnnotation($class, Injectable::class); + + $this->assertInstanceOf(Injectable::class, $annotation); + $this->assertTrue($annotation->isLazy()); + } +}