Last active
May 2, 2024 10:21
-
-
Save webbertakken/569409670bfc7c079e276f79260105ed to your computer and use it in GitHub Desktop.
DataTransferObject-UniqueEntityConstraint
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
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.
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
@sabat24 I could update the example if you provide me with a proposed diff.