From 2d1cd7f79b036239228d02e052c9cfc03fe111e2 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 6 May 2018 09:39:50 -0700 Subject: [PATCH] [DI] Extend semantic of `@param` to describe intent and use it when autowiring services and parameters --- .../DependencyInjection/CHANGELOG.md | 5 + .../AutowireAnnotatedArgumentsPass.php | 159 ++++++++++++++++++ .../Compiler/PassConfig.php | 1 + .../AutowireAnnotatedArgumentsPassTest.php | 47 ++++++ .../Fixtures/includes/autowiring_classes.php | 13 ++ 5 files changed, 225 insertions(+) create mode 100644 src/Symfony/Component/DependencyInjection/Compiler/AutowireAnnotatedArgumentsPass.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Compiler/AutowireAnnotatedArgumentsPassTest.php diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index fb9d0ef90c82a..81adb04d4deb9 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +4.2.0 +----- + + * extended `@param` semantic to hint autowiring of services and parameters + 4.1.0 ----- diff --git a/src/Symfony/Component/DependencyInjection/Compiler/AutowireAnnotatedArgumentsPass.php b/src/Symfony/Component/DependencyInjection/Compiler/AutowireAnnotatedArgumentsPass.php new file mode 100644 index 0000000000000..3a3403f9eb0fd --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Compiler/AutowireAnnotatedArgumentsPass.php @@ -0,0 +1,159 @@ + + * + * 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\Definition; +use Symfony\Component\DependencyInjection\Exception\AutowiringFailedException; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Looks for definitions with autowiring enabled and parses their "@param" annotations for service ids and parameters. + * + * @author Nicolas Grekas + */ +class AutowireAnnotatedArgumentsPass extends AbstractRecursivePass +{ + private const PARAM_REGEX = '{ + (?:^/\*\*|\n\s*+\*)\s*+ + @param + \s[^\$\*]*+ + \$([^\s\*]++) # argument name + \s++( + @[^\s\*]++ # service id + |%[^%\s\*]++% # parameter name + )(?=[\s\*]) + }six'; + private const INHERITDOC_REGEX = '#(?:^/\*\*|\n\s*+\*)\s*+(?:\{@inheritdoc\}|@inheritdoc)(?:\s|\*/$)#i'; + + /** + * {@inheritdoc} + */ + protected function processValue($value, $isRoot = false) + { + $value = parent::processValue($value, $isRoot); + + if (!$value instanceof Definition || !$value->isAutowired() || $value->isAbstract() || !$value->getClass()) { + return $value; + } + if (!$classReflector = $this->container->getReflectionClass($value->getClass(), false)) { + return $value; + } + + try { + if ($constructor = $this->getConstructor($value, false)) { + $constructor = strtolower($constructor->name); + } + } catch (RuntimeException $e) { + return $value; + } + if (!$annotatedParams = $this->getAnnotatedParams($classReflector, $constructor, $value)) { + return $value; + } + $methodCalls = $value->getMethodCalls(); + + if ($constructor) { + array_unshift($methodCalls, array($constructor, $value->getArguments())); + } + + foreach ($methodCalls as $i => $call) { + list($method, $arguments) = $call; + if (!isset($annotatedParams[$m = strtolower($method)])) { + continue; + } + + foreach ($annotatedParams[$m] as $j => $v) { + if (!array_key_exists($j, $arguments) || ('' === $arguments[$j] && $v instanceof Reference)) { + $arguments[$j] = $v; + } + } + ksort($arguments); + + if ($arguments !== $call[1]) { + $methodCalls[$i][1] = $arguments; + } + } + + if ($constructor) { + list(, $arguments) = array_shift($methodCalls); + + if ($arguments !== $value->getArguments()) { + $value->setArguments($arguments); + } + } + + if ($methodCalls !== $value->getMethodCalls()) { + $value->setMethodCalls($methodCalls); + } + + return $value; + } + + private function getAnnotatedParams(\ReflectionClass $classReflector, ?string $constructor, Definition $definition): array + { + $annotatedParams = array(); + + if (null !== $constructor) { + $annotatedParams[$constructor] = array(); + } + foreach ($definition->getMethodCalls() as list($method)) { + $annotatedParams[strtolower($method)] = array(); + } + + foreach ($classReflector->getMethods() as $methodReflector) { + $r = $methodReflector; + if (!isset($annotatedParams[$m = strtolower($r->name)])) { + continue; + } + + while (true) { + if (false !== $doc = $r->getDocComment()) { + if (false !== stripos($doc, '@param') && preg_match_all(self::PARAM_REGEX, $doc, $params, PREG_SET_ORDER)) { + $paramIndexes = array(); + foreach ($r->getParameters() as $i => $paramReflector) { + $paramIndexes[$paramReflector->name] = array($i, $paramReflector); + } + foreach ($params as list(, $k, $v)) { + if (!isset($paramIndexes[$k])) { + $this->container->log($this, sprintf('Skipping @param "$%s": no such argument on "%s::%s()".', $k, $r->class, $r->name)); + continue; + } + list($i, $paramReflector) = $paramIndexes[$k]; + if ('%' === $v[0] && $this->container->hasParameter($id = substr($v, 1, -1))) { + $v = $this->container->getParameter($id); + } elseif ('%' === $v[0]) { + throw new AutowiringFailedException($this->currentId, sprintf('Cannot autowire service "%s": parameter "%s" not found for argument "$%s" of method "%s()", you should either configure the argument explicitly or set the missing parameter.', $this->currentId, $id, $k, $r->class !== $this->currentId ? $r->class.'::'.$r->name : $r->name)); + } elseif ($this->container->has($id = substr($v, 1))) { + $v = new Reference($id); + } elseif ($paramReflector->allowsNull() || $this->container->has(ProxyHelper::getTypeHint($classReflector, $paramReflector, true))) { + $this->container->log($this, sprintf('Skipping @param "$%s" on "%s::%s(): service "%s" not found.', $k, $r->class, $r->name, $id)); + continue; + } else { + throw new AutowiringFailedException($this->currentId, sprintf('Cannot autowire service "%s": service "%s" not found for argument "$%s" of method "%s()", you should either configure the argument explicitly or define the missing service.', $this->currentId, $id, $k, $r->class !== $this->currentId ? $r->class.'::'.$r->name : $r->name)); + } + $annotatedParams[$m] += array($i => $v); + } + } + if (false === stripos($doc, '@inheritdoc') || !preg_match(self::INHERITDOC_REGEX, $doc)) { + break; + } + } + try { + $r = $r->getPrototype(); + } catch (\ReflectionException $e) { + break; // method has no prototype + } + } + } + + return array_filter($annotatedParams); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Compiler/PassConfig.php b/src/Symfony/Component/DependencyInjection/Compiler/PassConfig.php index 170a0edc8aabb..cbe80b396496d 100644 --- a/src/Symfony/Component/DependencyInjection/Compiler/PassConfig.php +++ b/src/Symfony/Component/DependencyInjection/Compiler/PassConfig.php @@ -60,6 +60,7 @@ public function __construct() new ResolveNamedArgumentsPass(), new AutowireRequiredMethodsPass(), new ResolveBindingsPass(), + new AutowireAnnotatedArgumentsPass(), new AutowirePass(false), new ResolveTaggedIteratorArgumentPass(), new ResolveServiceSubscribersPass(), diff --git a/src/Symfony/Component/DependencyInjection/Tests/Compiler/AutowireAnnotatedArgumentsPassTest.php b/src/Symfony/Component/DependencyInjection/Tests/Compiler/AutowireAnnotatedArgumentsPassTest.php new file mode 100644 index 0000000000000..9da494fc1be82 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Compiler/AutowireAnnotatedArgumentsPassTest.php @@ -0,0 +1,47 @@ + + * + * 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\Compiler\ResolveClassPass; +use Symfony\Component\DependencyInjection\Compiler\AutowireAnnotatedArgumentsPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +require_once __DIR__.'/../Fixtures/includes/autowiring_classes.php'; + +class AutowireAnnotatedArgumentsPassTest extends TestCase +{ + public function testSetterInjection() + { + $container = new ContainerBuilder(); + $container->setParameter('a', 'a'); + $container->setParameter('b', 'b'); + $container->register('c'); + $container->register('d'); + $foo = $container->register(AnnotatedParamsFoo::class) + ->setArguments(array(0 => 'A', 2 => new Reference('C'))) + ->setAutowired(true) + ; + + (new ResolveClassPass())->process($container); + (new AutowireAnnotatedArgumentsPass())->process($container); + + $expected = array( + 'A', + 'b', + new Reference('C'), + new Reference('d'), + ); + $this->assertEquals($expected, $foo->getArguments()); + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php index d806f294ddaa9..a679210055b46 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/autowiring_classes.php @@ -379,3 +379,16 @@ public function __construct(LoggerInterface $logger, DecoratorInterface $decorat { } } + +class AnnotatedParamsFoo +{ + /** + * @param $a %a% + * @param $b %b% + * @param $c @c + * @param $d @d + */ + public function __construct($a, $b, $c, $d) + { + } +}