-
-
Save webbertakken/569409670bfc7c079e276f79260105ed to your computer and use it in GitHub Desktop.
<?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, 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
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.
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)
@sabat24 I could update the example if you provide me with a proposed diff.
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
Example usage:
fieldMapping
should hold a property from the DTO as key and a property from the Entity as value.