Skip to content

Commit

Permalink
feature #36373 [DI] add syntax to stack decorators (nicolas-grekas)
Browse files Browse the repository at this point in the history
This PR was merged into the 5.1-dev branch.

Discussion
----------

[DI] add syntax to stack decorators

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fix #30599
| License       | MIT
| Doc PR        | -

Declare this:
```yaml
services:
    my_stack_of_decorators:
        stack:
            - class: App\ExternalDecorator
            - class: App\InternalDecorator
            - class: App\DecoratoredClass
```

And get this:
![image](https://user-images.githubusercontent.com/243674/78615803-b8c8e580-7872-11ea-95c2-22cb78f88ca8.png)

The PR is now ready with support for Yaml, XML and the PHP-DSL. It needs #36388, #36392 and #36389 to pass, and relates to #36390 to be DX-friendly.

The new syntax now supports composable stacks - i.e stack you can reuse in the middle of another stack.

RIP middleware, simple decorators FTW :)

From the test cases:
```yaml
services:
    reusable_stack:
        stack:
            - class: stdClass
              properties:
                  label: A
                  inner: '@.inner'
            - class: stdClass
              properties:
                  label: B
                  inner: '@.inner'

    concrete_stack:
        stack:
            - parent: reusable_stack
            - class: stdClass
              properties:
                  label: C
```

This will create a service similar to:
```php
(object) [
    'label' => 'A',
    'inner' => (object) [
        'label' => 'B',
        'inner' => (object) [
             'label' => 'C',
        ]
    ],
];
```

When used together with autowiring, this is enough to declare a stack of decorators:
```yaml
services:
    my_processing_stack:
        stack:
            - App\ExternalDecorator: ~
            - App\InternalDecorator: ~
            - App\TheDecoratedClass: ~
```

See fixtures for the other configuration formats.

See also https://twitter.com/nicolasgrekas/status/1248198573998604288

Todo:
- [x] rebase on top of #36388, #36392 and #36389 once they are merged
- [x] test declaring deeper nested stacks

Commits
-------

98eeeae [DI] add syntax to stack decorators
  • Loading branch information
fabpot committed Apr 24, 2020
2 parents 9d763e0 + 98eeeae commit d6b9011
Show file tree
Hide file tree
Showing 14 changed files with 559 additions and 62 deletions.
Expand Up @@ -38,6 +38,7 @@ class UnusedTagsPass implements CompilerPassInterface
'container.service_locator',
'container.service_locator_context',
'container.service_subscriber',
'container.stack',
'controller.argument_value_resolver',
'controller.service_arguments',
'data_collector',
Expand Down
Expand Up @@ -51,6 +51,7 @@ public function __construct()
$this->optimizationPasses = [[
new AutoAliasServicePass(),
new ValidateEnvPlaceholdersPass(),
new ResolveDecoratorStackPass(),
new ResolveChildDefinitionsPass(),
new RegisterServiceSubscribersPass(),
new ResolveParameterPlaceHoldersPass(false, false),
Expand Down
@@ -0,0 +1,127 @@
<?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\Alias;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Exception\ServiceCircularReferenceException;
use Symfony\Component\DependencyInjection\Reference;

/**
* @author Nicolas Grekas <p@tchwork.com>
*/
class ResolveDecoratorStackPass implements CompilerPassInterface
{
private $tag;

public function __construct(string $tag = 'container.stack')
{
$this->tag = $tag;
}

public function process(ContainerBuilder $container)
{
$stacks = [];

foreach ($container->findTaggedServiceIds($this->tag) as $id => $tags) {
$definition = $container->getDefinition($id);

if (!$definition instanceof ChildDefinition) {
throw new InvalidArgumentException(sprintf('Invalid service "%s": only definitions with a "parent" can have the "%s" tag.', $id, $this->tag));
}

if (!$stack = $definition->getArguments()) {
throw new InvalidArgumentException(sprintf('Invalid service "%s": the stack of decorators is empty.', $id));
}

$stacks[$id] = $stack;
}

if (!$stacks) {
return;
}

$resolvedDefinitions = [];

foreach ($container->getDefinitions() as $id => $definition) {
if (!isset($stacks[$id])) {
$resolvedDefinitions[$id] = $definition;
continue;
}

foreach (array_reverse($this->resolveStack($stacks, [$id]), true) as $k => $v) {
$resolvedDefinitions[$k] = $v;
}

$alias = $container->setAlias($id, $k);

if ($definition->getChanges()['public'] ?? false) {
$alias->setPublic($definition->isPublic());
}

if ($definition->isDeprecated()) {
$alias->setDeprecated(...array_values($definition->getDeprecation('%alias_id%')));
}
}

$container->setDefinitions($resolvedDefinitions);
}

private function resolveStack(array $stacks, array $path): array
{
$definitions = [];
$id = end($path);
$prefix = '.'.$id.'.';

if (!isset($stacks[$id])) {
return [$id => new ChildDefinition($id)];
}

if (key($path) !== $searchKey = array_search($id, $path)) {
throw new ServiceCircularReferenceException($id, \array_slice($path, $searchKey));
}

foreach ($stacks[$id] as $k => $definition) {
if ($definition instanceof ChildDefinition && isset($stacks[$definition->getParent()])) {
$path[] = $definition->getParent();
$definition = unserialize(serialize($definition)); // deep clone
} elseif ($definition instanceof Definition) {
$definitions[$decoratedId = $prefix.$k] = $definition;
continue;
} elseif ($definition instanceof Reference || $definition instanceof Alias) {
$path[] = (string) $definition;
} else {
throw new InvalidArgumentException(sprintf('Invalid service "%s": unexpected value of type "%s" found in the stack of decorators.', $id, get_debug_type($definition)));
}

$p = $prefix.$k;

foreach ($this->resolveStack($stacks, $path) as $k => $v) {
$definitions[$decoratedId = $p.$k] = $definition instanceof ChildDefinition ? $definition->setParent($k) : new ChildDefinition($k);
$definition = null;
}
array_pop($path);
}

if (1 === \count($path)) {
foreach ($definitions as $k => $definition) {
$definition->setPublic(false)->setTags([])->setDecoratedService($decoratedId);
}
$definition->setDecoratedService(null);
}

return $definitions;
}
}
Expand Up @@ -81,6 +81,18 @@ final public function get(string $id): ServiceConfigurator
return $this->parent->get($id);
}

/**
* Registers a stack of decorator services.
*
* @param InlineServiceConfigurator[]|ReferenceConfigurator[] $services
*/
final public function stack(string $id, array $services): AliasConfigurator
{
$this->__destruct();

return $this->parent->stack($id, $services);
}

/**
* Registers a service.
*/
Expand Down
Expand Up @@ -15,6 +15,7 @@
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;

Expand Down Expand Up @@ -131,6 +132,39 @@ final public function get(string $id): ServiceConfigurator
return new ServiceConfigurator($this->container, $definition->getInstanceofConditionals(), true, $this, $definition, $id, []);
}

/**
* Registers a stack of decorator services.
*
* @param InlineServiceConfigurator[]|ReferenceConfigurator[] $services
*/
final public function stack(string $id, array $services): AliasConfigurator
{
foreach ($services as $i => $service) {
if ($service instanceof InlineServiceConfigurator) {
$definition = $service->definition->setInstanceofConditionals($this->instanceof);

$changes = $definition->getChanges();
$definition->setAutowired((isset($changes['autowired']) ? $definition : $this->defaults)->isAutowired());
$definition->setAutoconfigured((isset($changes['autoconfigured']) ? $definition : $this->defaults)->isAutoconfigured());
$definition->setBindings(array_merge($this->defaults->getBindings(), $definition->getBindings()));
$definition->setChanges($changes);

$services[$i] = $definition;
} elseif (!$service instanceof ReferenceConfigurator) {
throw new InvalidArgumentException(sprintf('"%s()" expects a list of definitions as returned by "%s()" or "%s()", "%s" given at index "%s" for service "%s".', __METHOD__, InlineServiceConfigurator::FACTORY, ReferenceConfigurator::FACTORY, $service instanceof AbstractConfigurator ? $service::FACTORY.'()' : get_debug_type($service)), $i, $id);
}
}

$alias = $this->alias($id, '');
$alias->definition = $this->set($id)
->parent('')
->args($services)
->tag('container.stack')
->definition;

return $alias;
}

/**
* Registers a service.
*/
Expand Down
99 changes: 43 additions & 56 deletions src/Symfony/Component/DependencyInjection/Loader/XmlFileLoader.php
Expand Up @@ -112,12 +112,12 @@ private function parseImports(\DOMDocument $xml, string $file)
}
}

private function parseDefinitions(\DOMDocument $xml, string $file, array $defaults)
private function parseDefinitions(\DOMDocument $xml, string $file, Definition $defaults)
{
$xpath = new \DOMXPath($xml);
$xpath->registerNamespace('container', self::NS);

if (false === $services = $xpath->query('//container:services/container:service|//container:services/container:prototype')) {
if (false === $services = $xpath->query('//container:services/container:service|//container:services/container:prototype|//container:services/container:stack')) {
return;
}
$this->setCurrentDir(\dirname($file));
Expand All @@ -126,12 +126,34 @@ private function parseDefinitions(\DOMDocument $xml, string $file, array $defaul
$this->isLoadingInstanceof = true;
$instanceof = $xpath->query('//container:services/container:instanceof');
foreach ($instanceof as $service) {
$this->setDefinition((string) $service->getAttribute('id'), $this->parseDefinition($service, $file, []));
$this->setDefinition((string) $service->getAttribute('id'), $this->parseDefinition($service, $file, new Definition()));
}

$this->isLoadingInstanceof = false;
foreach ($services as $service) {
if (null !== $definition = $this->parseDefinition($service, $file, $defaults)) {
if ('stack' === $service->tagName) {
$service->setAttribute('parent', '-');
$definition = $this->parseDefinition($service, $file, $defaults)
->setTags(array_merge_recursive(['container.stack' => [[]]], $defaults->getTags()))
;
$this->setDefinition($id = (string) $service->getAttribute('id'), $definition);
$stack = [];

foreach ($this->getChildren($service, 'service') as $k => $frame) {
$k = $frame->getAttribute('id') ?: $k;
$frame->setAttribute('id', $id.'" at index "'.$k);

if ($alias = $frame->getAttribute('alias')) {
$this->validateAlias($frame, $file);
$stack[$k] = new Reference($alias);
} else {
$stack[$k] = $this->parseDefinition($frame, $file, $defaults)
->setInstanceofConditionals($this->instanceof);
}
}

$definition->setArguments($stack);
} elseif (null !== $definition = $this->parseDefinition($service, $file, $defaults)) {
if ('prototype' === $service->tagName) {
$excludes = array_column($this->getChildren($service, 'exclude'), 'nodeValue');
if ($service->hasAttribute('exclude')) {
Expand All @@ -148,60 +170,33 @@ private function parseDefinitions(\DOMDocument $xml, string $file, array $defaul
}
}

/**
* Get service defaults.
*/
private function getServiceDefaults(\DOMDocument $xml, string $file): array
private function getServiceDefaults(\DOMDocument $xml, string $file): Definition
{
$xpath = new \DOMXPath($xml);
$xpath->registerNamespace('container', self::NS);

if (null === $defaultsNode = $xpath->query('//container:services/container:defaults')->item(0)) {
return [];
}

$bindings = [];
foreach ($this->getArgumentsAsPhp($defaultsNode, 'bind', $file) as $argument => $value) {
$bindings[$argument] = new BoundArgument($value, true, BoundArgument::DEFAULTS_BINDING, $file);
return new Definition();
}

$defaults = [
'tags' => $this->getChildren($defaultsNode, 'tag'),
'bind' => $bindings,
];

foreach ($defaults['tags'] as $tag) {
if ('' === $tag->getAttribute('name')) {
throw new InvalidArgumentException(sprintf('The tag name for tag "<defaults>" in "%s" must be a non-empty string.', $file));
}
}
$defaultsNode->setAttribute('id', '<defaults>');

if ($defaultsNode->hasAttribute('autowire')) {
$defaults['autowire'] = XmlUtils::phpize($defaultsNode->getAttribute('autowire'));
}
if ($defaultsNode->hasAttribute('public')) {
$defaults['public'] = XmlUtils::phpize($defaultsNode->getAttribute('public'));
}
if ($defaultsNode->hasAttribute('autoconfigure')) {
$defaults['autoconfigure'] = XmlUtils::phpize($defaultsNode->getAttribute('autoconfigure'));
}

return $defaults;
return $this->parseDefinition($defaultsNode, $file, new Definition());
}

/**
* Parses an individual Definition.
*/
private function parseDefinition(\DOMElement $service, string $file, array $defaults): ?Definition
private function parseDefinition(\DOMElement $service, string $file, Definition $defaults): ?Definition
{
if ($alias = $service->getAttribute('alias')) {
$this->validateAlias($service, $file);

$this->container->setAlias((string) $service->getAttribute('id'), $alias = new Alias($alias));
if ($publicAttr = $service->getAttribute('public')) {
$alias->setPublic(XmlUtils::phpize($publicAttr));
} elseif (isset($defaults['public'])) {
$alias->setPublic($defaults['public']);
} elseif ($defaults->getChanges()['public'] ?? false) {
$alias->setPublic($defaults->isPublic());
}

if ($deprecated = $this->getChildren($service, 'deprecated')) {
Expand Down Expand Up @@ -231,16 +226,11 @@ private function parseDefinition(\DOMElement $service, string $file, array $defa
$definition = new Definition();
}

if (isset($defaults['public'])) {
$definition->setPublic($defaults['public']);
if ($defaults->getChanges()['public'] ?? false) {
$definition->setPublic($defaults->isPublic());
}
if (isset($defaults['autowire'])) {
$definition->setAutowired($defaults['autowire']);
}
if (isset($defaults['autoconfigure'])) {
$definition->setAutoconfigured($defaults['autoconfigure']);
}

$definition->setAutowired($defaults->isAutowired());
$definition->setAutoconfigured($defaults->isAutoconfigured());
$definition->setChanges([]);

foreach (['class', 'public', 'shared', 'synthetic', 'abstract'] as $key) {
Expand Down Expand Up @@ -324,10 +314,6 @@ private function parseDefinition(\DOMElement $service, string $file, array $defa

$tags = $this->getChildren($service, 'tag');

if (!empty($defaults['tags'])) {
$tags = array_merge($tags, $defaults['tags']);
}

foreach ($tags as $tag) {
$parameters = [];
foreach ($tag->attributes as $name => $node) {
Expand All @@ -349,16 +335,17 @@ private function parseDefinition(\DOMElement $service, string $file, array $defa
$definition->addTag($tag->getAttribute('name'), $parameters);
}

$definition->setTags(array_merge_recursive($definition->getTags(), $defaults->getTags()));

$bindings = $this->getArgumentsAsPhp($service, 'bind', $file);
$bindingType = $this->isLoadingInstanceof ? BoundArgument::INSTANCEOF_BINDING : BoundArgument::SERVICE_BINDING;
foreach ($bindings as $argument => $value) {
$bindings[$argument] = new BoundArgument($value, true, $bindingType, $file);
}

if (isset($defaults['bind'])) {
// deep clone, to avoid multiple process of the same instance in the passes
$bindings = array_merge(unserialize(serialize($defaults['bind'])), $bindings);
}
// deep clone, to avoid multiple process of the same instance in the passes
$bindings = array_merge(unserialize(serialize($defaults->getBindings())), $bindings);

if ($bindings) {
$definition->setBindings($bindings);
}
Expand Down Expand Up @@ -443,7 +430,7 @@ private function processAnonymousServices(\DOMDocument $xml, string $file)
// resolve definitions
uksort($definitions, 'strnatcmp');
foreach (array_reverse($definitions) as $id => list($domElement, $file)) {
if (null !== $definition = $this->parseDefinition($domElement, $file, [])) {
if (null !== $definition = $this->parseDefinition($domElement, $file, new Definition())) {
$this->setDefinition($id, $definition);
}
}
Expand Down

0 comments on commit d6b9011

Please sign in to comment.