-
-
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:
fieldMappingshould hold a property from the DTO as key and a property from the Entity as value.