Skip to content

Instantly share code, notes, and snippets.

@webbertakken
Last active May 2, 2024 10:21
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save webbertakken/569409670bfc7c079e276f79260105ed to your computer and use it in GitHub Desktop.
Save webbertakken/569409670bfc7c079e276f79260105ed to your computer and use it in GitHub Desktop.
DataTransferObject-UniqueEntityConstraint
<?php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
*/
class DtoUniqueEntity extends Constraint
{
public const NOT_UNIQUE_ERROR = 'e777db8d-3af0-41f6-8a73-55255375cdca';
protected static $errorNames = [
self::NOT_UNIQUE_ERROR => 'NOT_UNIQUE_ERROR',
];
public $em;
public $entityClass;
public $errorPath;
public $fieldMapping = [];
public $ignoreNull = true;
public $message = 'This value is already used.';
public $repositoryMethod = 'findBy';
public function getDefaultOption()
{
return 'entityClass';
}
public function getRequiredOptions()
{
return ['fieldMapping', 'entityClass'];
}
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
public function validatedBy()
{
return DtoUniqueEntityValidator::class;
}
}
<?php
namespace App\Validator\Constraints;
use App\Form\DataTransferObject\DataTransferObjectInterface;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\Mapping\Entity;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class DtoUniqueEntityValidator extends ConstraintValidator
{
/** @var DtoUniqueEntity */
private $constraint;
private $em;
private $entityMeta;
private $registry;
private $repository;
/** @var DataTransferObjectInterface */
private $validationObject;
public function __construct(ManagerRegistry $registry)
{
$this->registry = $registry;
}
public function validate($object, Constraint $constraint)
{
// Set arguments as class variables
$this->validationObject = $object;
$this->constraint = $constraint;
$this->checkTypes();
// Map types to criteria
$this->entityMeta = $this->getEntityManager()->getClassMetadata($this->constraint->entityClass);
$criteria = $this->getCriteria();
// skip validation if there are no criteria (this can happen when the
// "ignoreNull" option is enabled and fields to be checked are null
if (empty($criteria)) {
return;
}
$result = $this->checkConstraint($criteria);
// If no entity matched the query criteria or a single entity matched,
// which is the same as the entity being validated, the criteria is
// unique.
if (!$result || (1 === \count($result) && current($result) === $this->entityMeta)) {
return;
}
// Property to which to return the violation
$objectFields = array_keys($this->constraint->fieldMapping);
$errorPath = null !== $this->constraint->errorPath
? $this->constraint->errorPath
: $objectFields[0];
// Value that caused the violation
$invalidValue = isset($criteria[$this->constraint->fieldMapping[$errorPath]])
? $criteria[$this->constraint->fieldMapping[$errorPath]]
: $criteria[$this->constraint->fieldMapping[0]];
// Build violation
$this->context->buildViolation($this->constraint->message)
->atPath($errorPath)
->setParameter('{{ value }}', $this->formatWithIdentifiers($invalidValue))
->setInvalidValue($invalidValue)
->setCode(DtoUniqueEntity::NOT_UNIQUE_ERROR)
->setCause($result)
->addViolation();
}
private function checkTypes()
{
if (!$this->validationObject instanceof DataTransferObjectInterface) {
throw new UnexpectedTypeException($this->validationObject, DataTransferObjectInterface::class);
}
if (!$this->constraint instanceof DtoUniqueEntity) {
throw new UnexpectedTypeException($this->constraint, DtoUniqueEntity::class);
}
if (null === $this->constraint->entityClass || !\class_exists($this->constraint->entityClass)) {
throw new UnexpectedTypeException($this->constraint->entityClass, Entity::class);
}
if (!\is_array($this->constraint->fieldMapping) || 0 === \count($this->constraint->fieldMapping)) {
throw new UnexpectedTypeException($this->constraint->fieldMapping, '[objectProperty => entityProperty]');
}
if (null !== $this->constraint->errorPath && !is_string($this->constraint->errorPath)) {
throw new UnexpectedTypeException($this->constraint->errorPath, 'string or null');
}
}
private function getEntityManager()
{
if (null !== $this->em) {
return $this->em;
}
if ($this->constraint->em) {
$this->em = $this->registry->getManager($this->constraint->em);
if (!$this->em) {
throw new ConstraintDefinitionException(sprintf('Object manager "%s" does not exist.',
$this->constraint->em));
}
} else {
$this->em = $this->registry->getManagerForClass($this->constraint->entityClass);
if (!$this->em) {
throw new ConstraintDefinitionException(sprintf('Unable to find the object manager associated with an entity of class "%s".',
$this->constraint->entityClass));
}
}
return $this->em;
}
private function getCriteria()
{
$validationClass = new \ReflectionClass($this->validationObject);
$criteria = [];
foreach ($this->constraint->fieldMapping as $objectField => $entityField) {
// DTO Property (key) should exist on DataTransferObject
if (!$validationClass->hasProperty($objectField)) {
throw new ConstraintDefinitionException(sprintf(
'Property for fieldMapping key "%s" does not exist on this Object.',
$objectField
));
}
// Entity Property (value) should exist in the Entity Class
if (!property_exists($this->constraint->entityClass, $entityField)) {
throw new ConstraintDefinitionException(sprintf(
'Property for fieldMapping key "%s" does not exist in given EntityClass.',
$objectField
));
}
// Entity Property (value) should exist in the ORM
if (!$this->entityMeta->hasField($entityField) && !$this->entityMeta->hasAssociation($entityField)) {
throw new ConstraintDefinitionException(sprintf(
'The field "%s" is not mapped by Doctrine, so it cannot be validated for uniqueness.',
$entityField
));
}
$fieldValue = $validationClass->getProperty($objectField)->getValue($this->validationObject);
// validation doesn't fail if one of the fields is null and if null values should be ignored
if (null === $fieldValue && !$this->constraint->ignoreNull) {
throw new UniqueConstraintViolationException('Unique value can not be NULL');
}
$criteria[$entityField] = $fieldValue;
if (null !== $criteria[$entityField] && $this->entityMeta->hasAssociation($entityField)) {
/* Ensure the Proxy is initialized before using reflection to
* read its identifiers. This is necessary because the wrapped
* getter methods in the Proxy are being bypassed.
*/
$this->getEntityManager()->initializeObject($criteria[$entityField]);
}
}
return $criteria;
}
private function checkConstraint($criteria)
{
$result = $this->getRepository()->{$this->constraint->repositoryMethod}($criteria);
if ($result instanceof \IteratorAggregate) {
$result = $result->getIterator();
}
/* If the result is a MongoCursor, it must be advanced to the first
* element. Rewinding should have no ill effect if $result is another
* iterator implementation.
*/
if ($result instanceof \Iterator) {
$result->rewind();
if ($result instanceof \Countable && 1 < \count($result)) {
$result = [$result->current(), $result->current()];
} else {
$result = $result->current();
$result = null === $result ? [] : [$result];
}
} elseif (\is_array($result)) {
reset($result);
} else {
$result = null === $result ? [] : [$result];
}
return $result;
}
private function formatWithIdentifiers($value)
{
if (!is_object($value) || $value instanceof \DateTimeInterface) {
return $this->formatValue($value, self::PRETTY_DATE);
}
if ($this->entityMeta->getName() !== $idClass = get_class($value)) {
// non unique value might be a composite PK that consists of other entity objects
if ($this->getEntityManager()->getMetadataFactory()->hasMetadataFor($idClass)) {
$identifiers = $this->getEntityManager()->getClassMetadata($idClass)->getIdentifierValues($value);
} else {
// this case might happen if the non unique column has a custom doctrine type and its value is an object
// in which case we cannot get any identifiers for it
$identifiers = [];
}
} else {
$identifiers = $this->entityMeta->getIdentifierValues($value);
}
if (!$identifiers) {
return sprintf('object("%s")', $idClass);
}
array_walk($identifiers, function (&$id, $field) {
if (!is_object($id) || $id instanceof \DateTimeInterface) {
$idAsString = $this->formatValue($id, self::PRETTY_DATE);
} else {
$idAsString = sprintf('object("%s")', get_class($id));
}
$id = sprintf('%s => %s', $field, $idAsString);
});
return sprintf('object("%s") identified by (%s)', $idClass, implode(', ', $identifiers));
}
private function getRepository()
{
if (null === $this->repository) {
$this->repository = $this->getEntityManager()->getRepository($this->constraint->entityClass);
}
return $this->repository;
}
}
@webbertakken
Copy link
Author

webbertakken commented May 30, 2018

Example usage:

fieldMapping should hold a property from the DTO as key and a property from the Entity as value.

<?php

namespace App\Form\DataTransferObject;

use App\Validator\Constraints\DtoUniqueEntity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @DtoUniqueEntity(
 *     entityClass="App\Entity\User",
 *     fieldMapping={"emailAddress": "emailAddress"}
 * )
 */
class UserRegistrationData implements DataTransferObjectInterface
{
    /**
     * @var string
     * @Assert\NotBlank()
     * @Assert\Email()
     * @ORM\Column(type="string")
     */
    public $emailAddress;

    /**
     * @var string
     * @Assert\NotBlank()
     * @Assert\Length(
     *     min="10",
     *     minMessage="Strong passwords have a minimum of 10 characters.",
     *     max="2048",
     *     maxMessage="Did you type all this?"
     * )
     */
    public $password;

    /**
     * @var bool
     * @Assert\IsTrue(message="You must accept these to create an account.")
     */
    public $termsAccepted;
}

@melyouz
Copy link

melyouz commented Oct 21, 2018

@webbertakken, first of all, thank you for sharing your solution.

I need your help :) How would your solution work with Embeddables?

I got an entity (User) which has an Embedded (EmailAddress):

/**
 * @ORM\Embedded(class="EmailAddress")
 */
private $email;

On userRepository I use it like this:

$queryBuilder->expr()->eq('u.email.address', ':email');

But this does not work:

/**
 * @DtoUniqueEntity(entityClass="App\Entity\User", fieldMapping={"email": "email.address"}, message="Email already taken")
 * @DtoUniqueEntity(entityClass="App\Entity\User", fieldMapping={"username": "username"}, message="Username already taken")
 */

Because of this:

// Entity Property (value) should exist in the Entity Class
if (!property_exists($this->constraint->entityClass, $entityField)) {
	throw new ConstraintDefinitionException(sprintf(
		'Property for fieldMapping key "%s" does not exist in given EntityClass.',
		$objectField
	));
}

(property email.address does not really exist on entity User because it's an Embedded Object Value mapped to property email)

I have also tried with Embedded without column prefix (columnPrefix=false), but then it passes the property_exists check but fails on Doctrine Query parser, because there it should be 'address' (or whatever property the Embeddable has on its own class).

Thank you in advance <3

@melyouz
Copy link

melyouz commented Oct 22, 2018

Nvm, thank you anyways.
I've just removed the extra check of:

-// Entity Property (value) should exist in the Entity Class
-if (!property_exists($this->constraint->entityClass, $entityField)) {
-	throw new ConstraintDefinitionException(sprintf(
-		'Property for fieldMapping key "%s" does not exist in given EntityClass.',
-		$objectField
-	));
-}

Because after that there is this one:

if (!$this->entityMeta->hasField($entityField) && !$this->entityMeta->hasAssociation($entityField)) {
	throw new ConstraintDefinitionException(sprintf(
		'The field "%s" is not mapped by Doctrine, so it cannot be validated for uniqueness.',
		$entityField
	));
}

When the field exists on ClassMetadata->fieldMappings (which it does when it's Embedded too) this will pass & if it doesn't pass it's because the property does not exist o it's not mapped by Doctrine.

@sabat24
Copy link

sabat24 commented Nov 25, 2020

Right now in SF 5.1.x UniqueConstraintViolationException requires a second argument: \Doctrine\DBAL\Driver\DriverException
What should I put there in this example? (line 163)

@webbertakken
Copy link
Author

@sabat24 I could update the example if you provide me with a proposed diff.

@sabat24
Copy link

sabat24 commented Apr 19, 2021

I mentioned about it because I use Doctrine DBAL v.2.1.0 (lately v 3.x was released but I didn't check if problem still appears there) and your dependencies allow that. Which leads to a problem that you use following code to throw an exception: throw new UniqueConstraintViolationException('Unique value can not be NULL');

The UniqueConstraintViolationException extends finally Doctrine\DBAL\Exception\DriverException which has got following constructor

    /**
     * @param string                                $message         The exception message.
     * @param \Doctrine\DBAL\Driver\DriverException $driverException The DBAL driver exception to chain.
     */
    public function __construct($message, \Doctrine\DBAL\Driver\DriverException $driverException)

So there is a missing second parameter in your code.

@webbertakken
Copy link
Author

I'm going to call this deprecated in favour of the 7.1 branch of Symfony core.

@fabpot closed symfony/symfony#22592 (comment) as completed in symfony/symfony@5bc490c 1 hour ago

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment