feature #20167 [DependencyInjection] Make method (setter) autowiring configurable (dunglas)

This PR was merged into the 3.3-dev branch.

Discussion
----------

[DependencyInjection] Make method (setter) autowiring configurable

| Q | A |
| --- | --- |
| Branch? | master |
| Bug fix? | no |
| New feature? | yes |
| BC breaks? | no |
| Deprecations? | maybe? |
| Tests pass? | yes |
| Fixed tickets | #19631 |
| License | MIT |
| Doc PR | symfony/symfony-docs#7041 |

Follow up of #19631. Implements https://github.com/symfony/symfony/pull/19631#issuecomment-240646169:

Edit: the last supported format:

``` yaml
services:
    foo:
        class: Foo
        autowire: ['__construct', 'set*'] # Autowire constructor and all setters
        autowire: true # Converted by loaders in `autowire: ['__construct']` for BC
        autowire: ['foo', 'bar'] # Autowire only `foo` and `bar` methods
```

Outdated:

``` yaml
autowire: true # constructor autowiring
autowire: [__construct, setFoo, setBar] # autowire whitelisted methods only
autowire: '*' # autowire constructor + every setters (following existing rules for setters autowiring)
```
- [x] Allow to specify the list of methods in the XML loader
- [x] Add tests for the YAML loader

Commits
-------

6dd53c7 [DependencyInjection] Introduce method injection for autowiring
This commit is contained in:
Fabien Potencier 2016-12-13 16:48:43 +01:00
commit 69dcf41a3c
13 changed files with 375 additions and 33 deletions

View File

@ -24,6 +24,9 @@ use Symfony\Component\DependencyInjection\Reference;
*/
class AutowirePass implements CompilerPassInterface
{
/**
* @var ContainerBuilder
*/
private $container;
private $reflectionClasses = array();
private $definedTypes = array();
@ -41,8 +44,8 @@ class AutowirePass implements CompilerPassInterface
try {
$this->container = $container;
foreach ($container->getDefinitions() as $id => $definition) {
if ($definition->isAutowired()) {
$this->completeDefinition($id, $definition);
if ($autowiredMethods = $definition->getAutowiredMethods()) {
$this->completeDefinition($id, $definition, $autowiredMethods);
}
}
} finally {
@ -72,8 +75,10 @@ class AutowirePass implements CompilerPassInterface
$metadata['__construct'] = self::getResourceMetadataForMethod($constructor);
}
foreach (self::getSetters($reflectionClass) as $reflectionMethod) {
$metadata[$reflectionMethod->name] = self::getResourceMetadataForMethod($reflectionMethod);
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
if (!$reflectionMethod->isStatic()) {
$metadata[$reflectionMethod->name] = self::getResourceMetadataForMethod($reflectionMethod);
}
}
return new AutowireServiceResource($reflectionClass->name, $reflectionClass->getFileName(), $metadata);
@ -84,10 +89,11 @@ class AutowirePass implements CompilerPassInterface
*
* @param string $id
* @param Definition $definition
* @param string[] $autowiredMethods
*
* @throws RuntimeException
*/
private function completeDefinition($id, Definition $definition)
private function completeDefinition($id, Definition $definition, array $autowiredMethods)
{
if (!$reflectionClass = $this->getReflectionClass($id, $definition)) {
return;
@ -97,12 +103,75 @@ class AutowirePass implements CompilerPassInterface
$this->container->addResource(static::createResourceForClass($reflectionClass));
}
if (!$constructor = $reflectionClass->getConstructor()) {
return;
$methodsCalled = array();
foreach ($definition->getMethodCalls() as $methodCall) {
$methodsCalled[$methodCall[0]] = true;
}
$arguments = $definition->getArguments();
foreach ($constructor->getParameters() as $index => $parameter) {
foreach ($this->getMethodsToAutowire($id, $reflectionClass, $autowiredMethods) as $reflectionMethod) {
if (!isset($methodsCalled[$reflectionMethod->name])) {
$this->autowireMethod($id, $definition, $reflectionMethod);
}
}
}
/**
* Gets the list of methods to autowire.
*
* @param string $id
* @param \ReflectionClass $reflectionClass
* @param string[] $autowiredMethods
*
* @return \ReflectionMethod[]
*/
private function getMethodsToAutowire($id, \ReflectionClass $reflectionClass, array $autowiredMethods)
{
$found = array();
$regexList = array();
foreach ($autowiredMethods as $pattern) {
$regexList[] = '/^'.str_replace('\*', '.*', preg_quote($pattern, '/')).'$/i';
}
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
if ($reflectionMethod->isStatic()) {
continue;
}
foreach ($regexList as $k => $regex) {
if (preg_match($regex, $reflectionMethod->name)) {
$found[] = $autowiredMethods[$k];
yield $reflectionMethod;
continue 2;
}
}
}
if ($notFound = array_diff($autowiredMethods, $found)) {
$compiler = $this->container->getCompiler();
$compiler->addLogMessage($compiler->getLoggingFormatter()->formatUnusedAutowiringPatterns($this, $id, $notFound));
}
}
/**
* Autowires the constructor or a setter.
*
* @param string $id
* @param Definition $definition
* @param \ReflectionMethod $reflectionMethod
*
* @throws RuntimeException
*/
private function autowireMethod($id, Definition $definition, \ReflectionMethod $reflectionMethod)
{
if ($isConstructor = $reflectionMethod->isConstructor()) {
$arguments = $definition->getArguments();
} else {
$arguments = array();
}
$addMethodCall = false; // Whether the method should be added to the definition as a call or as arguments
foreach ($reflectionMethod->getParameters() as $index => $parameter) {
if (array_key_exists($index, $arguments) && '' !== $arguments[$index]) {
continue;
}
@ -111,7 +180,11 @@ class AutowirePass implements CompilerPassInterface
if (!$typeHint = $parameter->getClass()) {
// no default value? Then fail
if (!$parameter->isOptional()) {
throw new RuntimeException(sprintf('Unable to autowire argument index %d ($%s) for the service "%s". If this is an object, give it a type-hint. Otherwise, specify this argument\'s value explicitly.', $index, $parameter->name, $id));
if ($isConstructor) {
throw new RuntimeException(sprintf('Unable to autowire argument index %d ($%s) for the service "%s". If this is an object, give it a type-hint. Otherwise, specify this argument\'s value explicitly.', $index, $parameter->name, $id));
}
return;
}
// specifically pass the default value
@ -126,16 +199,23 @@ class AutowirePass implements CompilerPassInterface
if (isset($this->types[$typeHint->name])) {
$value = new Reference($this->types[$typeHint->name]);
$addMethodCall = true;
} else {
try {
$value = $this->createAutowiredDefinition($typeHint, $id);
$addMethodCall = true;
} catch (RuntimeException $e) {
if ($parameter->allowsNull()) {
$value = null;
} elseif ($parameter->isDefaultValueAvailable()) {
$value = $parameter->getDefaultValue();
} else {
throw $e;
// The exception code is set to 1 if the exception must be thrown even if it's a setter
if (1 === $e->getCode() || $isConstructor) {
throw $e;
}
return;
}
}
}
@ -143,7 +223,11 @@ class AutowirePass implements CompilerPassInterface
// Typehint against a non-existing class
if (!$parameter->isDefaultValueAvailable()) {
throw new RuntimeException(sprintf('Cannot autowire argument %s for %s because the type-hinted class does not exist (%s).', $index + 1, $definition->getClass(), $e->getMessage()), 0, $e);
if ($isConstructor) {
throw new RuntimeException(sprintf('Cannot autowire argument %s for %s because the type-hinted class does not exist (%s).', $index + 1, $definition->getClass(), $e->getMessage()), 0, $e);
}
return;
}
$value = $parameter->getDefaultValue();
@ -155,7 +239,12 @@ class AutowirePass implements CompilerPassInterface
// it's possible index 1 was set, then index 0, then 2, etc
// make sure that we re-order so they're injected as expected
ksort($arguments);
$definition->setArguments($arguments);
if ($isConstructor) {
$definition->setArguments($arguments);
} elseif ($addMethodCall) {
$definition->addMethodCall($reflectionMethod->name, $arguments);
}
}
/**
@ -253,7 +342,7 @@ class AutowirePass implements CompilerPassInterface
$classOrInterface = $typeHint->isInterface() ? 'interface' : 'class';
$matchingServices = implode(', ', $this->ambiguousServiceTypes[$typeHint->name]);
throw new RuntimeException(sprintf('Unable to autowire argument of type "%s" for the service "%s". Multiple services exist for this %s (%s).', $typeHint->name, $id, $classOrInterface, $matchingServices));
throw new RuntimeException(sprintf('Unable to autowire argument of type "%s" for the service "%s". Multiple services exist for this %s (%s).', $typeHint->name, $id, $classOrInterface, $matchingServices), 1);
}
if (!$typeHint->isInstantiable()) {
@ -269,7 +358,7 @@ class AutowirePass implements CompilerPassInterface
$this->populateAvailableType($argumentId, $argumentDefinition);
try {
$this->completeDefinition($argumentId, $argumentDefinition);
$this->completeDefinition($argumentId, $argumentDefinition, array('__construct'));
} catch (RuntimeException $e) {
$classOrInterface = $typeHint->isInterface() ? 'interface' : 'class';
$message = sprintf('Unable to autowire argument of type "%s" for the service "%s". No services were found matching this %s and it cannot be auto-registered.', $typeHint->name, $id, $classOrInterface);
@ -320,20 +409,6 @@ class AutowirePass implements CompilerPassInterface
$this->ambiguousServiceTypes[$type][] = $id;
}
/**
* @param \ReflectionClass $reflectionClass
*
* @return \ReflectionMethod[]
*/
private static function getSetters(\ReflectionClass $reflectionClass)
{
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
if (!$reflectionMethod->isStatic() && 1 === $reflectionMethod->getNumberOfParameters() && 0 === strpos($reflectionMethod->name, 'set')) {
yield $reflectionMethod;
}
}
}
private static function getResourceMetadataForMethod(\ReflectionMethod $method)
{
$methodArgumentsMetadata = array();

View File

@ -38,6 +38,11 @@ class LoggingFormatter
return $this->format($pass, sprintf('Resolving inheritance for "%s" (parent: %s).', $childId, $parentId));
}
public function formatUnusedAutowiringPatterns(CompilerPassInterface $pass, $id, array $patterns)
{
return $this->format($pass, sprintf('Autowiring\'s patterns "%s" for service "%s" don\'t match any method.', implode('", "', $patterns), $id));
}
public function format(CompilerPassInterface $pass, $message)
{
return sprintf('%s: %s', get_class($pass), $message);

View File

@ -36,7 +36,7 @@ class Definition
private $abstract = false;
private $lazy = false;
private $decoratedService;
private $autowired = false;
private $autowiredMethods = array();
private $autowiringTypes = array();
protected $arguments;
@ -662,19 +662,50 @@ class Definition
*/
public function isAutowired()
{
return $this->autowired;
return !empty($this->autowiredMethods);
}
/**
* Gets autowired methods.
*
* @return string[]
*/
public function getAutowiredMethods()
{
return $this->autowiredMethods;
}
/**
* Sets autowired.
*
* Allowed values:
* - true: constructor autowiring, same as $this->setAutowiredMethods(array('__construct'))
* - false: no autowiring, same as $this->setAutowiredMethods(array())
*
* @param bool $autowired
*
* @return Definition The current instance
*/
public function setAutowired($autowired)
{
$this->autowired = $autowired;
$this->autowiredMethods = $autowired ? array('__construct') : array();
return $this;
}
/**
* Sets autowired methods.
*
* Example of allowed value:
* - array('__construct', 'set*', 'initialize'): autowire whitelisted methods only
*
* @param string[] $autowiredMethods
*
* @return Definition The current instance
*/
public function setAutowiredMethods(array $autowiredMethods)
{
$this->autowiredMethods = $autowiredMethods;
return $this;
}

View File

@ -239,6 +239,19 @@ class XmlFileLoader extends FileLoader
$definition->addAutowiringType($type->textContent);
}
$autowireTags = array();
foreach ($this->getChildren($service, 'autowire') as $type) {
$autowireTags[] = $type->textContent;
}
if ($autowireTags) {
if ($service->hasAttribute('autowire')) {
throw new InvalidArgumentException(sprintf('The "autowire" attribute cannot be used together with "<autowire>" tags for service "%s" in %s.', (string) $service->getAttribute('id'), $file));
}
$definition->setAutowiredMethods($autowireTags);
}
if ($value = $service->getAttribute('decorates')) {
$renameId = $service->hasAttribute('decoration-inner-name') ? $service->getAttribute('decoration-inner-name') : null;
$priority = $service->hasAttribute('decoration-priority') ? $service->getAttribute('decoration-priority') : 0;

View File

@ -302,7 +302,11 @@ class YamlFileLoader extends FileLoader
}
if (isset($service['autowire'])) {
$definition->setAutowired($service['autowire']);
if (is_array($service['autowire'])) {
$definition->setAutowiredMethods($service['autowire']);
} else {
$definition->setAutowired($service['autowire']);
}
}
if (isset($service['autowiring_types'])) {

View File

@ -100,6 +100,7 @@
<xsd:element name="tag" type="tag" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="property" type="property" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="autowiring-type" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
<xsd:element name="autowire" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
</xsd:choice>
<xsd:attribute name="id" type="xsd:string" />
<xsd:attribute name="class" type="xsd:string" />

View File

@ -429,6 +429,74 @@ class AutowirePassTest extends \PHPUnit_Framework_TestCase
);
}
public function testSetterInjection()
{
$container = new ContainerBuilder();
$container->register('app_foo', Foo::class);
$container->register('app_a', A::class);
$container->register('app_collision_a', CollisionA::class);
$container->register('app_collision_b', CollisionB::class);
// manually configure *one* call, to override autowiring
$container
->register('setter_injection', SetterInjection::class)
->setAutowiredMethods(array('__construct', 'set*'))
->addMethodCall('setWithCallsConfigured', array('manual_arg1', 'manual_arg2'))
;
$pass = new AutowirePass();
$pass->process($container);
$methodCalls = $container->getDefinition('setter_injection')->getMethodCalls();
// grab the call method names
$actualMethodNameCalls = array_map(function ($call) {
return $call[0];
}, $methodCalls);
$this->assertEquals(
array('setWithCallsConfigured', 'setFoo', 'setDependencies'),
$actualMethodNameCalls
);
// test setWithCallsConfigured args
$this->assertEquals(
array('manual_arg1', 'manual_arg2'),
$methodCalls[0][1]
);
// test setFoo args
$this->assertEquals(
array(new Reference('app_foo')),
$methodCalls[1][1]
);
}
public function testExplicitMethodInjection()
{
$container = new ContainerBuilder();
$container->register('app_foo', Foo::class);
$container->register('app_a', A::class);
$container->register('app_collision_a', CollisionA::class);
$container->register('app_collision_b', CollisionB::class);
$container
->register('setter_injection', SetterInjection::class)
->setAutowiredMethods(array('setFoo', 'notASetter'))
;
$pass = new AutowirePass();
$pass->process($container);
$methodCalls = $container->getDefinition('setter_injection')->getMethodCalls();
$actualMethodNameCalls = array_map(function ($call) {
return $call[0];
}, $methodCalls);
$this->assertEquals(
array('setFoo', 'notASetter'),
$actualMethodNameCalls
);
}
/**
* @dataProvider getCreateResourceTests
*/
@ -476,6 +544,37 @@ class AutowirePassTest extends \PHPUnit_Framework_TestCase
$this->assertTrue($container->hasDefinition('bar'));
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\RuntimeException
* @expectedExceptionMessage Unable to autowire argument of type "Symfony\Component\DependencyInjection\Tests\Compiler\CollisionInterface" for the service "setter_injection_collision". Multiple services exist for this interface (c1, c2).
* @expectedExceptionCode 1
*/
public function testSetterInjectionCollisionThrowsException()
{
$container = new ContainerBuilder();
$container->register('c1', CollisionA::class);
$container->register('c2', CollisionB::class);
$aDefinition = $container->register('setter_injection_collision', SetterInjectionCollision::class);
$aDefinition->setAutowiredMethods(array('__construct', 'set*'));
$pass = new AutowirePass();
$pass->process($container);
}
public function testLogUnusedPatterns()
{
$container = new ContainerBuilder();
$definition = $container->register('foo', Foo::class);
$definition->setAutowiredMethods(array('not', 'exist*'));
$pass = new AutowirePass();
$pass->process($container);
$this->assertEquals(array(AutowirePass::class.': Autowiring\'s patterns "not", "exist*" for service "foo" don\'t match any method.'), $container->getCompiler()->getLog());
}
}
class Foo
@ -648,9 +747,74 @@ class ClassForResource
class IdenticalClassResource extends ClassForResource
{
}
class ClassChangedConstructorArgs extends ClassForResource
{
public function __construct($foo, Bar $bar, $baz)
{
}
}
class SetterInjection
{
public function setFoo(Foo $foo)
{
// should be called
}
public function setDependencies(Foo $foo, A $a)
{
// should be called
}
public function setBar()
{
// should not be called
}
public function setNotAutowireable(NotARealClass $n)
{
// should not be called
}
public function setArgCannotAutowire($foo)
{
// should not be called
}
public function setOptionalNotAutowireable(NotARealClass $n = null)
{
// should not be called
}
public function setOptionalNoTypeHint($foo = null)
{
// should not be called
}
public function setOptionalArgNoAutowireable($other = 'default_val')
{
// should not be called
}
public function setWithCallsConfigured(A $a)
{
// this method has a calls configured on it
// should not be called
}
public function notASetter(A $a)
{
// should be called only when explicitly specified
}
}
class SetterInjectionCollision
{
public function setMultipleInstancesForOneArg(CollisionInterface $collision)
{
// The CollisionInterface cannot be autowired - there are multiple
// should throw an exception
}
}

View File

@ -290,6 +290,16 @@ class DefinitionTest extends \PHPUnit_Framework_TestCase
$this->assertFalse($def->isAutowired());
$def->setAutowired(true);
$this->assertTrue($def->isAutowired());
$this->assertEquals(array('__construct'), $def->getAutowiredMethods());
$def->setAutowiredMethods(array('foo'));
$def->setAutowired(false);
$this->assertSame(array(), $def->getAutowiredMethods());
$this->assertFalse($def->isAutowired());
$def->setAutowiredMethods(array('getFoo', 'getBar'));
$this->assertEquals(array('getFoo', 'getBar'), $def->getAutowiredMethods());
$this->assertTrue($def->isAutowired());
}
public function testTypes()

View File

@ -0,0 +1,9 @@
<?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="autowire_array" class="Foo">
<autowire>set*</autowire>
<autowire>bar</autowire>
</service>
</services>
</container>

View File

@ -0,0 +1,8 @@
<?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="autowire_array" class="Foo" autowire="*">
<autowire>setFoo</autowire>
</service>
</services>
</container>

View File

@ -0,0 +1,4 @@
services:
autowire_array:
class: Foo
autowire: ['set*', 'bar']

View File

@ -554,6 +554,20 @@ class XmlFileLoaderTest extends \PHPUnit_Framework_TestCase
$loader->load('services23.xml');
$this->assertTrue($container->getDefinition('bar')->isAutowired());
$this->assertEquals(array('__construct'), $container->getDefinition('bar')->getAutowiredMethods());
$loader->load('services27.xml');
$this->assertEquals(array('set*', 'bar'), $container->getDefinition('autowire_array')->getAutowiredMethods());
}
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException
*/
public function testAutowireAttributeAndTag()
{
$container = new ContainerBuilder();
$loader = new XmlFileLoader($container, new FileLocator(self::$fixturesPath.'/xml'));
$loader->load('services28.xml');
}
/**

View File

@ -323,6 +323,10 @@ class YamlFileLoaderTest extends \PHPUnit_Framework_TestCase
$loader->load('services23.yml');
$this->assertTrue($container->getDefinition('bar_service')->isAutowired());
$this->assertEquals(array('__construct'), $container->getDefinition('bar_service')->getAutowiredMethods());
$loader->load('services27.yml');
$this->assertEquals(array('set*', 'bar'), $container->getDefinition('autowire_array')->getAutowiredMethods());
}
/**