8000 [Form] `AbstractChoiceLoader` can return invalid results · Issue #44655 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content
[Form] AbstractChoiceLoader can return invalid results #44655
Closed
@cs278

Description

@cs278

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:

public function loadChoiceList(callable $value = null): ChoiceListInterface
{
return $this->choiceList ?? ($this->choiceList = new ArrayChoiceList($this->loadChoices(), $value));
}

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      0