Created
July 16, 2023 22:16
-
-
Save ker0x/cd1d85f216998d72c42f5aea176b7500 to your computer and use it in GitHub Desktop.
Symfony UniqueDto Constraint
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 | |
declare(strict_types=1); | |
namespace App\Shared\Infrastructure\Validator\Constraints; | |
use Symfony\Component\Validator\Attribute\HasNamedArguments; | |
use Symfony\Component\Validator\Constraint; | |
use Symfony\Component\Validator\Exception\ConstraintDefinitionException; | |
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] | |
final class UniqueDto extends Constraint | |
{ | |
/** | |
* @param array<int|string, string> $fields | |
* @param class-string<T> $entityClass | |
* @param array<int|string, string>|null $idFields | |
* | |
* @template T of object | |
*/ | |
#[HasNamedArguments] | |
public function __construct( | |
public array $fields, | |
public string $entityClass, | |
public string $message = 'This value is already used.', | |
public ?string $errorPath = null, | |
public ?string $entityField = null, | |
public ?array $idFields = null, | |
public ?string $entityManager = null, | |
mixed $options = null, | |
array $groups = null, | |
mixed $payload = null, | |
) { | |
if (null !== $this->entityField && null !== $this->idFields) { | |
throw new ConstraintDefinitionException('Cannot define both entityField and idFields.'); | |
} | |
if (null !== $this->idFields && \count($this->idFields) < 1) { | |
throw new ConstraintDefinitionException('Please specify at least one id field to check.'); | |
} | |
parent::__construct($options, $groups, $payload); | |
} | |
public function getTargets(): string | |
{ | |
return self::CLASS_CONSTRAINT; | |
} | |
} |
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 | |
declare(strict_types=1); | |
namespace App\Shared\Infrastructure\Validator\Constraints; | |
use Doctrine\Common\Collections\Criteria; | |
use Doctrine\Common\Collections\Expr\Comparison; | |
use Doctrine\Common\Collections\Expr\Value; | |
use Doctrine\Common\Collections\Selectable; | |
use Doctrine\Persistence\ManagerRegistry; | |
use Doctrine\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; | |
final class UniqueDtoValidator extends ConstraintValidator | |
{ | |
private \Closure $privatePropertyAccessor; | |
public function __construct( | |
private readonly ManagerRegistry $registry, | |
private readonly PropertyAccessorInterface $propertyAccessor, | |
) { | |
} | |
public function validate(mixed $value, Constraint $constraint): void | |
{ | |
if (!$constraint instanceof UniqueDto) { | |
throw new UnexpectedTypeException($constraint, UniqueDto::class); | |
} | |
if (!\is_object($value)) { | |
throw new UnexpectedTypeException($value, 'object'); | |
} | |
$this->privatePropertyAccessor = \Closure::bind( | |
function (string $property) { | |
return $this->{$property}; | |
}, | |
$value, | |
$value::class | |
); | |
$fields = $this->normalizeFields($constraint->fields); | |
$values = $this->getFieldValues($fields, $value); | |
$criteria = $this->addComparisonsToCriteria( | |
Criteria::create(), | |
$this->buildComparisons($fields, $values) | |
); | |
$idFields = []; | |
$idValues = []; | |
if (null !== $constraint->entityField) { | |
$entity = $this->getPropertyValue($value, $constraint->entityField); | |
if (\is_object($entity)) { | |
$entityManager = $this->getObjectManager($constraint); | |
$metadata = $entityManager->getClassMetadata($constraint->entityClass); | |
// Fake that we have properties directly in DTO | |
$idFields = $this->normalizeFields($metadata->getIdentifierFieldNames()); | |
$idValues = $metadata->getIdentifierValues($entity); | |
} | |
} elseif (null !== $constraint->idFields) { | |
$idFields = $this->normalizeFields($constraint->idFields); | |
$idValues = $this->getFieldValues($idFields, $value); | |
} | |
if ([] !== $idFields) { | |
$idComparisons = $this->buildComparisons($idFields, $idValues, true); | |
$this->addComparisonsToCriteria($criteria, $idComparisons); | |
} | |
$results = $this->getRepository($constraint, $entityManager ?? null)->matching($criteria); | |
$resultsCount = $results->count(); | |
if (0 === $resultsCount) { | |
return; | |
} | |
$builder = $this->context->buildViolation($constraint->message); | |
$errorPath = $constraint->errorPath; | |
if (1 === \count($constraint->fields)) { | |
$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 (null === $errorPath) { | |
$errorPath = key($values); | |
} | |
} | |
$builder | |
->atPath((string) $errorPath) | |
->addViolation(); | |
} | |
private function getPropertyValue(object $object, string $property): mixed | |
{ | |
if ($this->propertyAccessor->isReadable($object, $property)) { | |
return $this->propertyAccessor->getValue($object, $property); | |
} | |
$accessor = $this->privatePropertyAccessor; | |
return $accessor($property); | |
} | |
private function getRepository(UniqueDto $constraint, ObjectManager $entityManager = null): Selectable | |
{ | |
if (null === $entityManager) { | |
$entityManager = $this->getObjectManager($constraint); | |
} | |
$repository = $entityManager->getRepository($constraint->entityClass); | |
if (!$repository instanceof Selectable) { | |
throw new \LogicException(sprintf('%s does not implement %s which is required for UniqueDto validation', $repository::class, Selectable::class)); | |
} | |
return $repository; | |
} | |
private function getObjectManager(UniqueDto $constraint): ObjectManager | |
{ | |
if (null !== $constraint->entityManager) { | |
return $this->registry->getManager($constraint->entityManager); | |
} | |
$entityManager = $this->registry->getManagerForClass($constraint->entityClass); | |
if (null === $entityManager) { | |
throw new ConstraintDefinitionException(sprintf('Class "%s" is not managed by doctrine', $constraint->entityClass)); | |
} | |
return $entityManager; | |
} | |
/** | |
* @param array<int|string, string> $initial | |
* | |
* @return array<string, string> | |
*/ | |
private function normalizeFields(array $initial): array | |
{ | |
$normalized = []; | |
foreach ($initial as $dtoProperty => $entityProperty) { | |
if (is_numeric($dtoProperty)) { | |
$dtoProperty = $entityProperty; | |
} | |
$normalized[$dtoProperty] = $entityProperty; | |
} | |
return $normalized; | |
} | |
/** | |
* @param array<string, string> $fields | |
* | |
* @return array<int|string, mixed> | |
*/ | |
private function getFieldValues(array $fields, object $object): array | |
{ | |
$values = []; | |
foreach ($fields as $dtoProperty => $entityProperty) { | |
$values[$dtoProperty] = $this->getPropertyValue($object, $dtoProperty); | |
} | |
return $values; | |
} | |
/** | |
* @param array<string, string> $fields | |
* @param array<string, mixed> $values | |
* | |
* @return Comparison[] | |
*/ | |
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; | |
} | |
/** | |
* @param Comparison[] $comparisons | |
*/ | |
private function addComparisonsToCriteria(Criteria $criteria, array $comparisons): Criteria | |
{ | |
if (1 === \count($comparisons)) { | |
return $criteria->andWhere($comparisons[0]); | |
} | |
return $criteria->andWhere(Criteria::expr()->andX(...$comparisons)); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment