feature #40600 [Config][DependencyInjection] Add configuration builder for writing PHP config (Nyholm)

This PR was squashed before being merged into the 5.3-dev branch.

Discussion
----------

[Config][DependencyInjection] Add configuration builder for writing PHP config

| Q             | A
| ------------- | ---
| Branch?       | 5.x
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       |
| License       | MIT
| Doc PR        | https://github.com/symfony/symfony-docs/pull/15181

I've spent most part of today to generate this PR. It is far from complete but it is ready for review.

This PR will build classes and store them in the build_dir. The classes will help you write PHP config. It will basically generate an array for you.

### Before

```php
// config/packages/security.php
<?php

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $container) {
  $array = [
        'firewalls' => [
            'main' => [
                'pattern' => '^/*',
                'lazy' => true,
                'anonymous' => [],
            ],
        ],
        'access_control' => [
            [
                'path' => '^/user',
                'roles' => [
                    0 => 'ROLE_USER',
                ],
            ],
            [
                'path' => '^/admin',
                'roles' => 'ROLE_ADMIN',
            ],
        ],
        'role_hierarchy' => [
            'ROLE_ADMIN' => ['ROLE_USER'],
            'ROLE_SUPER_ADMIN' => ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH',
            ],
        ],
    ];

    $container->extension('security', $array);
}
```

### After
```php
// config/packages/security.php
<?php

use Symfony\Config\SecurityConfig;

return static function (SecurityConfig $security) {
    $security
        ->roleHierarchy('ROLE_ADMIN', ['ROLE_USER'])
        ->roleHierarchy('ROLE_SUPER_ADMIN', ['ROLE_ADMIN', 'ROLE_ALLOWED_TO_SWITCH'])
        ->accessControl()
            ->path('^/user')
            ->role('ROLE_USER');

    $security->accessControl(['path' => '^/admin', 'roles' => 'ROLE_ADMIN']);
    $security->firewall('main')
        ->pattern('^/*')
        ->lazy(true)
        ->anonymous();

};
```

### About autogeneration

This PR is generating the extension's `ConfigBuilder`s when they are first used. Since the PR is already very large, I prefer to follow up with additional PRs to include a cache warmer and command to rebuild the `ConfigBuilder`s.

The generated `ConfigBuilder` uses a "ucfirst() camelCased" extension alias. If the alias is `acme_foo` the root `ConfigBuilder` will be `Symfony\Config\AcmeFooConfig`.

The recommended way of using this class is:

```php
// config/packages/acme_foo.php
use Symfony\Config\AcmeFooConfig;

return static function (AcmeFooConfig $foo) {
  // ...
  // No need to return
}
```

One may also init the class directly, But this will not help you with generation or autoloading

```php
// config/packages/acme_foo.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $container) {
  $foo = new \Symfony\Config\AcmeFooConfig();
  // ...
  $container->extension('acme_foo', $foo->toArray());
}
```

**I do think we should only talk about the first way**

If a third party bundle like this idea and want to provide their own `ConfigBuilder`, they have two options:

1) Create the class `Symfony\Config\TheBundleConfig` themselves and make sure they configure composer to autoload that file and that the class implements `ConfigBuilderInterface`. We will never regenerate a file that already exists.

2) Create any class implementing `ConfigBuilderInterface` and ask their users to use that class in their config in the same way they would use `Symfony\Config\TheBundleConfig`.

The first way is obviously the smoothest way of doing things.

### BC

There is a great discussion about backwards compatibility for the generated files. We can assure that the class generator don't introduce a BC break with our tests. However, if the bundle changes their configuration definition it may break BC. Things like renaming, changing type or changing a single value to array is obvious BC breaks, however, these can be fixed in the config definition with normalisation.

The generator does not support normalisation. It is way way more complicated to reverse engineer that. I think a future update could fix this in one of two ways:
1) Add extra definition rules to help the class generator
2) Allow the bundle to patch part of the generated code

I hate BC breaks as much as the next person, but all the BC breaks in the generated classes will be caught when building the container (not at runtime), so I am fine with not having a 100% complete solution for this issue in the initial PR.

### Other limitations

If a bundle is using a custom extension alias, then we cannot guess it.. so a user have to use a cache warmer because we cannot generate the `ConfigBuilder` on the fly.

### TODO

- [x] Add tests
- [x] Update changelog
- [x] Write documentation

-------------

The generated code can be found in this example app: https://github.com/Nyholm/sf-issue-40600/tree/main/var/cache/dev/Symfony/Config

Commits
-------

460b46f730 [Config][DependencyInjection] Add configuration builder for writing PHP config
This commit is contained in:
Nicolas Grekas 2021-04-13 18:59:17 +02:00
commit 44bb6918ad
28 changed files with 1290 additions and 3 deletions

View File

@ -0,0 +1,155 @@
<?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\Config\Builder;
/**
* Build PHP classes to generate config.
*
* @internal
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class ClassBuilder
{
/** @var string */
private $namespace;
/** @var string */
private $name;
/** @var Property[] */
private $properties = [];
/** @var Method[] */
private $methods = [];
private $require = [];
private $implements = [];
public function __construct(string $namespace, string $name)
{
$this->namespace = $namespace;
$this->name = ucfirst($this->camelCase($name)).'Config';
}
public function getDirectory(): string
{
return str_replace('\\', \DIRECTORY_SEPARATOR, $this->namespace);
}
public function getFilename(): string
{
return $this->name.'.php';
}
public function build(): string
{
$rootPath = explode(\DIRECTORY_SEPARATOR, $this->getDirectory());
$require = '';
foreach ($this->require as $class) {
// figure out relative path.
$path = explode(\DIRECTORY_SEPARATOR, $class->getDirectory());
$path[] = $class->getFilename();
foreach ($rootPath as $key => $value) {
if ($path[$key] !== $value) {
break;
}
unset($path[$key]);
}
$require .= sprintf('require_once __DIR__.\'%s\';', \DIRECTORY_SEPARATOR.implode(\DIRECTORY_SEPARATOR, $path))."\n";
}
$implements = [] === $this->implements ? '' : 'implements '.implode(', ', $this->implements);
$body = '';
foreach ($this->properties as $property) {
$body .= ' '.$property->getContent()."\n";
}
foreach ($this->methods as $method) {
$lines = explode("\n", $method->getContent());
foreach ($lines as $i => $line) {
$body .= ' '.$line."\n";
}
}
$content = strtr('<?php
namespace NAMESPACE;
REQUIRE
/**
* This class is automatically generated to help creating config.
*
* @experimental in 5.3
*/
class CLASS IMPLEMENTS
{
BODY
}
', ['NAMESPACE' => $this->namespace, 'REQUIRE' => $require, 'CLASS' => $this->getName(), 'IMPLEMENTS' => $implements, 'BODY' => $body]);
return $content;
}
public function addRequire(self $class)
{
$this->require[] = $class;
}
public function addImplements(string $interface)
{
$this->implements[] = '\\'.ltrim($interface, '\\');
}
public function addMethod(string $name, string $body, array $params = []): void
{
$this->methods[] = new Method(strtr($body, ['NAME' => $this->camelCase($name)] + $params));
}
public function addProperty(string $name, string $classType = null): Property
{
$property = new Property($name, $this->camelCase($name));
if (null !== $classType) {
$property->setType($classType);
}
$this->properties[] = $property;
$property->setContent(sprintf('private $%s;', $property->getName()));
return $property;
}
public function getProperties(): array
{
return $this->properties;
}
private function camelCase(string $input): string
{
$output = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $input))));
return preg_replace('#\W#', '', $output);
}
public function getName(): string
{
return $this->name;
}
public function getNamespace(): string
{
return $this->namespace;
}
public function getFqcn()
{
return '\\'.$this->namespace.'\\'.$this->name;
}
}

View File

@ -0,0 +1,391 @@
<?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\Config\Builder;
use Symfony\Component\Config\Definition\ArrayNode;
use Symfony\Component\Config\Definition\BooleanNode;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\EnumNode;
use Symfony\Component\Config\Definition\FloatNode;
use Symfony\Component\Config\Definition\IntegerNode;
use Symfony\Component\Config\Definition\NodeInterface;
use Symfony\Component\Config\Definition\PrototypedArrayNode;
use Symfony\Component\Config\Definition\ScalarNode;
use Symfony\Component\Config\Definition\VariableNode;
/**
* Generate ConfigBuilders to help create valid config.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class ConfigBuilderGenerator implements ConfigBuilderGeneratorInterface
{
private $classes;
private $outputDir;
public function __construct(string $outputDir)
{
$this->outputDir = $outputDir;
}
/**
* @return \Closure that will return the root config class
*/
public function build(ConfigurationInterface $configuration): \Closure
{
$this->classes = [];
$rootNode = $configuration->getConfigTreeBuilder()->buildTree();
$rootClass = new ClassBuilder('Symfony\\Config', $rootNode->getName());
$rootClass->addImplements(ConfigBuilderInterface::class);
$this->classes[] = $rootClass;
$this->buildNode($rootNode, $rootClass, $this->getSubNamespace($rootClass));
$rootClass->addMethod('getExtensionAlias', '
public function NAME(): string
{
return \'ALIAS\';
}
', ['ALIAS' => $rootNode->getPath()]);
$this->writeClasses($outputDir = $this->outputDir);
$loader = \Closure::fromCallable(function () use ($outputDir, $rootClass) {
$str = $outputDir.\DIRECTORY_SEPARATOR.$rootClass->getDirectory().\DIRECTORY_SEPARATOR.$rootClass->getFilename();
require_once $str;
$className = $rootClass->getFqcn();
return new $className();
});
return $loader;
}
private function writeClasses(string $outputDir)
{
foreach ($this->classes as $class) {
$this->buildConstructor($class);
$this->buildToArray($class);
$dir = $outputDir.\DIRECTORY_SEPARATOR.$class->getDirectory();
@mkdir($dir, 0777, true);
file_put_contents($dir.\DIRECTORY_SEPARATOR.$class->getFilename(), $class->build());
}
$this->classes = [];
}
private function buildNode(NodeInterface $node, ClassBuilder $class, string $namespace)
{
if (!$node instanceof ArrayNode) {
throw new \LogicException('The node was expected to be an ArrayNode. This Configuration includes an edge case not supported yet.');
}
foreach ($node->getChildren() as $child) {
switch (true) {
case $child instanceof ScalarNode:
$this->handleScalarNode($child, $class);
break;
case $child instanceof PrototypedArrayNode:
$this->handlePrototypedArrayNode($child, $class, $namespace);
break;
case $child instanceof VariableNode:
$this->handleVariableNode($child, $class);
break;
case $child instanceof ArrayNode:
$this->handleArrayNode($child, $class, $namespace);
break;
default:
throw new \RuntimeException(sprintf('Unknown node "%s".', \get_class($child)));
}
}
}
private function handleArrayNode(ArrayNode $node, ClassBuilder $class, string $namespace)
{
$childClass = new ClassBuilder($namespace, $node->getName());
$class->addRequire($childClass);
$this->classes[] = $childClass;
$property = $class->addProperty($node->getName(), $childClass->getName());
$body = '
public function NAME(array $value = []): CLASS
{
if (null === $this->PROPERTY) {
$this->PROPERTY = new CLASS($value);
} elseif ([] !== $value) {
throw new \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException(sprintf(\'The node created by "NAME()" has already been initialized. You cannot pass values the second time you call NAME().\'));
}
return $this->PROPERTY;
}';
$class->addMethod($node->getName(), $body, ['PROPERTY' => $property->getName(), 'CLASS' => $childClass->getFqcn()]);
$this->buildNode($node, $childClass, $this->getSubNamespace($childClass));
}
private function handleVariableNode(VariableNode $node, ClassBuilder $class)
{
$comment = $this->getComment($node);
$property = $class->addProperty($node->getName());
$body = '
/**
COMMENT * @return $this
*/
public function NAME($valueDEFAULT): self
{
$this->PROPERTY = $value;
return $this;
}';
$class->addMethod($node->getName(), $body, ['PROPERTY' => $property->getName(), 'COMMENT' => $comment, 'DEFAULT' => $node->hasDefaultValue() ? ' = '.var_export($node->getDefaultValue(), true) : '']);
}
private function handlePrototypedArrayNode(PrototypedArrayNode $node, ClassBuilder $class, string $namespace)
{
$name = $this->getSingularName($node);
$prototype = $node->getPrototype();
$methodName = $name;
$parameterType = $this->getParameterType($prototype);
if (null !== $parameterType || $prototype instanceof ScalarNode) {
$property = $class->addProperty($node->getName());
if (null === $key = $node->getKeyAttribute()) {
$body = '
/**
* @return $this
*/
public function NAME(TYPE$value): self
{
$this->PROPERTY = $value;
return $this;
}';
$class->addMethod($methodName, $body, ['PROPERTY' => $property->getName(), 'TYPE' => '' === $parameterType ? '' : $parameterType.' ']);
} else {
$body = '
/**
* @return $this
*/
public function NAME(string $VAR, TYPE$VALUE): self
{
$this->PROPERTY[$VAR] = $VALUE;
return $this;
}';
$class->addMethod($methodName, $body, ['PROPERTY' => $property->getName(), 'TYPE' => '' === $parameterType ? '' : $parameterType.' ', 'VAR' => '' === $key ? 'key' : $key, 'VALUE' => 'value' === $key ? 'data' : 'value']);
}
return;
}
$childClass = new ClassBuilder($namespace, $name);
$class->addRequire($childClass);
$this->classes[] = $childClass;
$property = $class->addProperty($node->getName(), $childClass->getName().'[]');
if (null === $key = $node->getKeyAttribute()) {
$body = '
public function NAME(array $value = []): CLASS
{
return $this->PROPERTY[] = new CLASS($value);
}';
$class->addMethod($methodName, $body, ['PROPERTY' => $property->getName(), 'CLASS' => $childClass->getFqcn()]);
} else {
$body = '
public function NAME(string $VAR, array $VALUE = []): CLASS
{
if (!isset($this->PROPERTY[$VAR])) {
return $this->PROPERTY[$VAR] = new CLASS($value);
}
if ([] === $value) {
return $this->PROPERTY[$VAR];
}
throw new \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException(sprintf(\'The node created by "NAME()" has already been initialized. You cannot pass values the second time you call NAME().\'));
}';
$class->addMethod($methodName, $body, ['PROPERTY' => $property->getName(), 'CLASS' => $childClass->getFqcn(), 'VAR' => '' === $key ? 'key' : $key, 'VALUE' => 'value' === $key ? 'data' : 'value']);
}
$this->buildNode($prototype, $childClass, $namespace.'\\'.$childClass->getName());
}
private function handleScalarNode(ScalarNode $node, ClassBuilder $class)
{
$comment = $this->getComment($node);
$property = $class->addProperty($node->getName());
$body = '
/**
COMMENT * @return $this
*/
public function NAME(TYPE$value): self
{
$this->PROPERTY = $value;
return $this;
}';
$parameterType = $this->getParameterType($node) ?? '';
$class->addMethod($node->getName(), $body, ['PROPERTY' => $property->getName(), 'TYPE' => '' === $parameterType ? '' : $parameterType.' ', 'COMMENT' => $comment]);
}
private function getParameterType(NodeInterface $node): ?string
{
if ($node instanceof BooleanNode) {
return 'bool';
}
if ($node instanceof IntegerNode) {
return 'int';
}
if ($node instanceof FloatNode) {
return 'float';
}
if ($node instanceof EnumNode) {
return '';
}
if ($node instanceof PrototypedArrayNode && $node->getPrototype() instanceof ScalarNode) {
// This is just an array of variables
return 'array';
}
if ($node instanceof VariableNode) {
// mixed
return '';
}
return null;
}
private function getComment(VariableNode $node): string
{
$comment = '';
if ('' !== $info = (string) $node->getInfo()) {
$comment .= ' * '.$info.\PHP_EOL;
}
foreach (((array) $node->getExample() ?? []) as $example) {
$comment .= ' * @example '.$example.\PHP_EOL;
}
if ('' !== $default = $node->getDefaultValue()) {
$comment .= ' * @default '.(null === $default ? 'null' : var_export($default, true)).\PHP_EOL;
}
if ($node instanceof EnumNode) {
$comment .= sprintf(' * @param %s $value', implode('|', array_map(function ($a) {
return var_export($a, true);
}, $node->getValues()))).\PHP_EOL;
}
if ($node->isDeprecated()) {
$comment .= ' * @deprecated '.$node->getDeprecation($node->getName(), $node->getParent()->getName())['message'].\PHP_EOL;
}
return $comment;
}
/**
* Pick a good singular name.
*/
private function getSingularName(PrototypedArrayNode $node): string
{
$name = $node->getName();
if ('s' !== substr($name, -1)) {
return $name;
}
$parent = $node->getParent();
$mappings = $parent instanceof ArrayNode ? $parent->getXmlRemappings() : [];
foreach ($mappings as $map) {
if ($map[1] === $name) {
$name = $map[0];
break;
}
}
return $name;
}
private function buildToArray(ClassBuilder $class): void
{
$body = '$output = [];';
foreach ($class->getProperties() as $p) {
$code = '$this->PROPERTY;';
if (null !== $p->getType()) {
if ($p->isArray()) {
$code = 'array_map(function($v) { return $v->toArray(); }, $this->PROPERTY);';
} else {
$code = '$this->PROPERTY->toArray();';
}
}
$body .= strtr('
if (null !== $this->PROPERTY) {
$output["ORG_NAME"] = '.$code.'
}', ['PROPERTY' => $p->getName(), 'ORG_NAME' => $p->getOriginalName()]);
}
$class->addMethod('toArray', '
public function NAME(): array
{
'.$body.'
return $output;
}
');
}
private function buildConstructor(ClassBuilder $class): void
{
$body = '';
foreach ($class->getProperties() as $p) {
$code = '$value["ORG_NAME"]';
if (null !== $p->getType()) {
if ($p->isArray()) {
$code = 'array_map(function($v) { return new '.$p->getType().'($v); }, $value["ORG_NAME"]);';
} else {
$code = 'new '.$p->getType().'($value["ORG_NAME"])';
}
}
$body .= strtr('
if (isset($value["ORG_NAME"])) {
$this->PROPERTY = '.$code.';
unset($value["ORG_NAME"]);
}
', ['PROPERTY' => $p->getName(), 'ORG_NAME' => $p->getOriginalName()]);
}
$body .= '
if ($value !== []) {
throw new \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException(sprintf(\'The following keys are not supported by "%s": \', __CLASS__) . implode(\', \', array_keys($value)));
}';
$class->addMethod('__construct', '
public function __construct(array $value = [])
{
'.$body.'
}
');
}
private function getSubNamespace(ClassBuilder $rootClass): string
{
return sprintf('%s\\%s', $rootClass->getNamespace(), substr($rootClass->getName(), 0, -6));
}
}

View File

@ -0,0 +1,27 @@
<?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\Config\Builder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
/**
* Generates ConfigBuilders to help create valid config.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
interface ConfigBuilderGeneratorInterface
{
/**
* @return \Closure that will return the root config class
*/
public function build(ConfigurationInterface $configuration): \Closure;
}

View File

@ -0,0 +1,32 @@
<?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\Config\Builder;
/**
* A ConfigBuilder provides helper methods to build a large complex array.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* @experimental in 5.3
*/
interface ConfigBuilderInterface
{
/**
* Gets all configuration represented as an array.
*/
public function toArray(): array;
/**
* Gets the alias for the extension which config we are building.
*/
public function getExtensionAlias(): string;
}

View File

@ -0,0 +1,34 @@
<?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\Config\Builder;
/**
* Represents a method when building classes.
*
* @internal
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class Method
{
private $content;
public function __construct(string $content)
{
$this->content = $content;
}
public function getContent(): string
{
return $this->content;
}
}

View File

@ -0,0 +1,75 @@
<?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\Config\Builder;
/**
* Represents a property when building classes.
*
* @internal
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class Property
{
private $name;
private $originalName;
private $array = false;
private $type = null;
private $content;
public function __construct(string $originalName, string $name)
{
$this->name = $name;
$this->originalName = $originalName;
}
public function getName(): string
{
return $this->name;
}
public function getOriginalName(): string
{
return $this->originalName;
}
public function setType(string $type): void
{
$this->array = false;
$this->type = $type;
if ('[]' === substr($type, -2)) {
$this->array = true;
$this->type = substr($type, 0, -2);
}
}
public function getType(): ?string
{
return $this->type;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): void
{
$this->content = $content;
}
public function isArray(): bool
{
return $this->array;
}
}

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
5.3.0
-----
* Add support for generating `ConfigBuilder` for extensions
5.1.0
-----

View File

@ -0,0 +1,21 @@
<?php
use Symfony\Config\AddToListConfig;
return static function (AddToListConfig $config) {
$config->translator()->fallback(['sv', 'fr', 'es']);
$config->translator()->source('\\Acme\\Foo', 'yellow');
$config->translator()->source('\\Acme\\Bar', 'green');
$config->messenger()
->routing('Foo\\Message')->senders(['workqueue']);
$config->messenger()
->routing('Foo\\DoubleMessage')->senders(['sync', 'workqueue']);
$config->messenger()->receiving()
->color('blue')
->priority(10);
$config->messenger()->receiving()
->color('red')
->priority(5);
};

View File

@ -0,0 +1,21 @@
<?php
return [
'translator' => [
'fallbacks' => ['sv', 'fr', 'es'],
'sources' => [
'\\Acme\\Foo' => 'yellow',
'\\Acme\\Bar' => 'green',
]
],
'messenger' => [
'routing' => [
'Foo\\Message'=> ['senders'=>['workqueue']],
'Foo\\DoubleMessage' => ['senders'=>['sync', 'workqueue']],
],
'receiving' => [
['priority'=>10, 'color'=>'blue'],
['priority'=>5, 'color'=>'red'],
]
],
];

View File

@ -0,0 +1,60 @@
<?php
namespace Symfony\Component\Config\Tests\Builder\Fixtures;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Translation\Translator;
class AddToList implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$tb = new TreeBuilder('add_to_list');
$rootNode = $tb->getRootNode();
$rootNode
->children()
->arrayNode('translator')
->fixXmlConfig('fallback')
->fixXmlConfig('source')
->children()
->arrayNode('fallbacks')
->prototype('scalar')->end()
->defaultValue([])
->end()
->arrayNode('sources')
->useAttributeAsKey('source_class')
->prototype('scalar')->end()
->end()
->end()
->end()
->arrayNode('messenger')
->children()
->arrayNode('routing')
->normalizeKeys(false)
->useAttributeAsKey('message_class')
->prototype('array')
->performNoDeepMerging()
->children()
->arrayNode('senders')
->requiresAtLeastOneElement()
->prototype('scalar')->end()
->end()
->end()
->end()
->end()
->arrayNode('receiving')
->prototype('array')
->children()
->integerNode('priority')->end()
->scalarNode('color')->end()
->end()
->end()
->end()
->end()
->end()
;
return $tb;
}
}

View File

@ -0,0 +1,15 @@
<?php
use Symfony\Config\NodeInitialValuesConfig;
return static function (NodeInitialValuesConfig $config) {
$config->someCleverName(['second'=>'foo'])->first('bar');
$config->messenger()
->transports('fast_queue', ['dsn'=>'sync://'])
->serializer('acme');
$config->messenger()
->transports('slow_queue')
->dsn('doctrine://')
->option(['table'=>'my_messages']);
};

View File

@ -0,0 +1,20 @@
<?php
return [
'some_clever_name' => [
'first' => 'bar',
'second' => 'foo',
],
'messenger' => [
'transports' => [
'fast_queue' => [
'dsn'=>'sync://',
'serializer'=>'acme',
],
'slow_queue' => [
'dsn'=>'doctrine://',
'options'=>['table'=>'my_messages'],
]
]
]
];

View File

@ -0,0 +1,50 @@
<?php
namespace Symfony\Component\Config\Tests\Builder\Fixtures;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Translation\Translator;
class NodeInitialValues implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$tb = new TreeBuilder('node_initial_values');
$rootNode = $tb->getRootNode();
$rootNode
->children()
->arrayNode('some_clever_name')
->children()
->scalarNode('first')->end()
->scalarNode('second')->end()
->end()
->end()
->arrayNode('messenger')
->children()
->arrayNode('transports')
->normalizeKeys(false)
->useAttributeAsKey('name')
->arrayPrototype()
->fixXmlConfig('option')
->children()
->scalarNode('dsn')->end()
->scalarNode('serializer')->defaultNull()->end()
->arrayNode('options')
->normalizeKeys(false)
->defaultValue([])
->prototype('variable')
->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end()
;
return $tb;
}
}

View File

@ -0,0 +1,11 @@
<?php
use Symfony\Config\PrimitiveTypesConfig;
return static function (PrimitiveTypesConfig $config) {
$config->booleanNode(true);
$config->enumNode('foo');
$config->floatNode(47.11);
$config->integerNode(1337);
$config->scalarNode('foobar');
};

View File

@ -0,0 +1,9 @@
<?php
return [
'boolean_node' => true,
'enum_node' => 'foo',
'float_node' => 47.11,
'integer_node' => 1337,
'scalar_node' => 'foobar',
];

View File

@ -0,0 +1,26 @@
<?php
namespace Symfony\Component\Config\Tests\Builder\Fixtures;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class PrimitiveTypes implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$tb = new TreeBuilder('primitive_types');
$rootNode = $tb->getRootNode();
$rootNode
->children()
->booleanNode('boolean_node')->end()
->enumNode('enum_node')->values(['foo', 'bar', 'baz'])->end()
->floatNode('float_node')->end()
->integerNode('integer_node')->end()
->scalarNode('scalar_node')->end()
->end()
;
return $tb;
}
}

View File

@ -0,0 +1,7 @@
<?php
use Symfony\Config\VariableTypeConfig;
return static function (VariableTypeConfig $config) {
$config->anyValue('foobar');
};

View File

@ -0,0 +1,5 @@
<?php
return [
'any_value' => 'foobar',
];

View File

@ -0,0 +1,22 @@
<?php
namespace Symfony\Component\Config\Tests\Builder\Fixtures;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class VariableType implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$tb = new TreeBuilder('variable_type');
$rootNode = $tb->getRootNode();
$rootNode
->children()
->variableNode('any_value')->end()
->end()
;
return $tb;
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace Symfony\Component\Config\Tests\Builder;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\Builder\ClassBuilder;
use Symfony\Component\Config\Builder\ConfigBuilderGenerator;
use Symfony\Component\Config\Builder\ConfigBuilderInterface;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\Tests\Builder\Fixtures\AddToList;
use Symfony\Component\Config\Tests\Builder\Fixtures\NodeInitialValues;
use Symfony\Config\AddToListConfig;
/**
* Test to use the generated config and test its output.
*
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*/
class GeneratedConfigTest extends TestCase
{
public function fixtureNames()
{
$array = [
'PrimitiveTypes' => 'primitive_types',
'VariableType' => 'variable_type',
'AddToList' => 'add_to_list',
'NodeInitialValues' => 'node_initial_values',
];
foreach ($array as $name => $alias) {
yield $name => [$name, $alias];
}
}
/**
* @dataProvider fixtureNames
*/
public function testConfig(string $name, string $alias)
{
$basePath = __DIR__.'/Fixtures/';
$configBuilder = $this->generateConfigBuilder('Symfony\\Component\\Config\\Tests\\Builder\\Fixtures\\'.$name);
$callback = include $basePath.$name.'.config.php';
$expectedOutput = include $basePath.$name.'.output.php';
$callback($configBuilder);
$this->assertInstanceOf(ConfigBuilderInterface::class, $configBuilder);
$this->assertSame($alias, $configBuilder->getExtensionAlias());
$this->assertSame($expectedOutput, $configBuilder->toArray());
}
/**
* When you create a node, you can provide it with initial values. But the second
* time you call a node, it is not created, hence you cannot give it initial values.
*/
public function testSecondNodeWithInitialValuesThrowsException()
{
$configBuilder = $this->generateConfigBuilder(NodeInitialValues::class);
$configBuilder->someCleverName(['second' => 'foo']);
$this->expectException(InvalidConfigurationException::class);
$configBuilder->someCleverName(['first' => 'bar']);
}
/**
* When you create a named node, you can provide it with initial values. But
* the second time you call a node, it is not created, hence you cannot give
* it initial values.
*/
public function testSecondNamedNodeWithInitialValuesThrowsException()
{
/** @var AddToListConfig $configBuilder */
$configBuilder = $this->generateConfigBuilder(AddToList::class);
$messenger = $configBuilder->messenger();
$foo = $messenger->routing('foo', ['senders' => 'a']);
$bar = $messenger->routing('bar', ['senders' => 'b']);
$this->assertNotEquals($foo, $bar);
$foo2 = $messenger->routing('foo');
$this->assertEquals($foo, $foo2);
$this->expectException(InvalidConfigurationException::class);
$messenger->routing('foo', ['senders' => 'c']);
}
/**
* Make sure you pass values that are defined.
*/
public function testWrongInitialValues()
{
$configBuilder = $this->generateConfigBuilder(NodeInitialValues::class);
$this->expectException(InvalidConfigurationException::class);
$configBuilder->someCleverName(['not_exists' => 'foo']);
}
/**
* Generate the ConfigBuilder or return an already generated instance.
*/
private function generateConfigBuilder(string $configurationClass)
{
$configuration = new $configurationClass();
$rootNode = $configuration->getConfigTreeBuilder()->buildTree();
$rootClass = new ClassBuilder('Symfony\\Config', $rootNode->getName());
if (class_exists($fqcn = $rootClass->getFqcn())) {
// Avoid generating the class again
return new $fqcn();
}
$outputDir = sys_get_temp_dir();
// This line is helpful for debugging
// $outputDir = __DIR__.'/.build';
$loader = (new ConfigBuilderGenerator($outputDir))->build(new $configurationClass());
return $loader();
}
}

View File

@ -13,6 +13,7 @@ CHANGELOG
* Add `ContainerBuilder::willBeAvailable()` to help with conditional configuration
* Add support an integer return value for default_index_method
* Add `env()` and `EnvConfigurator` in the PHP-DSL
* Add support for `ConfigBuilder` in the `PhpFileLoader`
5.2.0
-----

View File

@ -11,6 +11,15 @@
namespace Symfony\Component\DependencyInjection\Loader;
use Symfony\Component\Config\Builder\ConfigBuilderGenerator;
use Symfony\Component\Config\Builder\ConfigBuilderGeneratorInterface;
use Symfony\Component\Config\Builder\ConfigBuilderInterface;
use Symfony\Component\Config\FileLocatorInterface;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
/**
@ -24,6 +33,13 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigura
class PhpFileLoader extends FileLoader
{
protected $autoRegisterAliasesForSinglyImplementedInterfaces = false;
private $generator;
public function __construct(ContainerBuilder $container, FileLocatorInterface $locator, string $env = null, ?ConfigBuilderGeneratorInterface $generator = null)
{
parent::__construct($container, $locator, $env);
$this->generator = $generator;
}
/**
* {@inheritdoc}
@ -47,7 +63,7 @@ class PhpFileLoader extends FileLoader
$callback = $load($path);
if (\is_object($callback) && \is_callable($callback)) {
$callback(new ContainerConfigurator($this->container, $this, $this->instanceof, $path, $resource, $this->env), $this->container, $this);
$this->executeCallback($callback, new ContainerConfigurator($this->container, $this, $this->instanceof, $path, $resource, $this->env), $path);
}
} finally {
$this->instanceof = [];
@ -70,6 +86,97 @@ class PhpFileLoader extends FileLoader
return 'php' === $type;
}
/**
* Resolve the parameters to the $callback and execute it.
*/
private function executeCallback(callable $callback, ContainerConfigurator $containerConfigurator, string $path)
{
if (!$callback instanceof \Closure) {
$callback = \Closure::fromCallable($callback);
}
$arguments = [];
$configBuilders = [];
$parameters = (new \ReflectionFunction($callback))->getParameters();
foreach ($parameters as $parameter) {
$reflectionType = $parameter->getType();
if (!$reflectionType instanceof \ReflectionNamedType) {
throw new \InvalidArgumentException(sprintf('Could not resolve argument "%s" for "%s".', $parameter->getName(), $path));
}
$type = $reflectionType->getName();
switch ($type) {
case ContainerConfigurator::class:
$arguments[] = $containerConfigurator;
break;
case ContainerBuilder::class:
$arguments[] = $this->container;
break;
case FileLoader::class:
case self::class:
$arguments[] = $this;
break;
default:
try {
$configBuilder = $this->configBuilder($type);
} catch (InvalidArgumentException | \LogicException $e) {
throw new \InvalidArgumentException(sprintf('Could not resolve argument "%s" for "%s".', $type.' '.$parameter->getName(), $path), 0, $e);
}
$configBuilders[] = $configBuilder;
$arguments[] = $configBuilder;
}
}
$callback(...$arguments);
/** @var ConfigBuilderInterface $configBuilder */
foreach ($configBuilders as $configBuilder) {
$containerConfigurator->extension($configBuilder->getExtensionAlias(), $configBuilder->toArray());
}
}
/**
* @param string $namespace FQCN string for a class implementing ConfigBuilderInterface
*/
private function configBuilder(string $namespace): ConfigBuilderInterface
{
if (!class_exists(ConfigBuilderGenerator::class)) {
throw new \LogicException('You cannot use the config builder as the Config component is not installed. Try running "composer require symfony/config".');
}
if (null === $this->generator) {
throw new \LogicException('You cannot use the ConfigBuilders without providing a class implementing ConfigBuilderGeneratorInterface.');
}
// If class exists and implements ConfigBuilderInterface
if (class_exists($namespace) && is_subclass_of($namespace, ConfigBuilderInterface::class)) {
return new $namespace();
}
// If it does not start with Symfony\Config\ we dont know how to handle this
if ('Symfony\\Config\\' !== substr($namespace, 0, 15)) {
throw new InvalidargumentException(sprintf('Could not find or generate class "%s".', $namespace));
}
// Try to get the extension alias
$alias = Container::underscore(substr($namespace, 15, -6));
if (!$this->container->hasExtension($alias)) {
$extensions = array_filter(array_map(function (ExtensionInterface $ext) { return $ext->getAlias(); }, $this->container->getExtensions()));
throw new InvalidArgumentException(sprintf('There is no extension able to load the configuration for "%s". Looked for namespace "%s", found "%s".', $namespace, $namespace, $extensions ? implode('", "', $extensions) : 'none'));
}
$extension = $this->container->getExtension($alias);
if (!$extension instanceof ConfigurationExtensionInterface) {
throw new \LogicException(sprintf('You cannot use the config builder for "%s" because the extension does not implement "%s".', $namespace, ConfigurationExtensionInterface::class));
}
$configuration = $extension->getConfiguration([], $this->container);
$loader = $this->generator->build($configuration);
return $loader();
}
}
/**

View File

@ -0,0 +1,27 @@
<?php
namespace Symfony\Component\DependencyInjection\Tests\Fixtures;
use Symfony\Component\Config\Builder\ConfigBuilderInterface;
class AcmeConfigBuilder implements ConfigBuilderInterface
{
private $color;
public function color($value)
{
$this->color = $value;
}
public function toArray(): array
{
return [
'color' => $this->color
];
}
public function getExtensionAlias(): string
{
return 'acme';
}
}

View File

@ -0,0 +1,8 @@
parameters:
acme.configs: [{ color: blue }]
services:
service_container:
class: Symfony\Component\DependencyInjection\ContainerInterface
public: true
synthetic: true

View File

@ -0,0 +1,7 @@
<?php
use Symfony\Component\DependencyInjection\Tests\Fixtures\AcmeConfigBuilder;
return static function (AcmeConfigBuilder $config) {
$config->color('blue');
};

View File

@ -0,0 +1,29 @@
<?php
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
class AcmeExtension implements ExtensionInterface
{
public function load(array $configs, ContainerBuilder $configuration)
{
$configuration->setParameter('acme.configs', $configs);
return $configuration;
}
public function getXsdValidationBasePath()
{
return false;
}
public function getNamespace(): string
{
return 'http://www.example.com/schema/acme';
}
public function getAlias(): string
{
return 'acme';
}
}

View File

@ -11,8 +11,11 @@
namespace Symfony\Component\DependencyInjection\Tests\Loader;
require_once __DIR__.'/../Fixtures/includes/AcmeExtension.php';
use PHPUnit\Framework\TestCase;
use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait;
use Symfony\Component\Config\Builder\ConfigBuilderGenerator;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;
@ -60,7 +63,9 @@ class PhpFileLoaderTest extends TestCase
public function testConfig($file)
{
$fixtures = realpath(__DIR__.'/../Fixtures');
$loader = new PhpFileLoader($container = new ContainerBuilder(), new FileLocator());
$container = new ContainerBuilder();
$container->registerExtension(new \AcmeExtension());
$loader = new PhpFileLoader($container, new FileLocator(), 'prod', new ConfigBuilderGenerator(sys_get_temp_dir()));
$loader->load($fixtures.'/config/'.$file.'.php');
$container->compile();
@ -82,6 +87,7 @@ class PhpFileLoaderTest extends TestCase
yield ['anonymous'];
yield ['lazy_fqcn'];
yield ['remove'];
yield ['config_builder'];
}
public function testAutoConfigureAndChildDefinition()

View File

@ -13,6 +13,7 @@ namespace Symfony\Component\HttpKernel;
use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator;
use Symfony\Bridge\ProxyManager\LazyProxy\PhpDumper\ProxyDumper;
use Symfony\Component\Config\Builder\ConfigBuilderGenerator;
use Symfony\Component\Config\ConfigCache;
use Symfony\Component\Config\Loader\DelegatingLoader;
use Symfony\Component\Config\Loader\LoaderResolver;
@ -758,7 +759,7 @@ abstract class Kernel implements KernelInterface, RebootableInterface, Terminabl
new XmlFileLoader($container, $locator, $env),
new YamlFileLoader($container, $locator, $env),
new IniFileLoader($container, $locator, $env),
new PhpFileLoader($container, $locator, $env),
new PhpFileLoader($container, $locator, $env, class_exists(ConfigBuilderGenerator::class) ? new ConfigBuilderGenerator($this->getBuildDir()) : null),
new GlobFileLoader($container, $locator, $env),
new DirectoryLoader($container, $locator, $env),
new ClosureLoader($container, $env),