Skip to content

[DoctrineBridge] UniqueEntity validator problem with entityClass #22592

Closed
@tabbi89

Description

@tabbi89
Q A
Bug report? yes/no
Feature request? no
BC Break report? yes/no
RFC? yes
Symfony version 3.2.0

I wonder how this should work for very common example using DTOs for forms. I have applied validation to my DTO

AppBundle\Command\Register:
    constraints:
            - Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity:
                fields: email
                entityClass: AppBundle\Entity\User
                em: default

With this configuration I get error: "The class 'AppBundle\Command\Register' was not found in the chain configured namespaces AppBundle\Entity"

The flow currently works like this I set em for my entity: AppBundle\Entity\User but in validator we have getting current class metadata for main entity (in my example AppBundle\Command\Register this is not an entity) $em->getClassMetadata(get_class($entity)); so this is why it fails.

# Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator

        if ($constraint->em) {
            $em = $this->registry->getManager($constraint->em);

            if (!$em) {
                throw new ConstraintDefinitionException(sprintf('Object manager "%s" does not exist.', $constraint->em));
            }
        } else {
            $em = $this->registry->getManagerForClass(get_class($entity));

            if (!$em) {
                throw new ConstraintDefinitionException(sprintf('Unable to find the object manager associated with an entity of class "%s".', get_class($entity)));
            }
        }

        $class = $em->getClassMetadata(get_class($entity));
        /* @var $class \Doctrine\Common\Persistence\Mapping\ClassMetadata */

IMHO if we specify entityClass we should expect that everything will concern this entityClass. Another example of failure is when two entities: entity1 and entity2 belong to different entity managers. If this is an expected behaviour (IMHO it should work like this) we should at least add this information to documentation ?

Activity

linaori

linaori commented on May 1, 2017

@linaori
Contributor

The UniqueEntity does not work on non-entity objects afaik. However, I think a proper solution would would be to just try and insert, then catch the unique constraint exception and add a form error:

try {
    $this->em->flush();
} catch (UniqueConstraintViolationException $exception) {
    $form->get('username')->addError(new FormError(
        $this->translator->trans('authentication.username.taken', [], 'validators')
    ));
}
yceruto

yceruto commented on May 1, 2017

@yceruto
Member

Even when the purpose of the entityClass was to execute the query in a different repository (in some cases, such as when using Doctrine inheritance mapping), I wonder if we could expand its scope to apply UniqueEntity() for non-entity objects (being entityClass mandatory in these case).

The target of this constraint is validate that a particular field (or fields) in a Doctrine entity is (are) unique, so what if:

  • we use entityClass option (if set) instead of get_class($entity),
  • we use PropertyAccess component instead of ReflectionProperty to read the field value from current object (maintain the metadata verification and the DTO fields must be readable too),
  • we remove the check about the repository being able to support the current object,

We would have DTO's with multiple UniqueEntity constraints so:

/**
 * @UniqueEntity(fields={"email"}, entityClass="AppBundle\Entity\User")
 * @UniqueEntity(fields={"phone"}, entityClass="AppBundle\Entity\Phone", em="custom")
 */
class RegisterDTO
{
    /** @var string */
    private $email;

    /** @var string */
    private $phone;
    
    //...
}

Is it something we want/can to support?

#14917 related

ping @ogizanagi, @HeahDude

ogizanagi

ogizanagi commented on May 1, 2017

@ogizanagi
Contributor

@yceruto I considered something similar in the past; I wasn't sure about reusing the same constraint but now I'd say it makes sense. And the feature would be particularly useful for validating commands (command bus) for instance.

But we will also need a mapping between the DTOs fields and the targeted entity fields as their naming might slightly differ.

Foolish example:

/**
 * @UniqueEntity(fields={"userEmail": "email"}, entityClass="AppBundle\Entity\User")
 * @UniqueEntity(fields={"userPhone": "phoneNumber"}, entityClass="AppBundle\Entity\Phone", em="custom")
 */
class RegisterDTO
{
    /** @var string */
    private $userEmail;

    /** @var string */
    private $userPhone;
    
    //...
}
xabbuh

xabbuh commented on May 1, 2017

@xabbuh
Member

That's probably not enough. You also need to consider a way to determine whether or not a possible result is the object currently being modified (the current implementation checks for the object being returned to be the same as the one being validated).

linaori

linaori commented on May 2, 2017

@linaori
Contributor

Please also note that validating unique fields might still trigger the case I've listed before, so you'll need to add that either way. If so, you might just as well omit the validation imo, saves you a lot of work.

HeahDude

HeahDude commented on Jul 30, 2017

@HeahDude
Contributor

While this is not properly supported in core, one may consider using custom constraints to do so (example https://github.com/EnMarche/en-marche.fr/blob/master/src/Validator/UniqueMembershipValidator.php).

ostrolucky

ostrolucky commented on Dec 17, 2017

@ostrolucky
Contributor

Here's more generic solution we are using https://gist.github.com/ostrolucky/7b4a7133e51f6b5ffcb598a287acb5f7

Also, can we give this issue more appropriate title please?

Xymanek

Xymanek commented on Jan 21, 2018

@Xymanek

Another possible solution: https://gist.github.com/Xymanek/369f468d02069090770a1e4626d9f1e9

I can make a PR if it looks good

syther101

syther101 commented on Feb 27, 2018

@syther101

@iltar I'm unsure how your proposed solution would work. Or maybe I'm confused. But at the point of calling $this->em->flush(); the form should be in a submitted and valid state.

How would you then add errors to a field of an already submitted form?

linaori

linaori commented on Feb 27, 2018

@linaori
Contributor

While it may not display the errors in the WDT, the error will still be shown when you render the form.

But at the point of calling $this->em->flush(); the form should be in a submitted and valid state.

This is true. However, 1ms after the form was valid (it was unique at the moment of validation) and after that moment, someone else just flushed their entity with the exact same value, your flush will now fail with a UniqueConstraintException.

20 remaining items

Pictor13

Pictor13 commented on May 17, 2021

@Pictor13

Wondering if this could be worked out (or worked-around) on Doctrine side.

Wouldn't it make sense to configure/extend from Doctrine Bridge to make it aware that a DTO corresponds to a specific Entity?

This way the UniqueEntity constraints would work transparently and wouldn't need to care if the variable is an actual Entity or a representation of that (DTO).

AndreasA

AndreasA commented on Sep 30, 2021

@AndreasA
Contributor

I just encountered the same issue and it should definitely be fixed as using a DTO is just cleaner than using the entity directly, especially with multiple APIs and possible necessary transformations.

Would be great if it would make it into 5.4

And the PR above #38662 seems to address and fix this issue already, it even takes possible property / field mappings into account, would be nice if it is merged or, if necessary, adjusted and merged so it can make it into 5.4 and 6.0

CaptainFalcon92

CaptainFalcon92 commented on Nov 28, 2021

@CaptainFalcon92

Resurrecting that issue; i just came across this need today myself.

Having the ability to call UniqueEntity constraint from outisde an entity would be the perfect answer.
Especially at the time of writing with ApiPlatform and their DTO implementation.

I'm totally for it too :

class RegistrationInput {

  public static function loadValidatorMetadata(ClassMetadata $metadata)
  {
        $metadata->addConstraint(new UniqueEntity([
            'entityClass' => User::class,
            'fields' => ['email']
        ]));
  }

}
// sorry - i'm not keen on annotations ^^

And by doing an assoc array we could eventually indicate the input value to field mapping :

$metadata->addConstraint(new UniqueEntity([
   'entityClass' => User::class,
   'fields' => ['email' => 'username']
]));

Reading the error message, it sounds like the usage of UniqueEntity is being unnecessary restricted to within an entity.

Unable to find the object manager associated with an entity of class "App\DataTransfer\RegistrationInput."

carsonbot

carsonbot commented on May 29, 2022

@carsonbot

Thank you for this suggestion.
There has not been a lot of activity here for a while. Would you still like to see this feature?

AndreasA

AndreasA commented on May 30, 2022

@AndreasA
Contributor

Personally I still think this is quite an important feature.

CaptainFalcon92

CaptainFalcon92 commented on May 30, 2022

@CaptainFalcon92

I made my own like this. Not perfect, but it works fine so far.

class UniqueDTO extends Constraint
{
    public ?string $atPath = null;
    public string $entityClass;
    public string|array $fields;
    public string $message = 'error.duplicate';

    public function getTargets(): string
    {
        return parent::CLASS_CONSTRAINT;
    }
}
class UniqueDTOValidator extends ConstraintValidator
{
    private EntityManagerInterface $em;
    private PropertyAccessorInterface $accessor;

    public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $accessor)
    {
        $this->em = $em;
        $this->accessor = $accessor;
    }

    public function validate($value, Constraint $constraint): void
    {
        if (!$constraint instanceof UniqueDTO) {
            throw new UnexpectedTypeException($constraint, UniqueDTO::class);
        }

        if(!$constraint->entityClass) {
            throw new InvalidArgumentException('Entity class is required.');
        }

        $repository = $this->em->getRepository($constraint->entityClass);

        $fields = (array) $constraint->fields;
        $criteria = [];

        foreach ($fields as $from => $to) {
            $criteria[$to] = $this->accessor->getValue($value, $from);
        }

        if ($repository->count($criteria)) {
            $cvb = $this->context->buildViolation($constraint->message);

            if ($constraint->atPath) {
                $cvb->atPath($constraint->atPath);
            }

            $cvb->addViolation();
        }
    }
}
$metadata->addConstraint(new UniqueDTO([
    'atPath' => 'email',
    'entityClass' => User::class,
    'fields' => ['email' => 'email'],
    'message' => 'email.duplicate',
]));

Cheers

nickicool

nickicool commented on Dec 4, 2022

@nickicool

Another good solution for validate DTO's on unique field here

ii02735

ii02735 commented on Feb 17, 2023

@ii02735

Hello @nickicool,

This won't work if you intend to make an update of your entity.

janklan

janklan commented on Apr 3, 2023

@janklan

It's pretty odd the entityClass is not used to fetch the correct entity manager etc.

As for my solution, I decorated the standard UniqueEntityValidator, and when my decorator detects the $value to be one of my DTOs, I apply custom logic; otherwise, I pass it on to the standard validator:

<?php

namespace App\Validator\Constraint;

use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator as SymfonyUniqueEntityValidator;
// ... other imports, trimmed for brevity

#[AsDecorator('doctrine.orm.validator.unique')]
class UniqueEntityValidator extends ConstraintValidator
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        #[MapDecorated] private readonly SymfonyUniqueEntityValidator $inner
    ) {}

    public function validate($value, Constraint $constraint): void
    {
        if (!$value instanceof CrudDtoInterface) {
            $this->inner->validate($value, $constraint);

            return;
        }

        // Custom validation logic here. Part of my CrudDtoInterface is a method `belogsTo()` that returns FQCN of the Doctrine entity the DTO works with. Fetching the EM & checking for uniqueness is a simple use of `->findOneBy(...)`
    }
}

I looked at the stock validator, and the way it mingles $value::class and $constraint->entityClass doesn't make much sense. Does anyone know why $constraint->entityClass is not used exclusively when provided?

added a commit that references this issue on May 2, 2024

feature #38662 [DoctrineBridge][Validator] Allow validating every cla…

5bc490c
added a commit that references this issue on May 2, 2024
fb23eef
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Participants

      @spackmat@ostrolucky@Wirone@danaki@syther101

      Issue actions

        [DoctrineBridge] UniqueEntity validator problem with entityClass · Issue #22592 · symfony/symfony