Skip to content

Instantly share code, notes, and snippets.

@Xymanek
Last active February 17, 2022 13:52
Show Gist options
  • Save Xymanek/369f468d02069090770a1e4626d9f1e9 to your computer and use it in GitHub Desktop.
Save Xymanek/369f468d02069090770a1e4626d9f1e9 to your computer and use it in GitHub Desktop.
UniqueDto validator

It can

  • properly deal with multi-fields uniqueness checks (and composite PKs)
  • use symfony's property path or directly private properties
  • does not cause hydration when checking
  • allows to optionally map to existing entity (by having it as property) or by mapping id fields (or none at all so any matching record will trigger a failure)
  • allows to re-map fields between DTO and entity (eg. fields={"display_name": "username"}). Combined with sf property accessor this allows for "virtual properties". This also works for id fields
  • load the violating record to show in debug panel. Example http://prntscr.com/i3ifbn
  • not get confused if multiple violating records are found

Things to improve:

  • port $ignoreNull check over from UniqueEntity (I have no idea what it does)
  • check if id fields are actually properly specified
  • tests (will probably need help)
<?php
declare(strict_types=1);
namespace App\Form\Dto;
use App\Validator\Constraints\UniqueDto;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @UniqueDto(entityClass="App\Entity\User", fields={"username"}, message="Username already taken")
* @UniqueDto(entityClass="App\Entity\User", fields={"email"}, message="Email already used")
*/
class RegistrationFormData
{
/**
* @var string|null
*
* @Assert\NotNull()
*/
private $username;
/**
* @var string|null
*
* @Assert\NotNull()
* @Assert\Email()
*/
private $email;
/**
* @var string|null
*
* @Assert\NotNull()
*/
private $password;
/**
* @var string|null
*
* @Assert\IdenticalTo(propertyPath="password", message="password_confirm.no_match")
*/
private $passwordConfirm;
/**
* @return null|string
*/
public function getUsername (): ?string
{
return $this->username;
}
/**
* @param null|string $username
* @return RegistrationFormData
*/
public function setUsername (?string $username): RegistrationFormData
{
$this->username = $username;
return $this;
}
/**
* @return null|string
*/
public function getEmail (): ?string
{
return $this->email;
}
/**
* @param null|string $email
* @return RegistrationFormData
*/
public function setEmail (?string $email): RegistrationFormData
{
$this->email = $email;
return $this;
}
/**
* @return null|string
*/
public function getPassword (): ?string
{
return $this->password;
}
/**
* @param null|string $password
* @return RegistrationFormData
*/
public function setPassword (?string $password): RegistrationFormData
{
$this->password = $password;
return $this;
}
/**
* @return null|string
*/
public function getPasswordConfirm (): ?string
{
return $this->passwordConfirm;
}
/**
* @param null|string $passwordConfirm
* @return RegistrationFormData
*/
public function setPasswordConfirm (?string $passwordConfirm): RegistrationFormData
{
$this->passwordConfirm = $passwordConfirm;
return $this;
}
}
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* @Annotation
* @Target({"CLASS", "ANNOTATION"})
*/
class UniqueDto extends Constraint
{
public $message = 'This value is already used.';
/**
* @var string|null
*/
public $em = null;
/**
* @var string
*/
public $entityClass;
/**
* @var array
*/
public $fields;
/**
* @var string|null
*/
public $errorPath = null;
/**
* @var string|null
*/
public $entityField = null;
/**
* @var array|null
*/
public $idFields = null;
//public $ignoreNull = true; // TODO
public function __construct ($options = null)
{
parent::__construct($options);
if (!is_array($this->fields)) {
throw new UnexpectedTypeException($this->fields, 'array');
}
if (!is_string($this->entityClass)) {
throw new UnexpectedTypeException($this->entityClass, 'string');
}
if ($this->errorPath !== null && !is_string($this->errorPath)) {
throw new UnexpectedTypeException($this->errorPath, 'string or null');
}
if (count($this->fields) < 1) {
throw new ConstraintDefinitionException('Please specify at least one field to check');
}
if ($this->entityField !== null && $this->idFields !== null) {
throw new ConstraintDefinitionException('Cannot define both entityField and idFields');
}
if ($this->entityField !== null && !is_string($this->entityField)) {
throw new UnexpectedTypeException($this->entityField, 'string or null');
}
if ($this->idFields !== null && !is_array($this->idFields)) {
throw new UnexpectedTypeException($this->fields, 'array or null');
}
if ($this->idFields !== null && count($this->idFields) < 1) {
throw new ConstraintDefinitionException('Please specify at least one id field to check');
}
}
public function getRequiredOptions ()
{
return ['entityClass', 'fields'];
}
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Closure;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Expr\Comparison;
use Doctrine\Common\Collections\Expr\Value;
use Doctrine\Common\Collections\Selectable;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class UniqueDtoValidator extends ConstraintValidator
{
/**
* @var ManagerRegistry
*/
private $doctrine;
/**
* @var PropertyAccessorInterface
*/
private $propertyAccessor;
/**
* @var Closure
*/
private $privatePropertyAccessor;
/**
* @var bool
*/
private $debugFailures;
public function __construct (
ManagerRegistry $doctrine,
PropertyAccessorInterface $propertyAccessor,
bool $debugFailures
) {
$this->doctrine = $doctrine;
$this->propertyAccessor = $propertyAccessor;
$this->debugFailures = $debugFailures;
}
public function validate ($object, Constraint $constraint)
{
if (!$constraint instanceof UniqueDto) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\UniqueDto');
}
$this->privatePropertyAccessor = Closure::bind(
function (string $property) {
return $this->{$property};
},
$object,
get_class($object)
);
$fields = $this->normalizeFields($constraint->fields);
$values = $this->getFieldValues($fields, $object);
$criteria = $this->addComparisonsToCriteria(
Criteria::create(),
$this->buildComparisons($fields, $values)
);
$idFields = null;
if ($constraint->entityField !== null) {
$entity = $this->getPropertyValue($object, $constraint->entityField);
if ($entity !== null) {
$em = $this->getManager($constraint);
$metadata = $em->getClassMetadata($constraint->entityClass);
// Fake that we have properties directly in DTO
$idFields = $this->normalizeFields($metadata->getIdentifierFieldNames());
$idValues = $metadata->getIdentifierValues($entity);
}
} elseif ($constraint->idFields !== null) {
$idFields = $this->normalizeFields($constraint->idFields);
$idValues = $this->getFieldValues($idFields, $object);
}
if ($idFields !== null) {
/** @noinspection PhpUndefinedVariableInspection */
$idComparisons = $this->buildComparisons($idFields, $idValues, true);
$this->addComparisonsToCriteria($criteria, $idComparisons);
}
$results = $this->getRepository($constraint, $em ?? null)->matching($criteria);
$resultsCount = $results->count();
if ($resultsCount === 0) {
return;
}
$builder = $this->context->buildViolation($constraint->message);
$errorPath = $constraint->errorPath;
if (count($constraint->fields) === 1) {
$value = current($values);
if (
!is_array($value) && !is_resource($value) &&
(!is_object($value) || $value instanceof \DateTimeInterface || method_exists($value, '__toString'))
) {
$builder->setParameter(
'{{ value }}',
$this->formatValue($value, self::PRETTY_DATE & self::OBJECT_TO_STRING)
);
}
$builder->setInvalidValue($value);
if ($errorPath === null) {
$errorPath = key($values);
}
}
if ($this->debugFailures) {
$builder->setCause($resultsCount === 1 ? $results[0] : $results->toArray());
}
$builder
->atPath($errorPath)
->addViolation();
}
private function getPropertyValue ($object, $property)
{
if ($this->propertyAccessor->isReadable($object, $property)) {
return $this->propertyAccessor->getValue($object, $property);
} else {
$accessor = $this->privatePropertyAccessor;
return $accessor($property);
}
}
private function getRepository (UniqueDto $constraint, ?ObjectManager $manager = null): Selectable
{
if ($manager === null) {
$manager = $this->getManager($constraint);
}
$repository = $manager->getRepository($constraint->entityClass);
if (!$repository instanceof Selectable) {
throw new \LogicException(sprintf(
'%s does not implement %s which is required for UniqueDto validation',
get_class($repository), Selectable::class
));
}
return $repository;
}
private function getManager (UniqueDto $constraint): ObjectManager
{
if ($constraint->em !== null) {
return $this->doctrine->getManager($constraint->em);
}
$em = $this->doctrine->getManagerForClass($constraint->entityClass);
if ($em === null) {
throw new ConstraintDefinitionException(sprintf(
'Class "%s" is not managed by doctrine',
$constraint->entityClass
));
}
return $em;
}
private function normalizeFields (array $initial): array
{
$normalized = [];
foreach ($initial as $dtoProperty => $entityProperty) {
if (is_numeric($dtoProperty)) {
$dtoProperty = $entityProperty;
}
$normalized[$dtoProperty] = $entityProperty;
}
return $normalized;
}
private function getFieldValues (array $fields, $object): array
{
$values = [];
foreach ($fields as $dtoProperty => $entityProperty) {
$values[$dtoProperty] = $this->getPropertyValue($object, $dtoProperty);
}
return $values;
}
private function buildComparisons (array $fields, array $values, bool $negate = false): array
{
$comparisons = [];
foreach ($fields as $dtoProperty => $entityProperty) {
$operation = $negate ? Comparison::NEQ : Comparison::EQ;
$comparisons[] = new Comparison($entityProperty, $operation, new Value($values[$dtoProperty]));
}
return $comparisons;
}
private function addComparisonsToCriteria (Criteria $criteria, array $comparisons): Criteria
{
if (count($comparisons) === 1) {
$criteria->andWhere($comparisons[0]);
} else {
$criteria->andWhere(Criteria::expr()->andX(...$comparisons));
}
return $criteria;
}
}
@allfreelancers
Copy link

@Xymanek how to use this solution?

@arausseop
Copy link

arausseop commented Feb 17, 2022

Hello, you have saved me, I have been looking for a way to implement unique validations using Dto's for some time, thanks for sharing your code, the implementation I had made did not meet all the use cases and this one did.

It would be nice if you showed the DTO configuration to implement this validation.

@allfreelancers a little late I think but maybe someone else will give you some light on how to implement it, I did the following:

inside my validations folder I have a deviceDto.yml file with the following:

App\Form\Model\DeviceDto:
constraints:
-App\Validator\UniqueCustomTestConstraint:
fields: ['deviceIdentifier']
entityClass: App\Entity\Device
idFields: [id]
properties:
deviceIdentifier:
-NotNull:
groups: [Default]
-NotBlank:
groups: [Default]

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