feature #21383 [DependencyInjection] Add support for named arguments (dunglas, nicolas-grekas)

This PR was merged into the 3.3-dev branch.

Discussion
----------

[DependencyInjection] Add support for named arguments

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | yes
| Tests pass?   | yes
| Fixed tickets | -
| License       | MIT
| Doc PR        | todo

This PR introduces named arguments for services definitions. It's especially useful to inject parameters in an autowired service. It is (at least partially) an alternative to #21376 and #20738.

Usage:

```yml
services:
    _defaults: { autowire: true }
    Acme\NewsletterManager: { $apiKey: "%mandrill_api_key%" }

# Alternative (traditional) syntax
services:
    newsletter_manager:
        class: Acme\NewsletterManager
        arguments:
            $apiKey: "%mandrill_api_key%"
        autowire: true
```
```php
use Doctrine\ORM\EntityManager;
use Psr\Log\LoggerInterface;

namespace Acme;

class NewsletterManager
{
    private $logger;
    private $em;
    private $apiKey;

    public function __construct(LoggerInterface $logger, EntityManager $em, $apiKey)
    {
        $this->logger = $logger;
        $this->em = $em;
        $this->apiKey = $apiKey;
    }
}
```

Commits
-------

8a126c8537 [DI] Deprecate string keys in arguments
2ce36a6074 [DependencyInjection] Add a new pass to check arguments validity
6e501296f9 [DependencyInjection] Add support for named arguments
This commit is contained in:
Fabien Potencier 2017-02-13 15:38:11 +01:00
commit 9ac3a7e010
14 changed files with 447 additions and 12 deletions

View File

@ -213,12 +213,14 @@ class ChildDefinition extends Definition
*/
public function replaceArgument($index, $value)
{
if (!is_int($index)) {
if (is_int($index)) {
$this->arguments['index_'.$index] = $value;
} elseif (0 === strpos($index, '$')) {
$this->arguments[$index] = $value;
} else {
throw new InvalidArgumentException('$index must be an integer.');
}
$this->arguments['index_'.$index] = $value;
return $this;
}
}

View File

@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\RuntimeException;
/**
* Checks if arguments of methods are properly configured.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
class CheckArgumentsValidityPass extends AbstractRecursivePass
{
/**
* {@inheritdoc}
*/
protected function processValue($value, $isRoot = false)
{
if (!$value instanceof Definition) {
return parent::processValue($value, $isRoot);
}
$i = 0;
foreach ($value->getArguments() as $k => $v) {
if ($k !== $i++) {
if (!is_int($k)) {
throw new RuntimeException(sprintf('Invalid constructor argument for service "%s": integer expected but found string "%s". Check your service definition.', $this->currentId, $k));
}
throw new RuntimeException(sprintf('Invalid constructor argument %d for service "%s": argument %d must be defined before. Check your service definition.', 1 + $k, $this->currentId, $i));
}
}
foreach ($value->getMethodCalls() as $methodCall) {
$i = 0;
foreach ($methodCall[1] as $k => $v) {
if ($k !== $i++) {
if (!is_int($k)) {
throw new RuntimeException(sprintf('Invalid argument for method call "%s" of service "%s": integer expected but found string "%s". Check your service definition.', $methodCall[0], $this->currentId, $k));
}
throw new RuntimeException(sprintf('Invalid argument %d for method call "%s" of service "%s": argument %d must be defined before. Check your service definition.', 1 + $k, $methodCall[0], $this->currentId, $i));
}
}
}
}
}

View File

@ -53,12 +53,14 @@ class PassConfig
new ResolveFactoryClassPass(),
new FactoryReturnTypePass($resolveClassPass),
new CheckDefinitionValidityPass(),
new ResolveNamedArgumentsPass(),
new AutowirePass(),
new ResolveReferencesToAliasesPass(),
new ResolveInvalidReferencesPass(),
new AnalyzeServiceReferencesPass(true),
new CheckCircularReferencesPass(),
new CheckReferenceValidityPass(),
new CheckArgumentsValidityPass(),
));
$this->removingPasses = array(array(

View File

@ -145,11 +145,12 @@ class ResolveDefinitionTemplatesPass extends AbstractRecursivePass
continue;
}
if (0 !== strpos($k, 'index_')) {
if (0 === strpos($k, 'index_')) {
$index = (int) substr($k, strlen('index_'));
} elseif (0 !== strpos($k, '$')) {
throw new RuntimeException(sprintf('Invalid argument key "%s" found.', $k));
}
$index = (int) substr($k, strlen('index_'));
$def->replaceArgument($index, $v);
}

View File

@ -0,0 +1,117 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\InvalidArgumentException;
/**
* Resolves named arguments to their corresponding numeric index.
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class ResolveNamedArgumentsPass extends AbstractRecursivePass
{
/**
* {@inheritdoc}
*/
protected function processValue($value, $isRoot = false)
{
if (!$value instanceof Definition) {
return parent::processValue($value, $isRoot);
}
$parameterBag = $this->container->getParameterBag();
if ($class = $value->getClass()) {
$class = $parameterBag->resolveValue($class);
}
$calls = $value->getMethodCalls();
$calls[] = array('__construct', $value->getArguments());
foreach ($calls as $i => $call) {
list($method, $arguments) = $call;
$method = $parameterBag->resolveValue($method);
$parameters = null;
$resolvedArguments = array();
foreach ($arguments as $key => $argument) {
if (is_int($key) || '' === $key || '$' !== $key[0]) {
if (!is_int($key)) {
@trigger_error(sprintf('Using key "%s" for defining arguments of method "%s" for service "%s" is deprecated since Symfony 3.3 and will throw an exception in 4.0. Use no keys or $named arguments instead.', $key, $method, $this->currentId), E_USER_DEPRECATED);
}
$resolvedArguments[] = $argument;
continue;
}
$parameters = null !== $parameters ? $parameters : $this->getParameters($class, $method);
foreach ($parameters as $j => $p) {
if ($key === '$'.$p->name) {
$resolvedArguments[$j] = $argument;
continue 2;
}
}
throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": method "%s::%s" has no argument named "%s". Check your service definition.', $this->currentId, $class, $method, $key));
}
if ($resolvedArguments !== $call[1]) {
ksort($resolvedArguments);
$calls[$i][1] = $resolvedArguments;
}
}
list(, $arguments) = array_pop($calls);
if ($arguments !== $value->getArguments()) {
$value->setArguments($arguments);
}
if ($calls !== $value->getMethodCalls()) {
$value->setMethodCalls($calls);
}
return parent::processValue($value, $isRoot);
}
/**
* @param string|null $class
* @param string $method
*
* @throws InvalidArgumentException
*
* @return array
*/
private function getParameters($class, $method)
{
if (!$class) {
throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": the class is not set.', $this->currentId));
}
if (!$r = $this->container->getReflectionClass($class)) {
throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": class "%s" does not exist.', $this->currentId, $class));
}
if (!$r->hasMethod($method)) {
throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": method "%s::%s" does not exist.', $this->currentId, $class, $method));
}
$method = $r->getMethod($method);
if (!$method->isPublic()) {
throw new InvalidArgumentException(sprintf('Unable to resolve service "%s": method "%s::%s" must be public.', $this->currentId, $class, $method->name));
}
return $method->getParameters();
}
}

View File

@ -190,8 +190,8 @@ class Definition
/**
* Sets a specific argument.
*
* @param int $index
* @param mixed $argument
* @param int|string $index
* @param mixed $argument
*
* @return $this
*
@ -203,10 +203,14 @@ class Definition
throw new OutOfBoundsException('Cannot replace arguments if none have been configured yet.');
}
if ($index < 0 || $index > count($this->arguments) - 1) {
if (is_int($index) && ($index < 0 || $index > count($this->arguments) - 1)) {
throw new OutOfBoundsException(sprintf('The index "%d" is not in the range [0, %d].', $index, count($this->arguments) - 1));
}
if (!array_key_exists($index, $this->arguments)) {
throw new OutOfBoundsException(sprintf('The argument "%s" doesn\'t exist.', $index));
}
$this->arguments[$index] = $argument;
return $this;
@ -225,7 +229,7 @@ class Definition
/**
* Gets an argument to pass to the service constructor/factory method.
*
* @param int $index
* @param int|string $index
*
* @return mixed The argument value
*
@ -233,8 +237,8 @@ class Definition
*/
public function getArgument($index)
{
if ($index < 0 || $index > count($this->arguments) - 1) {
throw new OutOfBoundsException(sprintf('The index "%d" is not in the range [0, %d].', $index, count($this->arguments) - 1));
if (!array_key_exists($index, $this->arguments)) {
throw new OutOfBoundsException(sprintf('The argument "%s" doesn\'t exist.', $index));
}
return $this->arguments[$index];

View File

@ -254,6 +254,22 @@ class YamlFileLoader extends FileLoader
return $defaults;
}
/**
* @param array $service
*
* @return bool
*/
private function isUsingShortSyntax(array $service)
{
foreach ($service as $key => $value) {
if (is_string($key) && ('' === $key || '$' !== $key[0])) {
return false;
}
}
return true;
}
/**
* Parses a definition.
*
@ -273,7 +289,7 @@ class YamlFileLoader extends FileLoader
return;
}
if (is_array($service) && array_values($service) === $service) {
if (is_array($service) && $this->isUsingShortSyntax($service)) {
$service = array('arguments' => $service);
}

View File

@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 Symfony\Component\DependencyInjection\Compiler\CheckArgumentsValidityPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class CheckArgumentsValidityPassTest extends \PHPUnit_Framework_TestCase
{
public function testProcess()
{
$container = new ContainerBuilder();
$definition = $container->register('foo');
$definition->setArguments(array(null, 1, 'a'));
$definition->setMethodCalls(array(
array('bar', array('a', 'b')),
array('baz', array('c', 'd')),
));
$pass = new CheckArgumentsValidityPass();
$pass->process($container);
$this->assertEquals(array(null, 1, 'a'), $container->getDefinition('foo')->getArguments());
$this->assertEquals(array(
array('bar', array('a', 'b')),
array('baz', array('c', 'd')),
), $container->getDefinition('foo')->getMethodCalls());
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\RuntimeException
* @dataProvider definitionProvider
*/
public function testException(array $arguments, array $methodCalls)
{
$container = new ContainerBuilder();
$definition = $container->register('foo');
$definition->setArguments($arguments);
$definition->setMethodCalls($methodCalls);
$pass = new CheckArgumentsValidityPass();
$pass->process($container);
}
public function definitionProvider()
{
return array(
array(array(null, 'a' => 'a'), array()),
array(array(1 => 1), array()),
array(array(), array(array('baz', array(null, 'a' => 'a')))),
array(array(), array(array('baz', array(1 => 1)))),
);
}
}

View File

@ -0,0 +1,98 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 Symfony\Component\DependencyInjection\Compiler\ResolveNamedArgumentsPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\Tests\Fixtures\NamedArgumentsDummy;
/**
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class ResolveNamedArgumentsPassTest extends \PHPUnit_Framework_TestCase
{
public function testProcess()
{
$container = new ContainerBuilder();
$definition = $container->register(NamedArgumentsDummy::class, NamedArgumentsDummy::class);
$definition->setArguments(array(0 => new Reference('foo'), '$apiKey' => '123'));
$definition->addMethodCall('setApiKey', array('$apiKey' => '123'));
$pass = new ResolveNamedArgumentsPass();
$pass->process($container);
$this->assertEquals(array(0 => new Reference('foo'), 1 => '123'), $definition->getArguments());
$this->assertEquals(array(array('setApiKey', array('123'))), $definition->getMethodCalls());
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
*/
public function testClassNull()
{
$container = new ContainerBuilder();
$definition = $container->register(NamedArgumentsDummy::class);
$definition->setArguments(array('$apiKey' => '123'));
$pass = new ResolveNamedArgumentsPass();
$pass->process($container);
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
*/
public function testClassNotExist()
{
$container = new ContainerBuilder();
$definition = $container->register(NotExist::class, NotExist::class);
$definition->setArguments(array('$apiKey' => '123'));
$pass = new ResolveNamedArgumentsPass();
$pass->process($container);
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
*/
public function testClassNoConstructor()
{
$container = new ContainerBuilder();
$definition = $container->register(NoConstructor::class, NoConstructor::class);
$definition->setArguments(array('$apiKey' => '123'));
$pass = new ResolveNamedArgumentsPass();
$pass->process($container);
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
*/
public function testArgumentNotFound()
{
$container = new ContainerBuilder();
$definition = $container->register(NamedArgumentsDummy::class, NamedArgumentsDummy::class);
$definition->setArguments(array('$notFound' => '123'));
$pass = new ResolveNamedArgumentsPass();
$pass->process($container);
}
}
class NoConstructor
{
}

View File

@ -0,0 +1,17 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
/**
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class NamedArgumentsDummy
{
public function __construct(CaseSensitiveClass $c, $apiKey)
{
}
public function setApiKey($apiKey)
{
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="Symfony\Component\DependencyInjection\Tests\Fixtures\NamedArgumentsDummy">
<argument />
<argument key="$apiKey">ABCD</argument>
<call method="setApiKey">
<argument key="$apiKey">123</argument>
</call>
</service>
</services>
</container>

View File

@ -0,0 +1,10 @@
services:
Symfony\Component\DependencyInjection\Tests\Fixtures\NamedArgumentsDummy: { 0: ~, $apiKey: ABCD }
another_one:
class: Symfony\Component\DependencyInjection\Tests\Fixtures\NamedArgumentsDummy
arguments:
0: ~
$apiKey: ABCD
calls:
- ['setApiKey', { $apiKey: '123' }]

View File

@ -25,6 +25,7 @@ use Symfony\Component\Config\Resource\DirectoryResource;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\DependencyInjection\Tests\Fixtures\CaseSensitiveClass;
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
use Symfony\Component\DependencyInjection\Tests\Fixtures\NamedArgumentsDummy;
use Symfony\Component\ExpressionLanguage\Expression;
class XmlFileLoaderTest extends \PHPUnit_Framework_TestCase
@ -704,4 +705,18 @@ class XmlFileLoaderTest extends \PHPUnit_Framework_TestCase
$this->assertSame(array('setFoo'), $container->getDefinition('no_defaults_child')->getAutowiredCalls());
$this->assertSame(array(), $container->getDefinition('with_defaults_child')->getAutowiredCalls());
}
public function testNamedArguments()
{
$container = new ContainerBuilder();
$loader = new XmlFileLoader($container, new FileLocator(self::$fixturesPath.'/xml'));
$loader->load('services_named_args.xml');
$this->assertEquals(array(null, '$apiKey' => 'ABCD'), $container->getDefinition(NamedArgumentsDummy::class)->getArguments());
$container->compile();
$this->assertEquals(array(null, 'ABCD'), $container->getDefinition(NamedArgumentsDummy::class)->getArguments());
$this->assertEquals(array(array('setApiKey', array('123'))), $container->getDefinition(NamedArgumentsDummy::class)->getMethodCalls());
}
}

View File

@ -26,6 +26,7 @@ use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\DependencyInjection\Tests\Fixtures\CaseSensitiveClass;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Tests\Fixtures\Prototype;
use Symfony\Component\DependencyInjection\Tests\Fixtures\NamedArgumentsDummy;
use Symfony\Component\ExpressionLanguage\Expression;
class YamlFileLoaderTest extends \PHPUnit_Framework_TestCase
@ -452,6 +453,22 @@ class YamlFileLoaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals(array('getbar' => array('bar' => new Reference('bar'))), $container->getDefinition('foo')->getOverriddenGetters());
}
public function testNamedArguments()
{
$container = new ContainerBuilder();
$loader = new YamlFileLoader($container, new FileLocator(self::$fixturesPath.'/yaml'));
$loader->load('services_named_args.yml');
$this->assertEquals(array(null, '$apiKey' => 'ABCD'), $container->getDefinition(NamedArgumentsDummy::class)->getArguments());
$this->assertEquals(array(null, '$apiKey' => 'ABCD'), $container->getDefinition('another_one')->getArguments());
$container->compile();
$this->assertEquals(array(null, 'ABCD'), $container->getDefinition(NamedArgumentsDummy::class)->getArguments());
$this->assertEquals(array(null, 'ABCD'), $container->getDefinition('another_one')->getArguments());
$this->assertEquals(array(array('setApiKey', array('123'))), $container->getDefinition('another_one')->getMethodCalls());
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
* @expectedExceptionMessage The value of the "decorates" option for the "bar" service must be the id of the service without the "@" prefix (replace "@foo" with "foo").