From 90660255a21ea9a47191ff30c65e84d3a78c072c Mon Sep 17 00:00:00 2001 From: Bernhard Schussek Date: Wed, 20 Aug 2014 12:35:25 +0200 Subject: [PATCH] [OptionsResolver] Added a light-weight, low-level API for basic option resolving --- .../Exception/InvalidArgumentException.php | 21 + .../Component/OptionsResolver/Options.php | 261 ++++++++++- .../OptionsResolver/OptionsConfig.php | 354 +++++++++++++++ .../OptionsResolver/OptionsResolver.php | 327 +------------- .../Component/OptionsResolver/README.md | 99 +---- .../Tests/OptionsResolverTest.php | 25 +- .../OptionsResolver/Tests/OptionsTest.php | 414 +++++++++++++++++- 7 files changed, 1066 insertions(+), 435 deletions(-) create mode 100644 src/Symfony/Component/OptionsResolver/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/OptionsResolver/OptionsConfig.php diff --git a/src/Symfony/Component/OptionsResolver/Exception/InvalidArgumentException.php b/src/Symfony/Component/OptionsResolver/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000..6d421d68b3 --- /dev/null +++ b/src/Symfony/Component/OptionsResolver/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\OptionsResolver\Exception; + +/** + * Thrown when an argument is invalid. + * + * @author Bernhard Schussek + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/OptionsResolver/Options.php b/src/Symfony/Component/OptionsResolver/Options.php index 43c81f0c33..135b20a12a 100644 --- a/src/Symfony/Component/OptionsResolver/Options.php +++ b/src/Symfony/Component/OptionsResolver/Options.php @@ -11,6 +11,9 @@ namespace Symfony\Component\OptionsResolver; +use Symfony\Component\OptionsResolver\Exception\InvalidArgumentException; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; use Symfony\Component\OptionsResolver\Exception\OptionDefinitionException; /** @@ -56,6 +59,244 @@ class Options implements \ArrayAccess, \Iterator, \Countable */ private $reading = false; + /** + * Merges options with an array of default values and throws an exception if + * any of the options does not exist. + * + * @param array $options A list of option names and + * values + * @param array|Options|OptionsConfig $defaults The accepted options and + * their default values + * + * @return array The merged and validated options + * + * @throws InvalidOptionsException If any of the options is not present in + * the defaults array + * @throws InvalidArgumentException If the defaults are invalid + * + * @since 2.6 + */ + public static function resolve(array $options, $defaults) + { + if (is_array($defaults)) { + static::validateNames($options, $defaults, true); + + return array_replace($defaults, $options); + } + + if ($defaults instanceof self) { + static::validateNames($options, $defaults->options, true); + + // Make sure this method can be called multiple times + $combinedOptions = clone $defaults; + + // Override options set by the user + foreach ($options as $option => $value) { + $combinedOptions->set($option, $value); + } + + // Resolve options + return $combinedOptions->all(); + } + + if ($defaults instanceof OptionsConfig) { + static::validateNames($options, $defaults->knownOptions, true); + static::validateRequired($options, $defaults->requiredOptions, true); + + // Make sure this method can be called multiple times + $combinedOptions = clone $defaults->defaultOptions; + + // Override options set by the user + foreach ($options as $option => $value) { + $combinedOptions->set($option, $value); + } + + // Resolve options + $resolvedOptions = $combinedOptions->all(); + + static::validateTypes($resolvedOptions, $defaults->allowedTypes); + static::validateValues($resolvedOptions, $defaults->allowedValues); + + return $resolvedOptions; + } + + throw new InvalidArgumentException('The second argument is expected to be given as array, Options instance or OptionsConfig instance.'); + } + + /** + * Validates that the given option names exist and throws an exception + * otherwise. + * + * @param array $options A list of option names and values + * @param string|array $acceptedOptions The accepted option(s), either passed + * as single string or in the values of + * the given array + * @param bool $namesAsKeys If set to true, the option names + * should be passed in the keys of the + * accepted options array + * + * @throws InvalidOptionsException If any of the options is not present in + * the accepted options + * + * @since 2.6 + */ + public static function validateNames(array $options, $acceptedOptions, $namesAsKeys = false) + { + $acceptedOptions = (array) $acceptedOptions; + + if (!$namesAsKeys) { + $acceptedOptions = array_flip($acceptedOptions); + } + + $diff = array_diff_key($options, $acceptedOptions); + + if (count($diff) > 0) { + ksort($acceptedOptions); + ksort($diff); + + throw new InvalidOptionsException(sprintf( + (count($diff) > 1 ? 'The options "%s" do not exist.' : 'The option "%s" does not exist.').' Known options are: "%s"', + implode('", "', array_keys($diff)), + implode('", "', array_keys($acceptedOptions)) + )); + } + } + + /** + * Validates that the required options are given and throws an exception + * otherwise. + * + * The option names may be any strings that don't consist exclusively of + * digits. For example, "case1" is a valid option name, "1" is not. + * + * @param array $options A list of option names and values + * @param string|array $requiredOptions The required option(s), either + * passed as single string or in the + * values of the given array + * @param bool $namesAsKeys If set to true, the option names + * should be passed in the keys of the + * required options array + * + * @throws MissingOptionsException If a required option is missing + * + * @since 2.6 + */ + public static function validateRequired(array $options, $requiredOptions, $namesAsKeys = false) + { + $requiredOptions = (array) $requiredOptions; + + if (!$namesAsKeys) { + $requiredOptions = array_flip($requiredOptions); + } + + $diff = array_diff_key($requiredOptions, $options); + + if (count($diff) > 0) { + ksort($diff); + + throw new MissingOptionsException(sprintf( + count($diff) > 1 ? 'The required options "%s" are missing.' : 'The required option "%s" is missing.', + implode('", "', array_keys($diff)) + )); + } + } + + /** + * Validates that the given options match the accepted types and + * throws an exception otherwise. + * + * Accepted type names are any types for which a native "is_*()" function + * exists. For example, "int" is an acceptable type name and will be checked + * with the "is_int()" function. + * + * Types may also be passed as closures which return true or false. + * + * @param array $options A list of option names and values + * @param array $acceptedTypes A mapping of option names to accepted option + * types. The types may be given as + * string/closure or as array of strings/closures + * + * @throws InvalidOptionsException If any of the types does not match the + * accepted types of the option + * + * @since 2.6 + */ + public static function validateTypes(array $options, array $acceptedTypes) + { + foreach ($acceptedTypes as $option => $optionTypes) { + if (!array_key_exists($option, $options)) { + continue; + } + + $value = $options[$option]; + $optionTypes = (array) $optionTypes; + + foreach ($optionTypes as $type) { + $isFunction = 'is_'.$type; + + if (function_exists($isFunction) && $isFunction($value)) { + continue 2; + } elseif ($value instanceof $type) { + continue 2; + } + } + + $printableValue = is_object($value) + ? get_class($value) + : (is_array($value) + ? 'Array' + : (string) $value); + + throw new InvalidOptionsException(sprintf( + 'The option "%s" with value "%s" is expected to be of type "%s"', + $option, + $printableValue, + implode('", "', $optionTypes) + )); + } + } + + /** + * Validates that the given option values match the accepted values and + * throws an exception otherwise. + * + * @param array $options A list of option names and values + * @param array $acceptedValues A mapping of option names to accepted option + * values. The option values must be given as + * arrays + * + * @throws InvalidOptionsException If any of the values does not match the + * accepted values of the option + * + * @since 2.6 + */ + public static function validateValues(array $options, array $acceptedValues) + { + foreach ($acceptedValues as $option => $optionValues) { + if (array_key_exists($option, $options)) { + if (is_array($optionValues) && !in_array($options[$option], $optionValues, true)) { + throw new InvalidOptionsException(sprintf('The option "%s" has the value "%s", but is expected to be one of "%s"', $option, $options[$option], implode('", "', $optionValues))); + } + + if (is_callable($optionValues) && !call_user_func($optionValues, $options[$option])) { + throw new InvalidOptionsException(sprintf('The option "%s" has the value "%s", which it is not valid', $option, $options[$option])); + } + } + } + } + + /** + * Constructs a new object with a set of default options. + * + * @param array $options A list of option names and values + */ + public function __construct(array $options = array()) + { + foreach ($options as $option => $value) { + $this->set($option, $value); + } + } + /** * Sets the value of a given option. * @@ -179,8 +420,10 @@ class Options implements \ArrayAccess, \Iterator, \Countable // If an option is a closure that should be evaluated lazily, store it // in the "lazy" property. - if ($value instanceof \Closure) { - $reflClosure = new \ReflectionFunction($value); + if (is_callable($value)) { + $reflClosure = is_array($value) + ? new \ReflectionMethod($value[0], $value[1]) + : new \ReflectionFunction($value); $params = $reflClosure->getParameters(); if (isset($params[0]) && null !== ($class = $params[0]->getClass()) && __CLASS__ === $class->name) { @@ -229,11 +472,11 @@ class Options implements \ArrayAccess, \Iterator, \Countable } if (isset($this->lazy[$option])) { - $this->resolve($option); + $this->resolveOption($option); } if (isset($this->normalizers[$option])) { - $this->normalize($option); + $this->normalizeOption($option); } return $this->options[$option]; @@ -306,13 +549,13 @@ class Options implements \ArrayAccess, \Iterator, \Countable // Double check, in case the option has already been resolved // by cascade in the previous cycles if (isset($this->lazy[$option])) { - $this->resolve($option); + $this->resolveOption($option); } } foreach ($this->normalizers as $option => $normalizer) { if (isset($this->normalizers[$option])) { - $this->normalize($option); + $this->normalizeOption($option); } } @@ -444,7 +687,7 @@ class Options implements \ArrayAccess, \Iterator, \Countable * @throws OptionDefinitionException If the option has a cyclic dependency * on another option. */ - private function resolve($option) + private function resolveOption($option) { // The code duplication with normalize() exists for performance // reasons, in order to save a method call. @@ -464,7 +707,7 @@ class Options implements \ArrayAccess, \Iterator, \Countable $this->lock[$option] = true; foreach ($this->lazy[$option] as $closure) { - $this->options[$option] = $closure($this, $this->options[$option]); + $this->options[$option] = call_user_func($closure, $this, $this->options[$option]); } unset($this->lock[$option]); @@ -482,7 +725,7 @@ class Options implements \ArrayAccess, \Iterator, \Countable * @throws OptionDefinitionException If the option has a cyclic dependency * on another option. */ - private function normalize($option) + private function normalizeOption($option) { // The code duplication with resolve() exists for performance // reasons, in order to save a method call. diff --git a/src/Symfony/Component/OptionsResolver/OptionsConfig.php b/src/Symfony/Component/OptionsResolver/OptionsConfig.php new file mode 100644 index 0000000000..31c4237f71 --- /dev/null +++ b/src/Symfony/Component/OptionsResolver/OptionsConfig.php @@ -0,0 +1,354 @@ + + * + * 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\OptionDefinitionException; + +/** + * Stores option configuration. + * + * @author Bernhard Schussek + * @author Tobias Schultze + * + * @since 2.6 + */ +class OptionsConfig +{ + /** + * The default option values. + * + * @var Options + * + * @internal Public for performance reasons. Should not be accessed by user + * code. + */ + public $defaultOptions; + + /** + * The options known by the resolver. + * + * @var array + * + * @internal Public for performance reasons. Should not be accessed by user + * code. + */ + public $knownOptions = array(); + + /** + * The options without defaults that are required to be passed to resolve(). + * + * @var array + * + * @internal Public for performance reasons. Should not be accessed by user + * code. + */ + public $requiredOptions = array(); + + /** + * A list of accepted values for each option. + * + * @var array + * + * @internal Public for performance reasons. Should not be accessed by user + * code. + */ + public $allowedValues = array(); + + /** + * A list of accepted types for each option. + * + * @var array + * + * @internal Public for performance reasons. Should not be accessed by user + * code. + */ + public $allowedTypes = array(); + + /** + * Creates a new instance. + */ + public function __construct() + { + $this->defaultOptions = new Options(); + } + + /** + * Clones the resolver. + */ + public function __clone() + { + $this->defaultOptions = clone $this->defaultOptions; + } + + /** + * Sets default option values. + * + * The options can either be values of any types or closures that + * evaluate the option value lazily. These closures must have one + * of the following signatures: + * + * + * function (Options $options) + * function (Options $options, $value) + * + * + * The second parameter passed to the closure is the previously + * set default value, in case you are overwriting an existing + * default value. + * + * The closures should return the lazily created option value. + * + * @param array $defaultValues A list of option names as keys and default + * values or closures as values + * + * @return OptionsConfig This configuration instance + */ + public function setDefaults(array $defaultValues) + { + foreach ($defaultValues as $option => $value) { + $this->defaultOptions->overload($option, $value); + $this->knownOptions[$option] = true; + unset($this->requiredOptions[$option]); + } + + return $this; + } + + /** + * Replaces default option values. + * + * Old defaults are erased, which means that closures passed here cannot + * access the previous default value. This may be useful to improve + * performance if the previous default value is calculated by an expensive + * closure. + * + * @param array $defaultValues A list of option names as keys and default + * values or closures as values + * + * @return OptionsConfig This configuration instance + */ + public function replaceDefaults(array $defaultValues) + { + foreach ($defaultValues as $option => $value) { + $this->defaultOptions->set($option, $value); + $this->knownOptions[$option] = true; + unset($this->requiredOptions[$option]); + } + + return $this; + } + + /** + * Sets optional options. + * + * This method declares valid option names without setting default values for them. + * If these options are not passed to {@link resolve()} and no default has been set + * for them, they will be missing in the final options array. This can be helpful + * if you want to determine whether an option has been set or not because otherwise + * {@link resolve()} would trigger an exception for unknown options. + * + * @param array $optionNames A list of option names + * + * @return OptionsConfig This configuration instance + * + * @throws Exception\OptionDefinitionException When trying to pass default values + */ + public function setOptional(array $optionNames) + { + foreach ($optionNames as $key => $option) { + if (!is_int($key)) { + throw new OptionDefinitionException('You should not pass default values to setOptional()'); + } + + $this->knownOptions[$option] = true; + } + + return $this; + } + + /** + * Sets required options. + * + * If these options are not passed to {@link resolve()} and no default has been set for + * them, an exception will be thrown. + * + * @param array $optionNames A list of option names + * + * @return OptionsConfig This configuration instance + * + * @throws Exception\OptionDefinitionException When trying to pass default values + */ + public function setRequired(array $optionNames) + { + foreach ($optionNames as $key => $option) { + if (!is_int($key)) { + throw new OptionDefinitionException('You should not pass default values to setRequired()'); + } + + $this->knownOptions[$option] = true; + // set as required if no default has been set already + if (!isset($this->defaultOptions[$option])) { + $this->requiredOptions[$option] = true; + } + } + + return $this; + } + + /** + * Sets allowed values for a list of options. + * + * @param array $allowedValues A list of option names as keys and arrays + * with values acceptable for that option as + * values. + * + * @return OptionsConfig This configuration instance + * + * @throws Exception\InvalidOptionsException If an option has not been defined + * (see {@link isKnown()}) for which + * an allowed value is set + */ + public function setAllowedValues(array $allowedValues) + { + Options::validateNames($allowedValues, $this->knownOptions, true); + + $this->allowedValues = array_replace($this->allowedValues, $allowedValues); + + return $this; + } + + /** + * Adds allowed values for a list of options. + * + * The values are merged with the allowed values defined previously. + * + * @param array $allowedValues A list of option names as keys and arrays + * with values acceptable for that option as + * values + * + * @return OptionsConfig This configuration instance + * + * @throws Exception\InvalidOptionsException If an option has not been defined + * (see {@link isKnown()}) for which + * an allowed value is set + */ + public function addAllowedValues(array $allowedValues) + { + Options::validateNames($allowedValues, $this->knownOptions, true); + + $this->allowedValues = array_merge_recursive($this->allowedValues, $allowedValues); + + return $this; + } + + /** + * Sets allowed types for a list of options. + * + * @param array $allowedTypes A list of option names as keys and type + * names passed as string or array as values + * + * @return OptionsConfig This configuration instance + * + * @throws Exception\InvalidOptionsException If an option has not been defined for + * which an allowed type is set + */ + public function setAllowedTypes(array $allowedTypes) + { + Options::validateNames($allowedTypes, $this->knownOptions, true); + + $this->allowedTypes = array_replace($this->allowedTypes, $allowedTypes); + + return $this; + } + + /** + * Adds allowed types for a list of options. + * + * The types are merged with the allowed types defined previously. + * + * @param array $allowedTypes A list of option names as keys and type + * names passed as string or array as values + * + * @return OptionsConfig This configuration instance + * + * @throws Exception\InvalidOptionsException If an option has not been defined for + * which an allowed type is set + */ + public function addAllowedTypes(array $allowedTypes) + { + Options::validateNames($allowedTypes, $this->knownOptions, true); + + $this->allowedTypes = array_merge_recursive($this->allowedTypes, $allowedTypes); + + return $this; + } + + /** + * Sets normalizers that are applied on resolved options. + * + * The normalizers should be closures with the following signature: + * + * + * function (Options $options, $value) + * + * + * The second parameter passed to the closure is the value of + * the option. + * + * The closure should return the normalized value. + * + * @param array $normalizers An array of closures + * + * @return OptionsConfig This configuration instance + */ + public function setNormalizers(array $normalizers) + { + Options::validateNames($normalizers, $this->knownOptions, true); + + foreach ($normalizers as $option => $normalizer) { + $this->defaultOptions->setNormalizer($option, $normalizer); + } + + return $this; + } + + /** + * Returns whether an option is known. + * + * An option is known if it has been passed to either {@link setDefaults()}, + * {@link setRequired()} or {@link setOptional()} before. + * + * @param string $option The name of the option + * + * @return bool Whether the option is known + */ + public function isKnown($option) + { + return isset($this->knownOptions[$option]); + } + + /** + * Returns whether an option is required. + * + * An option is required if it has been passed to {@link setRequired()}, + * but not to {@link setDefaults()}. That is, the option has been declared + * as required and no default value has been set. + * + * @param string $option The name of the option + * + * @return bool Whether the option is required + */ + public function isRequired($option) + { + return isset($this->requiredOptions[$option]); + } +} diff --git a/src/Symfony/Component/OptionsResolver/OptionsResolver.php b/src/Symfony/Component/OptionsResolver/OptionsResolver.php index 9c5dfd628c..10424d58b4 100644 --- a/src/Symfony/Component/OptionsResolver/OptionsResolver.php +++ b/src/Symfony/Component/OptionsResolver/OptionsResolver.php @@ -11,342 +11,19 @@ namespace Symfony\Component\OptionsResolver; -use Symfony\Component\OptionsResolver\Exception\OptionDefinitionException; -use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; -use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; - /** * Helper for merging default and concrete option values. * * @author Bernhard Schussek * @author Tobias Schultze */ -class OptionsResolver implements OptionsResolverInterface +class OptionsResolver extends OptionsConfig implements OptionsResolverInterface { - /** - * The default option values. - * @var Options - */ - private $defaultOptions; - - /** - * The options known by the resolver. - * @var array - */ - private $knownOptions = array(); - - /** - * The options without defaults that are required to be passed to resolve(). - * @var array - */ - private $requiredOptions = array(); - - /** - * A list of accepted values for each option. - * @var array - */ - private $allowedValues = array(); - - /** - * A list of accepted types for each option. - * @var array - */ - private $allowedTypes = array(); - - /** - * Creates a new instance. - */ - public function __construct() - { - $this->defaultOptions = new Options(); - } - - /** - * Clones the resolver. - */ - public function __clone() - { - $this->defaultOptions = clone $this->defaultOptions; - } - - /** - * {@inheritdoc} - */ - public function setDefaults(array $defaultValues) - { - foreach ($defaultValues as $option => $value) { - $this->defaultOptions->overload($option, $value); - $this->knownOptions[$option] = true; - unset($this->requiredOptions[$option]); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function replaceDefaults(array $defaultValues) - { - foreach ($defaultValues as $option => $value) { - $this->defaultOptions->set($option, $value); - $this->knownOptions[$option] = true; - unset($this->requiredOptions[$option]); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function setOptional(array $optionNames) - { - foreach ($optionNames as $key => $option) { - if (!is_int($key)) { - throw new OptionDefinitionException('You should not pass default values to setOptional()'); - } - - $this->knownOptions[$option] = true; - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function setRequired(array $optionNames) - { - foreach ($optionNames as $key => $option) { - if (!is_int($key)) { - throw new OptionDefinitionException('You should not pass default values to setRequired()'); - } - - $this->knownOptions[$option] = true; - // set as required if no default has been set already - if (!isset($this->defaultOptions[$option])) { - $this->requiredOptions[$option] = true; - } - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function setAllowedValues(array $allowedValues) - { - $this->validateOptionsExistence($allowedValues); - - $this->allowedValues = array_replace($this->allowedValues, $allowedValues); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function addAllowedValues(array $allowedValues) - { - $this->validateOptionsExistence($allowedValues); - - $this->allowedValues = array_merge_recursive($this->allowedValues, $allowedValues); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function setAllowedTypes(array $allowedTypes) - { - $this->validateOptionsExistence($allowedTypes); - - $this->allowedTypes = array_replace($this->allowedTypes, $allowedTypes); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function addAllowedTypes(array $allowedTypes) - { - $this->validateOptionsExistence($allowedTypes); - - $this->allowedTypes = array_merge_recursive($this->allowedTypes, $allowedTypes); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function setNormalizers(array $normalizers) - { - $this->validateOptionsExistence($normalizers); - - foreach ($normalizers as $option => $normalizer) { - $this->defaultOptions->setNormalizer($option, $normalizer); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function isKnown($option) - { - return isset($this->knownOptions[$option]); - } - - /** - * {@inheritdoc} - */ - public function isRequired($option) - { - return isset($this->requiredOptions[$option]); - } - /** * {@inheritdoc} */ public function resolve(array $options = array()) { - $this->validateOptionsExistence($options); - $this->validateOptionsCompleteness($options); - - // Make sure this method can be called multiple times - $combinedOptions = clone $this->defaultOptions; - - // Override options set by the user - foreach ($options as $option => $value) { - $combinedOptions->set($option, $value); - } - - // Resolve options - $resolvedOptions = $combinedOptions->all(); - - $this->validateOptionTypes($resolvedOptions); - $this->validateOptionValues($resolvedOptions); - - return $resolvedOptions; - } - - /** - * Validates that the given option names exist and throws an exception - * otherwise. - * - * @param array $options An list of option names as keys. - * - * @throws InvalidOptionsException If any of the options has not been defined. - */ - private function validateOptionsExistence(array $options) - { - $diff = array_diff_key($options, $this->knownOptions); - - if (count($diff) > 0) { - ksort($this->knownOptions); - ksort($diff); - - throw new InvalidOptionsException(sprintf( - (count($diff) > 1 ? 'The options "%s" do not exist.' : 'The option "%s" does not exist.').' Known options are: "%s"', - implode('", "', array_keys($diff)), - implode('", "', array_keys($this->knownOptions)) - )); - } - } - - /** - * Validates that all required options are given and throws an exception - * otherwise. - * - * @param array $options An list of option names as keys. - * - * @throws MissingOptionsException If a required option is missing. - */ - private function validateOptionsCompleteness(array $options) - { - $diff = array_diff_key($this->requiredOptions, $options); - - if (count($diff) > 0) { - ksort($diff); - - throw new MissingOptionsException(sprintf( - count($diff) > 1 ? 'The required options "%s" are missing.' : 'The required option "%s" is missing.', - implode('", "', array_keys($diff)) - )); - } - } - - /** - * Validates that the given option values match the allowed values and - * throws an exception otherwise. - * - * @param array $options A list of option values. - * - * @throws InvalidOptionsException If any of the values does not match the - * allowed values of the option. - */ - private function validateOptionValues(array $options) - { - foreach ($this->allowedValues as $option => $allowedValues) { - if (isset($options[$option])) { - if (is_array($allowedValues) && !in_array($options[$option], $allowedValues, true)) { - throw new InvalidOptionsException(sprintf('The option "%s" has the value "%s", but is expected to be one of "%s"', $option, $options[$option], implode('", "', $allowedValues))); - } - - if (is_callable($allowedValues) && !call_user_func($allowedValues, $options[$option])) { - throw new InvalidOptionsException(sprintf('The option "%s" has the value "%s", which it is not valid', $option, $options[$option])); - } - } - } - } - - /** - * Validates that the given options match the allowed types and - * throws an exception otherwise. - * - * @param array $options A list of options. - * - * @throws InvalidOptionsException If any of the types does not match the - * allowed types of the option. - */ - private function validateOptionTypes(array $options) - { - foreach ($this->allowedTypes as $option => $allowedTypes) { - if (!array_key_exists($option, $options)) { - continue; - } - - $value = $options[$option]; - $allowedTypes = (array) $allowedTypes; - - foreach ($allowedTypes as $type) { - $isFunction = 'is_'.$type; - - if (function_exists($isFunction) && $isFunction($value)) { - continue 2; - } elseif ($value instanceof $type) { - continue 2; - } - } - - $printableValue = is_object($value) - ? get_class($value) - : (is_array($value) - ? 'Array' - : (string) $value); - - throw new InvalidOptionsException(sprintf( - 'The option "%s" with value "%s" is expected to be of type "%s"', - $option, - $printableValue, - implode('", "', $allowedTypes) - )); - } + return Options::resolve($options, $this); } } diff --git a/src/Symfony/Component/OptionsResolver/README.md b/src/Symfony/Component/OptionsResolver/README.md index 65617f79a3..a00aec5f57 100644 --- a/src/Symfony/Component/OptionsResolver/README.md +++ b/src/Symfony/Component/OptionsResolver/README.md @@ -1,101 +1,12 @@ OptionsResolver Component ========================= -OptionsResolver helps at configuring objects with option arrays. - -It supports default values on different levels of your class hierarchy, -option constraints (required vs. optional, allowed values) and lazy options -whose default value depends on the value of another option. - -The following example demonstrates a Person class with two required options -"firstName" and "lastName" and two optional options "age" and "gender", where -the default value of "gender" is derived from the passed first name, if -possible, and may only be one of "male" and "female". - - use Symfony\Component\OptionsResolver\OptionsResolver; - use Symfony\Component\OptionsResolver\OptionsResolverInterface; - use Symfony\Component\OptionsResolver\Options; - - class Person - { - protected $options; - - public function __construct(array $options = array()) - { - $resolver = new OptionsResolver(); - $this->setDefaultOptions($resolver); - - $this->options = $resolver->resolve($options); - } - - protected function setDefaultOptions(OptionsResolverInterface $resolver) - { - $resolver->setRequired(array( - 'firstName', - 'lastName', - )); - - $resolver->setDefaults(array( - 'age' => null, - 'gender' => function (Options $options) { - if (self::isKnownMaleName($options['firstName'])) { - return 'male'; - } - - return 'female'; - }, - )); - - $resolver->setAllowedValues(array( - 'gender' => array('male', 'female'), - )); - } - } - -We can now easily instantiate a Person object: - - // 'gender' is implicitly set to 'female' - $person = new Person(array( - 'firstName' => 'Jane', - 'lastName' => 'Doe', - )); - -We can also override the default values of the optional options: - - $person = new Person(array( - 'firstName' => 'Abdullah', - 'lastName' => 'Mogashi', - 'gender' => 'male', - 'age' => 30, - )); - -Options can be added or changed in subclasses by overriding the `setDefaultOptions` -method: - - use Symfony\Component\OptionsResolver\OptionsResolver; - use Symfony\Component\OptionsResolver\Options; - - class Employee extends Person - { - protected function setDefaultOptions(OptionsResolverInterface $resolver) - { - parent::setDefaultOptions($resolver); - - $resolver->setRequired(array( - 'birthDate', - )); - - $resolver->setDefaults(array( - // $previousValue contains the default value configured in the - // parent class - 'age' => function (Options $options, $previousValue) { - return self::calculateAge($options['birthDate']); - } - )); - } - } +This component processes and validates option arrays. +Documentation +------------- +The documentation for the component can be found [online] [1]. Resources --------- @@ -105,3 +16,5 @@ You can run the unit tests with the following command: $ cd path/to/Symfony/Component/OptionsResolver/ $ composer.phar install $ phpunit + +[1]: http://symfony.com/doc/current/components/options_resolver.html diff --git a/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php b/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php index fc3b3fc5d3..7420d050e6 100644 --- a/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php +++ b/src/Symfony/Component/OptionsResolver/Tests/OptionsResolverTest.php @@ -43,6 +43,23 @@ class OptionsResolverTest extends \PHPUnit_Framework_TestCase ), $this->resolver->resolve($options)); } + public function testResolveNumericOptions() + { + $this->resolver->setDefaults(array( + '1' => '1', + '2' => '2', + )); + + $options = array( + '2' => '20', + ); + + $this->assertEquals(array( + '1' => '1', + '2' => '20', + ), $this->resolver->resolve($options)); + } + public function testResolveLazy() { $this->resolver->setDefaults(array( @@ -94,7 +111,7 @@ class OptionsResolverTest extends \PHPUnit_Framework_TestCase }, )); - $options = array( + $options = array(, ); $this->assertEquals(array( @@ -623,7 +640,7 @@ class OptionsResolverTest extends \PHPUnit_Framework_TestCase )); $this->assertEquals(array( - 'foo' => 'bar' + 'foo' => 'bar', ), $this->resolver->resolve(array())); } @@ -637,7 +654,7 @@ class OptionsResolverTest extends \PHPUnit_Framework_TestCase )); $this->assertEquals(array( - 'foo' => 'bar' + 'foo' => 'bar', ), $this->resolver->resolve(array())); } @@ -652,7 +669,7 @@ class OptionsResolverTest extends \PHPUnit_Framework_TestCase $options = array( 'one' => '1', - 'two' => '2' + 'two' => '2', ); $this->assertEquals($options, $this->resolver->resolve($options)); diff --git a/src/Symfony/Component/OptionsResolver/Tests/OptionsTest.php b/src/Symfony/Component/OptionsResolver/Tests/OptionsTest.php index e24a764714..ce12ee8e25 100644 --- a/src/Symfony/Component/OptionsResolver/Tests/OptionsTest.php +++ b/src/Symfony/Component/OptionsResolver/Tests/OptionsTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\OptionsResolver\Tests; use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsConfig; class OptionsTest extends \PHPUnit_Framework_TestCase { @@ -25,6 +26,389 @@ class OptionsTest extends \PHPUnit_Framework_TestCase $this->options = new Options(); } + public function testResolve() + { + $defaults = array( + 'one' => '1', + 'two' => '2', + ); + + $options = array( + 'two' => '20', + ); + + $this->assertEquals(array( + 'one' => '1', + 'two' => '20', + ), Options::resolve($options, $defaults)); + } + + public function testResolveNumericOptions() + { + $defaults = array( + '1' => '1', + '2' => '2', + ); + + $options = array( + '2' => '20', + ); + + $this->assertEquals(array( + '1' => '1', + '2' => '20', + ), Options::resolve($options, $defaults)); + } + + public function testResolveLazy() + { + $defaults = new Options(array( + 'one' => '1', + 'two' => function (Options $options) { + return '20'; + }, + )); + + $options = array(); + + $this->assertEquals(array( + 'one' => '1', + 'two' => '20', + ), Options::resolve($options, $defaults)); + } + + public function testResolveConfig() + { + $config = new OptionsConfig(); + + $config->setDefaults(array( + 'one' => '1', + 'two' => '2', + )); + + $options = array( + 'two' => '20', + ); + + $this->assertEquals(array( + 'one' => '1', + 'two' => '20', + ), Options::resolve($options, $config)); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + */ + public function testResolveFailsIfNonExistingOption() + { + $defaults = array( + 'one' => '1', + ); + + $options = array( + 'foo' => 'bar', + ); + + Options::resolve($options, $defaults); + } + + public function testValidateNamesSucceedsIfValidOption() + { + $options = array( + 'one' => '1', + ); + + Options::validateNames($options, 'one'); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + */ + public function testValidateNamesFailsIfNonExistingOption() + { + $options = array( + 'foo' => 'bar', + ); + + Options::validateNames($options, 'one'); + } + + public function testValidateNamesSucceedsIfValidOptions() + { + $options = array( + 'one' => '1', + ); + + Options::validateNames($options, array( + 'one', + 'two', + )); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + */ + public function testValidateNamesFailsIfNonExistingOptions() + { + $options = array( + 'one' => '1', + 'foo' => 'bar', + ); + + Options::validateNames($options, array( + 'one', + 'two', + )); + } + + public function testValidateNamesSucceedsIfValidOptionsNamesAsKeys() + { + $options = array( + 'one' => '1', + ); + + Options::validateNames($options, array( + 'one' => null, + 'two' => null, + ), true); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + */ + public function testValidateNamesFailsIfNonExistingOptionsNamesAsKeys() + { + $options = array( + 'one' => '1', + 'foo' => 'bar', + ); + + Options::validateNames($options, array( + 'one' => null, + 'two' => null, + ), true); + } + + public function testValidateRequiredSucceedsIfRequiredOptionPresent() + { + $options = array( + 'one' => '10', + ); + + Options::validateRequired($options, 'one'); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\MissingOptionsException + */ + public function testValidateRequiredFailsIfMissingRequiredOption() + { + $options = array( + 'two' => '20', + ); + + Options::validateRequired($options, 'one'); + } + + public function testValidateRequiredSucceedsIfRequiredOptionsPresent() + { + $options = array( + 'one' => '10', + 'two' => '20', + ); + + Options::validateRequired($options, array( + 'one', + 'two', + )); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\MissingOptionsException + */ + public function testValidateRequiredFailsIfMissingRequiredOptions() + { + $options = array( + 'two' => '20', + ); + + Options::validateRequired($options, array( + 'one', + 'two', + )); + } + + public function testValidateRequiredSucceedsIfRequiredOptionsPresentNamesAsKeys() + { + $options = array( + 'one' => '10', + 'two' => '20', + ); + + Options::validateRequired($options, array( + 'one' => null, + 'two' => null, + ), true); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\MissingOptionsException + */ + public function testValidateRequiredFailsIfMissingRequiredOptionsNamesAsKeys() + { + $options = array( + 'two' => '20', + ); + + Options::validateRequired($options, array( + 'one' => null, + 'two' => null, + ), true); + } + + public function testValidateTypesSucceedsIfValidType() + { + $options = array( + 'one' => 'one', + ); + + Options::validateTypes($options, array( + 'one' => 'string', + )); + } + + public function testValidateTypesSucceedsIfValidTypePassArray() + { + $options = array( + 'one' => 'one', + ); + + Options::validateTypes($options, array( + 'one' => array('string', 'bool'), + )); + } + + public function testValidateTypesSucceedsIfValidTypePassObject() + { + $object = new \stdClass(); + $options = array( + 'one' => $object, + ); + + Options::validateTypes($options, array( + 'one' => 'object', + )); + } + + public function testValidateTypesSucceedsIfValidTypePassClass() + { + $object = new \stdClass(); + $options = array( + 'one' => $object, + ); + + Options::validateTypes($options, array( + 'one' => '\stdClass', + )); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + */ + public function testValidateTypesFailsIfInvalidType() + { + $options = array( + 'one' => 1.23, + ); + + Options::validateTypes($options, array( + 'one' => array('string', 'bool'), + )); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + */ + public function testValidateTypesFailsIfInvalidTypeMultipleOptions() + { + $options = array( + 'one' => 'foo', + 'two' => 1.23, + ); + + Options::validateTypes($options, array( + 'one' => 'string', + 'two' => 'bool', + )); + } + + public function testValidateValuesSucceedsIfValidValue() + { + $options = array( + 'one' => 'one', + ); + + Options::validateValues($options, array( + 'one' => array('1', 'one'), + )); + } + + public function testValidateValuesSucceedsIfValidValueMultipleOptions() + { + $options = array( + 'one' => '1', + 'two' => 'two', + ); + + Options::validateValues($options, array( + 'one' => array('1', 'one'), + 'two' => array('2', 'two'), + )); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + */ + public function testValidateValuesFailsIfInvalidValue() + { + $options = array( + 'one' => '2', + ); + + Options::validateValues($options, array( + 'one' => array('1', 'one'), + )); + } + + public function testValidateValuesSucceedsIfValidValueCallback() + { + $options = array( + 'test' => true, + ); + + Options::validateValues($options, array( + 'test' => function ($value) { + return true; + }, + )); + } + + /** + * @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidOptionsException + */ + public function testValidateValuesFailsIfInvalidValueCallback() + { + $options = array( + 'test' => true, + ); + + Options::validateValues($options, array( + 'test' => function ($value) { + return false; + }, + )); + } + public function testArrayAccess() { $this->assertFalse(isset($this->options['foo'])); @@ -91,15 +475,37 @@ class OptionsTest extends \PHPUnit_Framework_TestCase public function testSetLazyOption() { - $test = $this; - - $this->options->set('foo', function (Options $options) use ($test) { + $this->options->set('foo', function (Options $options) { return 'dynamic'; }); $this->assertEquals('dynamic', $this->options->get('foo')); } + public static function getLazyOptionStatic(Options $options) + { + return 'dynamic'; + } + + public function testSetLazyOptionToClassMethod() + { + $this->options->set('foo', array(__CLASS__, 'getLazyOptionStatic')); + + $this->assertEquals('dynamic', $this->options->get('foo')); + } + + public static function getLazyOption(Options $options) + { + return 'dynamic'; + } + + public function testSetLazyOptionToInstanceMethod() + { + $this->options->set('foo', array($this, 'getLazyOption')); + + $this->assertEquals('dynamic', $this->options->get('foo')); + } + public function testSetDiscardsPreviousValue() { $test = $this; @@ -372,7 +778,7 @@ class OptionsTest extends \PHPUnit_Framework_TestCase 'two' => '2', 'three' => function (Options $options) { return '2' === $options['two'] ? '3' : 'foo'; - } + }, )); $this->assertEquals(array(