How to validate unique entities in an entity collection in symfony2

16,654

Solution 1

I've created a custom constraint/validator for this.

It validates a form collection using the "All" assertion, and takes an optional parameter : the property path of the property to check the entity equality.

(it's for Symfony 2.1, to adapt it to Symfony 2.0 check the end of the answer) :

For more information on creating custom validation constraints, check The Cookbook

The constraint :

#src/Acme/DemoBundle/Validator/constraint/UniqueInCollection.php
<?php

namespace Acme\DemoBundle\Validator\Constraint;

use Symfony\Component\Validator\Constraint;

/**
* @Annotation
*/
class UniqueInCollection extends Constraint
{
    public $message = 'The error message (with %parameters%)';
    // The property path used to check wether objects are equal
    // If none is specified, it will check that objects are equal
    public $propertyPath = null;
}

And the validator :

#src/Acme/DemoBundle/Validator/constraint/UniqueInCollectionValidator.php
<?php

namespace Acme\DemoBundle\Validator\Constraint;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Form\Util\PropertyPath;

class UniqueInCollectionValidator extends ConstraintValidator
{

    // We keep an array with the previously checked values of the collection
    private $collectionValues = array();

    // validate is new in Symfony 2.1, in Symfony 2.0 use "isValid" (see below)
    public function validate($value, Constraint $constraint)
    {
        // Apply the property path if specified
        if($constraint->propertyPath){
            $propertyPath = new PropertyPath($constraint->propertyPath);
            $value = $propertyPath->getValue($value);
        }

        // Check that the value is not in the array
        if(in_array($value, $this->collectionValues))
            $this->context->addViolation($constraint->message, array());

        // Add the value in the array for next items validation
        $this->collectionValues[] = $value;
    }
}

In your case, you would use it like this :

use Acme\DemoBundle\Validator\Constraints as AcmeAssert;

// ...

/**
 * @ORM\OneToMany(targetEntity="Discount", mappedBy="client", cascade={"persist"}, orphanRemoval="true")
 * @Assert\All(constraints={
 *     @AcmeAssert\UniqueInCollection(propertyPath ="product")
 * })
 */

For Symfony 2.0, change the validate function by :

public function isValid($value, Constraint $constraint)
{
        $valid = true;

        if($constraint->propertyPath){
            $propertyPath = new PropertyPath($constraint->propertyPath);
            $value = $propertyPath->getValue($value);
        }

        if(in_array($value, $this->collectionValues)){
            $valid = false;
            $this->setMessage($constraint->message, array('%string%' => $value));
        }

        $this->collectionValues[] = $value;

        return $valid

}

Solution 2

Here is a version working with multiple fields just like UniqueEntity does. Validation fails if multiple objects have same values.

Usage:

/**
* ....
* @App\UniqueInCollection(fields={"name", "email"})
*/
private $contacts;
//Validation fails if multiple contacts have same name AND email

The constraint class ...

<?php
namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 */
class UniqueInCollection extends Constraint
{
    public $message = 'Entry is duplicated.';
    public $fields;

    public function validatedBy()
    {
        return UniqueInCollectionValidator::class;
    }
}

The validator itself ....

<?php

namespace App\Validator\Constraints;

use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

class UniqueInCollectionValidator extends ConstraintValidator
{
    /**
     * @var \Symfony\Component\PropertyAccess\PropertyAccessor
     */
    private $propertyAccessor;

    public function __construct()
    {
        $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
    }

    /**
     * @param mixed $collection
     * @param Constraint $constraint
     * @throws \Exception
     */
    public function validate($collection, Constraint $constraint)
    {
        if (!$constraint instanceof UniqueInCollection) {
            throw new UnexpectedTypeException($constraint, UniqueInCollection::class);
        }

        if (null === $collection) {
            return;
        }

        if (!\is_array($collection) && !$collection instanceof \IteratorAggregate) {
            throw new UnexpectedValueException($collection, 'array|IteratorAggregate');
        }

        if ($constraint->fields === null) {
            throw new \Exception('Option propertyPath can not be null');
        }

        if(is_array($constraint->fields)) $fields = $constraint->fields;
        else $fields = [$constraint->fields];


        $propertyValues = [];
        foreach ($collection as $key => $element) {
            $propertyValue = [];
            foreach ($fields as $field) {
                $propertyValue[] = $this->propertyAccessor->getValue($element, $field);
            }


            if (in_array($propertyValue, $propertyValues, true)) {

                $this->context->buildViolation($constraint->message)
                    ->atPath(sprintf('[%s]', $key))
                    ->addViolation();
            }

            $propertyValues[] = $propertyValue;
        }

    }
}

Solution 3

For Symfony 4.3(only tested version) you can use my custom validator. Prefered way of usage is as annotaion on validated collection:

use App\Validator\Constraints as App;

...

/**
 * @ORM\OneToMany
 *
 * @App\UniqueProperty(
 *     propertyPath="entityProperty"
 * )
 */
private $entities;

Difference between Julien and my solution is, that my Constraint is defined on validated Collection instead on element of Collection itself.

Constraint:

#src/Validator/Constraints/UniqueProperty.php
<?php


namespace App\Validator\Constraints;


use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 */
class UniqueProperty extends Constraint
{
    public $message = 'This collection should contain only elements with uniqe value.';
    public $propertyPath;

    public function validatedBy()
    {
        return UniquePropertyValidator::class;
    }
}

Validator:

#src/Validator/Constraints/UniquePropertyValidator.php
<?php

namespace App\Validator\Constraints;

use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

class UniquePropertyValidator extends ConstraintValidator
{
    /**
     * @var \Symfony\Component\PropertyAccess\PropertyAccessor
     */
    private $propertyAccessor;

    public function __construct()
    {
        $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
    }

    /**
     * @param mixed $value
     * @param Constraint $constraint
     * @throws \Exception
     */
    public function validate($value, Constraint $constraint)
    {
        if (!$constraint instanceof UniqueProperty) {
            throw new UnexpectedTypeException($constraint, UniqueProperty::class);
        }

        if (null === $value) {
            return;
        }

        if (!\is_array($value) && !$value instanceof \IteratorAggregate) {
            throw new UnexpectedValueException($value, 'array|IteratorAggregate');
        }

        if ($constraint->propertyPath === null) {
            throw new \Exception('Option propertyPath can not be null');
        }

        $propertyValues = [];
        foreach ($value as $key => $element) {
            $propertyValue = $this->propertyAccessor->getValue($element, $constraint->propertyPath);
            if (in_array($propertyValue, $propertyValues, true)) {
                $this->context->buildViolation($constraint->message)
                    ->atPath(sprintf('[%s]', $key))
                    ->addViolation();
            }

            $propertyValues[] = $propertyValue;
        }
    }
}

Solution 4

I can't manage to make the previous answer works on symfony 2.6. Because of the following code on l. 852 of RecursiveContextualValidator, it only goes once on the validate method when 2 items are equals.

if ($context->isConstraintValidated($cacheKey, $constraintHash)) {
    continue; 
} 

So, here is what I've done to deals with the original issue :

On the Entity :

* @AcmeAssert\UniqueInCollection(propertyPath ="product")

Instead of

* @Assert\All(constraints={
*     @AcmeAssert\UniqueInCollection(propertyPath ="product")
* })

On the validator :

public function validate($collection, Constraint $constraint){

    $propertyAccessor = PropertyAccess::getPropertyAccessor(); 

    $previousValues = array();
    foreach($collection as $collectionItem){
        $value = $propertyAccessor->getValue($collectionItem, $constraint->propertyPath);
        $previousSimilarValuesNumber = count(array_keys($previousValues,$value));
        if($previousSimilarValuesNumber == 1){
            $this->context->addViolation($constraint->message, array('%email%' => $value));
        }
        $previousValues[] = $value;
    }

}

Instead of :

public function isValid($value, Constraint $constraint)
{
    $valid = true;

    if($constraint->propertyPath){
        $propertyAccessor = PropertyAccess::getPropertyAccessor(); 
        $value = $propertyAccessor->getValue($value, $constraint->propertyPath);
    }

    if(in_array($value, $this->collectionValues)){
        $valid = false;
        $this->setMessage($constraint->message, array('%string%' => $value));
    }

    $this->collectionValues[] = $value;

    return $valid

}
Share:
16,654

Related videos on Youtube

Jens
Author by

Jens

I code mostly in javascript and C.

Updated on September 14, 2022

Comments

  • Jens
    Jens over 1 year

    I have an entity with a OneToMany relation to another entity, when I persist the parent entity I want to ensure the children contain no duplicates.

    Here's the classes I have been using, the discounts collection should not contain two products with the same name for a given client.

    I have a Client entity with a collection of discounts:

    /**
     * @ORM\Entity
     */
    class Client {
    
        /**
         * @ORM\Id
         * @ORM\Column(type="integer")
         * @ORM\GeneratedValue(strategy="AUTO")
         */
        protected $id;
    
        /**
         * @ORM\Column(type="string", length=128, nullable="true")
         */
        protected $name;
    
        /**
         * @ORM\OneToMany(targetEntity="Discount", mappedBy="client", cascade={"persist"}, orphanRemoval="true")
         */
        protected $discounts;
    
    }
    
    /**
     * @ORM\Entity
     * @UniqueEntity(fields={"product", "client"}, message="You can't create two discounts for the same product")
     */
        class Discount {
            /**
             * @ORM\Id
             * @ORM\Column(type="string", length=128, nullable="true")
             */
            protected $product;
    
            /**
             * @ORM\Id
             * @ORM\ManyToOne(targetEntity="Client", inversedBy="discounts")
             * @ORM\JoinColumn(name="client_id", referencedColumnName="id")
             */
            protected $client;
    
            /**
             * @ORM\Column(type="decimal", scale=2)
             */
            protected $percent;
        }
    

    I tried using UniqueEntity for the Discount class as you can see, the problem is that it seems the validator only checks what's loaded on the database (which is empty), so when the entities are persisted I get a "SQLSTATE[23000]: Integrity constraint violation".

    I have checked the Collection constraint buy it seems to handle only collections of fields, not entities.

    There's also the All validator, which lets you define constraints to be applied for each entity, but not to the collection as a whole.

    I need to know if there are entity collection constraints as a whole before persisting to the database, other than writing a custom validator or writing a Callback validator each time.

  • Julien
    Julien over 11 years
    Edited : $collectionValues should not be static for the validator to be re-used in the same form
  • Julien
    Julien over 11 years
    Also, the collection type has the 'error_bubbling' option to true by default. To identify which fields are duplicated, you need to set it to false (see stackoverflow.com/questions/8961083/… )
  • Jens
    Jens over 11 years
    Thanks a lot. I guessed it wasn't posible out of the box, I just wasn't sure. This or some other way of making collection wide assertions should be included in the framework.
  • Massimiliano Arione
    Massimiliano Arione about 9 years
    For Symfony 2.2 and higer, you need to use Symfony\Component\PropertyAccess\PropertyAccess and replace $propertyPath = new PropertyPath($constraint->propertyPath); $value = $propertyPath->getValue($value); with $propertyAccessor = PropertyAccess::getPropertyAccessor(); $value = $propertyAccessor->getValue($value, $constraint->propertyPath);
  • emottet
    emottet about 9 years
    Thanks for your answer. I try to make it works like you. Unfortunately, When I have two similar items, it only goes once in the validate method because of if ($context->isConstraintValidated($cacheKey, $constraintHash)) { continue; } on RecursiveContextualValidator l. 852. How do you manage to go through that ?
  • enumag
    enumag over 8 years
    This sollution is wrong. Validator should be stateless. If I would validate multiple entities with such collections this could result in unexpected results.
  • Julien
    Julien over 8 years
    @enumag If you need to obey to rules like "Validator should be stateless", then you should indeed build something more complicated, but I would say it's over-engineering. This validator's goal is to check whether the value doesn't exist in a list of other values, so it needs to know what those other values are. With what you are given in SF, this solution is simple, efficient, and it works. IIRC, you have one validator instance/form+field instance, so you shouldn't have multiple collection issues. And even if you had such problems, you could take the collection's class name.
  • Julien
    Julien over 8 years
    @emottet Is that with a newer version of SF? I didn't follow up, I guess you can do something with the cache key/constraint hash.
  • eronn
    eronn over 4 years
    Great it works! However, the error message only appears in the profiler. How to make it appear as a real error message like the {{form_error (field)}} near the field?