Skip to content

[Form] AbstractChoiceLoader can return invalid results #44655

@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

      pFad - Phonifier reborn

      Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

      Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


      Alternative Proxies:

      Alternative Proxy

      pFad Proxy

      pFad v3 Proxy

      pFad v4 Proxy