diff --git a/src/Symfony/Component/OptionsResolver/NestedOptions.php b/src/Symfony/Component/OptionsResolver/NestedOptions.php new file mode 100644 index 0000000000000..31671f768ff74 --- /dev/null +++ b/src/Symfony/Component/OptionsResolver/NestedOptions.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver; + +use Symfony\Component\OptionsResolver\Exception\AccessException; + +/** + * Validates nested options and merges them with default values. + * + * This class is used internally by the OptionsResolver. + * See {@link OptionsResolver::setNested()}. + * + * @author Jules Pietri + * + * @internal + */ +class NestedOptions extends OptionsResolver +{ + /** + * The root options instance. + * + * @var OptionsResolver + */ + private $root; + + /** + * The root option name. + * + * @var string + */ + private $rootName; + + /** + * This class should only be instantiated from an OptionsResolver. + * + * See {@link OptionsResolver::setNested()}. + * + * @param string $rootName The root option name + */ + public function __construct($rootName) + { + $this->rootName = $rootName; + } + + /** + * Binds the root options. + * + * This method should only be called from root OptionsResolver instance. + * See {@link OptionsResolver::__clone()}. + * + * @param OptionsResolver $root The root options + * + * @return NestedOptions This instance + */ + public function setRoot(OptionsResolver $root) + { + $this->root = $root; + + return $this; + } + + /** + * {@inheritdoc} + * + * This method should only be called while the root option is resolved + * {@link OptionsResolver::OffsetGet()} + * + * @throws AccessException If the root option is not locked + */ + public function resolve(array $options = array()) + { + if (!$this->root->isLocked()) { + throw new AccessException(sprintf('The Nested options of "%s" can only be resolved internally while their root is resolved.', $this->rootName)); + } + + return parent::resolve($options); + } + + /** + * {@inheritdoc} + */ + protected function normalize(\Closure $normalizer, $value) + { + // Pass the resolved parent options as third argument. + return $normalizer($this, $value, $this->root); + } +} diff --git a/src/Symfony/Component/OptionsResolver/Options.php b/src/Symfony/Component/OptionsResolver/Options.php index d444ec4230d51..2260cd0635051 100644 --- a/src/Symfony/Component/OptionsResolver/Options.php +++ b/src/Symfony/Component/OptionsResolver/Options.php @@ -19,4 +19,9 @@ */ interface Options extends \ArrayAccess, \Countable { + const NONE = 0; + const ALL = 1; + const DEFINED = 2; + const NESTED = 3; + const EXTRA = 4; } diff --git a/src/Symfony/Component/OptionsResolver/OptionsResolver.php b/src/Symfony/Component/OptionsResolver/OptionsResolver.php index bcaea9e4cbc0f..cd720b51ab0f4 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\ExceptionInterface; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; use Symfony\Component\OptionsResolver\Exception\NoSuchOptionException; @@ -40,6 +41,13 @@ class OptionsResolver implements Options */ private $defaults = array(); + /** + * The nested options. + * + * @var NestedOptions[] + */ + private $nested = array(); + /** * The names of required options. * @@ -68,6 +76,13 @@ class OptionsResolver implements Options */ private $allowedValues = array(); + /** + * A list of accepted values for all options. + * + * @var array + */ + private $allowedValuesForAll = array(); + /** * A list of accepted types for each option. * @@ -75,6 +90,20 @@ class OptionsResolver implements Options */ private $allowedTypes = array(); + /** + * A list of accepted types for all options. + * + * @var array + */ + private $allowedTypesForAll = array(); + + /** + * Whether to resolve undefined options. + * + * @var bool + */ + private $resolveUndefined = false; + /** * A list of closures for evaluating lazy options. * @@ -142,10 +171,23 @@ class OptionsResolver implements Options * is spread across different locations of your code, such as base and * sub-classes. * + * If you set default values of nested options, this method will return the + * nested instance for the same convenience as above. + * + * // Master class + * $options->setNested('connexion', array('port' => '80')); + * + * // Sub class inheriting $options + * $nestedOptions = $options->setDefault('connexion', array( + * 'port' => '443', // overrides default + * )); + * + * $nestedOptions->setRequired('type'); + * * @param string $option The name of the option * @param mixed $value The default value of the option * - * @return OptionsResolver This instance + * @return OptionsResolver|NestedOptions This instance or the nested instance * * @throws AccessException If called from a lazy option or normalizer */ @@ -167,7 +209,7 @@ public function setDefault($option, $value) if (isset($params[0]) && null !== ($class = $params[0]->getClass()) && Options::class === $class->name) { // Initialize the option if no previous value exists if (!isset($this->defaults[$option])) { - $this->defaults[$option] = null; + $this->defaults[$option] = $this->isNested($option) ? array() : null; } // Ignore previous lazy options if the closure has no second parameter @@ -189,6 +231,18 @@ public function setDefault($option, $value) // This option is not lazy anymore unset($this->lazy[$option]); + if ($this->isNested($option)) { + $defaults = isset($this->defaults[$option]) ? $this->defaults[$option] : array(); + $this->defaults[$option] = array_replace($defaults, $value); + $this->defined[$option] = true; + // Make sure the nested options are processed + unset($this->resolved[$option]); + + // Returning the nested options here is convenient when we need to + // override them from a sub class + return $this->nested[$option]; + } + // Yet undefined options can be marked as resolved, because we only need // to resolve options with lazy closures, normalizers or validation // rules, none of which can exist for undefined options @@ -236,6 +290,82 @@ public function hasDefault($option) return array_key_exists($option, $this->defaults); } + /** + * Defines an option as a new NestedOptions instance. + * + * Returns a new NestedOptions instance to configure nested options. + * + * $nestedOptions = $options->setNested('connexion', array( + * 'host' => 'localhost', + * 'port' => 80, + * ); + * + * $nestedOptions->setRequired('user'); + * $nestedOptions->setDefault('password', function (Options $nested) { + * return isset($nested['user']) ? '' : null; + * }); + * $nestedOptions->setDefined(array('secure')); + * $nestedOptions->setNormalizer('secure', function (Options $nested, $secure) { + * return 443 === $nested['port'] ?: $secure; + * }); + * $nestedOptions->setAllowedTypes('port', 'int'); + * + * @param string $option The option name + * @param array $defaults The default nested options + * + * @return NestedOptions The nested options resolver + * + * @throws AccessException If called from a lazy option or normalizer + */ + public function setNested($option, array $defaults = array()) + { + if ($this->locked) { + throw new AccessException('Options cannot be made nested from a lazy option or normalizer.'); + } + + $nestedOptions = new NestedOptions($option); + + foreach ($defaults as $name => $default) { + $nestedOptions->setDefault($name, $default); + } + + // Keep a raw copy of defaults until nested options are resolved allowing to + // easily override them, even using lazy definition with {@link setDefault()} + $this->defaults[$option] = $defaults; + $this->defined[$option] = true; + + // Make sure the nested options are processed + unset($this->resolved[$option]); + + return $this->nested[$option] = $nestedOptions; + } + + /** + * Returns whether an option is nested. + * + * An option is nested if it was passed to {@link setNested()}. + * + * @param string $option The name of the option + * + * @return bool Whether the option is nested + */ + public function isNested($option) + { + return isset($this->nested[$option]); + } + + /** + * Returns the names of all nested options. + * + * @return string[] The names of the nested options + * + * @see isNested() + */ + public function getNestedOptions() + { + return array_keys($this->nested); + } + /** * Marks one or more options as required. * @@ -443,7 +573,13 @@ public function setAllowedValues($option, $allowedValues) throw new AccessException('Allowed values cannot be set from a lazy option or normalizer.'); } - if (!isset($this->defined[$option])) { + if ($this->isNested($option)) { + @trigger_error(sprintf('The "%s" method should not be used with nested options. A failed attempt occurred with the option "%" for values %s', __METHOD__, $option, $this->formatValues($allowedValues)), E_USER_WARNING); + + return $this; + } + + if (!isset($this->defined[$option]) && !$this->resolveUndefined && Options::EXTRA !== $option) { throw new UndefinedOptionsException(sprintf( 'The option "%s" does not exist. Defined options are: "%s".', $option, @@ -488,7 +624,13 @@ public function addAllowedValues($option, $allowedValues) throw new AccessException('Allowed values cannot be added from a lazy option or normalizer.'); } - if (!isset($this->defined[$option])) { + if ($this->isNested($option)) { + @trigger_error(sprintf('The "%s" method should not be used with nested options. A failed attempt occurred with the option "%" for values %s', __METHOD__, $option, $this->formatValues($allowedValues)), E_USER_WARNING); + + return $this; + } + + if (!isset($this->defined[$option]) && !$this->resolveUndefined && Options::EXTRA !== $option) { throw new UndefinedOptionsException(sprintf( 'The option "%s" does not exist. Defined options are: "%s".', $option, @@ -512,6 +654,98 @@ public function addAllowedValues($option, $allowedValues) return $this; } + /** + * Adds allowed values for one or more options. + * + * First argument may be a name or an array of option names. + * + * You can pass a constant of Options interface as first argument to target + * a group of options. Supported values are: + * + * - Options::ALL Defined and extra options + * - Options::DEFINED + * - Options::NESTED + * - Options::EXTRA + * + * If a nested option name is passed it will apply to all nested options. + * You can prevent it by passing Options::NONE as fourth argument. + * + * Instead of passing values, you may also pass a closures with the + * following signature: + * + * function ($value) { + * // return true or false + * } + * + * The closure receives the value as argument and should return true to + * accept the value and false to reject the value. + * + * @param string|string[]|int $optionNames One or more option names + * or an Options constant + * @param mixed $allowedValues One or more accepted values + * or closures + * @param bool $replace Whether to replace previous + * values + * @param int $nested This method is recursive for + * nested options which is the + * default. Pass Options::NONE + * to change it. + * + * @return OptionsResolver This instance + * + * @throws UndefinedOptionsException If an option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function addAllowedValuesForAll($optionNames, $allowedValues, $replace = false, $nested = Options::ALL) + { + if ($this->locked) { + throw new AccessException('Allowed types cannot be added from a lazy option or normalizer.'); + } + + switch ($optionNames) { + case Options::ALL: + // Use this values for all defined and extra options + // except nested which are always arrays. + $this->allowedValuesForAll = $replace + ? $allowedValues : array_merge($this->allowedValuesForAll, $allowedValues); + + // If not recursive return + if (Options::NONE === $nested) { + return $this; + } + + $optionNames = $this->getNestedOptions(); + break; + case Options::NESTED: + $optionNames = $this->getNestedOptions(); + break; + case Options::DEFINED: + $optionNames = $this->getDefinedOptions(); + break; + case Options::NONE: + // Not supported + return $this; + default: + // A custom array of option names or + // one option name or Options::EXTRA + $optionNames = (array) $optionNames; + } + + foreach ($optionNames as $option) { + if ($this->isNested($option) && Options::NONE !== $nested) { + $this->nested[$option]->addAllowedValuesForAll($nested, $allowedValues, $replace); + } else { + if ($replace) { + $this->setAllowedValues($option, $allowedValues); + } else { + $this->addAllowedValues($option, $allowedValues); + } + } + } + + return $this; + } + /** * Sets allowed types for an option. * @@ -533,7 +767,13 @@ public function setAllowedTypes($option, $allowedTypes) throw new AccessException('Allowed types cannot be set from a lazy option or normalizer.'); } - if (!isset($this->defined[$option])) { + if ($this->isNested($option)) { + @trigger_error(sprintf('The "%s" method should not be used with nested options. A failed attempt occurred with the option "%" for types %s', __METHOD__, $option, $this->formatValues($allowedTypes)), E_USER_WARNING); + + return $this; + } + + if (!isset($this->defined[$option]) && !$this->resolveUndefined && Options::EXTRA !== $option) { throw new UndefinedOptionsException(sprintf( 'The option "%s" does not exist. Defined options are: "%s".', $option, @@ -572,7 +812,13 @@ public function addAllowedTypes($option, $allowedTypes) throw new AccessException('Allowed types cannot be added from a lazy option or normalizer.'); } - if (!isset($this->defined[$option])) { + if ($this->isNested($option)) { + @trigger_error(sprintf('The "%s" method should not be used with nested options. A failed attempt occurred with the option "%" for types %s', __METHOD__, $option, $this->formatValues($allowedTypes)), E_USER_WARNING); + + return $this; + } + + if (!isset($this->defined[$option]) && !$this->resolveUndefined && Options::EXTRA !== $option) { throw new UndefinedOptionsException(sprintf( 'The option "%s" does not exist. Defined options are: "%s".', $option, @@ -592,6 +838,159 @@ public function addAllowedTypes($option, $allowedTypes) return $this; } + /** + * Adds allowed types for one or more options. + * + * First argument may be a name or an array of option names. + * + * You can pass a constant of Options interface as first argument to target + * a group of options. Supported values are: + * + * - Options::ALL Defined and extra options + * - Options::DEFINED + * - Options::NESTED + * + * If a nested option name is passed it will apply to all nested options. + * You can prevent it by passing Options::NONE as fourth argument. + * + * Any type for which a corresponding is_() function exists is + * acceptable. Additionally, fully-qualified class or interface names may + * be passed. + * + * @param string|string[]|int $optionNames One or more option names + * or Options::ALL + * @param string|string[] $allowedTypes One or more accepted types + * @param bool $replace Whether to replace previous + * value + * @param int $nested This method is recursive for + * nested options which is the + * default. Pass Options::NONE + * to change it. + * + * @return OptionsResolver This instance + * + * @throws UndefinedOptionsException If an option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function addAllowedTypesForAll($optionNames, $allowedTypes, $replace = false, $nested = Options::ALL) + { + if ($this->locked) { + throw new AccessException('Allowed types cannot be added from a lazy option or normalizer.'); + } + + switch ($optionNames) { + case Options::ALL: + // Use this values for all defined and extra options + // except nested which are always arrays. + $this->allowedTypesForAll = $replace + ? array_merge($this->allowedTypesForAll, $allowedTypes) : $allowedTypes; + + // If not recursive return + if (Options::NONE === $nested) { + return $this; + } + + $optionNames = $this->getNestedOptions(); + break; + case Options::NESTED: + $optionNames = $this->getNestedOptions(); + break; + case Options::DEFINED: + $optionNames = $this->getDefinedOptions(); + break; + case Options::NONE: + // Not supported + return $this; + default: + // A custom array of option names or + // one option name or Options::EXTRA + $optionNames = (array) $optionNames; + } + + foreach ($optionNames as $option) { + if ($this->isNested($option) && Options::NONE !== $nested) { + $this->nested[$option]->addAllowedTypesForAll($nested, $allowedTypes, $replace); + } else { + if ($replace) { + $this->setAllowedTypes($option, $allowedTypes); + } else { + $this->addAllowedTypes($option, $allowedTypes); + } + } + } + + return $this; + } + + /** + * Add a prototype for extra or all options. + * + * If this instance was not accepting extra options before, this method + * allows it by default. + * + * First argument is an allowed type and second is one more values/closures. + * By default it only applies on undefined options, to validate previous defaults + * as well, pass Options::ALL as third argument. + * + * This method overrides previous allowed type and values for concerned options. + * + * $resolver->setNested('emails') + * ->setPrototype(array( + * 'string' => function ($email) use ($regex) { + * return preg_match($regex, $email); + * }, + * ); + * + * @param string $type A string defining an allowed type + * @param mixed $values One or more allowed values/closures + * @param int $options Options::EXTRA by default, but you can pass + * Options::ALL to apply the rule on previous defaults + * + * @return OptionsResolver This instance + * + * @throws UndefinedOptionsException If an option is undefined + * @throws AccessException If called from a lazy option or normalizer + */ + public function setPrototype($type, $values, $options = Options::EXTRA) + { + if ($this->locked) { + throw new AccessException('Allowed types cannot be added from a lazy option or normalizer.'); + } + + $this->resolveUndefined = true; + + $this->addAllowedTypesForAll($options, $type, true, Options::NONE); + $this->addAllowedValuesForAll($options, $values, true, Options::NONE); + + return $this; + } + + /** + * Defines whether undefined options should be resolved. + * + * @param bool $allow Whether to resolve undefined options + * + * @return OptionsResolver This instance + */ + public function allowExtraOptions($allow = true) + { + $this->resolveUndefined = $allow; + + return $this; + } + + /** + * Returns whether this instance is locked. + * + * @return bool Whether this instance is locked + * + * @internal Used by {@see NestedOptions} to prevent external resolving + */ + public function isLocked() + { + return $this->locked; + } + /** * Removes the option with the given name. * @@ -610,8 +1009,9 @@ public function remove($optionNames) } foreach ((array) $optionNames as $option) { - unset($this->defined[$option], $this->defaults[$option], $this->required[$option], $this->resolved[$option]); - unset($this->lazy[$option], $this->normalizers[$option], $this->allowedTypes[$option], $this->allowedValues[$option]); + unset($this->defined[$option], $this->defaults[$option], $this->nested[$option]); + unset($this->required[$option], $this->resolved[$option], $this->lazy[$option]); + unset($this->normalizers[$option], $this->allowedTypes[$option], $this->allowedValues[$option]); } return $this; @@ -632,6 +1032,7 @@ public function clear() $this->defined = array(); $this->defaults = array(); + $this->nested = array(); $this->required = array(); $this->resolved = array(); $this->lazy = array(); @@ -678,7 +1079,7 @@ public function resolve(array $options = array()) // Make sure that no unknown options are passed $diff = array_diff_key($options, $clone->defined); - if (count($diff) > 0) { + if (count($diff) > 0 && false === $this->resolveUndefined) { ksort($clone->defined); ksort($diff); @@ -691,7 +1092,13 @@ public function resolve(array $options = array()) // Override options set by the user foreach ($options as $option => $value) { - $clone->defaults[$option] = $value; + if ($clone->isNested($option)) { + $defaults = isset($clone->defaults[$option]) ? $clone->defaults[$option] : array(); + $clone->defaults[$option] = array_replace($defaults, $value); + } else { + $clone->defaults[$option] = $value; + } + unset($clone->resolved[$option], $clone->lazy[$option]); } @@ -789,11 +1196,30 @@ public function offsetGet($option) // END } + if ($this->isNested($option)) { + try { + $value = $this->nested[$option]->resolve($value); + } catch (ExceptionInterface $e) { + throw new InvalidOptionsException(sprintf('The nested options in the option "%s" could not be resolved.', $option), 0, $e); + } + } + // Validate the type of the resolved option - if (isset($this->allowedTypes[$option])) { + if (isset($this->allowedTypes[$option]) + || ($this->allowedTypesForAll && false === $this->isNested($option)) + || $extra = (isset($this->allowedTypes[Options::EXTRA]) && false === $this->isDefined($option)) + ) { $valid = false; - foreach ($this->allowedTypes[$option] as $type) { + if (isset($extra) && $extra && $this->resolveUndefined) { + $allowedTypes = $this->allowedTypes[Options::EXTRA]; + } else { + $allowedTypes = isset($this->allowedTypes[$option]) ? $this->allowedTypes[$option] : array(); + } + + $allowedTypes = array_unique(array_merge($allowedTypes, $this->allowedTypesForAll)); + + foreach ($allowedTypes as $type) { $type = isset(self::$typeAliases[$type]) ? self::$typeAliases[$type] : $type; if (function_exists($isFunction = 'is_'.$type)) { @@ -824,11 +1250,22 @@ public function offsetGet($option) } // Validate the value of the resolved option - if (isset($this->allowedValues[$option])) { + if (isset($this->allowedValues[$option]) + || ($this->allowedValuesForAll && false === $this->isNested($option)) + || $extra = (isset($this->allowedValues[Options::EXTRA]) && false === $this->isDefined($option)) + ) { $success = false; $printableAllowedValues = array(); - foreach ($this->allowedValues[$option] as $allowedValue) { + if (isset($extra) && $extra && $this->resolveUndefined) { + $allowedValues = $this->allowedTypes[Options::EXTRA]; + } else { + $allowedValues = isset($this->allowedValues[$option]) ? $this->allowedValues[$option] : array(); + } + + $allowedValues = array_merge($allowedValues, $this->allowedValuesForAll); + + foreach ($allowedValues as $allowedValue) { if ($allowedValue instanceof \Closure) { if ($allowedValue($value)) { $success = true; @@ -882,7 +1319,7 @@ public function offsetGet($option) // BEGIN $this->calling[$option] = true; try { - $value = $normalizer($this, $value); + $value = $this->normalize($normalizer, $value); } finally { unset($this->calling[$option]); } @@ -955,6 +1392,29 @@ public function count() return count($this->defaults); } + public function __clone() + { + foreach ($this->nested as $name => $options) { + $nestedOptions = clone $options; + $this->nested[$name] = $nestedOptions->setRoot($this); + } + } + + /** + * Executes the normalizer on the validated option value. + * + * Extract this bit of logic in order to override it in NestedOptions. + * + * @param \Closure $normalizer The option normalizer + * @param mixed $value The validated option value to normalize + * + * @return mixed The normalized option value + */ + protected function normalize(\Closure $normalizer, $value) + { + return $normalizer($this, $value); + } + /** * Returns a string representation of the type of the value. * diff --git a/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php b/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php index 216347a7950fa..2e683096eee79 100644 --- a/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php +++ b/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php @@ -230,6 +230,53 @@ public function testInvokeEachLazyOptionOnlyOnce() $this->assertSame(2, $calls); } + //////////////////////////////////////////////////////////////////////////// + // setNested()/isNested()/getNestedOptions() + //////////////////////////////////////////////////////////////////////////// + + public function testSetNestedReturnsNewResolver() + { + $this->assertNotSame($this->resolver, $this->resolver->setNested('foo')); + $this->assertInstanceOf('Symfony\Component\OptionsResolver\OptionsResolver', $this->resolver->setNested('bar')); + } + + public function testSetNested() + { + $this->resolver->setNested('one', array('un' => '1')); + $this->resolver->setNested('two', array('deux' => '2', 'vingt' => 20)); + + $this->assertEquals(array( + 'one' => array('un' => '1'), + 'two' => array('deux' => '2', 'vingt' => 20), + ), $this->resolver->resolve()); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\AccessException + */ + public function testFailIfSetNestedFromLazyOption() + { + $this->resolver->setDefault('lazy', function (Options $options) { + $options->setNested('nested', array('number' => 42)); + }); + + $this->resolver->resolve(); + } + + public function testIsNested() + { + $this->assertFalse($this->resolver->isNested('foo')); + $this->resolver->setNested('foo', array('number' => 42)); + $this->assertTrue($this->resolver->isNested('foo')); + } + + public function testIsNestedWithNoValue() + { + $this->assertFalse($this->resolver->isNested('foo')); + $this->resolver->setNested('foo'); + $this->assertTrue($this->resolver->isNested('foo')); + } + //////////////////////////////////////////////////////////////////////////// // setRequired()/isRequired()/getRequiredOptions() //////////////////////////////////////////////////////////////////////////// @@ -1547,4 +1594,20 @@ public function testCountFailsOutsideResolve() count($this->resolver); } + + //////////////////////////////////////////////////////////////////////////// + // Nested options + //////////////////////////////////////////////////////////////////////////// + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + * @expectedExceptionMessage The nested options in the option "z" could not be resolved. + */ + public function testResolveFailsIfNonExistingNestedOption() + { + $this->resolver->setNested('z', array('one' => '1')); + $this->resolver->setNested('a', array('two' => '2')); + + $this->resolver->resolve(array('z' => array('foo' => 'bar'))); + } }