From 460b46f7302ec7319b8334a43809523363bfef39 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Sat, 27 Mar 2021 16:20:02 +0100 Subject: [PATCH] [Config][DependencyInjection] Add configuration builder for writing PHP config --- .../Component/Config/Builder/ClassBuilder.php | 155 +++++++ .../Config/Builder/ConfigBuilderGenerator.php | 391 ++++++++++++++++++ .../ConfigBuilderGeneratorInterface.php | 27 ++ .../Config/Builder/ConfigBuilderInterface.php | 32 ++ .../Component/Config/Builder/Method.php | 34 ++ .../Component/Config/Builder/Property.php | 75 ++++ src/Symfony/Component/Config/CHANGELOG.md | 5 + .../Builder/Fixtures/AddToList.config.php | 21 + .../Builder/Fixtures/AddToList.output.php | 21 + .../Tests/Builder/Fixtures/AddToList.php | 60 +++ .../Fixtures/NodeInitialValues.config.php | 15 + .../Fixtures/NodeInitialValues.output.php | 20 + .../Builder/Fixtures/NodeInitialValues.php | 50 +++ .../Fixtures/PrimitiveTypes.config.php | 11 + .../Fixtures/PrimitiveTypes.output.php | 9 + .../Tests/Builder/Fixtures/PrimitiveTypes.php | 26 ++ .../Builder/Fixtures/VariableType.config.php | 7 + .../Builder/Fixtures/VariableType.output.php | 5 + .../Tests/Builder/Fixtures/VariableType.php | 22 + .../Tests/Builder/GeneratedConfigTest.php | 115 ++++++ .../DependencyInjection/CHANGELOG.md | 1 + .../Loader/PhpFileLoader.php | 109 ++++- .../Tests/Fixtures/AcmeConfigBuilder.php | 27 ++ .../config/config_builder.expected.yml | 8 + .../Tests/Fixtures/config/config_builder.php | 7 + .../Tests/Fixtures/includes/AcmeExtension.php | 29 ++ .../Tests/Loader/PhpFileLoaderTest.php | 8 +- src/Symfony/Component/HttpKernel/Kernel.php | 3 +- 28 files changed, 1290 insertions(+), 3 deletions(-) create mode 100644 src/Symfony/Component/Config/Builder/ClassBuilder.php create mode 100644 src/Symfony/Component/Config/Builder/ConfigBuilderGenerator.php create mode 100644 src/Symfony/Component/Config/Builder/ConfigBuilderGeneratorInterface.php create mode 100644 src/Symfony/Component/Config/Builder/ConfigBuilderInterface.php create mode 100644 src/Symfony/Component/Config/Builder/Method.php create mode 100644 src/Symfony/Component/Config/Builder/Property.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.config.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.output.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.config.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.output.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.config.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.output.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.config.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.output.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.php create mode 100644 src/Symfony/Component/Config/Tests/Builder/GeneratedConfigTest.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/AcmeConfigBuilder.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/config_builder.expected.yml create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/config_builder.php create mode 100644 src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/AcmeExtension.php diff --git a/src/Symfony/Component/Config/Builder/ClassBuilder.php b/src/Symfony/Component/Config/Builder/ClassBuilder.php new file mode 100644 index 0000000000..8ed798477c --- /dev/null +++ b/src/Symfony/Component/Config/Builder/ClassBuilder.php @@ -0,0 +1,155 @@ + + * + * 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 + */ +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(' $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; + } +} diff --git a/src/Symfony/Component/Config/Builder/ConfigBuilderGenerator.php b/src/Symfony/Component/Config/Builder/ConfigBuilderGenerator.php new file mode 100644 index 0000000000..83463e2ea1 --- /dev/null +++ b/src/Symfony/Component/Config/Builder/ConfigBuilderGenerator.php @@ -0,0 +1,391 @@ + + * + * 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 + */ +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)); + } +} diff --git a/src/Symfony/Component/Config/Builder/ConfigBuilderGeneratorInterface.php b/src/Symfony/Component/Config/Builder/ConfigBuilderGeneratorInterface.php new file mode 100644 index 0000000000..c52c9e5d53 --- /dev/null +++ b/src/Symfony/Component/Config/Builder/ConfigBuilderGeneratorInterface.php @@ -0,0 +1,27 @@ + + * + * 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 + */ +interface ConfigBuilderGeneratorInterface +{ + /** + * @return \Closure that will return the root config class + */ + public function build(ConfigurationInterface $configuration): \Closure; +} diff --git a/src/Symfony/Component/Config/Builder/ConfigBuilderInterface.php b/src/Symfony/Component/Config/Builder/ConfigBuilderInterface.php new file mode 100644 index 0000000000..52549e0b0f --- /dev/null +++ b/src/Symfony/Component/Config/Builder/ConfigBuilderInterface.php @@ -0,0 +1,32 @@ + + * + * 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 + * + * @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; +} diff --git a/src/Symfony/Component/Config/Builder/Method.php b/src/Symfony/Component/Config/Builder/Method.php new file mode 100644 index 0000000000..3577e3d7ae --- /dev/null +++ b/src/Symfony/Component/Config/Builder/Method.php @@ -0,0 +1,34 @@ + + * + * 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 + */ +class Method +{ + private $content; + + public function __construct(string $content) + { + $this->content = $content; + } + + public function getContent(): string + { + return $this->content; + } +} diff --git a/src/Symfony/Component/Config/Builder/Property.php b/src/Symfony/Component/Config/Builder/Property.php new file mode 100644 index 0000000000..1b24c47cc9 --- /dev/null +++ b/src/Symfony/Component/Config/Builder/Property.php @@ -0,0 +1,75 @@ + + * + * 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 + */ +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; + } +} diff --git a/src/Symfony/Component/Config/CHANGELOG.md b/src/Symfony/Component/Config/CHANGELOG.md index 6e873cf83e..75ef8bc441 100644 --- a/src/Symfony/Component/Config/CHANGELOG.md +++ b/src/Symfony/Component/Config/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3.0 +----- + + * Add support for generating `ConfigBuilder` for extensions + 5.1.0 ----- diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.config.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.config.php new file mode 100644 index 0000000000..ce8fbb432b --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.config.php @@ -0,0 +1,21 @@ +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); +}; diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.output.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.output.php new file mode 100644 index 0000000000..f37659ff7c --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.output.php @@ -0,0 +1,21 @@ + [ + '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'], + ] + ], +]; diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.php new file mode 100644 index 0000000000..66e5163a26 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/AddToList.php @@ -0,0 +1,60 @@ +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; + } +} diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.config.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.config.php new file mode 100644 index 0000000000..c7f023d0c5 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.config.php @@ -0,0 +1,15 @@ +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']); +}; diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.output.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.output.php new file mode 100644 index 0000000000..f1d839ea9c --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.output.php @@ -0,0 +1,20 @@ + [ + 'first' => 'bar', + 'second' => 'foo', + ], + 'messenger' => [ + 'transports' => [ + 'fast_queue' => [ + 'dsn'=>'sync://', + 'serializer'=>'acme', + ], + 'slow_queue' => [ + 'dsn'=>'doctrine://', + 'options'=>['table'=>'my_messages'], + ] + ] + ] +]; diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.php new file mode 100644 index 0000000000..35b2d0d928 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/NodeInitialValues.php @@ -0,0 +1,50 @@ +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; + } +} diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.config.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.config.php new file mode 100644 index 0000000000..6ca25d66a8 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.config.php @@ -0,0 +1,11 @@ +booleanNode(true); + $config->enumNode('foo'); + $config->floatNode(47.11); + $config->integerNode(1337); + $config->scalarNode('foobar'); +}; diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.output.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.output.php new file mode 100644 index 0000000000..6d3e12c563 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.output.php @@ -0,0 +1,9 @@ + true, + 'enum_node' => 'foo', + 'float_node' => 47.11, + 'integer_node' => 1337, + 'scalar_node' => 'foobar', +]; diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.php new file mode 100644 index 0000000000..aecdbe7953 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/PrimitiveTypes.php @@ -0,0 +1,26 @@ +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; + } +} diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.config.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.config.php new file mode 100644 index 0000000000..10b70bf6d2 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.config.php @@ -0,0 +1,7 @@ +anyValue('foobar'); +}; diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.output.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.output.php new file mode 100644 index 0000000000..87c38a584a --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.output.php @@ -0,0 +1,5 @@ + 'foobar', +]; diff --git a/src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.php b/src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.php new file mode 100644 index 0000000000..c3fa62cfd9 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/Fixtures/VariableType.php @@ -0,0 +1,22 @@ +getRootNode(); + $rootNode + ->children() + ->variableNode('any_value')->end() + ->end() + ; + + return $tb; + } +} diff --git a/src/Symfony/Component/Config/Tests/Builder/GeneratedConfigTest.php b/src/Symfony/Component/Config/Tests/Builder/GeneratedConfigTest.php new file mode 100644 index 0000000000..767ebe5452 --- /dev/null +++ b/src/Symfony/Component/Config/Tests/Builder/GeneratedConfigTest.php @@ -0,0 +1,115 @@ + + */ +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(); + } +} diff --git a/src/Symfony/Component/DependencyInjection/CHANGELOG.md b/src/Symfony/Component/DependencyInjection/CHANGELOG.md index aa1ff13314..2d3a51a7b7 100644 --- a/src/Symfony/Component/DependencyInjection/CHANGELOG.md +++ b/src/Symfony/Component/DependencyInjection/CHANGELOG.md @@ -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 ----- diff --git a/src/Symfony/Component/DependencyInjection/Loader/PhpFileLoader.php b/src/Symfony/Component/DependencyInjection/Loader/PhpFileLoader.php index 130d7cfba7..f1363d9b5e 100644 --- a/src/Symfony/Component/DependencyInjection/Loader/PhpFileLoader.php +++ b/src/Symfony/Component/DependencyInjection/Loader/PhpFileLoader.php @@ -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(); + } } /** diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AcmeConfigBuilder.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AcmeConfigBuilder.php new file mode 100644 index 0000000000..a89fe4a544 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/AcmeConfigBuilder.php @@ -0,0 +1,27 @@ +color = $value; + } + + public function toArray(): array + { + return [ + 'color' => $this->color + ]; + } + + public function getExtensionAlias(): string + { + return 'acme'; + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/config_builder.expected.yml b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/config_builder.expected.yml new file mode 100644 index 0000000000..efe9667c0c --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/config_builder.expected.yml @@ -0,0 +1,8 @@ +parameters: + acme.configs: [{ color: blue }] + +services: + service_container: + class: Symfony\Component\DependencyInjection\ContainerInterface + public: true + synthetic: true diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/config_builder.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/config_builder.php new file mode 100644 index 0000000000..3dcacf032f --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/config/config_builder.php @@ -0,0 +1,7 @@ +color('blue'); +}; diff --git a/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/AcmeExtension.php b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/AcmeExtension.php new file mode 100644 index 0000000000..7ae35af9b1 --- /dev/null +++ b/src/Symfony/Component/DependencyInjection/Tests/Fixtures/includes/AcmeExtension.php @@ -0,0 +1,29 @@ +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'; + } +} diff --git a/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php b/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php index 23b7111582..f5542b2227 100644 --- a/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php +++ b/src/Symfony/Component/DependencyInjection/Tests/Loader/PhpFileLoaderTest.php @@ -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() diff --git a/src/Symfony/Component/HttpKernel/Kernel.php b/src/Symfony/Component/HttpKernel/Kernel.php index dbe5a111da..330ca1ef85 100644 --- a/src/Symfony/Component/HttpKernel/Kernel.php +++ b/src/Symfony/Component/HttpKernel/Kernel.php @@ -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),