Skip to content

[Validator] Choice constraint as Attribute differs from Annotation when "choices" is an associative array #41508

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
emmanuelballery opened this issue Jun 2, 2021 · 9 comments

Comments

@emmanuelballery
Copy link

emmanuelballery commented Jun 2, 2021

Symfony version(s) affected: 5.2.0

Description

I was moving my projects to PHP8, using Attribute constraints (thanks to @derrabus) instead of Annotation ones. Regarding the Symfony\Component\Validator\Constraints\Choice constraint, I encountered a difference between the two. This started here.

I often use associative arrays as choices - using the translation key as choice key (see How to reproduce below) ; one reason would be to share this constant array with the ChoiceType choices option. It was completely ok with Annotations. But the compatibility rule implemented below for the new Attribute usage is now concidering my choices keys as constraint options and breaks my code. I get that this is required to support both Annotation and Attribute but this changes the way this constraint is working.

if (\is_array($choices) && \is_string(key($choices))) {
$options = array_merge($choices, $options);

How to reproduce

// stupid example
abstract class FormatEnum
{
    public const ALL = [
        'enum.format.example1' => 'f1',
        'enum.format.example2' => 'd6',
        'enum.format.example3' => 'u9',
    ];
}

// Annotation is fine 👍🏻 
/** @Choice(choices=FormatEnum::ALL) */

// Attribute is ko 👎🏻 
#[Choice(choices: FormatEnum::ALL)]

Possible Solution

Do not limit the test to \is_string(key($choices)) but check if key($choices) is in a "whitelist" of arguments (the one from the constructor) ;

Edit 03/06/21:

    $parameters = [
        'choices', 'callback', 'multiple', 'strict', 'min', 'max', 'message',
        'multipleMessage', 'minMessage', 'maxMessage', 'groups', 'payload', 'options',
    ];
    if (\is_array($choices) && \is_string(key($choices)) && \in_array(key($choices), $parameters, true)) {
        $options = array_merge($choices, $options);
    } elseif (null !== $choices) {
        $options['value'] = $choices;
    }
@alexandre-daubois
Copy link
Member

alexandre-daubois commented Jun 3, 2021

Hi! Can confirm I can reproduce it. The problem happens when choices array is a string-keyed array. Giving

public const ALL = [
        'enum.format.example1',
        'enum.format.example2',
        'enum.format.example3'
    ];

Works just fine.

If your possible solution works fine, you should definitely create a pull-request 👍 !

@emmanuelballery
Copy link
Author

My solution is a hack, someone could use a choices array with a key named "multiple" thus breaking this constraint again. There is no real solution to support both attribute and annotation.

Here I tried to reintroduce $value in the constructor ; there is one missing case, but this time using choices: forces the constraint to get that this cannot be merged with options:

    public function __construct(
        $value = null, // <--------------------------------- reintroduce "value" before "choices"
        $choices = null,
        // ...
    ) {
        
        if (\is_array($value)) { // <----------------------- @Choice({...}) or #[Choice([...])]
            if (\is_string(key($value))) { // <------------- @Choice({"min":1}) or #[Choice(['min' => 1])]
                $options = array_merge($value, $options); // @todo this does not eliminate the possibility of an associative array as choices
            } else {
                $options['value'] = $value; // <------------ @Choice({"foo","bar"}) or #[Choice(['foo','bar'])]
            }
        }

        if (null !== $choices) { // <----------------------- @Choice(choices={...}) or #[Choice(choices: [...])]
            if (null !== $options['value']) {
                throw new Exception('You cannot use both "value" and "choices" in ' . self::class);
            }
            $options['value'] = $choices;
        }

        // ...
    }

@emmanuelballery emmanuelballery changed the title Choice constraint as Attribute differs from Annotation when "choices" is an associative array [Validator] Choice constraint as Attribute differs from Annotation when "choices" is an associative array Jun 9, 2021
@carsonbot
Copy link

Hey, thanks for your report!
There has not been a lot of activity here for a while. Is this bug still relevant? Have you managed to find a workaround?

@emmanuelballery
Copy link
Author

Hi! Yes this is still relevant. My workaround was to create a new choice constraint extending the current one but removing the faulty compatibility layer between Annotation & Attribute.

I get that this is not a priority at all, but at least consider adding a note in the Choice constraint documentation stating that support for array was removed in favor of list<string>:

https://symfony.com/doc/current/reference/constraints/Choice.html#choices

@carsonbot carsonbot removed the Stalled label Dec 29, 2021
@derrabus
Copy link
Member

Are you able to submit a PR that fixes the problem?

@emmanuelballery
Copy link
Author

Hi @derrabus , I tried to find a way but could not find a real solution to fix this and cover all use cases. There is always an edge case. You added an if in the Choice constraint to make it a valid PHP Attribute and Annotation at the same time:

if (\is_array($choices) && \is_string(key($choices))) {
    //                      ^
    //                      +- this removed support for a string indexed array of choices
    //
    $options = array_merge($choices, $options);
} elseif (null !== $choices) {
    $options['choices'] = $choices;
}

Merging $options is required to support both attributes and annotations without a good way to detect if $choices really was meant to contain choices or options. We could check is_string + is in a list of valid option name but what if people really want an option with a key named multiple.

In short, it's a niche problem.


For people stuck with this, maybe use this simple hack #[Choice(options: ['value' => YOUR_STRING_INDEXED_LIST])]

@derrabus
Copy link
Member

I see. Using a string-keyed array as choices is indeed an edge case that broke completely when migratng to attributes. With annotations, it was only partly broken. Since the choice constraint does not use the keys, passing a non-list arrays was kind-of working by accident and not really by design.

A possible solution would be to move the $options parameter to position 0 of the constructor signature. This would allow us to enable support for string-keyed choices for attributes with the same limitations as with annotations.

one reason would be to share this constant array with the ChoiceType choices option

The ChoiceType should already make sure that the submitted data is among the configured choices. You would not really need the constraint in that case, would you?

@emmanuelballery
Copy link
Author

I previously tried to reintroduce a parameter to position 0 but there was still one use case where this wasn't working.

#41508 (comment)

This issue did not get a lot of feedback to really require all our attention. Feel free to close as I already provided workarounds if others were to need them.


The reasoning behind this all thing was to avoid duplicating stuff in our codes, mainly enums when this was not a thing in PHP.

Skipping the constraint because of the choice type usage was not on the table as our models are not always handled by forms.

abstract class FakeEnum {
    public const ALL = [
        'key.to.translation1' => 'Choice 1',
        'key.to.translation2' => 'Choice 2,
    ];
}

// then validator could use 
#[Choice(FakeEnum::ALL)]

// and form could use
$builder->add('choice', ChoiceType::class, ['choices' => array_flip(FakeEnum::ALL)]);

// the flipped version was also usefull to get translations from values in templates

@derrabus
Copy link
Member

#44860 would be my take at fixing the issue.

nicolas-grekas added a commit that referenced this issue Jan 26, 2022
… array (derrabus)

This PR was merged into the 5.3 branch.

Discussion
----------

[Validator] Fix Choice constraint with associative choices array

| Q             | A
| ------------- | ---
| Branch?       | 5.3
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Tickets       | Fix #41508
| License       | MIT
| Doc PR        | N/A

This PR moves the `$options` parameter to the beginning of the constructor signature in order to fix the scenario described in #41508.

I assume that the supported ways to construct this class are either an ordered arguments call with exactly one argument or a named argument call with zero or one ordered arguments. Those scenarios should continue to work and are covered with additional tests now.

However, an ordered arguments call to the constructor with at least two parameters would break after this change.

Commits
-------

ccd85fe Fix Choice constraint with associative choices array
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants