Skip to content

Instantly share code, notes, and snippets.

@webbertakken
Last active May 2, 2024 10:21
Show Gist options
  • Save webbertakken/569409670bfc7c079e276f79260105ed to your computer and use it in GitHub Desktop.
Save webbertakken/569409670bfc7c079e276f79260105ed to your computer and use it in GitHub Desktop.
DataTransferObject-UniqueEntityConstraint
<?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
Copy link
Author

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

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