diff --git a/src/Symfony/Component/OptionsResolver/CHANGELOG.md b/src/Symfony/Component/OptionsResolver/CHANGELOG.md index d996e309f3..84c45946a9 100644 --- a/src/Symfony/Component/OptionsResolver/CHANGELOG.md +++ b/src/Symfony/Component/OptionsResolver/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +5.3 +--- + + * Add prototype definition for nested options + 5.1.0 ----- diff --git a/src/Symfony/Component/OptionsResolver/OptionsResolver.php b/src/Symfony/Component/OptionsResolver/OptionsResolver.php index e369615e59..a8ae153f46 100644 --- a/src/Symfony/Component/OptionsResolver/OptionsResolver.php +++ b/src/Symfony/Component/OptionsResolver/OptionsResolver.php @@ -130,6 +130,16 @@ class OptionsResolver implements Options private $parentsOptions = []; + /** + * Whether the whole options definition is marked as array prototype. + */ + private $prototype; + + /** + * The prototype array's index that is being read. + */ + private $prototypeIndex; + /** * Sets the default value of a given option. * @@ -789,6 +799,33 @@ class OptionsResolver implements Options return $this->info[$option] ?? null; } + /** + * Marks the whole options definition as array prototype. + * + * @return $this + * + * @throws AccessException If called from a lazy option, a normalizer or a root definition + */ + public function setPrototype(bool $prototype): self + { + if ($this->locked) { + throw new AccessException('The prototype property cannot be set from a lazy option or normalizer.'); + } + + if (null === $this->prototype && $prototype) { + throw new AccessException('The prototype property cannot be set from a root definition.'); + } + + $this->prototype = $prototype; + + return $this; + } + + public function isPrototype(): bool + { + return $this->prototype ?? false; + } + /** * Removes the option with the given name. * @@ -970,13 +1007,29 @@ class OptionsResolver implements Options $this->calling[$option] = true; try { $resolver = new self(); + $resolver->prototype = false; $resolver->parentsOptions = $this->parentsOptions; $resolver->parentsOptions[] = $option; foreach ($this->nested[$option] as $closure) { $closure($resolver, $this); } - $value = $resolver->resolve($value); + + if ($resolver->prototype) { + $values = []; + foreach ($value as $index => $prototypeValue) { + if (!\is_array($prototypeValue)) { + throw new InvalidOptionsException(sprintf('The value of the option "%s" is expected to be of type array of array, but is of type array of "%s".', $this->formatOptions([$option]), get_debug_type($prototypeValue))); + } + + $resolver->prototypeIndex = $index; + $values[$index] = $resolver->resolve($prototypeValue); + } + $value = $values; + } else { + $value = $resolver->resolve($value); + } } finally { + $resolver->prototypeIndex = null; unset($this->calling[$option]); } } @@ -1286,6 +1339,10 @@ class OptionsResolver implements Options $prefix .= sprintf('[%s]', implode('][', $this->parentsOptions)); } + if ($this->prototype && null !== $this->prototypeIndex) { + $prefix .= sprintf('[%s]', $this->prototypeIndex); + } + $options = array_map(static function (string $option) use ($prefix): string { return sprintf('%s[%s]', $prefix, $option); }, $options); diff --git a/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php b/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php index 205f3fa1e8..3c36225e18 100644 --- a/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php +++ b/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php @@ -2504,4 +2504,91 @@ class OptionsResolverTest extends TestCase ->setDeprecated('foo') ; } + + public function testInvalidValueForPrototypeDefinition() + { + $this->expectException(InvalidOptionsException::class); + $this->expectExceptionMessage('The value of the option "connections" is expected to be of type array of array, but is of type array of "string".'); + + $this->resolver + ->setDefault('connections', static function (OptionsResolver $resolver) { + $resolver + ->setPrototype(true) + ->setDefined(['table', 'user', 'password']) + ; + }) + ; + + $this->resolver->resolve(['connections' => ['foo']]); + } + + public function testMissingOptionForPrototypeDefinition() + { + $this->expectException(MissingOptionsException::class); + $this->expectExceptionMessage('The required option "connections[1][table]" is missing.'); + + $this->resolver + ->setDefault('connections', static function (OptionsResolver $resolver) { + $resolver + ->setPrototype(true) + ->setRequired('table') + ; + }) + ; + + $this->resolver->resolve(['connections' => [ + ['table' => 'default'], + [], // <- missing required option "table" + ]]); + } + + public function testAccessExceptionOnPrototypeDefinition() + { + $this->expectException(AccessException::class); + $this->expectExceptionMessage('The prototype property cannot be set from a root definition.'); + + $this->resolver->setPrototype(true); + } + + public function testPrototypeDefinition() + { + $this->resolver + ->setDefault('connections', static function (OptionsResolver $resolver) { + $resolver + ->setPrototype(true) + ->setRequired('table') + ->setDefaults(['user' => 'root', 'password' => null]) + ; + }) + ; + + $actualOptions = $this->resolver->resolve([ + 'connections' => [ + 'default' => [ + 'table' => 'default', + ], + 'custom' => [ + 'user' => 'foo', + 'password' => 'pa$$', + 'table' => 'symfony', + ], + ], + ]); + $expectedOptions = [ + 'connections' => [ + 'default' => [ + 'user' => 'root', + 'password' => null, + 'table' => 'default', + ], + 'custom' => [ + 'user' => 'foo', + 'password' => 'pa$$', + 'table' => 'symfony', + ], + ], + ]; + + $this->assertSame($expectedOptions, $actualOptions); + } }