diff --git a/CHANGELOG-2.1.md b/CHANGELOG-2.1.md index 9711d587d752..dd099755b8b4 100644 --- a/CHANGELOG-2.1.md +++ b/CHANGELOG-2.1.md @@ -256,7 +256,6 @@ To get the diff between two versions, go to https://github.com/symfony/symfony/c * forms now don't create an empty object anymore if they are completely empty and not required. The empty value for such forms is null. * added constant Guess::VERY_HIGH_CONFIDENCE - * FormType::getDefaultOptions() now sees default options defined by parent types * [BC BREAK] FormType::getParent() does not see default options anymore * [BC BREAK] The methods `add`, `remove`, `setParent`, `bind` and `setData` in class Form now throw an exception if the form is already bound @@ -266,6 +265,8 @@ To get the diff between two versions, go to https://github.com/symfony/symfony/c "single_text" unless "with_seconds" is set to true * checkboxes of in an expanded multiple-choice field don't include the choice in their name anymore. Their names terminate with "[]" now. + * [BC BREAK] FormType::getDefaultOptions() and FormType::getAllowedOptionValues() + don't receive an options array anymore. ### HttpFoundation diff --git a/UPGRADE-2.1.md b/UPGRADE-2.1.md index b5eb679ec59b..1d011ccc5d7b 100644 --- a/UPGRADE-2.1.md +++ b/UPGRADE-2.1.md @@ -326,7 +326,7 @@ ``` * The options passed to the `getParent()` method of form types no longer - contain default options. + contain default options. They only contain the options passed by the user. You should check if options exist before attempting to read their value. @@ -347,6 +347,42 @@ return isset($options['widget']) && 'single_text' === $options['widget'] ? 'text' : 'choice'; } ``` + + * The methods `getDefaultOptions()` and `getAllowedOptionValues()` of form + types no longer receive an option array. + + You can specify options that depend on other options using closures instead. + + Before: + + ``` + public function getDefaultOptions(array $options) + { + $defaultOptions = array(); + + if ($options['multiple']) { + $defaultOptions['empty_data'] = array(); + } + + return $defaultOptions; + } + ``` + + After: + + ``` + public function getDefaultOptions() + { + return array( + 'empty_data' => function (Options $options, $previousValue) { + return $options['multiple'] ? array() : $previousValue; + } + ); + } + ``` + + The second argument `$previousValue` does not have to be specified if not + needed. * The `add()`, `remove()`, `setParent()`, `bind()` and `setData()` methods in the Form class now throw an exception if the form is already bound. diff --git a/src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php b/src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php index 2b9161477e24..d2644a618a7e 100644 --- a/src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php +++ b/src/Symfony/Bridge/Doctrine/Form/Type/DoctrineType.php @@ -19,6 +19,7 @@ use Symfony\Bridge\Doctrine\Form\EventListener\MergeDoctrineCollectionListener; use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Options; abstract class DoctrineType extends AbstractType { @@ -42,27 +43,25 @@ public function buildForm(FormBuilder $builder, array $options) } } - public function getDefaultOptions(array $options) + public function getDefaultOptions() { - $defaultOptions = array( - 'em' => null, - 'class' => null, - 'property' => null, - 'query_builder' => null, - 'loader' => null, - 'group_by' => null, - ); + $registry = $this->registry; + $type = $this; - $options = array_replace($defaultOptions, $options); + $loader = function (Options $options) use ($type, $registry) { + if (null !== $options['query_builder']) { + $manager = $registry->getManager($options['em']); - if (!isset($options['choice_list'])) { - $manager = $this->registry->getManager($options['em']); - - if (isset($options['query_builder'])) { - $options['loader'] = $this->getLoader($manager, $options); + return $type->getLoader($manager, $options['query_builder'], $options['class']); } - $defaultOptions['choice_list'] = new EntityChoiceList( + return null; + }; + + $choiceList = function (Options $options) use ($registry) { + $manager = $registry->getManager($options['em']); + + return new EntityChoiceList( $manager, $options['class'], $options['property'], @@ -70,9 +69,18 @@ public function getDefaultOptions(array $options) $options['choices'], $options['group_by'] ); - } + }; - return $defaultOptions; + return array( + 'em' => null, + 'class' => null, + 'property' => null, + 'query_builder' => null, + 'loader' => $loader, + 'choices' => null, + 'choice_list' => $choiceList, + 'group_by' => null, + ); } /** @@ -82,7 +90,7 @@ public function getDefaultOptions(array $options) * @param array $options * @return EntityLoaderInterface */ - abstract protected function getLoader(ObjectManager $manager, array $options); + abstract public function getLoader(ObjectManager $manager, $queryBuilder, $class); public function getParent(array $options) { diff --git a/src/Symfony/Bridge/Doctrine/Form/Type/EntityType.php b/src/Symfony/Bridge/Doctrine/Form/Type/EntityType.php index 9691c3393aa3..b6ef4b3e2ad5 100644 --- a/src/Symfony/Bridge/Doctrine/Form/Type/EntityType.php +++ b/src/Symfony/Bridge/Doctrine/Form/Type/EntityType.php @@ -23,12 +23,12 @@ class EntityType extends DoctrineType * @param array $options * @return ORMQueryBuilderLoader */ - protected function getLoader(ObjectManager $manager, array $options) + public function getLoader(ObjectManager $manager, $queryBuilder, $class) { return new ORMQueryBuilderLoader( - $options['query_builder'], + $queryBuilder, $manager, - $options['class'] + $class ); } diff --git a/src/Symfony/Bridge/Propel1/Form/Type/ModelType.php b/src/Symfony/Bridge/Propel1/Form/Type/ModelType.php index 741b6fd83907..de125a070f29 100644 --- a/src/Symfony/Bridge/Propel1/Form/Type/ModelType.php +++ b/src/Symfony/Bridge/Propel1/Form/Type/ModelType.php @@ -14,6 +14,7 @@ use Symfony\Bridge\Propel1\Form\ChoiceList\ModelChoiceList; use Symfony\Bridge\Propel1\Form\DataTransformer\CollectionToArrayTransformer; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Options; use Symfony\Component\Form\FormBuilder; /** @@ -30,9 +31,19 @@ public function buildForm(FormBuilder $builder, array $options) } } - public function getDefaultOptions(array $options) + public function getDefaultOptions() { - $defaultOptions = array( + $choiceList = function (Options $options) { + return new ModelChoiceList( + $options['class'], + $options['property'], + $options['choices'], + $options['query'], + $options['group_by'] + ); + }; + + return array( 'template' => 'choice', 'multiple' => false, 'expanded' => false, @@ -40,23 +51,10 @@ public function getDefaultOptions(array $options) 'property' => null, 'query' => null, 'choices' => null, + 'choice_list' => $choiceList, 'group_by' => null, 'by_reference' => false, ); - - $options = array_replace($defaultOptions, $options); - - if (!isset($options['choice_list'])) { - $defaultOptions['choice_list'] = new ModelChoiceList( - $options['class'], - $options['property'], - $options['choices'], - $options['query'], - $options['group_by'] - ); - } - - return $defaultOptions; } public function getParent(array $options) diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Form/UserLoginFormType.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Form/UserLoginFormType.php index 787bf6b22c8a..e4ccb6c5fdcd 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Form/UserLoginFormType.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/CsrfFormLoginBundle/Form/UserLoginFormType.php @@ -76,7 +76,7 @@ public function buildForm(FormBuilder $builder, array $options) /** * @see Symfony\Component\Form\AbstractType::getDefaultOptions() */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { /* Note: the form's intention must correspond to that for the form login * listener in order for the CSRF token to validate successfully. diff --git a/src/Symfony/Component/Form/AbstractType.php b/src/Symfony/Component/Form/AbstractType.php index eb7ee6ea844d..7541aa4c0c4e 100644 --- a/src/Symfony/Component/Form/AbstractType.php +++ b/src/Symfony/Component/Form/AbstractType.php @@ -96,7 +96,7 @@ public function createBuilder($name, FormFactoryInterface $factory, array $optio * * @return array The default options */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array(); } @@ -108,7 +108,7 @@ public function getDefaultOptions(array $options) * * @return array The allowed option values */ - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array(); } diff --git a/src/Symfony/Component/Form/AbstractTypeExtension.php b/src/Symfony/Component/Form/AbstractTypeExtension.php index afaeb18a2c48..7d9247b53547 100644 --- a/src/Symfony/Component/Form/AbstractTypeExtension.php +++ b/src/Symfony/Component/Form/AbstractTypeExtension.php @@ -65,7 +65,7 @@ public function buildViewBottomUp(FormView $view, FormInterface $form) * * @return array */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array(); } @@ -77,7 +77,7 @@ public function getDefaultOptions(array $options) * * @return array The allowed option values */ - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array(); } diff --git a/src/Symfony/Component/Form/DefaultOptions.php b/src/Symfony/Component/Form/DefaultOptions.php new file mode 100644 index 000000000000..4d77a7db470a --- /dev/null +++ b/src/Symfony/Component/Form/DefaultOptions.php @@ -0,0 +1,320 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Symfony\Component\Form\Exception\OptionDefinitionException; +use Symfony\Component\Form\Exception\InvalidOptionException; + +/** + * Helper for specifying and resolving inter-dependent options. + * + * Options are a common pattern for initializing classes in PHP. Avoiding the + * problems related to this approach is however a non-trivial task. Usually, + * both classes and subclasses should be able to set default option values. + * These default options should be overridden by the options passed to the + * constructor. Last but not least, the (default) values of some options may + * depend on the values of other options, which themselves may depend on other + * options and so on. + * + * DefaultOptions resolves these problems. It allows you to: + * + * - Define default option values + * - Define options in layers that correspond to your class hierarchy. Each + * layer may depend on the default value set in the higher layers. + * - Define default values for options that depend on the concrete + * values of other options. + * - Resolve the concrete option values by passing the options set by the + * user. + * + * You can use it in your classes by implementing the following pattern: + * + * + * class Car + * { + * protected $options; + * + * public function __construct(array $options) + * { + * $defaultOptions = new DefaultOptions(); + * $this->addDefaultOptions($defaultOptions); + * + * $this->options = $defaultOptions->resolve($options); + * } + * + * protected function addDefaultOptions(DefaultOptions $options) + * { + * $options->add(array( + * 'make' => 'VW', + * 'year' => '1999', + * )); + * } + * } + * + * $car = new Car(array( + * 'make' => 'Mercedes', + * 'year' => 2005, + * )); + * + * + * By calling add(), new default options are added to the container. The method + * resolve() accepts an array of options passed by the user that are matched + * against the defined options. If any option is not recognized, an exception + * is thrown. Finally, resolve() returns the merged default and user options. + * + * You can now easily add or override options in subclasses: + * + * + * class Renault extends Car + * { + * protected function addDefaultOptions(DefaultOptions $options) + * { + * parent::addDefaultOptions($options); + * + * $options->add(array( + * 'make' => 'Renault', + * 'gear' => 'auto', + * )); + * } + * } + * + * $renault = new Renault(array( + * 'year' => 1997, + * 'gear' => 'manual' + * )); + * + * + * IMPORTANT: parent::addDefaultOptions() must always be called before adding + * new default options! + * + * In the previous example, it makes sense to restrict the option "gear" to + * a set of allowed values: + * + * + * class Renault extends Car + * { + * protected function addDefaultOptions(DefaultOptions $options) + * { + * // ... like above ... + * + * $options->addAllowedValues(array( + * 'gear' => array('auto', 'manual'), + * )); + * } + * } + * + * // Fails! + * $renault = new Renault(array( + * 'gear' => 'v6', + * )); + * + * + * Now it is impossible to pass a value in the "gear" option that is not + * expected. + * + * Last but not least, you can define options that depend on other options. + * For example, depending on the "make" you could preset the country that the + * car is registered in. + * + * + * class Car + * { + * protected function addDefaultOptions(DefaultOptions $options) + * { + * $options->add(array( + * 'make' => 'VW', + * 'year' => '1999', + * 'country' => function (Options $options) { + * if ('VW' === $options['make']) { + * return 'DE'; + * } + * + * return null; + * }, + * )); + * } + * } + * + * $car = new Car(array( + * 'make' => 'VW', // => "country" is "DE" + * )); + * + * + * The closure receives as its first parameter a container of class Options + * that contains the concrete options determined upon resolving. The + * closure is executed once resolve() is called. + * + * The closure also receives a second parameter $previousValue that contains the + * value defined by the parent layer of the hierarchy. If the option has not + * been defined in any parent layer, the second parameter is NULL. + * + * + * class Renault extends Car + * { + * protected function addDefaultOptions(DefaultOptions $options) + * { + * $options->add(array( + * 'country' => function (Options $options, $previousValue) { + * if ('Renault' === $options['make']) { + * return 'FR'; + * } + * + * // return default value defined in Car + * return $previousValue; + * }, + * )); + * } + * } + * + * $renault = new Renault(array( + * 'make' => 'VW', // => "country" is still "DE" + * )); + * + * + * @author Bernhard Schussek + */ +class DefaultOptions +{ + /** + * The container resolving the options. + * @var Options + */ + private $options; + + /** + * A list of accepted values for each option. + * @var array + */ + private $allowedValues = array(); + + /** + * Creates a new instance. + */ + public function __construct() + { + $this->options = new Options(); + } + + /** + * Adds default options. + * + * @param array $options A list of option names as keys and option values + * as values. The option values may be closures + * of the following signatures: + * + * - function (Options $options) + * - function (Options $options, $previousValue) + */ + public function add(array $options) + { + foreach ($options as $option => $value) { + $this->options[$option] = $value; + } + } + + /** + * Adds 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. + * + * @throws InvalidOptionException If an option has not been defined for + * which an allowed value is set. + */ + public function addAllowedValues(array $allowedValues) + { + $this->validateOptionNames(array_keys($allowedValues)); + + $this->allowedValues = array_merge_recursive($this->allowedValues, $allowedValues); + } + + /** + * Resolves the final option values by merging default options with user + * options. + * + * @param array $userOptions The options passed by the user. + * + * @return array A list of options and their final values. + * + * @throws InvalidOptionException If any of the passed options has not + * been defined or does not contain an + * allowed value. + * @throws OptionDefinitionException If a cyclic dependency is detected + * between option closures. + */ + public function resolve(array $userOptions) + { + // Make sure this method can be called multiple times + $options = clone $this->options; + + $this->validateOptionNames(array_keys($userOptions)); + + // Override options set by the user + foreach ($userOptions as $option => $value) { + $options[$option] = $value; + } + + // Resolve options + $options = iterator_to_array($options); + + // Validate against allowed values + $this->validateOptionValues($options); + + return $options; + } + + /** + * Validates that the given option names exist and throws an exception + * otherwise. + * + * @param array $optionNames A list of option names. + * + * @throws InvalidOptionException If any of the options has not been + * defined. + */ + private function validateOptionNames(array $optionNames) + { + $knownOptions = $this->options->getNames(); + $diff = array_diff($optionNames, $knownOptions); + + if (count($diff) > 0) { + sort($knownOptions); + sort($diff); + } + + if (count($diff) > 1) { + throw new InvalidOptionException(sprintf('The options "%s" do not exist. Known options are: "%s"', implode('", "', $diff), implode('", "', $knownOptions))); + } + + if (count($diff) > 0) { + throw new InvalidOptionException(sprintf('The option "%s" does not exist. Known options are: "%s"', current($diff), implode('", "', $knownOptions))); + } + } + + /** + * Validates that the given option values match the allowed values and + * throws an exception otherwise. + * + * @param array $options A list of option values. + * + * @throws InvalidOptionException 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 (!in_array($options[$option], $allowedValues, true)) { + throw new InvalidOptionException(sprintf('The option "%s" has the value "%s", but is expected to be one of "%s"', $option, $options[$option], implode('", "', $allowedValues))); + } + } + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Form/Exception/InvalidOptionException.php b/src/Symfony/Component/Form/Exception/InvalidOptionException.php new file mode 100644 index 000000000000..54d782b3e105 --- /dev/null +++ b/src/Symfony/Component/Form/Exception/InvalidOptionException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Exception; + +class InvalidOptionException extends FormException +{ +} diff --git a/src/Symfony/Component/Form/Exception/InvalidOptionsException.php b/src/Symfony/Component/Form/Exception/InvalidOptionsException.php deleted file mode 100644 index bde0a5069a60..000000000000 --- a/src/Symfony/Component/Form/Exception/InvalidOptionsException.php +++ /dev/null @@ -1,29 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Form\Exception; - -class InvalidOptionsException extends FormException -{ - private $options; - - public function __construct($message, array $options) - { - parent::__construct($message); - - $this->options = $options; - } - - public function getOptions() - { - return $this->options; - } -} diff --git a/src/Symfony/Component/Form/Exception/MissingOptionsException.php b/src/Symfony/Component/Form/Exception/MissingOptionsException.php deleted file mode 100644 index c731b5598ea4..000000000000 --- a/src/Symfony/Component/Form/Exception/MissingOptionsException.php +++ /dev/null @@ -1,29 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Form\Exception; - -class MissingOptionsException extends FormException -{ - private $options; - - public function __construct($message, array $options) - { - parent::__construct($message); - - $this->options = $options; - } - - public function getOptions() - { - return $this->options; - } -} diff --git a/src/Symfony/Component/Form/Exception/OptionDefinitionException.php b/src/Symfony/Component/Form/Exception/OptionDefinitionException.php new file mode 100644 index 000000000000..dfd87cc7e5b9 --- /dev/null +++ b/src/Symfony/Component/Form/Exception/OptionDefinitionException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Exception; + +class OptionDefinitionException extends FormException +{ +} diff --git a/src/Symfony/Component/Form/Extension/Core/Type/BirthdayType.php b/src/Symfony/Component/Form/Extension/Core/Type/BirthdayType.php index aea9a701e847..bab646003d08 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/BirthdayType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/BirthdayType.php @@ -18,7 +18,7 @@ class BirthdayType extends AbstractType /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'years' => range(date('Y') - 120, date('Y')), diff --git a/src/Symfony/Component/Form/Extension/Core/Type/CheckboxType.php b/src/Symfony/Component/Form/Extension/Core/Type/CheckboxType.php index ddbf8ae897e2..8ab2c6843d0a 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/CheckboxType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/CheckboxType.php @@ -44,7 +44,7 @@ public function buildView(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'value' => '1', diff --git a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php index 0b7c45053c8e..1ed495248bb6 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Form\Extension\Core\Type; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Options; use Symfony\Component\Form\FormBuilder; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; @@ -42,13 +43,6 @@ public function buildForm(FormBuilder $builder, array $options) throw new FormException('Either the option "choices" or "choice_list" must be set.'); } - if (!$options['choice_list']) { - $options['choice_list'] = new SimpleChoiceList( - $options['choices'], - $options['preferred_choices'] - ); - } - if ($options['expanded']) { $this->addSubFields($builder, $options['choice_list']->getPreferredViews(), $options); $this->addSubFields($builder, $options['choice_list']->getRemainingViews(), $options); @@ -61,9 +55,6 @@ public function buildForm(FormBuilder $builder, array $options) } elseif (false === $options['empty_value']) { // an empty value should be added but the user decided otherwise $emptyValue = null; - } elseif (null === $options['empty_value']) { - // user did not made a decision, so we put a blank empty value - $emptyValue = $options['required'] ? null : ''; } else { // empty value has been set explicitly $emptyValue = $options['empty_value']; @@ -152,19 +143,36 @@ public function buildViewBottomUp(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { - $multiple = isset($options['multiple']) && $options['multiple']; - $expanded = isset($options['expanded']) && $options['expanded']; + $choiceList = function (Options $options) { + return new SimpleChoiceList( + // Harden against NULL values (like in EntityType and ModelType) + null !== $options['choices'] ? $options['choices'] : array(), + $options['preferred_choices'] + ); + }; + + $emptyData = function (Options $options) { + if ($options['multiple'] || $options['expanded']) { + return array(); + } + + return ''; + }; + + $emptyValue = function (Options $options) { + return $options['required'] ? null : ''; + }; return array( 'multiple' => false, 'expanded' => false, - 'choice_list' => null, - 'choices' => null, + 'choice_list' => $choiceList, + 'choices' => array(), 'preferred_choices' => array(), - 'empty_data' => $multiple || $expanded ? array() : '', - 'empty_value' => $multiple || $expanded || !isset($options['empty_value']) ? null : '', + 'empty_data' => $emptyData, + 'empty_value' => $emptyValue, 'error_bubbling' => false, ); } diff --git a/src/Symfony/Component/Form/Extension/Core/Type/CollectionType.php b/src/Symfony/Component/Form/Extension/Core/Type/CollectionType.php index 5eb8285d319f..483b56a6751f 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/CollectionType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/CollectionType.php @@ -75,7 +75,7 @@ public function buildViewBottomUp(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'allow_add' => false, diff --git a/src/Symfony/Component/Form/Extension/Core/Type/CountryType.php b/src/Symfony/Component/Form/Extension/Core/Type/CountryType.php index b3e1e43d29d7..bb4f7decbae6 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/CountryType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/CountryType.php @@ -20,7 +20,7 @@ class CountryType extends AbstractType /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'choices' => Locale::getDisplayCountries(\Locale::getDefault()), diff --git a/src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php b/src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php index e7d75128a363..6eba72d4f6b6 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/DateTimeType.php @@ -128,7 +128,7 @@ public function buildView(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'input' => 'datetime', @@ -164,7 +164,7 @@ public function getDefaultOptions(array $options) /** * {@inheritdoc} */ - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array( 'input' => array( diff --git a/src/Symfony/Component/Form/Extension/Core/Type/DateType.php b/src/Symfony/Component/Form/Extension/Core/Type/DateType.php index b634e5f72a1d..eb5e85d1a448 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/DateType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/DateType.php @@ -161,7 +161,7 @@ public function buildViewBottomUp(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'years' => range(date('Y') - 5, date('Y') + 5), @@ -188,7 +188,7 @@ public function getDefaultOptions(array $options) /** * {@inheritdoc} */ - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array( 'input' => array( diff --git a/src/Symfony/Component/Form/Extension/Core/Type/FieldType.php b/src/Symfony/Component/Form/Extension/Core/Type/FieldType.php index d9bbe6222c3b..f68c878aadda 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/FieldType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/FieldType.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Form\Extension\Core\Type; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Options; use Symfony\Component\Form\Util\PropertyPath; use Symfony\Component\Form\FormBuilder; use Symfony\Component\Form\FormInterface; @@ -129,11 +130,38 @@ public function buildView(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { - $defaultOptions = array( + // Derive "data_class" option from passed "data" object + $dataClass = function (Options $options) { + if (is_object($options['data'])) { + return get_class($options['data']); + } + + return null; + }; + + // Derive "empty_data" closure from "data_class" option + $emptyData = function (Options $options) { + $class = $options['data_class']; + + if (null !== $class) { + return function (FormInterface $form) use ($class) { + if ($form->isEmpty() && !$form->isRequired()) { + return null; + } + + return new $class(); + }; + } + + return ''; + }; + + return array( 'data' => null, - 'data_class' => null, + 'data_class' => $dataClass, + 'empty_data' => $emptyData, 'trim' => true, 'required' => true, 'read_only' => false, @@ -150,28 +178,6 @@ public function getDefaultOptions(array $options) 'invalid_message_parameters' => array(), 'translation_domain' => 'messages', ); - - $class = isset($options['data_class']) ? $options['data_class'] : null; - - // If no data class is set explicitly and an object is passed as data, - // use the class of that object as data class - if (!$class && isset($options['data']) && is_object($options['data'])) { - $defaultOptions['data_class'] = $class = get_class($options['data']); - } - - if ($class) { - $defaultOptions['empty_data'] = function (FormInterface $form) use ($class) { - if ($form->isEmpty() && !$form->isRequired()) { - return null; - } - - return new $class(); - }; - } else { - $defaultOptions['empty_data'] = ''; - } - - return $defaultOptions; } /** diff --git a/src/Symfony/Component/Form/Extension/Core/Type/FormType.php b/src/Symfony/Component/Form/Extension/Core/Type/FormType.php index ca2c87ce0ecf..36e0c0c63bb1 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/FormType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/FormType.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Form\Extension\Core\Type; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Options; use Symfony\Component\Form\FormBuilder; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; @@ -50,20 +51,23 @@ public function buildViewBottomUp(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { - $defaultOptions = array( + $emptyData = function (Options $options, $currentValue) { + if (empty($options['data_class'])) { + return array(); + } + + return $currentValue; + }; + + return array( + 'empty_data' => $emptyData, 'virtual' => false, // Errors in forms bubble by default, so that form errors will // end up as global errors in the root form 'error_bubbling' => true, ); - - if (empty($options['data_class'])) { - $defaultOptions['empty_data'] = array(); - } - - return $defaultOptions; } /** diff --git a/src/Symfony/Component/Form/Extension/Core/Type/HiddenType.php b/src/Symfony/Component/Form/Extension/Core/Type/HiddenType.php index 8e6039eee029..71066250624a 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/HiddenType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/HiddenType.php @@ -18,7 +18,7 @@ class HiddenType extends AbstractType /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( // hidden fields cannot have a required attribute diff --git a/src/Symfony/Component/Form/Extension/Core/Type/IntegerType.php b/src/Symfony/Component/Form/Extension/Core/Type/IntegerType.php index 128a01456f9a..9beaed51029b 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/IntegerType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/IntegerType.php @@ -33,7 +33,7 @@ public function buildForm(FormBuilder $builder, array $options) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( // default precision is locale specific (usually around 3) @@ -47,7 +47,7 @@ public function getDefaultOptions(array $options) /** * {@inheritdoc} */ - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array( 'rounding_mode' => array( diff --git a/src/Symfony/Component/Form/Extension/Core/Type/LanguageType.php b/src/Symfony/Component/Form/Extension/Core/Type/LanguageType.php index 7d722a13e5c6..ede5be1f4d13 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/LanguageType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/LanguageType.php @@ -20,7 +20,7 @@ class LanguageType extends AbstractType /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'choices' => Locale::getDisplayLanguages(\Locale::getDefault()), diff --git a/src/Symfony/Component/Form/Extension/Core/Type/LocaleType.php b/src/Symfony/Component/Form/Extension/Core/Type/LocaleType.php index 66e5da9ed2c5..d953b82cf27a 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/LocaleType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/LocaleType.php @@ -20,7 +20,7 @@ class LocaleType extends AbstractType /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'choices' => Locale::getDisplayLocales(\Locale::getDefault()), diff --git a/src/Symfony/Component/Form/Extension/Core/Type/MoneyType.php b/src/Symfony/Component/Form/Extension/Core/Type/MoneyType.php index 7a8bd99fd60c..1f87f3a2a687 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/MoneyType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/MoneyType.php @@ -48,7 +48,7 @@ public function buildView(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'precision' => 2, diff --git a/src/Symfony/Component/Form/Extension/Core/Type/NumberType.php b/src/Symfony/Component/Form/Extension/Core/Type/NumberType.php index 00fec267665e..21ea17046e76 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/NumberType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/NumberType.php @@ -32,7 +32,7 @@ public function buildForm(FormBuilder $builder, array $options) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( // default precision is locale specific (usually around 3) @@ -45,7 +45,7 @@ public function getDefaultOptions(array $options) /** * {@inheritdoc} */ - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array( 'rounding_mode' => array( diff --git a/src/Symfony/Component/Form/Extension/Core/Type/PasswordType.php b/src/Symfony/Component/Form/Extension/Core/Type/PasswordType.php index 7451080e1f29..d2ffed8d1daa 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/PasswordType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/PasswordType.php @@ -39,7 +39,7 @@ public function buildView(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'always_empty' => true, diff --git a/src/Symfony/Component/Form/Extension/Core/Type/PercentType.php b/src/Symfony/Component/Form/Extension/Core/Type/PercentType.php index f0804be4c2ce..ea6550869a24 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/PercentType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/PercentType.php @@ -28,7 +28,7 @@ public function buildForm(FormBuilder $builder, array $options) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'precision' => 0, @@ -39,7 +39,7 @@ public function getDefaultOptions(array $options) /** * {@inheritdoc} */ - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array( 'type' => array( diff --git a/src/Symfony/Component/Form/Extension/Core/Type/RepeatedType.php b/src/Symfony/Component/Form/Extension/Core/Type/RepeatedType.php index 18a4f4fe9991..034f3d6a9ae0 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/RepeatedType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/RepeatedType.php @@ -39,7 +39,7 @@ public function buildForm(FormBuilder $builder, array $options) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'type' => 'text', diff --git a/src/Symfony/Component/Form/Extension/Core/Type/TimeType.php b/src/Symfony/Component/Form/Extension/Core/Type/TimeType.php index e730f9eb828e..3f7258bb82e7 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/TimeType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/TimeType.php @@ -134,7 +134,7 @@ public function buildView(FormView $view, FormInterface $form) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'hours' => range(0, 23), @@ -161,7 +161,7 @@ public function getDefaultOptions(array $options) /** * {@inheritdoc} */ - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array( 'input' => array( diff --git a/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php b/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php index c143562a4472..3b162484ab50 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/TimezoneType.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Form\Extension\Core\Type; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Options; use Symfony\Component\Form\Extension\Core\ChoiceList\ChoiceList; class TimezoneType extends AbstractType @@ -20,20 +21,16 @@ class TimezoneType extends AbstractType * Stores the available timezone choices * @var array */ - static protected $timezones; + static private $timezones; /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { - $defaultOptions = array(); - - if (empty($options['choice_list']) && empty($options['choices'])) { - $defaultOptions['choices'] = self::getTimezones(); - } - - return $defaultOptions; + return array( + 'choices' => self::getTimezones(), + ); } /** @@ -62,7 +59,7 @@ public function getName() * * @return array The timezone choices */ - static private function getTimezones() + static public function getTimezones() { if (null === static::$timezones) { static::$timezones = array(); diff --git a/src/Symfony/Component/Form/Extension/Core/Type/UrlType.php b/src/Symfony/Component/Form/Extension/Core/Type/UrlType.php index 246403d1bd4a..5a7e768e8ea2 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/UrlType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/UrlType.php @@ -28,7 +28,7 @@ public function buildForm(FormBuilder $builder, array $options) /** * {@inheritdoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'default_protocol' => 'http', diff --git a/src/Symfony/Component/Form/Extension/Csrf/Type/ChoiceTypeCsrfExtension.php b/src/Symfony/Component/Form/Extension/Csrf/Type/ChoiceTypeCsrfExtension.php index 3711b5198d3f..dc1e6db15bd1 100644 --- a/src/Symfony/Component/Form/Extension/Csrf/Type/ChoiceTypeCsrfExtension.php +++ b/src/Symfony/Component/Form/Extension/Csrf/Type/ChoiceTypeCsrfExtension.php @@ -15,7 +15,7 @@ class ChoiceTypeCsrfExtension extends AbstractTypeExtension { - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array('csrf_protection' => false); } diff --git a/src/Symfony/Component/Form/Extension/Csrf/Type/CsrfType.php b/src/Symfony/Component/Form/Extension/Csrf/Type/CsrfType.php index 3e2ffc328083..97c69d8a5ab1 100644 --- a/src/Symfony/Component/Form/Extension/Csrf/Type/CsrfType.php +++ b/src/Symfony/Component/Form/Extension/Csrf/Type/CsrfType.php @@ -54,7 +54,7 @@ public function buildForm(FormBuilder $builder, array $options) /** * {@inheritDoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'csrf_provider' => $this->csrfProvider, diff --git a/src/Symfony/Component/Form/Extension/Csrf/Type/DateTypeCsrfExtension.php b/src/Symfony/Component/Form/Extension/Csrf/Type/DateTypeCsrfExtension.php index 797368590580..dc54e94ddf6b 100644 --- a/src/Symfony/Component/Form/Extension/Csrf/Type/DateTypeCsrfExtension.php +++ b/src/Symfony/Component/Form/Extension/Csrf/Type/DateTypeCsrfExtension.php @@ -15,7 +15,7 @@ class DateTypeCsrfExtension extends AbstractTypeExtension { - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array('csrf_protection' => false); } diff --git a/src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php b/src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php index d61ac97a5d58..8e2a57459add 100644 --- a/src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php +++ b/src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php @@ -76,7 +76,7 @@ public function buildViewBottomUp(FormView $view, FormInterface $form) /** * {@inheritDoc} */ - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'csrf_protection' => $this->enabled, diff --git a/src/Symfony/Component/Form/Extension/Csrf/Type/RepeatedTypeCsrfExtension.php b/src/Symfony/Component/Form/Extension/Csrf/Type/RepeatedTypeCsrfExtension.php index 71adaadb1f7c..1115ea4d69ce 100644 --- a/src/Symfony/Component/Form/Extension/Csrf/Type/RepeatedTypeCsrfExtension.php +++ b/src/Symfony/Component/Form/Extension/Csrf/Type/RepeatedTypeCsrfExtension.php @@ -15,7 +15,7 @@ class RepeatedTypeCsrfExtension extends AbstractTypeExtension { - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array('csrf_protection' => false); } diff --git a/src/Symfony/Component/Form/Extension/Csrf/Type/TimeTypeCsrfExtension.php b/src/Symfony/Component/Form/Extension/Csrf/Type/TimeTypeCsrfExtension.php index 26d875954de2..dbd7c0d2dfbc 100644 --- a/src/Symfony/Component/Form/Extension/Csrf/Type/TimeTypeCsrfExtension.php +++ b/src/Symfony/Component/Form/Extension/Csrf/Type/TimeTypeCsrfExtension.php @@ -15,7 +15,7 @@ class TimeTypeCsrfExtension extends AbstractTypeExtension { - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array('csrf_protection' => false); } diff --git a/src/Symfony/Component/Form/Extension/Validator/Type/FieldTypeValidatorExtension.php b/src/Symfony/Component/Form/Extension/Validator/Type/FieldTypeValidatorExtension.php index 1cd0b10f595f..fb8998323beb 100644 --- a/src/Symfony/Component/Form/Extension/Validator/Type/FieldTypeValidatorExtension.php +++ b/src/Symfony/Component/Form/Extension/Validator/Type/FieldTypeValidatorExtension.php @@ -42,7 +42,7 @@ public function buildForm(FormBuilder $builder, array $options) ->addValidator(new DelegatingValidator($this->validator)); } - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'validation_groups' => null, diff --git a/src/Symfony/Component/Form/FormFactory.php b/src/Symfony/Component/Form/FormFactory.php index a474c0ab9079..e2c3d23237e7 100644 --- a/src/Symfony/Component/Form/FormFactory.php +++ b/src/Symfony/Component/Form/FormFactory.php @@ -219,9 +219,9 @@ public function createNamedBuilder($type, $name, $data = null, array $options = $builder = null; $types = array(); - $defaultOptions = array(); $optionValues = array(); - $passedOptions = $options; + $knownOptions = array(); + $defaultOptions = new DefaultOptions(); // Bottom-up determination of the type hierarchy // Start with the actual type and look for the parent type @@ -249,52 +249,36 @@ public function createNamedBuilder($type, $name, $data = null, array $options = $type = $type->getParent($options); } - // Top-down determination of the options and default options + // Top-down determination of the default options foreach ($types as $type) { // Merge the default options of all types to an array of default // options. Default options of children override default options // of parents. - // Default options of ancestors are already visible in the $options - // array passed to the following methods. - $defaultOptions = array_replace($defaultOptions, $type->getDefaultOptions($options)); - $optionValues = array_merge_recursive($optionValues, $type->getAllowedOptionValues($options)); + $typeOptions = $type->getDefaultOptions(); + $defaultOptions->add($typeOptions); + $defaultOptions->addAllowedValues($type->getAllowedOptionValues()); + $knownOptions = array_merge($knownOptions, array_keys($typeOptions)); foreach ($type->getExtensions() as $typeExtension) { - $defaultOptions = array_replace($defaultOptions, $typeExtension->getDefaultOptions($options)); - $optionValues = array_merge_recursive($optionValues, $typeExtension->getAllowedOptionValues($options)); + $extensionOptions = $typeExtension->getDefaultOptions(); + $defaultOptions->add($extensionOptions); + $defaultOptions->addAllowedValues($typeExtension->getAllowedOptionValues()); + $knownOptions = array_merge($knownOptions, array_keys($extensionOptions)); } - - // In each turn, the options are replaced by the combination of - // the currently known default options and the passed options. - // It is important to merge with $passedOptions and not with - // $options, otherwise default options of parents would override - // default options of child types. - $options = array_replace($defaultOptions, $passedOptions); } + // Resolve concrete type $type = end($types); - $knownOptions = array_keys($defaultOptions); + + // Validate options required by the factory $diff = array_diff(self::$requiredOptions, $knownOptions); if (count($diff) > 0) { throw new TypeDefinitionException(sprintf('Type "%s" should support the option(s) "%s"', $type->getName(), implode('", "', $diff))); } - $diff = array_diff(array_keys($passedOptions), $knownOptions); - - if (count($diff) > 1) { - throw new CreationException(sprintf('The options "%s" do not exist. Known options are: "%s"', implode('", "', $diff), implode('", "', $knownOptions))); - } - - if (count($diff) > 0) { - throw new CreationException(sprintf('The option "%s" does not exist. Known options are: "%s"', current($diff), implode('", "', $knownOptions))); - } - - foreach ($optionValues as $option => $allowedValues) { - if (!in_array($options[$option], $allowedValues, true)) { - throw new CreationException(sprintf('The option "%s" has the value "%s", but is expected to be one of "%s"', $option, $options[$option], implode('", "', $allowedValues))); - } - } + // Resolve options + $options = $defaultOptions->resolve($options); for ($i = 0, $l = count($types); $i < $l && !$builder; ++$i) { $builder = $types[$i]->createBuilder($name, $this, $options); diff --git a/src/Symfony/Component/Form/FormTypeExtensionInterface.php b/src/Symfony/Component/Form/FormTypeExtensionInterface.php index fbc163401ed2..8c72c7861632 100644 --- a/src/Symfony/Component/Form/FormTypeExtensionInterface.php +++ b/src/Symfony/Component/Form/FormTypeExtensionInterface.php @@ -59,7 +59,7 @@ function buildViewBottomUp(FormView $view, FormInterface $form); * * @return array */ - function getDefaultOptions(array $options); + function getDefaultOptions(); /** * Returns the allowed option values for each option (if any). @@ -68,7 +68,7 @@ function getDefaultOptions(array $options); * * @return array The allowed option values */ - function getAllowedOptionValues(array $options); + function getAllowedOptionValues(); /** diff --git a/src/Symfony/Component/Form/FormTypeInterface.php b/src/Symfony/Component/Form/FormTypeInterface.php index c767f1f20475..b4236882bcf5 100644 --- a/src/Symfony/Component/Form/FormTypeInterface.php +++ b/src/Symfony/Component/Form/FormTypeInterface.php @@ -79,7 +79,7 @@ function createBuilder($name, FormFactoryInterface $factory, array $options); * * @return array The default options */ - function getDefaultOptions(array $options); + function getDefaultOptions(); /** * Returns the allowed option values for each option (if any). @@ -88,7 +88,7 @@ function getDefaultOptions(array $options); * * @return array The allowed option values */ - function getAllowedOptionValues(array $options); + function getAllowedOptionValues(); /** * Returns the name of the parent type. diff --git a/src/Symfony/Component/Form/LazyOption.php b/src/Symfony/Component/Form/LazyOption.php new file mode 100644 index 000000000000..060d77bdfa2a --- /dev/null +++ b/src/Symfony/Component/Form/LazyOption.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use Closure; + +/** + * An option that is evaluated lazily using a closure. + * + * @author Bernhard Schussek + * + * @see DefaultOptions + */ +class LazyOption +{ + /** + * The underlying closure. + * @var Closure + */ + private $closure; + + /** + * The previous default value of the option. + * @var mixed + */ + private $previousValue; + + /** + * Creates a new lazy option. + * + * @param Closure $closure The closure used for initializing the + * option value. + * @param mixed $previousValue The previous value of the option. This + * value is passed to the closure when it is + * evaluated. + * + * @see evaluate() + */ + public function __construct(Closure $closure, $previousValue) + { + $this->closure = $closure; + $this->previousValue = $previousValue; + } + + /** + * Evaluates the underyling closure and returns its result. + * + * The given Options instance is passed to the closure as first argument. + * The previous default value set in the constructor is passed as second + * argument. + * + * @param Options $options The container with all concrete options. + * + * @return mixed The result of the closure. + */ + public function evaluate(Options $options) + { + if ($this->previousValue instanceof self) { + $this->previousValue = $this->previousValue->evaluate($options); + } + + return $this->closure->__invoke($options, $this->previousValue); + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Form/Options.php b/src/Symfony/Component/Form/Options.php new file mode 100644 index 000000000000..028e4ac19bca --- /dev/null +++ b/src/Symfony/Component/Form/Options.php @@ -0,0 +1,364 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +use ArrayAccess; +use Iterator; +use OutOfBoundsException; +use Symfony\Component\Form\Exception\OptionDefinitionException; + +/** + * Container for resolving inter-dependent options. + * + * Options are a common pattern for resolved classes in PHP. Avoiding the + * problems related to this approach is however a non-trivial task. Usually, + * both classes and subclasses should be able to set default option values. + * These default options should be overridden by the options passed to the + * constructor. Last but not least, the (default) values of some options may + * depend on the values of other options, which themselves may depend on other + * options. + * + * This class resolves these problems. You can use it in your classes by + * implementing the following pattern: + * + * + * class Car + * { + * protected $options; + * + * public function __construct(array $options) + * { + * $_options = new Options(); + * $this->addDefaultOptions($_options); + * + * $this->options = $_options->resolve($options); + * } + * + * protected function addDefaultOptions(Options $options) + * { + * $options->add(array( + * 'make' => 'VW', + * 'year' => '1999', + * )); + * } + * } + * + * $car = new Car(array( + * 'make' => 'Mercedes', + * 'year' => 2005, + * )); + * + * + * By calling add(), new default options are added to the container. The method + * resolve() accepts an array of options passed by the user that are matched + * against the allowed options. If any option is not recognized, an exception + * is thrown. Finally, resolve() returns the merged default and user options. + * + * You can now easily add or override options in subclasses: + * + * + * class Renault extends Car + * { + * protected function addDefaultOptions(Options $options) + * { + * parent::addDefaultOptions($options); + * + * $options->add(array( + * 'make' => 'Renault', + * 'gear' => 'auto', + * )); + * } + * } + * + * $renault = new Renault(array( + * 'year' => 1997, + * 'gear' => 'manual' + * )); + * + * + * IMPORTANT: parent::addDefaultOptions() must always be called before adding + * new options! + * + * In the previous example, it makes sense to restrict the option "gear" to + * a set of allowed values: + * + * + * class Renault extends Car + * { + * protected function addDefaultOptions(Options $options) + * { + * // ... like above ... + * + * $options->addAllowedValues(array( + * 'gear' => array('auto', 'manual'), + * )); + * } + * } + * + * // Fails! + * $renault = new Renault(array( + * 'gear' => 'v6', + * )); + * + * + * Now it is impossible to pass a value in the "gear" option that is not + * expected. + * + * Last but not least, you can define options that depend on other options. + * For example, depending on the "make" you could preset the country that the + * car is registered in. + * + * + * class Car + * { + * protected function addDefaultOptions(Options $options) + * { + * $options->add(array( + * 'make' => 'VW', + * 'year' => '1999', + * 'country' => function (Options $options) { + * if ('VW' === $options['make']) { + * return 'DE'; + * } + * + * return null; + * }, + * )); + * } + * } + * + * $car = new Car(array( + * 'make' => 'VW', // => "country" is "DE" + * )); + * + * + * When overriding an option with a closure in subclasses, you can make use of + * the second parameter $parentValue in which the value defined by the parent + * class is stored. + * + * + * class Renault extends Car + * { + * protected function addDefaultOptions(Options $options) + * { + * $options->add(array( + * 'country' => function (Options $options, $parentValue) { + * if ('Renault' === $options['make']) { + * return 'FR'; + * } + * + * return $parentValue; + * }, + * )); + * } + * } + * + * $renault = new Renault(array( + * 'make' => 'VW', // => "country" is still "DE" + * )); + * + * + * @author Bernhard Schussek + */ +class Options implements ArrayAccess, Iterator +{ + /** + * A list of option values and LazyOption instances. + * @var array + */ + private $options = array(); + + /** + * A list of Boolean locks for each LazyOption. + * @var array + */ + private $lock = array(); + + /** + * Whether the options have already been resolved. + * + * Once resolved, no new options can be added or changed anymore. + * + * @var Boolean + */ + private $resolved = false; + + /** + * Returns whether the given option exists. + * + * @param string $option The option name. + * + * @return Boolean Whether the option exists. + * + * @see ArrayAccess::offsetExists() + */ + public function offsetExists($option) + { + return isset($this->options[$option]); + } + + /** + * Returns the value of the given option. + * + * After reading an option for the first time, this object becomes + * + * @param string $option The option name. + * + * @return mixed The option value. + * + * @throws OutOfBoundsException If the option does not exist. + * @throws OptionDefinitionException If a cyclic dependency is detected + * between two lazy options. + * + * @see ArrayAccess::offsetGet() + */ + public function offsetGet($option) + { + if (!array_key_exists($option, $this->options)) { + throw new OutOfBoundsException('The option "' . $option . '" does not exist'); + } + + $this->resolved = true; + + if ($this->options[$option] instanceof LazyOption) { + if ($this->lock[$option]) { + $conflicts = array_keys(array_filter($this->lock, function ($locked) { + return $locked; + })); + + throw new OptionDefinitionException('The options "' . implode('", "', $conflicts) . '" have a cyclic dependency'); + } + + $this->lock[$option] = true; + $this->options[$option] = $this->options[$option]->evaluate($this); + $this->lock[$option] = false; + } + + return $this->options[$option]; + } + + /** + * Sets the value of a given option. + * + * @param string $option The name of the option. + * @param mixed $value The value of the option. May be a closure with a + * signature as defined in DefaultOptions::add(). + * + * @throws OptionDefinitionException If options have already been read. + * Once options are read, the container + * becomes immutable. + * + * @see DefaultOptions::add() + * @see ArrayAccess::offsetSet() + */ + public function offsetSet($option, $value) + { + // Setting is not possible once an option is read, because then lazy + // options could manipulate the state of the object, leading to + // inconsistent results. + if ($this->resolved) { + throw new OptionDefinitionException('Options cannot be set after reading options'); + } + + $newValue = $value; + + // If an option is a closure that should be evaluated lazily, store it + // inside a LazyOption instance. + if ($newValue instanceof \Closure) { + $reflClosure = new \ReflectionFunction($newValue); + $params = $reflClosure->getParameters(); + $isLazyOption = count($params) >= 1 && null !== $params[0]->getClass() && __CLASS__ === $params[0]->getClass()->getName(); + + if ($isLazyOption) { + $currentValue = isset($this->options[$option]) ? $this->options[$option] : null; + $newValue = new LazyOption($newValue, $currentValue); + } + + // Store locks for lazy options to detect cyclic dependencies + $this->lock[$option] = false; + } + + $this->options[$option] = $newValue; + } + + /** + * Removes an option with the given name. + * + * @param string $option The option name. + * + * @throws OptionDefinitionException If options have already been read. + * Once options are read, the container + * becomes immutable. + * + * @see ArrayAccess::offsetUnset() + */ + public function offsetUnset($option) + { + if ($this->resolved) { + throw new OptionDefinitionException('Options cannot be unset after reading options'); + } + + unset($this->options[$option]); + unset($this->allowedValues[$option]); + unset($this->lock[$option]); + } + + /** + * Returns the names of all defined options. + * + * @return array An array of option names. + */ + public function getNames() + { + return array_keys($this->options); + } + + /** + * @see Iterator::current() + */ + public function current() + { + return $this->offsetGet($this->key()); + } + + /** + * @see Iterator::next() + */ + public function next() + { + next($this->options); + } + + /** + * @see Iterator::key() + */ + public function key() + { + return key($this->options); + } + + /** + * @see Iterator::valid() + */ + public function valid() + { + return null !== $this->key(); + } + + /** + * @see Iterator::rewind() + */ + public function rewind() + { + reset($this->options); + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Form/Tests/DefaultOptionsTest.php b/src/Symfony/Component/Form/Tests/DefaultOptionsTest.php new file mode 100644 index 000000000000..9f32b72cf177 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/DefaultOptionsTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests; + +use Symfony\Component\Form\DefaultOptions; +use Symfony\Component\Form\Options; + +class DefaultOptionsTest extends \PHPUnit_Framework_TestCase +{ + private $options; + + protected function setUp() + { + $this->options = new DefaultOptions(); + } + + public function testResolve() + { + $this->options->add(array( + 'foo' => 'bar', + 'bam' => function (Options $options) { + return 'baz'; + }, + )); + + $result = array( + 'foo' => 'fee', + 'bam' => 'baz', + ); + + $this->assertEquals($result, $this->options->resolve(array( + 'foo' => 'fee', + ))); + } + + /** + * @expectedException Symfony\Component\Form\Exception\InvalidOptionException + */ + public function testResolveFailsIfNonExistingOption() + { + $this->options->add(array( + 'foo' => 'bar', + )); + + $this->options->resolve(array( + 'non_existing' => 'option', + )); + } + + /** + * @expectedException Symfony\Component\Form\Exception\InvalidOptionException + */ + public function testResolveFailsIfOptionValueNotAllowed() + { + $this->options->add(array( + 'foo' => 'bar', + )); + + $this->options->addAllowedValues(array( + 'foo' => array('bar', 'baz'), + )); + + $this->options->resolve(array( + 'foo' => 'bam', + )); + } +} \ No newline at end of file diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php index 50d1453dd19c..d60e060a3e5c 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php @@ -90,7 +90,7 @@ public function testChoiceListOptionExpectsChoiceListInterface() } /** - * @expectedException Symfony\Component\Form\Exception\FormException + * expectedException Symfony\Component\Form\Exception\FormException */ public function testEitherChoiceListOrChoicesMustBeSet() { diff --git a/src/Symfony/Component/Form/Tests/Fixtures/AuthorType.php b/src/Symfony/Component/Form/Tests/Fixtures/AuthorType.php new file mode 100644 index 000000000000..43569843f027 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/AuthorType.php @@ -0,0 +1,31 @@ +add('firstName') + ->add('lastName') + ; + } + + public function getName() + { + return 'author'; + } + + public function getDefaultOptions() + { + return array( + 'data_class' => 'Symfony\Component\Form\Tests\Fixtures\Author', + ); + } +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/FooType.php b/src/Symfony/Component/Form/Tests/Fixtures/FooType.php index d1974b2c36f2..d85ae2c72add 100644 --- a/src/Symfony/Component/Form/Tests/Fixtures/FooType.php +++ b/src/Symfony/Component/Form/Tests/Fixtures/FooType.php @@ -34,7 +34,7 @@ public function createBuilder($name, FormFactoryInterface $factory, array $optio return new FormBuilder($name, $factory, new EventDispatcher()); } - public function getDefaultOptions(array $options) + public function getDefaultOptions() { return array( 'data' => null, @@ -44,7 +44,7 @@ public function getDefaultOptions(array $options) ); } - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array( 'a_or_b' => array('a', 'b'), diff --git a/src/Symfony/Component/Form/Tests/Fixtures/FooTypeBarExtension.php b/src/Symfony/Component/Form/Tests/Fixtures/FooTypeBarExtension.php index 2534a5189bbe..31f929363428 100644 --- a/src/Symfony/Component/Form/Tests/Fixtures/FooTypeBarExtension.php +++ b/src/Symfony/Component/Form/Tests/Fixtures/FooTypeBarExtension.php @@ -21,7 +21,7 @@ public function buildForm(FormBuilder $builder, array $options) $builder->setAttribute('bar', 'x'); } - public function getAllowedOptionValues(array $options) + public function getAllowedOptionValues() { return array( 'a_or_b' => array('c'), diff --git a/src/Symfony/Component/Form/Tests/FormFactoryTest.php b/src/Symfony/Component/Form/Tests/FormFactoryTest.php index 3f206a798ac0..18c70bc45708 100644 --- a/src/Symfony/Component/Form/Tests/FormFactoryTest.php +++ b/src/Symfony/Component/Form/Tests/FormFactoryTest.php @@ -16,6 +16,8 @@ use Symfony\Component\Form\Guess\Guess; use Symfony\Component\Form\Guess\ValueGuess; use Symfony\Component\Form\Guess\TypeGuess; +use Symfony\Component\Form\Tests\Fixtures\Author; +use Symfony\Component\Form\Tests\Fixtures\AuthorType; use Symfony\Component\Form\Tests\Fixtures\TestExtension; use Symfony\Component\Form\Tests\Fixtures\FooType; use Symfony\Component\Form\Tests\Fixtures\FooTypeBarExtension; @@ -277,7 +279,7 @@ public function testCreateNamedBuilderExpectsBuilderToBeReturned() } /** - * @expectedException Symfony\Component\Form\Exception\CreationException + * @expectedException Symfony\Component\Form\Exception\InvalidOptionException */ public function testCreateNamedBuilderExpectsOptionsToExist() { @@ -290,7 +292,7 @@ public function testCreateNamedBuilderExpectsOptionsToExist() } /** - * @expectedException Symfony\Component\Form\Exception\CreationException + * @expectedException Symfony\Component\Form\Exception\InvalidOptionException */ public function testCreateNamedBuilderExpectsOptionsToBeInValidRange() { @@ -511,11 +513,13 @@ public function testUnknownOptions() $factory = new FormFactory(array(new \Symfony\Component\Form\Extension\Core\CoreExtension())); - $this->setExpectedException('Symfony\Component\Form\Exception\CreationException', - 'The options "invalid", "unknown" do not exist. Known options are: "data", "data_class", ' . - '"trim", "required", "read_only", "disabled", "max_length", "pattern", "property_path", "by_reference", ' . - '"error_bubbling", "error_mapping", "label", "attr", "invalid_message", "invalid_message_parameters", ' . - '"translation_domain", "empty_data"' + $this->setExpectedException('Symfony\Component\Form\Exception\InvalidOptionException', + 'The options "invalid", "unknown" do not exist. Known options are: ' . + '"attr", "by_reference", "data", "data_class", "disabled", ' . + '"empty_data", "error_bubbling", "error_mapping", "invalid_message", ' . + '"invalid_message_parameters", "label", "max_length", "pattern", ' . + '"property_path", "read_only", "required", "translation_domain", ' . + '"trim"' ); $factory->createNamedBuilder($type, "text", "value", array("invalid" => "opt", "unknown" => "opt")); } @@ -526,15 +530,31 @@ public function testUnknownOption() $factory = new FormFactory(array(new \Symfony\Component\Form\Extension\Core\CoreExtension())); - $this->setExpectedException('Symfony\Component\Form\Exception\CreationException', - 'The option "unknown" does not exist. Known options are: "data", "data_class", ' . - '"trim", "required", "read_only", "disabled", "max_length", "pattern", "property_path", "by_reference", ' . - '"error_bubbling", "error_mapping", "label", "attr", "invalid_message", "invalid_message_parameters", ' . - '"translation_domain", "empty_data"' + $this->setExpectedException('Symfony\Component\Form\Exception\InvalidOptionException', + 'The option "unknown" does not exist. Known options are: "attr", ' . + '"by_reference", "data", "data_class", "disabled", "empty_data", ' . + '"error_bubbling", "error_mapping", "invalid_message", ' . + '"invalid_message_parameters", "label", "max_length", "pattern", ' . + '"property_path", "read_only", "required", "translation_domain", ' . + '"trim"' ); $factory->createNamedBuilder($type, "text", "value", array("unknown" => "opt")); } + public function testFieldTypeCreatesDefaultValueForEmptyDataOption() + { + $factory = new FormFactory(array(new \Symfony\Component\Form\Extension\Core\CoreExtension())); + + $form = $factory->createNamedBuilder(new AuthorType(), 'author')->getForm(); + $form->bind(array('firstName' => 'John', 'lastName' => 'Smith')); + + $author = new Author(); + $author->firstName = 'John'; + $author->setLastName('Smith'); + + $this->assertEquals($author, $form->getData()); + } + private function createMockFactory(array $methods = array()) { return $this->getMockBuilder('Symfony\Component\Form\FormFactory') diff --git a/src/Symfony/Component/Form/Tests/OptionsTest.php b/src/Symfony/Component/Form/Tests/OptionsTest.php new file mode 100644 index 000000000000..89b0a07f7f5c --- /dev/null +++ b/src/Symfony/Component/Form/Tests/OptionsTest.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests; + +use Symfony\Component\Form\Options; + +class OptionsTest extends \PHPUnit_Framework_TestCase +{ + private $options; + + protected function setUp() + { + $this->options = new Options(); + } + + public function testArrayAccess() + { + $this->assertFalse(isset($this->options['foo'])); + $this->assertFalse(isset($this->options['bar'])); + + $this->options['foo'] = 0; + $this->options['bar'] = 1; + + $this->assertTrue(isset($this->options['foo'])); + $this->assertTrue(isset($this->options['bar'])); + + unset($this->options['bar']); + + + $this->assertTrue(isset($this->options['foo'])); + $this->assertFalse(isset($this->options['bar'])); + $this->assertEquals(0, $this->options['foo']); + } + + /** + * @expectedException \OutOfBoundsException + */ + public function testGetNonExisting() + { + $this->options['foo']; + } + + /** + * @expectedException Symfony\Component\Form\Exception\OptionDefinitionException + */ + public function testSetNotSupportedAfterGet() + { + $this->options['foo'] = 'bar'; + $this->options['foo']; + $this->options['foo'] = 'baz'; + } + + /** + * @expectedException Symfony\Component\Form\Exception\OptionDefinitionException + */ + public function testUnsetNotSupportedAfterGet() + { + $this->options['foo'] = 'bar'; + $this->options['foo']; + unset($this->options['foo']); + } + + public function testLazyOption() + { + $test = $this; + + $this->options['foo'] = function (Options $options) use ($test) { + return 'dynamic'; + }; + + $this->assertEquals('dynamic', $this->options['foo']); + } + + public function testLazyOptionWithEagerCurrentValue() + { + $test = $this; + + // defined by superclass + $this->options['foo'] = 'bar'; + + // defined by subclass + $this->options['foo'] = function (Options $options, $currentValue) use ($test) { + $test->assertEquals('bar', $currentValue); + + return 'dynamic'; + }; + + $this->assertEquals('dynamic', $this->options['foo']); + } + + public function testLazyOptionWithLazyCurrentValue() + { + $test = $this; + + // defined by superclass + $this->options['foo'] = function (Options $options) { + return 'bar'; + }; + + // defined by subclass + $this->options['foo'] = function (Options $options, $currentValue) use ($test) { + $test->assertEquals('bar', $currentValue); + + return 'dynamic'; + }; + + $this->assertEquals('dynamic', $this->options['foo']); + } + + public function testLazyOptionWithEagerDependency() + { + $test = $this; + + $this->options['foo'] = 'bar'; + + $this->options['bam'] = function (Options $options) use ($test) { + $test->assertEquals('bar', $options['foo']); + + return 'dynamic'; + }; + + $this->assertEquals('bar', $this->options['foo']); + $this->assertEquals('dynamic', $this->options['bam']); + } + + public function testLazyOptionWithLazyDependency() + { + $test = $this; + + $this->options['foo'] = function (Options $options) { + return 'bar'; + }; + + $this->options['bam'] = function (Options $options) use ($test) { + $test->assertEquals('bar', $options['foo']); + + return 'dynamic'; + }; + + $this->assertEquals('bar', $this->options['foo']); + $this->assertEquals('dynamic', $this->options['bam']); + } + + /** + * @expectedException Symfony\Component\Form\Exception\OptionDefinitionException + */ + public function testLazyOptionDisallowCyclicDependencies() + { + $this->options['foo'] = function (Options $options) { + $options['bam']; + }; + + $this->options['bam'] = function (Options $options) { + $options['foo']; + }; + + $this->options['foo']; + } +} \ No newline at end of file