feature #24290 Adding Definition::addError() and a compiler pass to throw errors as exceptions (weaverryan)

This PR was squashed before being merged into the 3.4 branch (closes #24290).

Discussion
----------

Adding Definition::addError() and a compiler pass to throw errors as exceptions

| Q             | A
| ------------- | ---
| Branch?       | 3.4
| Bug fix?      | yes & no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | yes (very minor)
| Tests pass?   | yes
| Fixed tickets | #23606
| License       | MIT
| Doc PR        | Not needed

Hi guys!

Very simple: when there is an error with a Definition, we can now call `Definition::addError()` instead of throwing an exception. Then, a new compiler pass (after removal) actually throws an exception. The advantage is that we can avoid throwing exceptions for services that are ultimately removed from the container. That's important for auto-registration, where we commonly register all services in `src/`... but then many of them are removed later.

A few interesting notes:
- We can probably convert more things from exceptions to `Definition::addError()`. I've only converted autowiring errors and things in `CheckArgumentsValidityPass` (that was necessary because it was throwing exceptions in some cases due to autowiring failing... which was the true error)
- `Definition` can hold multiple errors, but I'm only showing the first error in the exception message. The reason is clarity: I think usually the first error is the most (or only) important. But having `Definition::addError()` avoids the possibility of a later error overriding an earlier one

Cheers!

Commits
-------

a85b37a Adding Definition::addError() and a compiler pass to throw errors as exceptions
This commit is contained in:
Nicolas Grekas 2017-09-25 11:29:01 +02:00
commit 8136fa5050
14 changed files with 194 additions and 9 deletions

View File

@ -22,6 +22,9 @@ use Symfony\Component\DependencyInjection\Reference;
*/
abstract class AbstractRecursivePass implements CompilerPassInterface
{
/**
* @var ContainerBuilder
*/
protected $container;
protected $currentId;

View File

@ -11,11 +11,15 @@
namespace Symfony\Component\DependencyInjection\Compiler;
@trigger_error('The '.__NAMESPACE__.'\AutowireExceptionPass class is deprecated since version 3.4 and will be removed in 4.0. Use the DefinitionErrorExceptionPass class instead.', E_USER_DEPRECATED);
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* Throws autowire exceptions from AutowirePass for definitions that still exist.
*
* @deprecated since version 3.4, will be removed in 4.0.
*
* @author Ryan Weaver <ryan@knpuniversity.com>
*/
class AutowireExceptionPass implements CompilerPassInterface

View File

@ -36,7 +36,7 @@ class AutowirePass extends AbstractRecursivePass
private $autowiringExceptions = array();
/**
* @param bool $throwOnAutowireException If false, retrieved errors via getAutowiringExceptions
* @param bool $throwOnAutowireException Errors can be retrieved via Definition::getErrors()
*/
public function __construct($throwOnAutowireException = true)
{
@ -44,10 +44,14 @@ class AutowirePass extends AbstractRecursivePass
}
/**
* @deprecated since version 3.4, to be removed in 4.0.
*
* @return AutowiringFailedException[]
*/
public function getAutowiringExceptions()
{
@trigger_error('Calling AutowirePass::getAutowiringExceptions() is deprecated since Symfony 3.4 and will be removed in 4.0. Use Definition::getErrors() instead.', E_USER_DEPRECATED);
return $this->autowiringExceptions;
}
@ -106,6 +110,7 @@ class AutowirePass extends AbstractRecursivePass
}
$this->autowiringExceptions[] = $e;
$this->container->getDefinition($this->currentId)->addError($e->getMessage());
return parent::processValue($value, $isRoot);
}

View File

@ -22,6 +22,13 @@ use Symfony\Component\DependencyInjection\Exception\RuntimeException;
*/
class CheckArgumentsValidityPass extends AbstractRecursivePass
{
private $throwExceptions;
public function __construct($throwExceptions = true)
{
$this->throwExceptions = $throwExceptions;
}
/**
* {@inheritdoc}
*/
@ -35,10 +42,20 @@ class CheckArgumentsValidityPass extends AbstractRecursivePass
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));
$msg = sprintf('Invalid constructor argument for service "%s": integer expected but found string "%s". Check your service definition.', $this->currentId, $k);
$value->addError($msg);
if ($this->throwExceptions) {
throw new RuntimeException($msg);
}
break;
}
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));
$msg = sprintf('Invalid constructor argument %d for service "%s": argument %d must be defined before. Check your service definition.', 1 + $k, $this->currentId, $i);
$value->addError($msg);
if ($this->throwExceptions) {
throw new RuntimeException($msg);
}
}
}
@ -47,10 +64,20 @@ class CheckArgumentsValidityPass extends AbstractRecursivePass
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));
$msg = 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);
$value->addError($msg);
if ($this->throwExceptions) {
throw new RuntimeException($msg);
}
break;
}
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));
$msg = 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);
$value->addError($msg);
if ($this->throwExceptions) {
throw new RuntimeException($msg);
}
}
}
}

View File

@ -0,0 +1,39 @@
<?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;
/**
* Throws an exception for any Definitions that have errors and still exist.
*
* @author Ryan Weaver <ryan@knpuniversity.com>
*/
class DefinitionErrorExceptionPass extends AbstractRecursivePass
{
/**
* {@inheritdoc}
*/
protected function processValue($value, $isRoot = false)
{
if (!$value instanceof Definition || empty($value->getErrors())) {
return parent::processValue($value, $isRoot);
}
// only show the first error so they user can focus on it
$errors = $value->getErrors();
$message = reset($errors);
throw new RuntimeException($message);
}
}

View File

@ -38,10 +38,14 @@ class InlineServiceDefinitionsPass extends AbstractRecursivePass implements Repe
*
* The key is the inlined service id and its value is the list of services it was inlined into.
*
* @deprecated since version 3.4, to be removed in 4.0.
*
* @return array
*/
public function getInlinedServiceIds()
{
@trigger_error('Calling InlineServiceDefinitionsPass::getInlinedServiceIds() is deprecated since Symfony 3.4 and will be removed in 4.0.', E_USER_DEPRECATED);
return $this->inlinedServiceIds;
}

View File

@ -60,14 +60,14 @@ class PassConfig
new ResolveNamedArgumentsPass(),
new AutowireRequiredMethodsPass(),
new ResolveBindingsPass(),
$autowirePass = new AutowirePass(false),
new AutowirePass(false),
new ResolveServiceSubscribersPass(),
new ResolveReferencesToAliasesPass(),
new ResolveInvalidReferencesPass(),
new AnalyzeServiceReferencesPass(true),
new CheckCircularReferencesPass(),
new CheckReferenceValidityPass(),
new CheckArgumentsValidityPass(),
new CheckArgumentsValidityPass(false),
));
$this->removingPasses = array(array(
@ -76,11 +76,11 @@ class PassConfig
new RemoveAbstractDefinitionsPass(),
new RepeatedPass(array(
new AnalyzeServiceReferencesPass(),
$inlinedServicePass = new InlineServiceDefinitionsPass(),
new InlineServiceDefinitionsPass(),
new AnalyzeServiceReferencesPass(),
new RemoveUnusedDefinitionsPass(),
)),
new AutowireExceptionPass($autowirePass, $inlinedServicePass),
new DefinitionErrorExceptionPass(),
new CheckExceptionOnInvalidReferenceBehaviorPass(),
));
}

View File

@ -44,6 +44,7 @@ class Definition
private $autowiringTypes = array();
private $changes = array();
private $bindings = array();
private $errors = array();
protected $arguments = array();
@ -959,4 +960,24 @@ class Definition
return $this;
}
/**
* Add an error that occurred when building this Definition.
*
* @param string $error
*/
public function addError($error)
{
$this->errors[] = $error;
}
/**
* Returns any errors that occurred while building this Definition.
*
* @return array
*/
public function getErrors()
{
return $this->errors;
}
}

View File

@ -18,6 +18,9 @@ use Symfony\Component\DependencyInjection\Compiler\InlineServiceDefinitionsPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\AutowiringFailedException;
/**
* @group legacy
*/
class AutowireExceptionPassTest extends TestCase
{
public function testThrowsException()

View File

@ -160,6 +160,9 @@ class AutowirePassTest extends TestCase
$this->assertEquals(DInterface::class, (string) $container->getDefinition('h')->getArgument(1));
}
/**
* @group legacy
*/
public function testExceptionsAreStored()
{
$container = new ContainerBuilder();

View File

@ -64,4 +64,15 @@ class CheckArgumentsValidityPassTest extends TestCase
array(array(), array(array('baz', array(1 => 1)))),
);
}
public function testNoException()
{
$container = new ContainerBuilder();
$definition = $container->register('foo');
$definition->setArguments(array(null, 'a' => 'a'));
$pass = new CheckArgumentsValidityPass(false);
$pass->process($container);
$this->assertCount(1, $definition->getErrors());
}
}

View File

@ -0,0 +1,53 @@
<?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 PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\Compiler\DefinitionErrorExceptionPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
class DefinitionErrorExceptionPassTest extends TestCase
{
/**
* @expectedException \Symfony\Component\DependencyInjection\Exception\RuntimeException
* @expectedExceptionMessage Things went wrong!
*/
public function testThrowsException()
{
$container = new ContainerBuilder();
$def = new Definition();
$def->addError('Things went wrong!');
$def->addError('Now something else!');
$container->register('foo_service_id')
->setArguments(array(
$def,
));
$pass = new DefinitionErrorExceptionPass();
$pass->process($container);
}
public function testNoExceptionThrown()
{
$container = new ContainerBuilder();
$def = new Definition();
$container->register('foo_service_id')
->setArguments(array(
$def,
));
$pass = new DefinitionErrorExceptionPass();
$pass->process($container);
$this->assertSame($def, $container->getDefinition('foo_service_id')->getArgument(0));
}
}

View File

@ -252,6 +252,9 @@ class InlineServiceDefinitionsPassTest extends TestCase
$this->assertSame('inline', (string) $values[0]);
}
/**
* @group legacy
*/
public function testGetInlinedServiceIdData()
{
$container = new ContainerBuilder();

View File

@ -387,4 +387,13 @@ class DefinitionTest extends TestCase
$def->setAutoconfigured(true);
$this->assertTrue($def->isAutoconfigured());
}
public function testAddError()
{
$def = new Definition('stdClass');
$this->assertEmpty($def->getErrors());
$def->addError('First error');
$def->addError('Second error');
$this->assertSame(array('First error', 'Second error'), $def->getErrors());
}
}