Description
Symfony version(s) affected
5.1.0, 5.4-dev, 6.1-dev
Description
I've discovered a design issue with AbstractChoiceLoader
which manifested itself in a form failing to recognise a valid submitted value.
In our application this causes problems when the (new in 5.1) choice_filter
option is used with the expanded => true
option, initially I thought this was something specific to us (we actually had a choice_filter
option on our type before Symfony added one) but it seems this combination are not used by many/any people as it just flat out doesn't work.
The root cause I believe is the following code:
Observe how the ArrayChoiceList
is stored in a property without regard for the supplied $value
callback. It appears that $value
is sometimes called with null
or the value of the choice_filter
option which results in inconsistent data.
How to reproduce
This code demonstrates the problem if run from the root directory of a Symfony checkout.
<?php
require 'vendor/autoload.php';
use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader;
$loader = new CallbackChoiceLoader(function () {
return [
'ABC',
'XYZ',
];
});
$loader2 = clone $loader;
dump($loader->loadChoiceList(null)->getValues());
/*
^ array:2 [
0 => "ABC"
1 => "XYZ"
]
*/
// Call with a value callback which is not applied
dump($loader->loadChoiceList('strrev')->getValues());
/*
^ array:2 [
0 => "ABC"
1 => "XYZ"
]
*/
// Call the cloned object which will apply the callback
dump($loader2->loadChoiceList('strrev')->getValues());
/*
^ array:2 [
0 => "CBA"
1 => "ZYX"
]
*/
// Call again and notice the values are still reversed.
dump($loader2->loadChoiceList(null)->getValues());
/*
^ array:2 [
0 => "CBA"
1 => "ZYX"
]
*/
Again a more representative example of the problem:
<?php
require 'vendor/autoload.php';
use Symfony\Component\Form\Extension\Core\CoreExtension;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormFactory;
use Symfony\Component\Form\FormRegistry;
use Symfony\Component\Form\ResolvedFormTypeFactory;
$registry = new FormRegistry([
new CoreExtension(),
], new ResolvedFormTypeFactory());
$factory = new FormFactory($registry);
$test = function (bool $expanded, ?callable $filter) use ($factory) {
$builder = $factory->createBuilder();
$builder->add('choice', ChoiceType::class, [
'choices' => ['ABC', 'XYZ'],
'expanded' => $expanded,
'choice_filter' => $filter,
'choice_value' => function ($choice) {
return strrev($choice);
},
]);
$form = $builder->getForm();
$form->submit([
'choice' => 'CBA',
]);
echo 'display: ', $expanded ? 'expanded' : 'collapsed', "\n";
echo 'filter: ', $filter !== null ? 'yes' : 'no', "\n";
if (!$form->isValid()) {
echo "failed...\n\n";
dump($form->getErrors(true, true)[0]->getCause());
} else {
echo "pass\n";
}
echo "\n\n";
};
$test(true, null);
$test(false, null);
// Any old callable is sufficient to trigger the problem
$test(true, fn () => true);
$test(false, fn () => true);
Possible Solution
This is one possible solution, with the downside that ArrayChoiceList
is going to be constructed more frequently.
diff --git a/src/Symfony/Component/Form/ChoiceList/Loader/AbstractChoiceLoader.php b/src/Symfony/Component/Form/ChoiceList/Loader/AbstractChoiceLoader.php
index a30af63b1a..00ebc87568 100644
--- a/src/Symfony/Component/Form/ChoiceList/Loader/AbstractChoiceLoader.php
+++ b/src/Symfony/Component/Form/ChoiceList/Loader/AbstractChoiceLoader.php
@@ -20,11 +20,11 @@
abstract class AbstractChoiceLoader implements ChoiceLoaderInterface
{
/**
- * The loaded choice list.
+ * The stored choices.
*
- * @var ArrayChoiceList
+ * @var iterable
*/
- private $choiceList;
+ private $choices;
/**
* @final
@@ -33,7 +33,11 @@ abstract class AbstractChoiceLoader implements ChoiceLoaderInterface
*/
public function loadChoiceList(callable $value = null): ChoiceListInterface
{
- return $this->choiceList ?? ($this->choiceList = new ArrayChoiceList($this->loadChoices(), $value));
+ if (null === $this->choices) {
+ $this->choices = $this->loadChoices();
+ }
+
+ return new ArrayChoiceList($this->choices, $value);
}
/**
@@ -45,10 +49,6 @@ public function loadChoicesForValues(array $values, callable $value = null)
return [];
}
- if ($this->choiceList) {
- return $this->choiceList->getChoicesForValues($values);
- }
-
return $this->doLoadChoicesForValues($values, $value);
}
@@ -66,11 +66,7 @@ public function loadValuesForChoices(array $choices, callable $value = null)
return array_map($value, $choices);
}
- if ($this->choiceList) {
- return $this->choiceList->getValuesForChoices($choices);
- }
-
- return $this->doLoadValuesForChoices($choices);
+ return $this->loadChoiceList($value)->getValuesForChoices($choices);
}
abstract protected function loadChoices(): iterable;
Another option would be storing the ArrayChoiceList
indexed by the callback. Or more involved add an ArrayChoiceList::withValue()
method allowing the values to be overridden without a rebuild of the whole object.
Seems this simple change fixes the ChoiceType
error but doesn't address the underlying issue within AbstractChoiceLoader
.
diff --git a/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php b/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php
index a52f3b82e4..6a1122a695 100644
--- a/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php
+++ b/src/Symfony/Component/Form/ChoiceList/Loader/FilterChoiceLoaderDecorator.php
@@ -50,7 +50,7 @@ protected function loadChoices(): iterable
*/
public function loadChoicesForValues(array $values, callable $value = null): array
{
- return array_filter($this->decoratedLoader->loadChoicesForValues($values, $value), $this->filter);
+ return array_filter(parent::loadChoicesForValues($values, $value), $this->filter);
}
/**
@@ -58,6 +58,6 @@ public function loadChoicesForValues(array $values, callable $value = null): arr
*/
public function loadValuesForChoices(array $choices, callable $value = null): array
{
- return $this->decoratedLoader->loadValuesForChoices(array_filter($choices, $this->filter), $value);
+ return parent::loadValuesForChoices(array_filter($choices, $this->filter), $value);
}
}
Additional Context
No response