From f8746ce8bd7d3707c291fb7bf175c5c880ce3cdb Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Wed, 9 May 2018 12:49:31 -0400 Subject: [PATCH] Add ability to deprecate options --- .../Component/OptionsResolver/CHANGELOG.md | 5 + .../OptionsResolver/OptionsResolver.php | 71 +++++++ .../Tests/OptionsResolverTest.php | 181 ++++++++++++++++++ 3 files changed, 257 insertions(+) diff --git a/src/Symfony/Component/OptionsResolver/CHANGELOG.md b/src/Symfony/Component/OptionsResolver/CHANGELOG.md index 6e9d49fb61d75..ec0084acd15b0 100644 --- a/src/Symfony/Component/OptionsResolver/CHANGELOG.md +++ b/src/Symfony/Component/OptionsResolver/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +4.2.0 +----- + + * added `setDeprecated` and `isDeprecated` methods + 3.4.0 ----- diff --git a/src/Symfony/Component/OptionsResolver/OptionsResolver.php b/src/Symfony/Component/OptionsResolver/OptionsResolver.php index 68b4154b10da2..d42c567ab74e9 100644 --- a/src/Symfony/Component/OptionsResolver/OptionsResolver.php +++ b/src/Symfony/Component/OptionsResolver/OptionsResolver.php @@ -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 @@ public function getDefinedOptions() 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 @@ public function clear() $this->normalizers = array(); $this->allowedTypes = array(); $this->allowedValues = array(); + $this->deprecated = array(); return $this; } @@ -836,6 +894,19 @@ public function offsetGet($option) } } + // 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 diff --git a/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php b/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php index 12dc77b3c6b1c..8f3ab6370dc4c 100644 --- a/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php +++ b/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php @@ -450,6 +450,187 @@ public function testClearedOptionsAreNotDefined() $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 */