Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Apply specific validation group for each element of a collection in Symfony 3

I had to upgrade one of my projects from symfony 2.8 to symfony 3.4, and I noticed a huge change in the validation process.

To simplify let's say I have a User entity, with many Addresses entities. When I create / update my User I want to be able to add / remove / update any number of addresses. So in symfony 2.8 I had this kind of situation

User

I use annotation validators

src/AppBundle/Entity/User.php

//...
class User
{
    //...
    /** 
     * @Assert\Count(min=1, max=10)
     * @ORM\OneToMany(targetEntity="AppBundle\Entity\Address", mappedBy="user", cascade={"persist", "remove"})
     */
    protected $addresses;
    //...
}

UserForm

src/AppBundle/Form/UserForm.php

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        // ...
        ->add('addresses', CollectionType::class, [
            'type' => AddressType::class,
            'cascade_validation' => true,
            'allow_add' => true,
            'allow_delete' => true,
            'by_reference' => false,
        ])
    ;
}

public function setDefaultOptions(OptionsResolverInterface $resolver)
{
    $resolver->setDefaults([
        'data_class' => User::class,
        'cascade_validation' => true,
        'validation_groups' => // User's logic
    ]);
}

Address

src/AppBundle/Entity/Address.php

//...
class Address
{
    //...
    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="user")
     */
    protected $user;

    /**
     * @Assert\NotBlank(groups={"zipRequired"})
     * @ORM\Column(type="text", nullable="true")
     */
    protected $zipCode;
    //...
}

AddressForm

src/AppBundle/Form/AddressForm.php

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder
        // ...
        ->add('zipCode', TextType::class)
    ;
}

public function setDefaultOptions(OptionsResolverInterface $resolver)
{
    $resolver->setDefaults([
        'data_class' => Address::class,
        'cascade_validation' => true,
        'validation_groups' => function(FormInterface $form) {
            /** @var Address $data */
            $data = $form->getData();
            $validation_groups = [];

            // Simplified here, it's a service call with heavy logic
            if ($data->doesRequireZip()) {
                $validation_groups[] = 'zipRequired';
            }

            return $validation_groups;
        },
    ]);
}

In symfony 2.8

On 3 addresses added, two have to valid the zipRequired group, one not. I works !

In symfony 3.4

I added @Assert\Valid() to User::$zipCode declaration and removed the 'cascade_validation' => true (not in method configureOptions but it seems unused) as it's deprecated.

But now on 3 addresses added, two have should to valid the zipRequired group, and one not : Only User's class validator_groups are used, so I can valid a form with incoherent data !

I checked with xdebug and the validator_groups callback in AddressForm is called but validators are not.

I tested the solutions decribed here : Specify different validation groups for each item of a collection in Symfony 2? but it can't work anymore as in symfony 3.4 cascade_validation on a property throws an error

In my situation the logic involved is too heavy to use a solution ad described here Specify different validation groups for each item of a collection in Symfony 3? as it is very inneficient to rewrite the whole validation_groups callback in individual methods and it apply the groups on all child entities.

The Behaviour of @Assert\Valid and cascade_validation are different, is there a way to handle embed form individual entity validation_groups in symfony 3.4 or the feature is definitely gone ?

like image 661
Jihel Avatar asked Nov 07 '22 09:11

Jihel


1 Answers

As this is an expected behavior (the entire form should be validated from the root validation_groups, as you can see the explanation here: https://github.com/symfony/symfony/issues/31441) the only way i could find to solve this problem is using an callback validation inside the collection item (entity):

src/AppBundle/Entity/Address.php

//...
class Address
{
    //...
    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\User", inversedBy="user")
     */
    protected $user;

    /**
     * @ORM\Column(type="text", nullable="true")
     */
    protected $zipCode;
    //...

    /**
     * @Assert\Callback()
     */
    public function validate(): void {
        if ($data->doesRequireZip()) {
            // validate if $zipCode isn't null
            // other validations ...  
        }
    }
}

more about symfony callback assert: https://symfony.com/doc/current/reference/constraints/Callback.html

this solution was made in symfony 5.1, but this probably works from 2.8+

Update

just to add my final solution was adding the validation throug the validator, forcing each property against an specific group (also you dont need to force Default if your form already did it).

/**
 * @Assert\Callback()
 */
public function validate(ExecutionContext $context): void
{
    $validator = $context->getValidator();
    $groups = $this->doesRequireZip() ? ['requiredZipGroup'] : [];
    $violations = $validator->validate($this, null, $groups);

    /** @var \Symfony\Component\Validator\ConstraintViolationInterface $violation */
    foreach ($violations as $violation) {
        $context->buildViolation($violation->getMessage(), $violation->getParameters())
            ->atPath($violation->getPropertyPath())
            ->addViolation();
    }
}

passing null to second argument on $validator->validate() will force the Assert\Valid to run through the object $this, so, all constraints and also callbacks within the $groups will run

like image 166
Dreanmer Avatar answered Nov 11 '22 15:11

Dreanmer