15

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.

Jens
  • 5,767
  • 5
  • 54
  • 69

5 Answers5

9

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

}
Mick
  • 30,759
  • 16
  • 111
  • 130
Julien
  • 9,312
  • 10
  • 63
  • 86
  • 1
    Edited : $collectionValues should not be static for the validator to be re-used in the same form – Julien Aug 04 '12 at 10:01
  • 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 http://stackoverflow.com/questions/8961083/why-errors-from-custom-validation-constrain-not-attached-to-specific-field-by-to ) – Julien Aug 04 '12 at 10:24
  • 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. – Jens Aug 06 '12 at 04:16
  • 1
    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);` – Massimiliano Arione Feb 12 '15 at 13:45
  • 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 ? – emottet Mar 02 '15 at 09:43
  • This sollution is wrong. Validator should be stateless. If I would validate multiple entities with such collections this could result in unexpected results. – enumag Jan 03 '16 at 15:33
  • @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 Jan 04 '16 at 12:54
  • @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. – Julien Jan 04 '16 at 12:56
6

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

    }
}
Diego Montero
  • 61
  • 1
  • 4
4

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;
        }
    }
}
Pavol Velky
  • 770
  • 3
  • 9
  • 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? – eronn Jan 12 '20 at 16:52
  • For me it's works too but form_errors(form.field) not working... (Symfony 5.4). I need to write form_errors(form) to display error... have you find a solution for this ? – barbuslex Sep 09 '22 at 12:30
1

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

}
emottet
  • 457
  • 6
  • 18
1

Can be used Unique built-in validator for Symfony >= 6.1

The fields option was introduced in Symfony 6.1.

Serhii Vasko
  • 383
  • 4
  • 11