feature #27277 [OptionsResolver] Introduce ability to deprecate options, allowed types and values (yceruto)

This PR was merged into the 4.2-dev branch.

Discussion
----------

[OptionsResolver] Introduce ability to deprecate options, allowed types and values

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #27216
| License       | MIT
| Doc PR        | https://github.com/symfony/symfony-docs/pull/9859

**Deprecating an option**
```php
$resolver = (new OptionsResolver())
    ->setDefined(['foo', 'bar'])
    ->setDeprecated('foo')
;
$resolver->resolve(['foo' => 'baz']); // PHP Deprecated: The option "foo" is deprecated.
```
With custom message:
```php
$resolver = (new OptionsResolver())
    ->setDefined('foo')
    ->setDefault('bar', function (Options $options) {
        return $options['foo'];
    })
    ->setDeprecated('foo', 'The option "foo" is deprecated, use "bar" option instead.')
;
$resolver->resolve(['foo' => 'baz']); // PHP Deprecated: The option "foo" is deprecated, use "bar" option instead.
$resolver->resolve(['bar' => 'baz']); // OK.
```
**Deprecating allowed types**
```php
$resolver = (new OptionsResolver())
    ->setDefault('type', null)
    ->setAllowedTypes('type', ['null', 'string', FormTypeInterface::class])
    ->setDeprecated('type', function ($value) {
        if ($value instanceof FormTypeInterface) {
            return sprintf('Passing an instance of "%s" to option "type" is deprecated, pass its FQCN instead.', FormTypeInterface::class);
        }
    })
;
$resolver->resolve(['type' => new ChoiceType()]); // PHP Deprecated: Passing an instance of "Symfony\Component\Form\FormTypeInterface" to option "type" is deprecated, pass its FQCN instead.
$resolver->resolve(['type' => ChoiceType::class]); // OK.
```
The closure is invoked when `resolve()` is called. The closure must return a string (the deprecation message) or an empty string to ignore the option deprecation.

Multiple types and normalizer:
```php
$resolver = (new OptionsResolver())
    ->setDefault('percent', 0.0)
    ->setAllowedTypes('percent', ['null', 'int', 'float'])
    ->setDeprecated('percent', function ($value) {
        if (null === $value) {
            return 'Passing "null" to option "percent" is deprecated, pass a float number instead.';
        }

        if (is_int($value)) {
            return sprintf('Passing an integer "%d" to option "percent" is deprecated, pass a float number instead.', $value);
        }
    })
    ->setNormalizer('percent', function (Options $options, $value) {
        return (float) $value;
    })
;
$resolver->resolve(['percent' => null]); // PHP Deprecated: Passing "null" to option "percent" is deprecated, pass a float number instead.
$resolver->resolve(['percent' => 20]); // PHP Deprecated: Passing an integer "20" to option "percent" is deprecated, pass a float number instead.
$resolver->resolve(['percent' => 20.0]); // OK.
```
The parameter passed to the closure is the value of the option after validating it and before normalizing it.

**Deprecating allowed values**
```php
$resolver = (new OptionsResolver())
    ->setDefault('percent', 0.0)
    ->setAllowedTypes('percent', 'float')
    ->setDeprecated('percent', function ($value) {
        if ($value < 0) {
            return 'Passing a number less than 0 to option "percent" is deprecated.';
        }
    })
;
$resolver->resolve(['percent' => -50.0]); // PHP Deprecated: Passing a number less than 0 to option "percent" is deprecated.
```

Commits
-------

f8746ce8bd Add ability to deprecate options
This commit is contained in:
Nicolas Grekas 2018-06-19 14:56:30 +02:00
commit 76d3589a44
3 changed files with 257 additions and 0 deletions

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
4.2.0
-----
* added `setDeprecated` and `isDeprecated` methods
3.4.0
-----

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\OptionsResolver;
use Symfony\Component\OptionsResolver\Exception\AccessException;
use Symfony\Component\OptionsResolver\Exception\InvalidArgumentException;
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Exception\MissingOptionsException;
use Symfony\Component\OptionsResolver\Exception\NoSuchOptionException;
@ -75,6 +76,11 @@ class OptionsResolver implements Options
*/
private $calling = array();
/**
* A list of deprecated options.
*/
private $deprecated = array();
/**
* Whether the instance is locked for reading.
*
@ -348,6 +354,57 @@ class OptionsResolver implements Options
return array_keys($this->defined);
}
/**
* Deprecates an option, allowed types or values.
*
* Instead of passing the message, you may also pass a closure with the
* following signature:
*
* function ($value) {
* // ...
* }
*
* The closure receives the value as argument and should return a string.
* Returns an empty string to ignore the option deprecation.
*
* The closure is invoked when {@link resolve()} is called. The parameter
* passed to the closure is the value of the option after validating it
* and before normalizing it.
*
* @param string|\Closure $deprecationMessage
*/
public function setDeprecated(string $option, $deprecationMessage = 'The option "%name%" is deprecated.'): self
{
if ($this->locked) {
throw new AccessException('Options cannot be deprecated from a lazy option or normalizer.');
}
if (!isset($this->defined[$option])) {
throw new UndefinedOptionsException(sprintf('The option "%s" does not exist, defined options are: "%s".', $option, implode('", "', array_keys($this->defined))));
}
if (!\is_string($deprecationMessage) && !$deprecationMessage instanceof \Closure) {
throw new InvalidArgumentException(sprintf('Invalid type for deprecation message argument, expected string or \Closure, but got "%s".', \gettype($deprecationMessage)));
}
// ignore if empty string
if ('' === $deprecationMessage) {
return $this;
}
$this->deprecated[$option] = $deprecationMessage;
// Make sure the option is processed
unset($this->resolved[$option]);
return $this;
}
public function isDeprecated(string $option): bool
{
return isset($this->deprecated[$option]);
}
/**
* Sets the normalizer for an option.
*
@ -620,6 +677,7 @@ class OptionsResolver implements Options
$this->normalizers = array();
$this->allowedTypes = array();
$this->allowedValues = array();
$this->deprecated = array();
return $this;
}
@ -836,6 +894,19 @@ class OptionsResolver implements Options
}
}
// Check whether the option is deprecated
if (isset($this->deprecated[$option])) {
$deprecationMessage = $this->deprecated[$option];
if ($deprecationMessage instanceof \Closure && !\is_string($deprecationMessage = $deprecationMessage($value))) {
throw new InvalidOptionsException(sprintf('Invalid type for deprecation message, expected string but got "%s", returns an empty string to ignore.', \gettype($deprecationMessage)));
}
if ('' !== $deprecationMessage) {
@trigger_error(strtr($deprecationMessage, array('%name%' => $option)), E_USER_DEPRECATED);
}
}
// Normalize the validated option
if (isset($this->normalizers[$option])) {
// If the closure is already being called, we have a cyclic

View File

@ -450,6 +450,187 @@ class OptionsResolverTest extends TestCase
$this->assertFalse($this->resolver->isDefined('foo'));
}
/**
* @expectedException \Symfony\Component\OptionsResolver\Exception\AccessException
*/
public function testFailIfSetDeprecatedFromLazyOption()
{
$this->resolver
->setDefault('bar', 'baz')
->setDefault('foo', function (Options $options) {
$options->setDeprecated('bar');
})
->resolve()
;
}
/**
* @expectedException \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
*/
public function testSetDeprecatedFailsIfUnknownOption()
{
$this->resolver->setDeprecated('foo');
}
/**
* @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidArgumentException
* @expectedExceptionMessage Invalid type for deprecation message argument, expected string or \Closure, but got "boolean".
*/
public function testSetDeprecatedFailsIfInvalidDeprecationMessageType()
{
$this->resolver
->setDefined('foo')
->setDeprecated('foo', true)
;
}
/**
* @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidArgumentException
* @expectedExceptionMessage Invalid type for deprecation message, expected string but got "boolean", returns an empty string to ignore.
*/
public function testLazyDeprecationFailsIfInvalidDeprecationMessageType()
{
$this->resolver
->setDefault('foo', true)
->setDeprecated('foo', function ($value) {
return false;
})
;
$this->resolver->resolve();
}
public function testIsDeprecated()
{
$this->resolver
->setDefined('foo')
->setDeprecated('foo')
;
$this->assertTrue($this->resolver->isDeprecated('foo'));
}
public function testIsNotDeprecatedIfEmptyString()
{
$this->resolver
->setDefined('foo')
->setDeprecated('foo', '')
;
$this->assertFalse($this->resolver->isDeprecated('foo'));
}
/**
* @dataProvider provideDeprecationData
*/
public function testDeprecationMessages(\Closure $configureOptions, array $options, ?array $expectedError)
{
error_clear_last();
set_error_handler(function () { return false; });
$e = error_reporting(0);
$configureOptions($this->resolver);
$this->resolver->resolve($options);
error_reporting($e);
restore_error_handler();
$lastError = error_get_last();
unset($lastError['file'], $lastError['line']);
$this->assertSame($expectedError, $lastError);
}
public function provideDeprecationData()
{
yield 'It deprecates an option with default message' => array(
function (OptionsResolver $resolver) {
$resolver
->setDefined(array('foo', 'bar'))
->setDeprecated('foo')
;
},
array('foo' => 'baz'),
array(
'type' => E_USER_DEPRECATED,
'message' => 'The option "foo" is deprecated.',
),
);
yield 'It deprecates an option with custom message' => array(
function (OptionsResolver $resolver) {
$resolver
->setDefined('foo')
->setDefault('bar', function (Options $options) {
return $options['foo'];
})
->setDeprecated('foo', 'The option "foo" is deprecated, use "bar" option instead.')
;
},
array('foo' => 'baz'),
array(
'type' => E_USER_DEPRECATED,
'message' => 'The option "foo" is deprecated, use "bar" option instead.',
),
);
yield 'It deprecates a missing option with default value' => array(
function (OptionsResolver $resolver) {
$resolver
->setDefaults(array('foo' => null, 'bar' => null))
->setDeprecated('foo')
;
},
array('bar' => 'baz'),
array(
'type' => E_USER_DEPRECATED,
'message' => 'The option "foo" is deprecated.',
),
);
yield 'It deprecates allowed type and value' => array(
function (OptionsResolver $resolver) {
$resolver
->setDefault('foo', null)
->setAllowedTypes('foo', array('null', 'string', \stdClass::class))
->setDeprecated('foo', function ($value) {
if ($value instanceof \stdClass) {
return sprintf('Passing an instance of "%s" to option "foo" is deprecated, pass its FQCN instead.', \stdClass::class);
}
return '';
})
;
},
array('foo' => new \stdClass()),
array(
'type' => E_USER_DEPRECATED,
'message' => 'Passing an instance of "stdClass" to option "foo" is deprecated, pass its FQCN instead.',
),
);
yield 'It ignores deprecation for missing option without default value' => array(
function (OptionsResolver $resolver) {
$resolver
->setDefined(array('foo', 'bar'))
->setDeprecated('foo')
;
},
array('bar' => 'baz'),
null,
);
yield 'It ignores deprecation if closure returns an empty string' => array(
function (OptionsResolver $resolver) {
$resolver
->setDefault('foo', null)
->setDeprecated('foo', function ($value) {
return '';
})
;
},
array('foo' => Bar::class),
null,
);
}
/**
* @expectedException \Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException
*/