From 64ab6a28505ed03d8ac59c8f0f67f4f44f145143 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 12 Jan 2021 19:42:50 +0100 Subject: [PATCH] [DependencyInjection] Add `#[Autoconfigure]` to help define autoconfiguration rules --- .../Attribute/Autoconfigure.php | 34 +++++++ .../Attribute/AutoconfigureTag.php | 30 ++++++ .../DependencyInjection/CHANGELOG.md | 1 + .../Compiler/PassConfig.php | 1 + .../RegisterAutoconfigureAttributesPass.php | 92 +++++++++++++++++++ .../DependencyInjection/Loader/FileLoader.php | 10 +- .../Loader/YamlFileLoader.php | 10 +- ...egisterAutoconfigureAttributesPassTest.php | 81 ++++++++++++++++ .../Fixtures/AutoconfigureAttributed.php | 29 ++++++ .../Fixtures/AutoconfiguredInterface.php | 10 ++ .../Tests/Fixtures/Prototype/FooInterface.php | 3 + .../Tests/Loader/FileLoaderTest.php | 7 +- 12 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 src/Symfony/Component/DependencyInjection/Attribute/Autoconfigure.php create mode 100644 src/Symfony/Component/DependencyInjection/Attribute/AutoconfigureTag.php create mode 100644 src/Symfony/Component/DependencyInjection/Compiler/RegisterAutoconfigureAttributesPass.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterAutoconfigureAttributesPassTest.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfigureAttributed.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface.php diff --git a/src/Symfony/Component/DependencyInjection/Attribute/Autoconfigure.php b/src/Symfony/Component/DependencyInjection/Attribute/Autoconfigure.php new file mode 100644 index 0000000000..abab040101 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Attribute/Autoconfigure.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\Attribute; + +/** + * An attribute to tell how a base type should be autoconfigured. + * + * @author Nicolas Grekas + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +class Autoconfigure +{ + public function __construct( + public ?array $tags = null, + public ?array $calls = null, + public ?array $bind = null, + public bool|string|null $lazy = null, + public ?bool $public = null, + public ?bool $shared = null, + public ?bool $autowire = null, + public ?array $properties = null, + public array|string|null $configurator = null, + ) { + } +} diff --git a/src/Symfony/Component/DependencyInjection/Attribute/AutoconfigureTag.php b/src/Symfony/Component/DependencyInjection/Attribute/AutoconfigureTag.php new file mode 100644 index 0000000000..ed5807ca02 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Attribute/AutoconfigureTag.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Attribute; + +/** + * An attribute to tell how a base type should be tagged. + * + * @author Nicolas Grekas + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +class AutoconfigureTag extends Autoconfigure +{ + public function __construct(string $name = null, array $attributes = []) + { + parent::__construct( + tags: [ + [$name ?? 0 => $attributes], + ] + ); + } +} diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index c801155694..4dd8a1da63 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add `ServicesConfigurator::remove()` in the PHP-DSL * Add `%env(not:...)%` processor to negate boolean values + * Add support for loading autoconfiguration rules via the `#[Autoconfigure]` and `#[AutoconfigureTag]` attributes on PHP 8 5.2.0 ----- diff --git a/src/Symfony/Component/DependencyInjection/Compiler/PassConfig.php b/src/Symfony/Component/DependencyInjection/Compiler/PassConfig.php index 961711fd28..29e7218bc6 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/PassConfig.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/PassConfig.php @@ -42,6 +42,7 @@ class PassConfig $this->beforeOptimizationPasses = [ 100 => [ new ResolveClassPass(), + new RegisterAutoconfigureAttributesPass(), new ResolveInstanceofConditionalsPass(), new RegisterEnvVarProcessorsPass(), ], diff --git a/src/Symfony/Component/DependencyInjection/Compiler/RegisterAutoconfigureAttributesPass.php b/src/Symfony/Component/DependencyInjection/Compiler/RegisterAutoconfigureAttributesPass.php new file mode 100644 index 0000000000..d332406271 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Compiler/RegisterAutoconfigureAttributesPass.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; + +/** + * Reads #[Autoconfigure] attributes on definitions that are autoconfigured + * and don't have the "container.ignore_attributes" tag. + * + * @author Nicolas Grekas + */ +final class RegisterAutoconfigureAttributesPass implements CompilerPassInterface +{ + private $ignoreAttributesTag; + private $registerForAutoconfiguration; + + public function __construct(string $ignoreAttributesTag = 'container.ignore_attributes') + { + if (80000 > \PHP_VERSION_ID) { + return; + } + + $this->ignoreAttributesTag = $ignoreAttributesTag; + + $parseDefinitions = new \ReflectionMethod(YamlFileLoader::class, 'parseDefinitions'); + $parseDefinitions->setAccessible(true); + $yamlLoader = $parseDefinitions->getDeclaringClass()->newInstanceWithoutConstructor(); + + $this->registerForAutoconfiguration = static function (ContainerBuilder $container, \ReflectionClass $class, \ReflectionAttribute $attribute) use ($parseDefinitions, $yamlLoader) { + $attribute = (array) $attribute->newInstance(); + + foreach ($attribute['tags'] ?? [] as $i => $tag) { + if (\is_array($tag) && [0] === array_keys($tag)) { + $attribute['tags'][$i] = [$class->name => $tag[0]]; + } + } + + $parseDefinitions->invoke( + $yamlLoader, + [ + 'services' => [ + '_instanceof' => [ + $class->name => [$container->registerForAutoconfiguration($class->name)] + $attribute, + ], + ], + ], + $class->getFileName() + ); + }; + } + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container) + { + if (80000 > \PHP_VERSION_ID) { + return; + } + + foreach ($container->getDefinitions() as $id => $definition) { + if ($this->accept($definition) && null !== $class = $container->getReflectionClass($definition->getClass())) { + $this->processClass($container, $class); + } + } + } + + public function accept(Definition $definition): bool + { + return 80000 <= \PHP_VERSION_ID && $definition->isAutoconfigured() && !$definition->hasTag($this->ignoreAttributesTag); + } + + public function processClass(ContainerBuilder $container, \ReflectionClass $class) + { + foreach ($class->getAttributes(Autoconfigure::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) { + ($this->registerForAutoconfiguration)($container, $class, $attribute); + } + } +} diff --git a/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php index f8f9fc4523..b0d3e952e9 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/FileLoader.php @@ -18,6 +18,7 @@ use Symfony\Component\Config\Loader\FileLoader as BaseFileLoader; use Symfony\Component\Config\Loader\Loader; use Symfony\Component\Config\Resource\GlobResource; use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\RegisterAutoconfigureAttributesPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; @@ -96,7 +97,8 @@ abstract class FileLoader extends BaseFileLoader throw new InvalidArgumentException(sprintf('Namespace is not a valid PSR-4 prefix: "%s".', $namespace)); } - $classes = $this->findClasses($namespace, $resource, (array) $exclude); + $autoconfigureAttributes = new RegisterAutoconfigureAttributesPass(); + $classes = $this->findClasses($namespace, $resource, (array) $exclude, $autoconfigureAttributes->accept($prototype) ? $autoconfigureAttributes : null); // prepare for deep cloning $serializedPrototype = serialize($prototype); @@ -149,7 +151,7 @@ abstract class FileLoader extends BaseFileLoader } } - private function findClasses(string $namespace, string $pattern, array $excludePatterns): array + private function findClasses(string $namespace, string $pattern, array $excludePatterns, ?RegisterAutoconfigureAttributesPass $autoconfigureAttributes): array { $parameterBag = $this->container->getParameterBag(); @@ -207,6 +209,10 @@ abstract class FileLoader extends BaseFileLoader if ($r->isInstantiable() || $r->isInterface()) { $classes[$class] = null; } + + if ($autoconfigureAttributes && !$r->isInstantiable()) { + $autoconfigureAttributes->processClass($this->container, $r); + } } // track only for new & removed files diff --git a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php index 8eeb40ad8e..22f029cca8 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/YamlFileLoader.php @@ -389,6 +389,9 @@ class YamlFileLoader extends FileLoader ]; } + $definition = isset($service[0]) && $service[0] instanceof Definition ? array_shift($service) : null; + $return = null === $definition ? $return : true; + $this->checkDefinition($id, $service, $file); if (isset($service['alias'])) { @@ -423,7 +426,9 @@ class YamlFileLoader extends FileLoader return $return ? $alias : $this->container->setAlias($id, $alias); } - if ($this->isLoadingInstanceof) { + if (null !== $definition) { + // no-op + } elseif ($this->isLoadingInstanceof) { $definition = new ChildDefinition(''); } elseif (isset($service['parent'])) { if ('' !== $service['parent'] && '@' === $service['parent'][0]) { @@ -627,7 +632,8 @@ class YamlFileLoader extends FileLoader if (isset($defaults['bind']) || isset($service['bind'])) { // deep clone, to avoid multiple process of the same instance in the passes - $bindings = isset($defaults['bind']) ? unserialize(serialize($defaults['bind'])) : []; + $bindings = $definition->getBindings(); + $bindings += isset($defaults['bind']) ? unserialize(serialize($defaults['bind'])) : []; if (isset($service['bind'])) { if (!\is_array($service['bind'])) { diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterAutoconfigureAttributesPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterAutoconfigureAttributesPassTest.php new file mode 100644 index 0000000000..274cde655e --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/RegisterAutoconfigureAttributesPassTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Tests\Compiler; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Argument\BoundArgument; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\RegisterAutoconfigureAttributesPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfigureAttributed; +use Symfony\Component\DependencyInjection\Tests\Fixtures\AutoconfiguredInterface; + +/** + * @requires PHP 8 + */ +class RegisterAutoconfigureAttributesPassTest extends TestCase +{ + public function testProcess() + { + $container = new ContainerBuilder(); + $container->register('foo', AutoconfigureAttributed::class) + ->setAutoconfigured(true); + + (new RegisterAutoconfigureAttributesPass())->process($container); + + $argument = new BoundArgument(1, true, BoundArgument::INSTANCEOF_BINDING, realpath(__DIR__.'/../Fixtures/AutoconfigureAttributed.php')); + $values = $argument->getValues(); + --$values[1]; + $argument->setValues($values); + + $expected = (new ChildDefinition('')) + ->setLazy(true) + ->setPublic(true) + ->setAutowired(true) + ->setShared(true) + ->setProperties(['bar' => 'baz']) + ->setConfigurator(new Reference('bla')) + ->addTag('a_tag') + ->addTag('another_tag', ['attr' => 234]) + ->addMethodCall('setBar', [2, 3]) + ->setBindings(['$bar' => $argument]) + ; + $this->assertEquals([AutoconfigureAttributed::class => $expected], $container->getAutoconfiguredInstanceof()); + } + + public function testIgnoreAttribute() + { + $container = new ContainerBuilder(); + $container->register('foo', AutoconfigureAttributed::class) + ->addTag('container.ignore_attributes') + ->setAutoconfigured(true); + + (new RegisterAutoconfigureAttributesPass())->process($container); + + $this->assertSame([], $container->getAutoconfiguredInstanceof()); + } + + public function testAutoconfiguredTag() + { + $container = new ContainerBuilder(); + $container->register('foo', AutoconfiguredInterface::class) + ->setAutoconfigured(true); + + (new RegisterAutoconfigureAttributesPass())->process($container); + + $expected = (new ChildDefinition('')) + ->addTag(AutoconfiguredInterface::class, ['foo' => 123]) + ; + $this->assertEquals([AutoconfiguredInterface::class => $expected], $container->getAutoconfiguredInstanceof()); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfigureAttributed.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfigureAttributed.php new file mode 100644 index 0000000000..7761e7134b --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfigureAttributed.php @@ -0,0 +1,29 @@ + 'baz', + ], + configurator: '@bla', + tags: [ + 'a_tag', + ['another_tag' => ['attr' => 234]], + ], + calls: [ + ['setBar' => [2, 3]] + ], + bind: [ + '$bar' => 1, + ], +)] +class AutoconfigureAttributed +{ +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface.php new file mode 100644 index 0000000000..413a0630cf --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AutoconfiguredInterface.php @@ -0,0 +1,10 @@ + 123])] +interface AutoconfiguredInterface +{ +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/FooInterface.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/FooInterface.php index 1855dcfc59..c6fc34fb68 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/FooInterface.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/Prototype/FooInterface.php @@ -2,6 +2,9 @@ namespace Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype; +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; + +#[Autoconfigure(tags: ['foo'])] interface FooInterface { } diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/FileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/FileLoaderTest.php index 2d9fcef4e0..fbf825e299 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/FileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/FileLoaderTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface as PsrContainerInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\LoaderResolver; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; @@ -171,7 +172,7 @@ class FileLoaderTest extends TestCase $container = new ContainerBuilder(); $loader = new TestFileLoader($container, new FileLocator(self::$fixturesPath.'/Fixtures')); - $prototype = new Definition(); + $prototype = (new Definition())->setAutoconfigured(true); $loader->registerClasses($prototype, 'Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype\\', 'Prototype/*'); $this->assertTrue($container->has(Bar::class)); @@ -191,6 +192,10 @@ class FileLoaderTest extends TestCase $this->assertSame(Foo::class, (string) $alias); $this->assertFalse($alias->isPublic()); $this->assertTrue($alias->isPrivate()); + + if (\PHP_VERSION_ID >= 80000) { + $this->assertEquals([FooInterface::class => (new ChildDefinition(''))->addTag('foo')], $container->getAutoconfiguredInstanceof()); + } } public function testMissingParentClass()