[Form] Added a "choice_filter" option to ChoiceType

This commit is contained in:
Jules Pietri 2019-04-07 22:08:39 +02:00 committed by Jules Pietri
parent df1caaeaa6
commit ed2c312609
No known key found for this signature in database
GPG Key ID: C924CC98D39AA885
20 changed files with 711 additions and 41 deletions

View File

@ -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
---------------

View File

@ -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
---------------

View File

@ -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.

View File

@ -13,6 +13,7 @@ namespace Symfony\Component\Form\ChoiceList;
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 @@ final class ChoiceList
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.
*

View File

@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <jules@heahprod.com>
*/
final class ChoiceFilter extends AbstractStaticOption
{
}

View File

@ -19,6 +19,9 @@ use Symfony\Contracts\Service\ResetInterface;
/**
* 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 <bschussek@gmail.com>
* @author Jules Pietri <jules@heahprod.com>
*/
@ -80,25 +83,42 @@ class CachingFactoryDecorator implements ChoiceListFactoryInterface, ResetInterf
/**
* {@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;
}
$hash = self::generateHash([$choices, $value], 'fromChoices');
if (!$cache) {
return $this->decoratedFactory->createListFromChoices($choices, $value, $filter);
}
$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 @@ class CachingFactoryDecorator implements ChoiceListFactoryInterface, ResetInterf
/**
* {@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 @@ class CachingFactoryDecorator implements ChoiceListFactoryInterface, ResetInterf
$cache = false;
}
if (!$cache) {
return $this->decoratedFactory->createListFromLoader($loader, $value);
if ($filter instanceof Cache\ChoiceFilter) {
$filter = $filter->getOption();
} elseif ($filter) {
$cache = false;
}
$hash = self::generateHash([$loader, $value], 'fromLoader');
if (!$cache) {
return $this->decoratedFactory->createListFromLoader($loader, $value, $filter);
}
$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 @@ class CachingFactoryDecorator implements ChoiceListFactoryInterface, ResetInterf
/**
* {@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)
{

View File

@ -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 @@ 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 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.

View File

@ -14,7 +14,9 @@ namespace Symfony\Component\Form\ChoiceList\Factory;
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 @@ use Symfony\Component\Form\ChoiceList\View\ChoiceView;
* Default implementation of {@link ChoiceListFactoryInterface}.
*
* @author Bernhard Schussek <bschussek@gmail.com>
* @author Jules Pietri <jules@heahprod.com>
*/
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);
}

View File

@ -59,13 +59,17 @@ class PropertyAccessDecorator implements ChoiceListFactoryInterface
/**
* {@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 @@ class PropertyAccessDecorator implements ChoiceListFactoryInterface
};
}
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 @@ class PropertyAccessDecorator implements ChoiceListFactoryInterface
};
}
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);
}
/**

View File

@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <jules@heahprod.com>
*/
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);
}
}

View File

@ -15,6 +15,7 @@ use Symfony\Component\Form\AbstractType;
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\FormInterface;
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 @@ class ChoiceType extends AbstractType
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 @@ class ChoiceType extends AbstractType
'multiple' => false,
'expanded' => false,
'choices' => [],
'choice_filter' => null,
'choice_loader' => null,
'choice_label' => null,
'choice_name' => null,
@ -332,6 +346,7 @@ class ChoiceType extends AbstractType
$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 @@ class ChoiceType extends AbstractType
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)

View File

@ -135,16 +135,21 @@ class CachingFactoryDecoratorTest extends TestCase
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 @@ class CachingFactoryDecoratorTest extends TestCase
$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 @@ class CachingFactoryDecoratorTest extends TestCase
$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'];

View File

@ -16,6 +16,7 @@ use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
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 @@ class DefaultChoiceListFactoryTest extends TestCase
$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 @@ class DefaultChoiceListFactoryTest extends TestCase
$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);

View File

@ -67,6 +67,47 @@ class PropertyAccessDecoratorTest extends TestCase
$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 @@ class PropertyAccessDecoratorTest extends TestCase
$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()
{

View File

@ -0,0 +1,99 @@
<?php
namespace Symfony\Component\Form\Tests\ChoiceList\Loader;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
use Symfony\Component\Form\ChoiceList\Loader\FilterChoiceLoaderDecorator;
class FilterChoiceLoaderDecoratorTest extends TestCase
{
public function testLoadChoiceList()
{
$decorated = $this->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));
}
}

View File

@ -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 @@ class ChoiceTypeTest extends BaseTypeTest
'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());
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Symfony\Component\Form\Tests\Fixtures\ChoiceList;
use Symfony\Component\Form\ChoiceList\ChoiceListInterface;
use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface;
use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
class DeprecatedChoiceListFactory implements ChoiceListFactoryInterface
{
public function createListFromChoices(iterable $choices, callable $value = null)
{
}
public function createListFromLoader(ChoiceLoaderInterface $loader, callable $value = null)
{
}
public function createView(ChoiceListInterface $list, $preferredChoices = null, $label = null, callable $index = null, callable $groupBy = null, $attr = null)
{
}
}

View File

@ -4,6 +4,7 @@
"options": {
"own": [
"choice_attr",
"choice_filter",
"choice_label",
"choice_loader",
"choice_name",

View File

@ -6,18 +6,18 @@ Symfony\Component\Form\Extension\Core\Type\ChoiceType (Block prefix: "choice")
Options Overridden options Parent options Extension options
--------------------------- -------------------- ------------------------------ -----------------------
choice_attr FormType FormType FormTypeCsrfExtension
choice_label -------------------- ------------------------------ -----------------------
choice_loader compound action csrf_field_name
choice_name data_class allow_file_upload csrf_message
choice_translation_domain empty_data attr csrf_protection
choice_value error_bubbling attr_translation_parameters csrf_token_id
choices trim auto_initialize csrf_token_manager
expanded block_name
group_by block_prefix
multiple by_reference
placeholder data
preferred_choices disabled
help
choice_filter -------------------- ------------------------------ -----------------------
choice_label compound action csrf_field_name
choice_loader data_class allow_file_upload csrf_message
choice_name empty_data attr csrf_protection
choice_translation_domain error_bubbling attr_translation_parameters csrf_token_id
choice_value trim auto_initialize csrf_token_manager
choices block_name
expanded block_prefix
group_by by_reference
multiple data
placeholder disabled
preferred_choices help
help_attr
help_html
help_translation_parameters

View File

@ -43,6 +43,7 @@
"symfony/console": "<4.4",
"symfony/dependency-injection": "<4.4",
"symfony/doctrine-bridge": "<4.4",
"symfony/error-handler": "<4.4.5",
"symfony/framework-bundle": "<4.4",
"symfony/http-kernel": "<4.4",
"symfony/intl": "<4.4",