How to validate unique entities in an entity collection in symfony2

Viewed 17494

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.

5 Answers

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;
        }

    }
}

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;
        }
    }
}
Related