From ed2c3126092c4a9364a60bbcd1a139777639571b Mon Sep 17 00:00:00 2001 From: Jules Pietri Date: Sun, 7 Apr 2019 22:08:39 +0200 Subject: [PATCH] [Form] Added a "choice_filter" option to ChoiceType --- UPGRADE-5.1.md | 1 + UPGRADE-6.0.md | 1 + src/Symfony/Component/Form/CHANGELOG.md | 2 + .../Component/Form/ChoiceList/ChoiceList.php | 11 ++ .../ChoiceList/Factory/Cache/ChoiceFilter.php | 27 ++++ .../Factory/CachingFactoryDecorator.php | 59 ++++++-- .../Factory/ChoiceListFactoryInterface.php | 8 +- .../Factory/DefaultChoiceListFactory.php | 28 +++- .../Factory/PropertyAccessDecorator.php | 46 +++++- .../Loader/FilterChoiceLoaderDecorator.php | 63 ++++++++ .../Form/Extension/Core/Type/ChoiceType.php | 24 ++- .../Factory/CachingFactoryDecoratorTest.php | 143 +++++++++++++++++- .../Factory/DefaultChoiceListFactoryTest.php | 64 ++++++++ .../Factory/PropertyAccessDecoratorTest.php | 61 ++++++++ .../FilterChoiceLoaderDecoratorTest.php | 99 ++++++++++++ .../Extension/Core/Type/ChoiceTypeTest.php | 65 ++++++++ .../DeprecatedChoiceListFactory.php | 22 +++ .../Descriptor/resolved_form_type_1.json | 1 + .../Descriptor/resolved_form_type_1.txt | 24 +-- src/Symfony/Component/Form/composer.json | 1 + 20 files changed, 710 insertions(+), 40 deletions(-) create mode 100644 src/Symfony/Component/Form/ChoiceList/Factory/Cache/ChoiceFilter.php create mode 100644 src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php create mode 100644 src/Symfony/Component/Form/Tests/ChoiceList/Loader/FilterChoiceLoaderDecoratorTest.php create mode 100644 src/Symfony/Component/Form/Tests/Fixtures/ChoiceList/DeprecatedChoiceListFactory.php diff --git a/UPGRADE-5.1.md b/UPGRADE-5.1.md index 169f5b683d7b9..1733eada12f42 100644 --- a/UPGRADE-5.1.md +++ b/UPGRADE-5.1.md @@ -23,6 +23,7 @@ Form is deprecated. The method will be added to the interface in 6.0. * Implementing the `FormConfigBuilderInterface` without implementing the `setIsEmptyCallback()` method is deprecated. The method will be added to the interface in 6.0. + * Added argument `callable|null $filter` to `ChoiceListFactoryInterface::createListFromChoices()` and `createListFromLoader()` - not defining them is deprecated. FrameworkBundle --------------- diff --git a/UPGRADE-6.0.md b/UPGRADE-6.0.md index 36eb66645d622..83a5df0465ae7 100644 --- a/UPGRADE-6.0.md +++ b/UPGRADE-6.0.md @@ -21,6 +21,7 @@ Form * Added the `getIsEmptyCallback()` method to the `FormConfigInterface`. * Added the `setIsEmptyCallback()` method to the `FormConfigBuilderInterface`. + * Added argument `callable|null $filter` to `ChoiceListFactoryInterface::createListFromChoices()` and `createListFromLoader()`. FrameworkBundle --------------- diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index 95a3d435b23c0..1ef15343c6d25 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -4,6 +4,8 @@ CHANGELOG 5.1.0 ----- + * Added a `choice_filter` option to `ChoiceType` + * Added argument `callable|null $filter` to `ChoiceListFactoryInterface::createListFromChoices()` and `createListFromLoader()` - not defining them is deprecated. * Added a `ChoiceList` facade to leverage explicit choice list caching based on options * Added an `AbstractChoiceLoader` to simplify implementations and handle global optimizations * The `view_timezone` option defaults to the `model_timezone` if no `reference_date` is configured. diff --git a/src/Symfony/Component/Form/ChoiceList/ChoiceList.php b/src/Symfony/Component/Form/ChoiceList/ChoiceList.php index d386f88eba671..045ded01e2e05 100644 --- a/src/Symfony/Component/Form/ChoiceList/ChoiceList.php +++ b/src/Symfony/Component/Form/ChoiceList/ChoiceList.php @@ -13,6 +13,7 @@ use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceAttr; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFieldName; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFilter; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLabel; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLoader; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceValue; @@ -66,6 +67,16 @@ public static function value($formType, $value, $vary = null): ChoiceValue return new ChoiceValue($formType, $value, $vary); } + /** + * @param FormTypeInterface|FormTypeExtensionInterface $formType A form type or type extension configuring a cacheable choice list + * @param callable $filter Any pseudo callable to filter a choice list + * @param mixed|null $vary Dynamic data used to compute a unique hash when caching the callback + */ + public static function filter($formType, $filter, $vary = null): ChoiceFilter + { + return new ChoiceFilter($formType, $filter, $vary); + } + /** * Decorates a "choice_label" option to make it cacheable. * diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/Cache/ChoiceFilter.php b/src/Symfony/Component/Form/ChoiceList/Factory/Cache/ChoiceFilter.php new file mode 100644 index 0000000000000..13b8cd8ed3223 --- /dev/null +++ b/src/Symfony/Component/Form/ChoiceList/Factory/Cache/ChoiceFilter.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Factory\Cache; + +use Symfony\Component\Form\FormTypeExtensionInterface; +use Symfony\Component\Form\FormTypeInterface; + +/** + * A cacheable wrapper for any {@see FormTypeInterface} or {@see FormTypeExtensionInterface} + * which configures a "choice_filter" option. + * + * @internal + * + * @author Jules Pietri + */ +final class ChoiceFilter extends AbstractStaticOption +{ +} diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php b/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php index f7fe8c2465ff1..2e1dc9a317654 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/CachingFactoryDecorator.php @@ -19,6 +19,9 @@ /** * Caches the choice lists created by the decorated factory. * + * To cache a list based on its options, arguments must be decorated + * by a {@see Cache\AbstractStaticOption} implementation. + * * @author Bernhard Schussek * @author Jules Pietri */ @@ -80,25 +83,42 @@ public function getDecoratedFactory() /** * {@inheritdoc} + * + * @param callable|Cache\ChoiceValue|null $value The callable or static option for + * generating the choice values + * @param callable|Cache\ChoiceFilter|null $filter The callable or static option for + * filtering the choices */ - public function createListFromChoices(iterable $choices, $value = null) + public function createListFromChoices(iterable $choices, $value = null/*, $filter = null*/) { + $filter = \func_num_args() > 2 ? func_get_arg(2) : null; + if ($choices instanceof \Traversable) { $choices = iterator_to_array($choices); } - // Only cache per value when needed. The value is not validated on purpose. + $cache = true; + // Only cache per value and filter when needed. The value is not validated on purpose. // The decorated factory may decide which values to accept and which not. if ($value instanceof Cache\ChoiceValue) { $value = $value->getOption(); } elseif ($value) { - return $this->decoratedFactory->createListFromChoices($choices, $value); + $cache = false; + } + if ($filter instanceof Cache\ChoiceFilter) { + $filter = $filter->getOption(); + } elseif ($filter) { + $cache = false; + } + + if (!$cache) { + return $this->decoratedFactory->createListFromChoices($choices, $value, $filter); } - $hash = self::generateHash([$choices, $value], 'fromChoices'); + $hash = self::generateHash([$choices, $value, $filter], 'fromChoices'); if (!isset($this->lists[$hash])) { - $this->lists[$hash] = $this->decoratedFactory->createListFromChoices($choices, $value); + $this->lists[$hash] = $this->decoratedFactory->createListFromChoices($choices, $value, $filter); } return $this->lists[$hash]; @@ -106,9 +126,18 @@ public function createListFromChoices(iterable $choices, $value = null) /** * {@inheritdoc} + * + * @param ChoiceLoaderInterface|Cache\ChoiceLoader $loader The loader or static loader to load + * the choices lazily + * @param callable|Cache\ChoiceValue|null $value The callable or static option for + * generating the choice values + * @param callable|Cache\ChoiceFilter|null $filter The callable or static option for + * filtering the choices */ - public function createListFromLoader(ChoiceLoaderInterface $loader, $value = null) + public function createListFromLoader(ChoiceLoaderInterface $loader, $value = null/*, $filter = null*/) { + $filter = \func_num_args() > 2 ? func_get_arg(2) : null; + $cache = true; if ($loader instanceof Cache\ChoiceLoader) { @@ -123,14 +152,20 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, $value = nul $cache = false; } + if ($filter instanceof Cache\ChoiceFilter) { + $filter = $filter->getOption(); + } elseif ($filter) { + $cache = false; + } + if (!$cache) { - return $this->decoratedFactory->createListFromLoader($loader, $value); + return $this->decoratedFactory->createListFromLoader($loader, $value, $filter); } - $hash = self::generateHash([$loader, $value], 'fromLoader'); + $hash = self::generateHash([$loader, $value, $filter], 'fromLoader'); if (!isset($this->lists[$hash])) { - $this->lists[$hash] = $this->decoratedFactory->createListFromLoader($loader, $value); + $this->lists[$hash] = $this->decoratedFactory->createListFromLoader($loader, $value, $filter); } return $this->lists[$hash]; @@ -138,6 +173,12 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, $value = nul /** * {@inheritdoc} + * + * @param array|callable|Cache\PreferredChoice|null $preferredChoices The preferred choices + * @param callable|false|Cache\ChoiceLabel|null $label The option or static option generating the choice labels + * @param callable|Cache\ChoiceFieldName|null $index The option or static option generating the view indices + * @param callable|Cache\GroupBy|null $groupBy The option or static option generating the group names + * @param array|callable|Cache\ChoiceAttr|null $attr The option or static option generating the HTML attributes */ public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, $index = null, $groupBy = null, $attr = null) { diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php b/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php index 7cb37e1a82c65..82b1e4dc7de6b 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/ChoiceListFactoryInterface.php @@ -31,9 +31,11 @@ interface ChoiceListFactoryInterface * The callable receives the choice as only argument. * Null may be passed when the choice list contains the empty value. * + * @param callable|null $filter The callable filtering the choices + * * @return ChoiceListInterface The choice list */ - public function createListFromChoices(iterable $choices, callable $value = null); + public function createListFromChoices(iterable $choices, callable $value = null/*, callable $filter = null*/); /** * Creates a choice list that is loaded with the given loader. @@ -42,9 +44,11 @@ public function createListFromChoices(iterable $choices, callable $value = null) * The callable receives the choice as only argument. * Null may be passed when the choice list contains the empty value. * + * @param callable|null $filter The callable filtering the choices + * * @return ChoiceListInterface The choice list */ - public function createListFromLoader(ChoiceLoaderInterface $loader, callable $value = null); + public function createListFromLoader(ChoiceLoaderInterface $loader, callable $value = null/*, callable $filter = null*/); /** * Creates a view for the given choice list. diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php b/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php index 1b184b6ab4ccf..45d3d046bd36e 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/DefaultChoiceListFactory.php @@ -14,7 +14,9 @@ use Symfony\Component\Form\ChoiceList\ArrayChoiceList; use Symfony\Component\Form\ChoiceList\ChoiceListInterface; use Symfony\Component\Form\ChoiceList\LazyChoiceList; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface; +use Symfony\Component\Form\ChoiceList\Loader\FilterChoiceLoaderDecorator; use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView; use Symfony\Component\Form\ChoiceList\View\ChoiceListView; use Symfony\Component\Form\ChoiceList\View\ChoiceView; @@ -23,22 +25,44 @@ * Default implementation of {@link ChoiceListFactoryInterface}. * * @author Bernhard Schussek + * @author Jules Pietri */ class DefaultChoiceListFactory implements ChoiceListFactoryInterface { /** * {@inheritdoc} + * + * @param callable|null $filter */ - public function createListFromChoices(iterable $choices, callable $value = null) + public function createListFromChoices(iterable $choices, callable $value = null/*, callable $filter = null*/) { + $filter = \func_num_args() > 2 ? func_get_arg(2) : null; + + if ($filter) { + // filter the choice list lazily + return $this->createListFromLoader(new FilterChoiceLoaderDecorator( + new CallbackChoiceLoader(static function () use ($choices) { + return $choices; + } + ), $filter), $value); + } + return new ArrayChoiceList($choices, $value); } /** * {@inheritdoc} + * + * @param callable|null $filter */ - public function createListFromLoader(ChoiceLoaderInterface $loader, callable $value = null) + public function createListFromLoader(ChoiceLoaderInterface $loader, callable $value = null/*, callable $filter = null*/) { + $filter = \func_num_args() > 2 ? func_get_arg(2) : null; + + if ($filter) { + $loader = new FilterChoiceLoaderDecorator($loader, $filter); + } + return new LazyChoiceList($loader, $value); } diff --git a/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php b/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php index 42b8a022c41f4..bfa37973a565e 100644 --- a/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php +++ b/src/Symfony/Component/Form/ChoiceList/Factory/PropertyAccessDecorator.php @@ -59,13 +59,17 @@ public function getDecoratedFactory() /** * {@inheritdoc} * - * @param callable|string|PropertyPath|null $value The callable or path for - * generating the choice values + * @param callable|string|PropertyPath|null $value The callable or path for + * generating the choice values + * @param callable|string|PropertyPath|null $filter The callable or path for + * filtering the choices * * @return ChoiceListInterface The choice list */ - public function createListFromChoices(iterable $choices, $value = null) + public function createListFromChoices(iterable $choices, $value = null/*, $filter = null*/) { + $filter = \func_num_args() > 2 ? func_get_arg(2) : null; + if (\is_string($value)) { $value = new PropertyPath($value); } @@ -81,19 +85,34 @@ public function createListFromChoices(iterable $choices, $value = null) }; } - return $this->decoratedFactory->createListFromChoices($choices, $value); + if (\is_string($filter)) { + $filter = new PropertyPath($filter); + } + + if ($filter instanceof PropertyPath) { + $accessor = $this->propertyAccessor; + $filter = static function ($choice) use ($accessor, $filter) { + return (\is_object($choice) || \is_array($choice)) && $accessor->getValue($choice, $filter); + }; + } + + return $this->decoratedFactory->createListFromChoices($choices, $value, $filter); } /** * {@inheritdoc} * - * @param callable|string|PropertyPath|null $value The callable or path for - * generating the choice values + * @param callable|string|PropertyPath|null $value The callable or path for + * generating the choice values + * @param callable|string|PropertyPath|null $filter The callable or path for + * filtering the choices * * @return ChoiceListInterface The choice list */ - public function createListFromLoader(ChoiceLoaderInterface $loader, $value = null) + public function createListFromLoader(ChoiceLoaderInterface $loader, $value = null/*, $filter = null*/) { + $filter = \func_num_args() > 2 ? func_get_arg(2) : null; + if (\is_string($value)) { $value = new PropertyPath($value); } @@ -109,7 +128,18 @@ public function createListFromLoader(ChoiceLoaderInterface $loader, $value = nul }; } - return $this->decoratedFactory->createListFromLoader($loader, $value); + if (\is_string($filter)) { + $filter = new PropertyPath($filter); + } + + if ($filter instanceof PropertyPath) { + $accessor = $this->propertyAccessor; + $filter = static function ($choice) use ($accessor, $filter) { + return (\is_object($choice) || \is_array($choice)) && $accessor->getValue($choice, $filter); + }; + } + + return $this->decoratedFactory->createListFromLoader($loader, $value, $filter); } /** diff --git a/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php b/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php new file mode 100644 index 0000000000000..a52f3b82e432e --- /dev/null +++ b/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\ChoiceList\Loader; + +/** + * A decorator to filter choices only when they are loaded or partially loaded. + * + * @author Jules Pietri + */ +class FilterChoiceLoaderDecorator extends AbstractChoiceLoader +{ + private $decoratedLoader; + private $filter; + + public function __construct(ChoiceLoaderInterface $loader, callable $filter) + { + $this->decoratedLoader = $loader; + $this->filter = $filter; + } + + protected function loadChoices(): iterable + { + $list = $this->decoratedLoader->loadChoiceList(); + + if (array_values($list->getValues()) === array_values($structuredValues = $list->getStructuredValues())) { + return array_filter(array_combine($list->getOriginalKeys(), $list->getChoices()), $this->filter); + } + + foreach ($structuredValues as $group => $values) { + if ($values && $filtered = array_filter($list->getChoicesForValues($values), $this->filter)) { + $choices[$group] = $filtered; + } + // filter empty groups + } + + return $choices ?? []; + } + + /** + * {@inheritdoc} + */ + public function loadChoicesForValues(array $values, callable $value = null): array + { + return array_filter($this->decoratedLoader->loadChoicesForValues($values, $value), $this->filter); + } + + /** + * {@inheritdoc} + */ + public function loadValuesForChoices(array $choices, callable $value = null): array + { + return $this->decoratedLoader->loadValuesForChoices(array_filter($choices, $this->filter), $value); + } +} diff --git a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php index 90e973fb7a0bd..6921ffa27fe42 100644 --- a/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php +++ b/src/Symfony/Component/Form/Extension/Core/Type/ChoiceType.php @@ -15,6 +15,7 @@ use Symfony\Component\Form\ChoiceList\ChoiceListInterface; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceAttr; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFieldName; +use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceFilter; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLabel; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceLoader; use Symfony\Component\Form\ChoiceList\Factory\Cache\ChoiceValue; @@ -40,6 +41,7 @@ use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\PropertyAccess\PropertyPath; class ChoiceType extends AbstractType { @@ -52,6 +54,17 @@ public function __construct(ChoiceListFactoryInterface $choiceListFactory = null new DefaultChoiceListFactory() ) ); + + // BC, to be removed in 6.0 + if ($this->choiceListFactory instanceof CachingFactoryDecorator) { + return; + } + + $ref = new \ReflectionMethod($this->choiceListFactory, 'createListFromChoices'); + + if ($ref->getNumberOfParameters() < 3) { + trigger_deprecation('symfony/form', '5.1', 'Not defining a third parameter "callable|null $filter" in "%s::%s()" is deprecated.', $ref->class, $ref->name); + } } /** @@ -307,6 +320,7 @@ public function configureOptions(OptionsResolver $resolver) 'multiple' => false, 'expanded' => false, 'choices' => [], + 'choice_filter' => null, 'choice_loader' => null, 'choice_label' => null, 'choice_name' => null, @@ -332,6 +346,7 @@ public function configureOptions(OptionsResolver $resolver) $resolver->setAllowedTypes('choices', ['null', 'array', '\Traversable']); $resolver->setAllowedTypes('choice_translation_domain', ['null', 'bool', 'string']); $resolver->setAllowedTypes('choice_loader', ['null', 'Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface', ChoiceLoader::class]); + $resolver->setAllowedTypes('choice_filter', ['null', 'callable', 'string', PropertyPath::class, ChoiceFilter::class]); $resolver->setAllowedTypes('choice_label', ['null', 'bool', 'callable', 'string', 'Symfony\Component\PropertyAccess\PropertyPath', ChoiceLabel::class]); $resolver->setAllowedTypes('choice_name', ['null', 'callable', 'string', 'Symfony\Component\PropertyAccess\PropertyPath', ChoiceFieldName::class]); $resolver->setAllowedTypes('choice_value', ['null', 'callable', 'string', 'Symfony\Component\PropertyAccess\PropertyPath', ChoiceValue::class]); @@ -396,14 +411,19 @@ private function createChoiceList(array $options) if (null !== $options['choice_loader']) { return $this->choiceListFactory->createListFromLoader( $options['choice_loader'], - $options['choice_value'] + $options['choice_value'], + $options['choice_filter'] ); } // Harden against NULL values (like in EntityType and ModelType) $choices = null !== $options['choices'] ? $options['choices'] : []; - return $this->choiceListFactory->createListFromChoices($choices, $options['choice_value']); + return $this->choiceListFactory->createListFromChoices( + $choices, + $options['choice_value'], + $options['choice_filter'] + ); } private function createChoiceListView(ChoiceListInterface $choiceList, array $options) diff --git a/src/Symfony/Component/Form/Tests/ChoiceList/Factory/CachingFactoryDecoratorTest.php b/src/Symfony/Component/Form/Tests/ChoiceList/Factory/CachingFactoryDecoratorTest.php index 55e01dd206c1d..bc32a7a439c36 100644 --- a/src/Symfony/Component/Form/Tests/ChoiceList/Factory/CachingFactoryDecoratorTest.php +++ b/src/Symfony/Component/Form/Tests/ChoiceList/Factory/CachingFactoryDecoratorTest.php @@ -135,16 +135,21 @@ public function testCreateFromChoicesDifferentChoices($choice1, $choice2) public function testCreateFromChoicesSameValueClosure() { $choices = [1]; - $list = new ArrayChoiceList([]); + $list1 = new ArrayChoiceList([]); + $list2 = new ArrayChoiceList([]); $closure = function () {}; - $this->decoratedFactory->expects($this->exactly(2)) + $this->decoratedFactory->expects($this->at(0)) ->method('createListFromChoices') ->with($choices, $closure) - ->willReturn($list); + ->willReturn($list1); + $this->decoratedFactory->expects($this->at(1)) + ->method('createListFromChoices') + ->with($choices, $closure) + ->willReturn($list2); - $this->assertSame($list, $this->factory->createListFromChoices($choices, $closure)); - $this->assertSame($list, $this->factory->createListFromChoices($choices, $closure)); + $this->assertSame($list1, $this->factory->createListFromChoices($choices, $closure)); + $this->assertSame($list2, $this->factory->createListFromChoices($choices, $closure)); } public function testCreateFromChoicesSameValueClosureUseCache() @@ -185,6 +190,64 @@ public function testCreateFromChoicesDifferentValueClosure() $this->assertSame($list2, $this->factory->createListFromChoices($choices, $closure2)); } + public function testCreateFromChoicesSameFilterClosure() + { + $choices = [1]; + $list1 = new ArrayChoiceList([]); + $list2 = new ArrayChoiceList([]); + $filter = function () {}; + + $this->decoratedFactory->expects($this->at(0)) + ->method('createListFromChoices') + ->with($choices, null, $filter) + ->willReturn($list1); + $this->decoratedFactory->expects($this->at(1)) + ->method('createListFromChoices') + ->with($choices, null, $filter) + ->willReturn($list2); + + $this->assertSame($list1, $this->factory->createListFromChoices($choices, null, $filter)); + $this->assertSame($list2, $this->factory->createListFromChoices($choices, null, $filter)); + } + + public function testCreateFromChoicesSameFilterClosureUseCache() + { + $choices = [1]; + $list = new ArrayChoiceList([]); + $formType = $this->createMock(FormTypeInterface::class); + $filterCallback = function () {}; + + $this->decoratedFactory->expects($this->once()) + ->method('createListFromChoices') + ->with($choices, null, $filterCallback) + ->willReturn($list) + ; + + $this->assertSame($list, $this->factory->createListFromChoices($choices, null, ChoiceList::filter($formType, $filterCallback))); + $this->assertSame($list, $this->factory->createListFromChoices($choices, null, ChoiceList::filter($formType, function () {}))); + } + + public function testCreateFromChoicesDifferentFilterClosure() + { + $choices = [1]; + $list1 = new ArrayChoiceList([]); + $list2 = new ArrayChoiceList([]); + $closure1 = function () {}; + $closure2 = function () {}; + + $this->decoratedFactory->expects($this->at(0)) + ->method('createListFromChoices') + ->with($choices, null, $closure1) + ->willReturn($list1); + $this->decoratedFactory->expects($this->at(1)) + ->method('createListFromChoices') + ->with($choices, null, $closure2) + ->willReturn($list2); + + $this->assertSame($list1, $this->factory->createListFromChoices($choices, null, $closure1)); + $this->assertSame($list2, $this->factory->createListFromChoices($choices, null, $closure2)); + } + public function testCreateFromLoaderSameLoader() { $loader = $this->getMockBuilder('Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface')->getMock(); @@ -310,6 +373,76 @@ public function testCreateFromLoaderDifferentValueClosure() $this->assertSame($list2, $this->factory->createListFromLoader(ChoiceList::loader($type, $this->createMock(ChoiceLoaderInterface::class)), $closure2)); } + public function testCreateFromLoaderSameFilterClosure() + { + $loader = $this->getMockBuilder('Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface')->getMock(); + $type = $this->createMock(FormTypeInterface::class); + $list = new ArrayChoiceList([]); + $list2 = new ArrayChoiceList([]); + $closure = function () {}; + + $this->decoratedFactory->expects($this->at(0)) + ->method('createListFromLoader') + ->with($loader, null, $closure) + ->willReturn($list) + ; + $this->decoratedFactory->expects($this->at(1)) + ->method('createListFromLoader') + ->with($loader, null, $closure) + ->willReturn($list2) + ; + + $this->assertSame($list, $this->factory->createListFromLoader(ChoiceList::loader($type, $loader), null, $closure)); + $this->assertSame($list2, $this->factory->createListFromLoader(ChoiceList::loader($type, $this->createMock(ChoiceLoaderInterface::class)), null, $closure)); + } + + public function testCreateFromLoaderSameFilterClosureUseCache() + { + $type = $this->createMock(FormTypeInterface::class); + $loader = $this->createMock(ChoiceLoaderInterface::class); + $list = new ArrayChoiceList([]); + $closure = function () {}; + + $this->decoratedFactory->expects($this->once()) + ->method('createListFromLoader') + ->with($loader, null, $closure) + ->willReturn($list) + ; + + $this->assertSame($list, $this->factory->createListFromLoader( + ChoiceList::loader($type, $loader), + null, + ChoiceList::filter($type, $closure) + )); + $this->assertSame($list, $this->factory->createListFromLoader( + ChoiceList::loader($type, $this->createMock(ChoiceLoaderInterface::class)), + null, + ChoiceList::filter($type, function () {}) + )); + } + + public function testCreateFromLoaderDifferentFilterClosure() + { + $loader = $this->getMockBuilder('Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface')->getMock(); + $type = $this->createMock(FormTypeInterface::class); + $list1 = new ArrayChoiceList([]); + $list2 = new ArrayChoiceList([]); + $closure1 = function () {}; + $closure2 = function () {}; + + $this->decoratedFactory->expects($this->at(0)) + ->method('createListFromLoader') + ->with($loader, null, $closure1) + ->willReturn($list1); + $this->decoratedFactory->expects($this->at(1)) + ->method('createListFromLoader') + ->with($loader, null, $closure2) + ->willReturn($list2); + + $this->assertSame($list1, $this->factory->createListFromLoader(ChoiceList::loader($type, $loader), null, $closure1)); + $this->assertSame($list2, $this->factory->createListFromLoader(ChoiceList::loader($type, $this->createMock(ChoiceLoaderInterface::class)), null, $closure2)); + } + public function testCreateViewSamePreferredChoices() { $preferred = ['a']; diff --git a/src/Symfony/Component/Form/Tests/ChoiceList/Factory/DefaultChoiceListFactoryTest.php b/src/Symfony/Component/Form/Tests/ChoiceList/Factory/DefaultChoiceListFactoryTest.php index 7c717f441b426..a124b48ffda31 100644 --- a/src/Symfony/Component/Form/Tests/ChoiceList/Factory/DefaultChoiceListFactoryTest.php +++ b/src/Symfony/Component/Form/Tests/ChoiceList/Factory/DefaultChoiceListFactoryTest.php @@ -16,6 +16,7 @@ use Symfony\Component\Form\ChoiceList\ChoiceListInterface; use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory; use Symfony\Component\Form\ChoiceList\LazyChoiceList; +use Symfony\Component\Form\ChoiceList\Loader\FilterChoiceLoaderDecorator; use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView; use Symfony\Component\Form\ChoiceList\View\ChoiceListView; use Symfony\Component\Form\ChoiceList\View\ChoiceView; @@ -30,6 +31,10 @@ class DefaultChoiceListFactoryTest extends TestCase private $obj4; + private $obj5; + + private $obj6; + private $list; /** @@ -191,6 +196,55 @@ function ($object) { return $object->value; } $this->assertObjectListWithCustomValues($list); } + public function testCreateFromFilteredChoices() + { + $list = $this->factory->createListFromChoices( + ['A' => $this->obj1, 'B' => $this->obj2, 'C' => $this->obj3, 'D' => $this->obj4, 'E' => $this->obj5, 'F' => $this->obj6], + null, + function ($choice) { + return $choice !== $this->obj5 && $choice !== $this->obj6; + } + ); + + $this->assertObjectListWithGeneratedValues($list); + } + + public function testCreateFromChoicesGroupedAndFiltered() + { + $list = $this->factory->createListFromChoices( + [ + 'Group 1' => ['A' => $this->obj1, 'B' => $this->obj2], + 'Group 2' => ['C' => $this->obj3, 'D' => $this->obj4], + 'Group 3' => ['E' => $this->obj5, 'F' => $this->obj6], + 'Group 4' => [/* empty group should be filtered */], + ], + null, + function ($choice) { + return $choice !== $this->obj5 && $choice !== $this->obj6; + } + ); + + $this->assertObjectListWithGeneratedValues($list); + } + + public function testCreateFromChoicesGroupedAndFilteredTraversable() + { + $list = $this->factory->createListFromChoices( + new \ArrayIterator([ + 'Group 1' => ['A' => $this->obj1, 'B' => $this->obj2], + 'Group 2' => ['C' => $this->obj3, 'D' => $this->obj4], + 'Group 3' => ['E' => $this->obj5, 'F' => $this->obj6], + 'Group 4' => [/* empty group should be filtered */], + ]), + null, + function ($choice) { + return $choice !== $this->obj5 && $choice !== $this->obj6; + } + ); + + $this->assertObjectListWithGeneratedValues($list); + } + public function testCreateFromLoader() { $loader = $this->getMockBuilder('Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface')->getMock(); @@ -210,6 +264,16 @@ public function testCreateFromLoaderWithValues() $this->assertEquals(new LazyChoiceList($loader, $value), $list); } + public function testCreateFromLoaderWithFilter() + { + $loader = $this->getMockBuilder('Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface')->getMock(); + $filter = function () {}; + + $list = $this->factory->createListFromLoader($loader, null, $filter); + + $this->assertEquals(new LazyChoiceList(new FilterChoiceLoaderDecorator($loader, $filter)), $list); + } + public function testCreateViewFlat() { $view = $this->factory->createView($this->list); diff --git a/src/Symfony/Component/Form/Tests/ChoiceList/Factory/PropertyAccessDecoratorTest.php b/src/Symfony/Component/Form/Tests/ChoiceList/Factory/PropertyAccessDecoratorTest.php index df3a6bb7051d4..02ae93198b1e7 100644 --- a/src/Symfony/Component/Form/Tests/ChoiceList/Factory/PropertyAccessDecoratorTest.php +++ b/src/Symfony/Component/Form/Tests/ChoiceList/Factory/PropertyAccessDecoratorTest.php @@ -67,6 +67,47 @@ public function testCreateFromChoicesPropertyPathInstance() $this->assertSame(['value' => 'value'], $this->factory->createListFromChoices($choices, new PropertyPath('property'))->getChoices()); } + public function testCreateFromChoicesFilterPropertyPath() + { + $filteredChoices = [ + 'two' => (object) ['property' => 'value 2', 'filter' => true], + ]; + $choices = [ + 'one' => (object) ['property' => 'value 1', 'filter' => false], + ] + $filteredChoices; + + $this->decoratedFactory->expects($this->once()) + ->method('createListFromChoices') + ->with($choices, $this->isInstanceOf('\Closure'), $this->isInstanceOf('\Closure')) + ->willReturnCallback(function ($choices, $value, $callback) { + return new ArrayChoiceList(array_map($value, array_filter($choices, $callback))); + }); + + $this->assertSame(['value 2' => 'value 2'], $this->factory->createListFromChoices($choices, 'property', 'filter')->getChoices()); + } + + public function testCreateFromChoicesFilterPropertyPathInstance() + { + $filteredChoices = [ + 'two' => (object) ['property' => 'value 2', 'filter' => true], + ]; + $choices = [ + 'one' => (object) ['property' => 'value 1', 'filter' => false], + ] + $filteredChoices; + + $this->decoratedFactory->expects($this->once()) + ->method('createListFromChoices') + ->with($choices, $this->isInstanceOf('\Closure'), $this->isInstanceOf('\Closure')) + ->willReturnCallback(function ($choices, $value, $callback) { + return new ArrayChoiceList(array_map($value, array_filter($choices, $callback))); + }); + + $this->assertSame( + ['value 2' => 'value 2'], + $this->factory->createListFromChoices($choices, new PropertyPath('property'), new PropertyPath('filter'))->getChoices() + ); + } + public function testCreateFromLoaderPropertyPath() { $loader = $this->getMockBuilder('Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface')->getMock(); @@ -81,6 +122,26 @@ public function testCreateFromLoaderPropertyPath() $this->assertSame(['value' => 'value'], $this->factory->createListFromLoader($loader, 'property')->getChoices()); } + public function testCreateFromLoaderFilterPropertyPath() + { + $loader = $this->getMockBuilder('Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface')->getMock(); + $filteredChoices = [ + 'two' => (object) ['property' => 'value 2', 'filter' => true], + ]; + $choices = [ + 'one' => (object) ['property' => 'value 1', 'filter' => false], + ] + $filteredChoices; + + $this->decoratedFactory->expects($this->once()) + ->method('createListFromLoader') + ->with($loader, $this->isInstanceOf('\Closure'), $this->isInstanceOf('\Closure')) + ->willReturnCallback(function ($loader, $value, $callback) use ($choices) { + return new ArrayChoiceList(array_map($value, array_filter($choices, $callback))); + }); + + $this->assertSame(['value 2' => 'value 2'], $this->factory->createListFromLoader($loader, 'property', 'filter')->getChoices()); + } + // https://github.com/symfony/symfony/issues/5494 public function testCreateFromChoicesAssumeNullIfValuePropertyPathUnreadable() { diff --git a/src/Symfony/Component/Form/Tests/ChoiceList/Loader/FilterChoiceLoaderDecoratorTest.php b/src/Symfony/Component/Form/Tests/ChoiceList/Loader/FilterChoiceLoaderDecoratorTest.php new file mode 100644 index 0000000000000..8a71287c76236 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/ChoiceList/Loader/FilterChoiceLoaderDecoratorTest.php @@ -0,0 +1,99 @@ +getMockBuilder(ChoiceLoaderInterface::class)->getMock(); + $decorated->expects($this->once()) + ->method('loadChoiceList') + ->willReturn(new ArrayChoiceList(range(1, 4))) + ; + + $filter = function ($choice) { + return 0 === $choice % 2; + }; + + $loader = new FilterChoiceLoaderDecorator($decorated, $filter); + + $this->assertEquals(new ArrayChoiceList([1 => 2, 3 => 4]), $loader->loadChoiceList()); + } + + public function testLoadChoiceListWithGroupedChoices() + { + $decorated = $this->getMockBuilder(ChoiceLoaderInterface::class)->getMock(); + $decorated->expects($this->once()) + ->method('loadChoiceList') + ->willReturn(new ArrayChoiceList(['units' => range(1, 9), 'tens' => range(10, 90, 10)])) + ; + + $filter = function ($choice) { + return $choice < 9 && 0 === $choice % 2; + }; + + $loader = new FilterChoiceLoaderDecorator($decorated, $filter); + + $this->assertEquals(new ArrayChoiceList([ + 'units' => [ + 1 => 2, + 3 => 4, + 5 => 6, + 7 => 8, + ], + ]), $loader->loadChoiceList()); + } + + public function testLoadValuesForChoices() + { + $evenValues = [1 => '2', 3 => '4']; + + $decorated = $this->getMockBuilder(ChoiceLoaderInterface::class)->getMock(); + $decorated->expects($this->never()) + ->method('loadChoiceList') + ; + $decorated->expects($this->once()) + ->method('loadValuesForChoices') + ->with([1 => 2, 3 => 4]) + ->willReturn($evenValues) + ; + + $filter = function ($choice) { + return 0 === $choice % 2; + }; + + $loader = new FilterChoiceLoaderDecorator($decorated, $filter); + + $this->assertSame($evenValues, $loader->loadValuesForChoices(range(1, 4))); + } + + public function testLoadChoicesForValues() + { + $evenChoices = [1 => 2, 3 => 4]; + $values = array_map('strval', range(1, 4)); + + $decorated = $this->getMockBuilder(ChoiceLoaderInterface::class)->getMock(); + $decorated->expects($this->never()) + ->method('loadChoiceList') + ; + $decorated->expects($this->once()) + ->method('loadChoicesForValues') + ->with($values) + ->willReturn(range(1, 4)) + ; + + $filter = function ($choice) { + return 0 === $choice % 2; + }; + + $loader = new FilterChoiceLoaderDecorator($decorated, $filter); + + $this->assertEquals($evenChoices, $loader->loadChoicesForValues($values)); + } +} 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 e82ab917c590f..b087206dba870 100644 --- a/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/ChoiceTypeTest.php @@ -11,8 +11,11 @@ namespace Symfony\Component\Form\Tests\Extension\Core\Type; +use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader; use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView; use Symfony\Component\Form\ChoiceList\View\ChoiceView; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Tests\Fixtures\ChoiceList\DeprecatedChoiceListFactory; class ChoiceTypeTest extends BaseTypeTest { @@ -2090,4 +2093,66 @@ public function expandedIsEmptyWhenNoRealChoiceIsSelectedProvider() 'Placeholder submitted / single / not required / with a placeholder -> should not be empty' => [false, '', false, false, 'ccc'], // The placeholder is a selected value ]; } + + public function testFilteredChoices() + { + $form = $this->factory->create(static::TESTED_TYPE, null, [ + 'choices' => $this->choices, + 'choice_filter' => function ($choice) { + return \in_array($choice, range('a', 'c'), true); + }, + ]); + + $this->assertEquals([ + new ChoiceView('a', 'a', 'Bernhard'), + new ChoiceView('b', 'b', 'Fabien'), + new ChoiceView('c', 'c', 'Kris'), + ], $form->createView()->vars['choices']); + } + + public function testFilteredGroupedChoices() + { + $form = $this->factory->create(static::TESTED_TYPE, null, [ + 'choices' => $this->groupedChoices, + 'choice_filter' => function ($choice) { + return \in_array($choice, range('a', 'c'), true); + }, + ]); + + $this->assertEquals(['Symfony' => new ChoiceGroupView('Symfony', [ + new ChoiceView('a', 'a', 'Bernhard'), + new ChoiceView('b', 'b', 'Fabien'), + new ChoiceView('c', 'c', 'Kris'), + ])], $form->createView()->vars['choices']); + } + + public function testFilteredChoiceLoader() + { + $form = $this->factory->create(static::TESTED_TYPE, null, [ + 'choice_loader' => new CallbackChoiceLoader(function () { + return $this->choices; + }), + 'choice_filter' => function ($choice) { + return \in_array($choice, range('a', 'c'), true); + }, + ]); + + $this->assertEquals([ + new ChoiceView('a', 'a', 'Bernhard'), + new ChoiceView('b', 'b', 'Fabien'), + new ChoiceView('c', 'c', 'Kris'), + ], $form->createView()->vars['choices']); + } + + /** + * @group legacy + * + * @expectedDeprecation The "Symfony\Component\Form\Tests\Fixtures\ChoiceList\DeprecatedChoiceListFactory::createListFromChoices()" method will require a new "callable|null $filter" argument in the next major version of its interface "Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface", not defining it is deprecated. + * @expectedDeprecation The "Symfony\Component\Form\Tests\Fixtures\ChoiceList\DeprecatedChoiceListFactory::createListFromLoader()" method will require a new "callable|null $filter" argument in the next major version of its interface "Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface", not defining it is deprecated. + * @expectedDeprecation Since symfony/form 5.1: Not defining a third parameter "callable|null $filter" in "Symfony\Component\Form\Tests\Fixtures\ChoiceList\DeprecatedChoiceListFactory::createListFromChoices()" is deprecated. + */ + public function testUsingDeprecatedChoiceListFactory() + { + new ChoiceType(new DeprecatedChoiceListFactory()); + } } diff --git a/src/Symfony/Component/Form/Tests/Fixtures/ChoiceList/DeprecatedChoiceListFactory.php b/src/Symfony/Component/Form/Tests/Fixtures/ChoiceList/DeprecatedChoiceListFactory.php new file mode 100644 index 0000000000000..6361c2eedc33f --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/ChoiceList/DeprecatedChoiceListFactory.php @@ -0,0 +1,22 @@ +