feature #39913 [OptionsResolver] Add prototype definition support for nested options (yceruto)

This PR was merged into the 5.3-dev branch.

Discussion
----------

[OptionsResolver] Add prototype definition support for nested options

| Q             | A
| ------------- | ---
| Branch?       | 5.x
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Tickets       | Fix #34207
| License       | MIT
| Doc PR        | symfony/symfony-docs#...

This proposal adds a new method `setPrototype(true)` to the `OptionsResolver` component to mark options definition as array prototype:
```php
$this->resolver
    ->setDefault('connections', static function (OptionsResolver $resolver) { // nested option
        $resolver
            ->setPrototype(true) // <- the new method
            ->setRequired('table')
            ->setDefaults(['user' => 'root', 'password' => null]);
    })
;
```
This feature will allow passing options this way:
```php
$this->resolver->resolve([
    'connections' => [
        'default' => [ // <- the array index "default" is optional and validation free
            'table' => 'default',
        ],
        'custom' => [
            'user' => 'foo',
            'password' => 'pa$$',
            'table' => 'symfony',
        ],
    ],
])
```
You can add as many items as you want with the advantage of validating each item according to its prototype definition.

The result for this example would be:
```php
[
    'connections' => [
        'default' => [
            'user' => 'root',
            'password' => null,
            'table' => 'default',
        ],
        'custom' => [
            'user' => 'foo',
            'password' => 'pa$$',
            'table' => 'symfony',
        ],
    ],
]
```

This feature is feasible only for nested options so far and the nested option (e.g. "connections") must be of type array of array.

See the test cases for more details about this feature.

Cheers!

Commits
-------

29d41b1970 Add prototype definition support for nested options
This commit is contained in:
Fabien Potencier 2021-05-01 10:55:42 +02:00
commit 0489ffcaed
3 changed files with 150 additions and 1 deletions

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
5.3
---
* Add prototype definition for nested options
5.1.0
-----

View File

@ -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);

View File

@ -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);
}
}