Skip to content

[Validator] Constraints as php 8 Attributes #38309

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

Merged
merged 1 commit into from
Sep 30, 2020

Conversation

derrabus
Copy link
Member

@derrabus derrabus commented Sep 26, 2020

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

This is my attempt to teach the validator to load constraints from PHP attributes. Like we've done it for the Route attribute, I've hooked into the existing AnnotationLoader, so we can again mix and match annotations and attributes.

Named Arguments

An attribute declaration is basically a constructor call. This is why, in order to effectively use a constraint as attribute, we need to equip it with a constructor that works nicely with named arguments. This way, IDEs like PhpStorm can provide auto-completion and guide a developer when declaring a constraint without the need for additional plugins. Right now, PhpStorm supports neither attributes nor named arguments, but I expect those features to be implemented relatively soon.

To showcase this, I have migrated the Range and Choice constraints. The example presented in #38096 works with this PR.

#[Assert\Choice(
    choices: ['fiction', 'non-fiction'],
    message: 'Choose a valid genre.',
)]
private $genre;

A nice side effect is that we get a more decent constructor call in php 8 in situations where we directly instantiate constraints, for instance when attaching constraints to a form field via the form builder.

$builder->add('genre, TextType::class, [
    'constraints' => [new Assert\Choice(
        choices: ['fiction', 'non-fiction'],
        message: 'Choose a valid genre.',
    )],
]);

The downside is that all those constructors generate the need for some boilerplate code that was previously abstracted away by the Constraint class.

The alternative to named arguments would be leaving the constructors as they are. That would basically mean that when declaring a constraint we would have to emulate the array that Doctrine annotations would crate. We would lose IDE support and the declarations would be uglier, but it would work.

#[Assert\Choice([
    'choices' => ['fiction', 'non-fiction'],
    'message' => 'Choose a valid genre.',
])]
private $genre;

Nested Attributes

PHP does not support nesting attributes (yet?). This is why I could not recreate composite annotations like All and Collection. I think it's okay if they're not included in the first iteration of attribute constraints and we can work on a solution in a later PR. Possible options:

  • A later PHP 8.x release might give us nested attributes.
  • We could find a way to flatten those constraints so we can recreate them without nesting.
  • We could come up with a convention for a structure that lets us emulate nested attributes in userland.

Repeatable attributes

In order to attach two instances of the same attribute class to an element, we explicitly have to allow repetition via the flag Attribute::IS_REPEATABLE. While we could argue if it really makes sense to do this for certain constraints (like NotNull for instance), there are others (like Callback) where having two instances with different configurations could make sense. On the other hand, the validator certainly won't break if we repeat any of the constraints and all other ways to configure constraints allow repetition. This is why I decided to allow repetition for all constraints I've marked as attributes in this PR and propose to continue with that practice for all other constraints.

Migration Path

This PR only migrates a handful of constraints. My plan is to discuss the general idea with this PR first and use it as a blueprint to migrate the individual constraints afterwards. Right now, the migration path would look like this:

  • Attach the #[Attribute] attribute.
  • Recreate all options of the constraint as constructor arguments.
  • Add test cases for constructor calls with named arguments to the test class of the constraint's validator.

@nicolas-grekas
Copy link
Member

The downside is that all those constructors generate the need for some boilerplate code that was previously abstracted away by the Constraint class.

this is a pure implementation concern, isn't it? that's fine to me then.

We could find a way to flatten those constraints so we can recreate them without nesting.

This would be the best IMHO. Any idea? Here is one that could be compatible with IDE autocompletion:

#[Assert\All(constraints: foo)]
#[Assert\NotNull(group: foo)]
#[Assert\Length(max: 6, group: foo)]

Dunno how this would work technically, but I feel like it could.

Any better idea?

@nicolas-grekas
Copy link
Member

Another idea, similar to how the config component works (indentation would be "manual"):

#[Assert\All]
	#[Assert\NotNull]
	#[Assert\Length(max: 6)]
#[EndAssert\All]

@javiereguiluz
Copy link
Member

javiereguiluz commented Sep 27, 2020

I prefer the previous recommendation based on #[Assert\All(constraints: foo)] (and #[Assert\Any(constraints: foo)] ?) Nested attributes look cumbersome.

Given that PHP attributes use the exact same syntax that Rust has been using for years, we could look into them for inspiration. A random example taken from this library:

struct SignupData {
    #[validate(email)]
    mail: String,
    #[validate(phone)]
    phone: String,
    #[validate(url)]
    site: String,
    #[validate(length(min = 1), custom = "validate_unique_username")]
    #[serde(rename = "firstName")]
    first_name: String,
    #[validate(range(min = 18, max = 20))]
    age: u32,
}

@derrabus
Copy link
Member Author

Another idea, similar to how the config component works (indentation would be "manual"):

#[Assert\All]
    #[Assert\NotNull]
    #[Assert\Length(max: 6)]
#[EndAssert\All]

So, we would basically have dedicated start/end attributes as a bracket around nested attributes. And the annotation loader would understand how to transform a series of attributes to the expected nested structure. That'll work, I guess, though I would not reuse an existing attribute as start marker. Otherwise things might get complicated if one day php really implements nested attributes.

Regarding the code-style: You can combine multiple attributes into one #[]. We can use that construct to group nested constraints:

#[Assert\BeginAll,
    AssertNotNull,
    Assert\Length(max: 6),
Assert\EndAll]
#[SomeOtherConstraint]
#[SomeUnrelatedAttribute]

This is of course just a visual aid. There is no way to query for the individual attribute blocks via reflection.

Another idea that I had was to define an array structure that can be converted easily to a constructor call.

#[Assert\All(constraints: [
    [Assert\NotNull::class],
    [Assert\Length::class, 'max' => 6],
])]

We could provide a helper function (via a trait or the abstract Constraint class) to parse those arrays and emulate nested attributes this way. But we would again lose IDE support, at least for the inner constraints.

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Sep 28, 2020

Assert\BeginAll

not convinced at all.

I like your idea of tricking the syntax to emulate nested attributes.
That leads to another proposal, based on a generic End attribute (quite similar to how the config component does nesting btw):

#[Assert\All,
    AssertNotNull,
    Assert\Length(max: 6),
EndAssert]

Such a list could be interpreted quite generically I think, from an implementation pov;

@derrabus
Copy link
Member Author

Not having a distinct starter for a nested block makes the implementation more difficult, but I think we can do without. The generic EndAssert is a good idea. I'll give it a try.

@javiereguiluz
Copy link
Member

Here are some other proposals to flatten the nested attributes. Taking this example from Symfony Docs:

class User
{
    /**
     * @Assert\All({
     *     @Assert\NotBlank,
     *     @Assert\Length(min=5)
     * })
     */
    protected $favoriteColors = [];
}

We could add a "special marker" in the attribute name to say that this only applies to items (the idea would be to get this "marker" somehow and turn it into a config option ... not duplicating all constraint for items):

class User
{
    #[Assert\Items\NotBlank]
    #[Assert\Items\Length(min: 5)]
    protected $favoriteColors = [];
}

Another idea: add an option which tells Symfony if this applies to the property or to each of its elements:

class User
{
    #[Assert\NotBlank, apply_to: items]
    #[Assert\Length(min: 5), apply_to: items]
    protected $favoriteColors = [];
}

@nicolas-grekas
Copy link
Member

nicolas-grekas commented Sep 28, 2020

@javiereguiluz I'm not sure how this would work: how would one tell they want either Sequentially, AtLeastOneOf, or All (or any other composite constraint)?

@derrabus
Copy link
Member Author

Before this PR escalates into a discussion on nesting: Reviews of the actual code of this PR would be very much appreciated. 😃

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me. Do you want to merge this first and iterate?
Just don't miss adding an entry in the changelog.

@derrabus derrabus force-pushed the improvement/validator branch from 72a16c0 to 522ec73 Compare September 28, 2020 15:51
@derrabus
Copy link
Member Author

Do you want to merge this first and iterate?

Yes, please. This way, others could join in and migrate more constraints while I work on the nesting.

Just don't miss adding an entry in the changelog.

Added!

Copy link
Member

@fabpot fabpot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM (cosmetic comments only)

@derrabus derrabus force-pushed the improvement/validator branch from 522ec73 to b71b352 Compare September 28, 2020 16:32
@derrabus derrabus force-pushed the improvement/validator branch 2 times, most recently from 5785e93 to 4d06b99 Compare September 29, 2020 13:29
@derrabus derrabus force-pushed the improvement/validator branch from 4d06b99 to 61554e3 Compare September 29, 2020 22:42
@derrabus derrabus force-pushed the improvement/validator branch from 61554e3 to d1cb2d6 Compare September 29, 2020 22:44
@fabpot
Copy link
Member

fabpot commented Sep 30, 2020

Thank you @derrabus.

@fabpot fabpot merged commit 5eb442e into symfony:master Sep 30, 2020
@derrabus derrabus deleted the improvement/validator branch September 30, 2020 08:36
@nicolas-grekas nicolas-grekas modified the milestones: next, 5.2 Oct 5, 2020
@fabpot fabpot mentioned this pull request Oct 5, 2020
fabpot added a commit that referenced this pull request Nov 8, 2020
…t doctrine/annotations (derrabus)

This PR was merged into the 5.2-dev branch.

Discussion
----------

[Validator] Allow load mappings from attributes without doctrine/annotations

| Q             | A
| ------------- | ---
| Branch?       | 5.2
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | yes
| Tickets       | #38096
| License       | MIT
| Doc PR        | TODO

Follows #38309.

Currently, we cannot enable constraint mapping from attributes without having `doctrine/annotations` installed. Lifting that limitation is a bit tricky because `ValidatorBuilder::enableAnnotationMapping()` creates an annotation reader if you don't pass one. This PR aims at deprecating this behavior.

I know it's a bit late for such a change in 5.2 and I should have seen earlier that this part was missing. 😓 Since I don't expect people to go all-in on attributes on day one, it's probably okay to postpone this change to 5.3.

Commits
-------

441c806 [Validator] Allow load mappings from attributes without doctrine/annotations.
OskarStark added a commit to symfony/symfony-docs that referenced this pull request Jan 21, 2021
…wkania)

This PR was squashed before being merged into the 5.2 branch.

Discussion
----------

[Validator] Document constraints as php 8 Attributes

We already have [examples](https://github.com/symfony/symfony-docs/pull/14230/files) of code for the PHP attributes.
We also have [Constraints as php 8 Attributes](symfony/symfony#38309) and [2](#14305).
The rest will be implemented in the [future](symfony/symfony#38503).

Commits
-------

748bd54 [Validator] Document constraints as php 8 Attributes
Comment on lines +69 to +70
if (\is_array($choices) && \is_string(key($choices))) {
$options = array_merge($choices, $options);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@derrabus I often use associative arrays as choices - using the translation key as choice key ; one reason would be to share this constant array with the ChoiceType choices option. It was completely ok with Annotations. But this compatibility rule 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.

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

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

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please open a bug for your issue?

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

Successfully merging this pull request may close these issues.

7 participants