Skip to content

Instantly share code, notes, and snippets.

@ker0x
Created July 16, 2023 22:16
Show Gist options
  • Save ker0x/cd1d85f216998d72c42f5aea176b7500 to your computer and use it in GitHub Desktop.
Save ker0x/cd1d85f216998d72c42f5aea176b7500 to your computer and use it in GitHub Desktop.
Symfony UniqueDto Constraint
<?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;
}
}
<?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