Skip to content

[Validator] Support enums in Choice constraints #43047

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
wants to merge 3 commits into from

Conversation

bpolaszek
Copy link
Contributor

Q A
Branch? 5.4
Bug fix? no
New feature? yes
Deprecations? no
Tickets
License MIT
Doc PR TODO

This PR allows using PHP 8.1 (backed) enums in Choice constraints.

Usage:

enum Suit: string {
  case Hearts = 'H';
  case Diamonds = 'D';
  case Clubs = 'C';
  case Spades = 'S';
}
class Card {
    public Suit $suit;
}
class CardDTO {

    #[Choice(choices: Suit::class)]
    public string $suit;
}

@bpolaszek
Copy link
Contributor Author

bpolaszek commented Sep 16, 2021

Regarding CI:

  • Phpunit fails on PHP8.1, but that seems unrelated
  • Psalm yells about the BackedEnum class - I'll require some help fixing this

@stof
Copy link
Member

stof commented Sep 16, 2021

* Psalm yells about the `BackedEnum` class - I'll require some help fixing this

Ignore it for now. That will be fixed in the future when running Psalm on PHP 8.1. And it won't impact the future PRs, as the CI job uses the existing codebase as the baseline.

@derrabus
Copy link
Member

We could silence this error in psalm.xml though.

@derrabus
Copy link
Member

#43050

@derrabus
Copy link
Member

If you perform a rebase on 5.4, Psalm should not complain about the enum classes anymore.

@bpolaszek bpolaszek force-pushed the feat/enum-support-in-choices branch from d4f9553 to 62940fc Compare September 16, 2021 11:00
@bpolaszek
Copy link
Contributor Author

If you perform a rebase on 5.4, Psalm should not complain about the enum classes anymore.

Thank you @derrabus - it works 🙂

@derrabus
Copy link
Member

Can you elaborate, when would I use this validator?

Your example was:

class CardDTO
{
    #[Choice(choices: Suit::class)]
    public string $suit;
}

But my understanding of enums is that they're type-safe. Why would I prefer a string field over using the actual enum type like in your other example:

class Card {
    public Suit $suit;
}

Alternatively:

class Card {
    #[Type(Suit::class)]
    public $suit;
}

@stof
Copy link
Member

stof commented Sep 16, 2021

@derrabus my guess is that this would be used on a string field in a DTO receiving user input (which cannot be the enum instance and can contain invalid data, which is the whole point of using the validator), for which you want to make sure that you can use Suit::from($cardDto->suit) when converting the validated DTO to the domain object.

@ro0NL
Copy link
Contributor

ro0NL commented Sep 16, 2021

We could argue hydrating raw payloads/userdata into objects generally, i believe. The alternative route to me is #40830 + #42502, thus hydrating valid state, or validating invalid hydrated state.

From a DIY POV, this makes sense. (you need the constraint for raw payloads either way, if decoupled from Serializer).

@bpolaszek
Copy link
Contributor Author

bpolaszek commented Sep 16, 2021

Yes, @stof summed it up well. If you try to unserialize an invalid value to the enum property directly, the serializer won't be happy and you'll end up with either a 500 or a 400 error.

By pre-validating it through a DTO, you keep 1 source of truth about the valid values, and you can get a more accurate constraint validation error which will tell you the exact path where it failed. Besides, if you just type-hint your property as string with Choice the old way, you still don't have 100% certainty that your property cannot have an unexpected value, what is the exact point of using enums.

Here's another example, like altering a Post entity with Api-Platform:

enum PostStatus: int {
    case DRAFT = 0;
    case PUBLISHED = 1;
    case ARCHIVED = 2;
}

#[ApiResource(input: PostInput::class)]
class Post {
    public Ulid $id;
    public string $title;
    public PostStatus $status;
}

class PostInput {
    public string $title;
    #[Choice(choices: PostStatus::class)]
    public int $status;
}

The following request:

PUT /posts/01FEB4F1F374ZZWVD1XWHS2SEM
Content-Type: application/json

{"title": "foo", "status": 42}

would get the following HTTP 422 unprocessable entity response:

{
  "@context": "\/api\/contexts\/ConstraintViolationList",
  "@type": "ConstraintViolationList",
  "hydra:title": "An error occurred",
  "hydra:description": "status: The value you selected is not a valid choice.",
  "violations": [
    {
      "propertyPath": "status",
      "message": "The value you selected is not a valid choice.",
      "code": "8e179f1b-97aa-4560-a02f-2a8b42e49df7"
    }
  ]
}

@bpolaszek
Copy link
Contributor Author

bpolaszek commented Sep 16, 2021

@ro0NL I didn't notice the existence of @lyrixx's #42502 - it looks indeed that we addressed similar issues :-)

However, I think his approach is more like a global setting you would apply to your whole API, whereas mine targets specific properties usable in DTO contexts.

Another use case, with the Choice validator allowing multiple values, you can unserialize an array of strings resulting in valid enums, i.e.

enum Feature: string {
   case LIGHT_DETECTION = 'light';
   case MOVEMENT_DETECTION = 'movement';
}

#[ApiResource(input: DeviceInput::class)]
class Device {
    /**
     * @var array<Feature>
     */
    public array $features;
}

class DeviceInput {
    /**
     * @var array<string>
     */
    #[Choice(choices: Feature::class, multiple: true)]
    public array $features;
}

@ro0NL
Copy link
Contributor

ro0NL commented Sep 16, 2021

AFAIK, what we pursue is

#[DeserializeFromRequest(request, #[Assert\Collection(...)])
controller(Infra\Request $request, Domain\Data $domainObject): mixed {
    try {
        return $domainObject->dataOfInterest; // typed and serializable
    } catch (ConstraintViolations $e) {
        throw $e; // typed, serializable and translatable
    }
}

and be done. In that sense, to me the "controller" is the source of truth.

Transforming validated payload is another interesting area, e.g. ID => entity

@nicolas-grekas nicolas-grekas added this to the 5.4 milestone Sep 17, 2021
@derrabus
Copy link
Member

The alternative route to me is #40830 + #42502

That would've been my idea as well. This is a serialization/hydration issue. Storing the enum instance as a string defeats the purpose of enums.

If we want to pursue this approach anyway, I'd suggest to introduce a new property (e.g. $enum) instead of reusing the $choices property.

@bpolaszek
Copy link
Contributor Author

There's no such thing as an enum "instance" - my only options to refer to an enum's cases are Enum::class and Enum::cases(), the latter one being prohibited by annotations/attributes.

Since an enum actually is a choice (and something more exhaustive / reliable than an array), I don't really get the point of not being able to use it in a choice constraint.

I didn't think about a $enum property, but it makes sense indeed, since using a callable already requires a separate property. I just need to know the precedence to follow (callback -> enum -> choices ?) - if this matters. Should I proceed?

@derrabus
Copy link
Member

There's no such thing as an enum "instance"

https://3v4l.org/5jH8Q/rfc#vgit.master

@bpolaszek
Copy link
Contributor Author

bpolaszek commented Sep 17, 2021

There's no such thing as an enum "instance"

https://3v4l.org/5jH8Q/rfc#vgit.master

There's a misundertanding - the "instance" here is one of the available cases, not a list of all allowed cases (which is what we want for the Choice constraint). Thought you were speaking about some sort of object instance or callable where we could iterate over all cases.

@derrabus
Copy link
Member

the "instance" here is one of the available cases,

Correct and each case is an instance of the enum. We can use PHP's type system to check this.

not a list of all allowed cases (which is what we want for the Choice constraint).

No, what you define as cases here are actually the scalar values that are used to back the enum. My understanding of enums is that the backed value exists for storage and transport serialization. Deserializing an object with an enum should yield an instance of the enum class and not the backed value. That's what I meant when I wrote that this is a serialization/hydration concern.

  • The serializer should already be able to restore an enum from its backed value.
  • The form component should be patched to use enums in choice fields.
  • Doctrine ORM should be patched to hydrate an enum from a VARCHAR field.

In other words: enums exists so we don't need to perform this kind of validation anymore.

Don't get me wrong, I won't block this PR. All I'm saying is that I don't feel we need it.

@ro0NL
Copy link
Contributor

ro0NL commented Sep 19, 2021

@derrabus IMHO validator constraints provide i18n friendly error messages, in APIs, forms, everywhere. In that sense, i'm waiting for a ValidatingDeserializer (bridging both components) and have validation before hydration.

@bpolaszek
Copy link
Contributor Author

bpolaszek commented Sep 19, 2021

In other words: enums exists so we don't need to perform this kind of validation anymore.

Enums currently address a single value - but it's quite easy to validate an array of values against an enum, if you want to keep 1 source of truth for allowed values (and benefit from static analysis when processing values).
However you have to type it as an array - that's why I thought that one-liner would do the job.

class DeviceInput {
    /**
     * @var array<string>
     */
    #[Choice(choices: Feature::class, multiple: true)]
    public array $features;
}

@HeahDude
Copy link
Contributor

Coming late on this, but I don't really understand the arguments here.
It looks like the main reason for closing a lot of related issues/PRs is because the enum could avoid the need of the Choice constraint in the first place.

I don't agree that this is enough when there is still a need for using the Choice constraint.
If I need the Choice constraint on a property it's not because I want/can use a typed enum instead nor deal with the extra complexity of trying to transform it.
Yet using an enum to define a set of choices can also provide many benefits since such enum can be used elsewhere and in contexts presented by everyone here as well, and would prevent IMHO to overengineer something quite simple.

Unless we choose to deprecate the Choice constraint in favor of native PHP enums, I'd say we should not be against improving its DX, unless it's too much complexity in the core to be considered.

@derrabus
Copy link
Member

It looks like the main reason for closing a lot of related issues/PRs is because the enum could avoid the need of the Choice constraint in the first place.

Kind of, yes.

  • If you want to validate a property that should hold an enum case, there's the Type constraint that does that.
  • If hydrating the enum case from its backed values is too complicated, fix your hydrator (e.g. Symfony Form, Symfony Serializer and Doctrine ORM do support this now).
  • If you don't want to hydrate the enum case at all, you don't need an enum and the current Choice constraint will work perfectly fine for you.

I don't agree that this is enough when there is still a need for using the Choice constraint.

You're trying to disprove an argument nobody makes. The Choice is absolutely useful, nobody wants to remove it.

If you read and understood all of this and still think you need this feature, please describe your use-case and we'll talk about it.

@HeahDude
Copy link
Contributor

You're trying to disprove an argument nobody makes. The Choice is absolutely useful, nobody wants to remove it.

If you don't want to hydrate the enum case at all, you don't need an enum and the current Choice constraint will work perfectly fine for you

I you agree with this why don't you accept that everyone wants to set the choices with enums 🤔, they are basically designed for such use case.

If you want to validate a property that should hold an enum case, there's the Type constraint that does that.

It looks like a poor alternative to me since the default message will tell that value X is not typed (enum) Y.
Also the Type constraint makes it a pain to implode cases in mapping depending on the chosen format (i.e other than native PHP).
Whereas the Choice constraint tells a value is not among a given set of values which is what we want by default.

If you read and understood all of this and still think you need this feature, please describe your use-case and we'll talk about it.

The use a case is simple: a string needs to be validated against a set of values. There is no hydration, no serialization, no form, just a string validated among other values validated with the validator.
Changing the type is not a way to go since it introduces complexity instead of letting a constraint doing the job as it should.

@stof
Copy link
Member

stof commented Apr 25, 2022

I you agree with this why don't you accept that everyone wants to set the choices with enums thinking, they are basically designed for such use case.

Enums are designed to be hydrated. They are not designed to work with the backing value all the time.

just a string validated among other values validated with the validator.

and that's not what PHP enums are. They are not strings but objects.

@HeahDude
Copy link
Contributor

HeahDude commented Apr 25, 2022

Enums are designed to be hydrated. They are not designed to work with the backing value all the time.

Yes of course, the scope of this feature is the backed enum (string or int), which by design is always backed, and fits the needs to use iterable cases to define an immutable set of valid choices for string or int values to be validated. The enum can then also be safely hydrated in other layers if needed.

@ThomasLandauer
Copy link
Contributor

If you want to validate a property that should hold an enum case, there's the Type constraint that does that.

So the recommended way for what used to be

#[Assert\Choice(choices: ['foo','bar'], message: 'Choose either foo or bar')]

...is now?:

#[Assert\Type(type: FooBarEnum::class, message: 'Choose either foo or bar'))]

I wouldn't have expected that, so I'd like to add an enum example to https://symfony.com/doc/current/reference/constraints/Type.html and even a note about enums at https://symfony.com/doc/current/reference/constraints/Choice.html
Good idea?

@derrabus
Copy link
Member

derrabus commented Apr 25, 2022

#[Assert\Choice(choices: ['foo','bar'], message: 'Choose either foo or bar')]

If you want to validate a string against a list of allowed strings, this is still the way to go.

@bpolaszek
Copy link
Contributor Author

#[Assert\Choice(choices: ['foo','bar'], message: 'Choose either foo or bar')]

If you want to validate a string against a list of allowed strings, this is still the way to go.

Enum has the advantage of single source of truth, IMO

@fancyweb
Copy link
Contributor

Personally, I've already used:

new Choice([
    'choices' => array_map(
        static fn (MyEnum $myEnum): string => $myEnum->value,
        MyEnum::cases(),
    ),
])

many times already and I'd prefer to be able to use the enum class directly.

@derrabus
Copy link
Member

@fancyweb Do you actually hydrate the enum elsewhere, e.g. by calling MyEnum:from(…)?

@fancyweb
Copy link
Contributor

Yes, but once the value has been validated.

My personal use case is:

  1. manually json_decode the request content
  2. validate that whole array with the Collection constraint
  3. manually instantiate an object for further usage

So before PHP 8.1:

$content = json_decode($request->getContent(), true);

$this->myCustomValidatorService->validate($content, new Collection([
    'foo' => new Choice([MyFakeEnum::FOO, MyFakeEnum::BAR]),
]));

$obj = new MyClass($content['foo']); // MyClass::$foo type is string

What I expected on PHP 8.1:

$content = json_decode($request->getContent(), true);

$this->myCustomValidatorService->validate($content, new Collection([
    'foo' => new Choice(MyRealEnum::class),
]));

$obj = new MyClass(MyRealEnum::from($content['foo'])); // MyClass::$foo type is MyRealEnum

The real code is obviously way more complicated than that, I summarized it.

myCustomValidatorService kind of "rethrows" Symfony's constraint violations as an exception. In my use case, providing an unexpected value is a bad request error, I want invalid values to be treated as an "invalid choice" just like before.

I'm not saying the existing code is state-of-the-art or anything but it works and I won't personally re-architecture it just to be able to use MyEnum::from() directly without any validation. I just wanted to use the MyRealEnum type to strengthen the code (string -> MyRealEnum in properties) and to have one source of truth for the input valid values.

Personally, I'm +0 on this PR, I understand arguments from both sides and I'm fine using the workaround. I guess it really depends on how the app is structured.

@AlexeyKosov
Copy link

What did this end up with? Will we get the ability to use Enums with the Choice validator?

@stof
Copy link
Member

stof commented May 24, 2022

@AlexeyKosov if you want to check that the property is one of the instances of the enum, you can already use @Assert\Choice(callback="MyEnum::cases") (but then, asserting the type as being MyEnum is simpler).
If you want to assert that the value is one of the backing value of the enum, we don't have a shortcut for that in core (you will need to implement the callback yourselves, or pass the choices in a different way)

@HeahDude
Copy link
Contributor

So can we consider reopening such PR for 6.2 🙏 ? Or shall we lock this discussion as "won't fix"?

@stof
Copy link
Member

stof commented May 24, 2022

This one has been rejected by several members of the core team. So it has no chance to pass for 6.2 IMO (we would have to all change our mind). Please read the discussion above for the arguments

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Jun 21, 2022

Adding this method to backed enum classes could be a nice way to go for ppl that really want to deal with backed values directly:

public static function values(): array
{
    return array_column(self::cases(), 'value');
}

We could even suggest making this the default implementation provided by the engine. /cc @Crell

@HeahDude
Copy link
Contributor

For the record #47691 (reply in thread).

Very not convenient, this PR should really be reconsidered.

@Tobion
Copy link
Contributor

Tobion commented Dec 27, 2022

I also think we should re-consider this. While I agree that in most cases it would be cleaner to handle this in the serialization directly, I don't think Symfony should enforce this by not supporting enums in the validator.
People are coming from different directions and maybe they don't want to or cannot handle this in the serialization.

To me it is unexpected that I can define my choices with an array of constants but not with an enum. Symfony and the individual components should stay flexible and this feature request would just be a few lines of code. So it improves ease of use by just adding a tiny feature (even if not everybody agrees that this is the best approach).

@derrabus
Copy link
Member

by not supporting enums in the validator.

I'm fine with revisiting the topic, but please let's get this straight: The validator does support enums, via the Type or Choice constraints. It always has. This PR on this other hand has never been about validating enums, but about validating if a given value is a possible backing value of a specific enum.

Unlike other languages and unlike people had been building enums before PHP supported them, PHP makes a big difference between an enum and its backed value and we should do the same unless we want to cause even more confusion.

If we want to implement this kind of validation:

  1. The constraint declaration should make it absolutely clear that we're not validating an enum, e.g. make it a new constraint and call it BackedEnumValue or something like that.
  2. Such a constraint must not accept an enum case, only backed values.
  3. It should come with a piece of documentation explaining the difference between Type(MyEnum::class) and the new constraint and why that new constraint isn't always the best option.

@vincent-joignie-dd

This comment was marked as off-topic.

@derrabus

This comment was marked as off-topic.

@vincent-joignie-dd

This comment was marked as off-topic.

@xabbuh

This comment was marked as off-topic.

@derrabus
Copy link
Member

derrabus commented Jun 30, 2023

Anyway, I'd like to lock this discussion. This PR usually pops up if people google for how to validate enums in Symfony and I'd like to keep it a helpful resource in that regard. New comments on this PR just add more noise and confusion.

This PR was about validating if a string value is a valid backing value for an enum. In this comment, I summed up why I felt that the validator is the wrong place for that:

  • If you want to validate a property that should hold an enum case, there's the Type constraint that does that.

  • If hydrating the enum case from its backed values is too complicated, fix your hydrator (e.g. Symfony Form, Symfony Serializer and Doctrine ORM do support this now).

  • If you don't want to hydrate the enum case at all, you don't need an enum and the current Choice constraint will work perfectly fine for you.

If you still want to validate strings this way, Nicolas has shared a snippet for a function that you can use as a callback for the Choice constraint:

Adding this method to backed enum classes could be a nice way to go for ppl that really want to deal with backed values directly:

public static function values(): array
{
    return array_column(self::cases(), 'value');
}

Anyone who still wants to create a dedicated constraint for this kind of validation, please read this comment first.

@symfony symfony locked as resolved and limited conversation to collaborators Jun 30, 2023
@derrabus
Copy link
Member

A new attempt to solve this issue has been made in #54226.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.