From 5072a751760a879dc02315a063a0cad800017e6b Mon Sep 17 00:00:00 2001 From: Wojciech Kania Date: Sun, 18 Oct 2020 18:44:00 +0200 Subject: [PATCH] [DoctrineBridge][Validator] Allow validating every class against unique entity constraint --- src/Symfony/Bridge/Doctrine/CHANGELOG.md | 5 + .../Tests/Fixtures/CreateDoubleNameEntity.php | 24 ++ .../Bridge/Doctrine/Tests/Fixtures/Dto.php | 17 ++ .../Tests/Fixtures/HireAnEmployee.php | 22 ++ .../Fixtures/UpdateCompositeIntIdEntity.php | 26 ++ ...pdateCompositeObjectNoToStringIdEntity.php | 44 ++++ .../Tests/Fixtures/UpdateEmployeeProfile.php | 24 ++ .../Constraints/UniqueEntityValidatorTest.php | 245 ++++++++++++++++++ .../Validator/Constraints/UniqueEntity.php | 3 + .../Constraints/UniqueEntityValidator.php | 106 ++++++-- 10 files changed, 494 insertions(+), 22 deletions(-) create mode 100644 src/Symfony/Bridge/Doctrine/Tests/Fixtures/CreateDoubleNameEntity.php create mode 100644 src/Symfony/Bridge/Doctrine/Tests/Fixtures/Dto.php create mode 100644 src/Symfony/Bridge/Doctrine/Tests/Fixtures/HireAnEmployee.php create mode 100644 src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateCompositeIntIdEntity.php create mode 100644 src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateCompositeObjectNoToStringIdEntity.php create mode 100644 src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateEmployeeProfile.php diff --git a/src/Symfony/Bridge/Doctrine/CHANGELOG.md b/src/Symfony/Bridge/Doctrine/CHANGELOG.md index 754e6938da402..d4e9c6f6de642 100644 --- a/src/Symfony/Bridge/Doctrine/CHANGELOG.md +++ b/src/Symfony/Bridge/Doctrine/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.1 +--- + +* Allow validating every class against `UniqueEntity` constraint + 7.0 --- diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/CreateDoubleNameEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/CreateDoubleNameEntity.php new file mode 100644 index 0000000000000..421b67c5c1d77 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/CreateDoubleNameEntity.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +class CreateDoubleNameEntity +{ + public $primaryName; + public $secondaryName; + + public function __construct($primaryName, $secondaryName) + { + $this->primaryName = $primaryName; + $this->secondaryName = $secondaryName; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Dto.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Dto.php new file mode 100644 index 0000000000000..1a9444324496b --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/Dto.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +class Dto +{ + public string $foo; +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/HireAnEmployee.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/HireAnEmployee.php new file mode 100644 index 0000000000000..4ef9d610077a8 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/HireAnEmployee.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +class HireAnEmployee +{ + public $name; + + public function __construct($name) + { + $this->name = $name; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateCompositeIntIdEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateCompositeIntIdEntity.php new file mode 100644 index 0000000000000..3c134e084bea7 --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateCompositeIntIdEntity.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +class UpdateCompositeIntIdEntity +{ + public $id1; + public $id2; + public $name; + + public function __construct($id1, $id2, $name) + { + $this->id1 = $id1; + $this->id2 = $id2; + $this->name = $name; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateCompositeObjectNoToStringIdEntity.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateCompositeObjectNoToStringIdEntity.php new file mode 100644 index 0000000000000..4b18c54044aee --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateCompositeObjectNoToStringIdEntity.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +class UpdateCompositeObjectNoToStringIdEntity +{ + /** + * @var SingleIntIdNoToStringEntity + */ + protected $object1; + + /** + * @var SingleIntIdNoToStringEntity + */ + protected $object2; + + public $name; + + public function __construct(SingleIntIdNoToStringEntity $object1, SingleIntIdNoToStringEntity $object2, $name) + { + $this->object1 = $object1; + $this->object2 = $object2; + $this->name = $name; + } + + public function getObject1(): SingleIntIdNoToStringEntity + { + return $this->object1; + } + + public function getObject2(): SingleIntIdNoToStringEntity + { + return $this->object2; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateEmployeeProfile.php b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateEmployeeProfile.php new file mode 100644 index 0000000000000..92c1d56a90e8d --- /dev/null +++ b/src/Symfony/Bridge/Doctrine/Tests/Fixtures/UpdateEmployeeProfile.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bridge\Doctrine\Tests\Fixtures; + +class UpdateEmployeeProfile +{ + public $id; + public $name; + + public function __construct($id, $name) + { + $this->id = $id; + $this->name = $name; + } +} diff --git a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php index aaae5ede92cf1..abe31d474dfab 100644 --- a/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php +++ b/src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php @@ -25,9 +25,12 @@ use Symfony\Bridge\Doctrine\Tests\Fixtures\AssociationEntity2; use Symfony\Bridge\Doctrine\Tests\Fixtures\CompositeIntIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\CompositeObjectNoToStringIdEntity; +use Symfony\Bridge\Doctrine\Tests\Fixtures\CreateDoubleNameEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\DoubleNameEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\DoubleNullableNameEntity; +use Symfony\Bridge\Doctrine\Tests\Fixtures\Dto; use Symfony\Bridge\Doctrine\Tests\Fixtures\Employee; +use Symfony\Bridge\Doctrine\Tests\Fixtures\HireAnEmployee; use Symfony\Bridge\Doctrine\Tests\Fixtures\Person; use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleIntIdNoToStringEntity; @@ -35,6 +38,9 @@ use Symfony\Bridge\Doctrine\Tests\Fixtures\SingleStringIdEntity; use Symfony\Bridge\Doctrine\Tests\Fixtures\Type\StringWrapper; use Symfony\Bridge\Doctrine\Tests\Fixtures\Type\StringWrapperType; +use Symfony\Bridge\Doctrine\Tests\Fixtures\UpdateCompositeIntIdEntity; +use Symfony\Bridge\Doctrine\Tests\Fixtures\UpdateCompositeObjectNoToStringIdEntity; +use Symfony\Bridge\Doctrine\Tests\Fixtures\UpdateEmployeeProfile; use Symfony\Bridge\Doctrine\Tests\TestRepositoryFactory; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator; @@ -943,4 +949,243 @@ public function rewind(): void }], ]; } + + public function testValidateDTOUniqueness() + { + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['name'], + 'em' => self::EM_NAME, + 'entityClass' => Person::class, + ]); + + $entity = new Person(1, 'Foo'); + $dto = new HireAnEmployee('Foo'); + + $this->validator->validate($entity, $constraint); + + $this->assertNoViolation(); + + $this->em->persist($entity); + $this->em->flush(); + + $this->validator->validate($entity, $constraint); + + $this->assertNoViolation(); + + $this->validator->validate($dto, $constraint); + + $this->buildViolation('myMessage') + ->atPath('property.path.name') + ->setInvalidValue('Foo') + ->setCode(UniqueEntity::NOT_UNIQUE_ERROR) + ->setCause([$entity]) + ->setParameters(['{{ value }}' => '"Foo"']) + ->assertRaised(); + } + + public function testValidateMappingOfFieldNames() + { + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['primaryName' => 'name', 'secondaryName' => 'name2'], + 'em' => self::EM_NAME, + 'entityClass' => DoubleNameEntity::class, + ]); + + $entity = new DoubleNameEntity(1, 'Foo', 'Bar'); + $dto = new CreateDoubleNameEntity('Foo', 'Bar'); + + $this->em->persist($entity); + $this->em->flush(); + + $this->validator->validate($dto, $constraint); + + $this->buildViolation('myMessage') + ->atPath('property.path.name') + ->setParameter('{{ value }}', '"Foo"') + ->setInvalidValue('Foo') + ->setCause([$entity]) + ->setCode(UniqueEntity::NOT_UNIQUE_ERROR) + ->assertRaised(); + } + + public function testInvalidateDTOFieldName() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The field "primaryName" is not a property of class "Symfony\Bridge\Doctrine\Tests\Fixtures\HireAnEmployee".'); + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['primaryName' => 'name'], + 'em' => self::EM_NAME, + 'entityClass' => SingleStringIdEntity::class, + ]); + + $dto = new HireAnEmployee('Foo'); + $this->validator->validate($dto, $constraint); + } + + public function testInvalidateEntityFieldName() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The field "name2" is not mapped by Doctrine, so it cannot be validated for uniqueness.'); + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['name2'], + 'em' => self::EM_NAME, + 'entityClass' => SingleStringIdEntity::class, + ]); + + $dto = new HireAnEmployee('Foo'); + $this->validator->validate($dto, $constraint); + } + + public function testValidateDTOUniquenessWhenUpdatingEntity() + { + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['name'], + 'em' => self::EM_NAME, + 'entityClass' => Person::class, + 'identifierFieldNames' => ['id'], + ]); + + $entity1 = new Person(1, 'Foo'); + $entity2 = new Person(2, 'Bar'); + + $this->em->persist($entity1); + $this->em->persist($entity2); + $this->em->flush(); + + $dto = new UpdateEmployeeProfile(2, 'Foo'); + + $this->validator->validate($dto, $constraint); + + $this->buildViolation('myMessage') + ->atPath('property.path.name') + ->setInvalidValue('Foo') + ->setCode(UniqueEntity::NOT_UNIQUE_ERROR) + ->setCause([$entity1]) + ->setParameters(['{{ value }}' => '"Foo"']) + ->assertRaised(); + } + + public function testValidateDTOUniquenessWhenUpdatingEntityWithTheSameValue() + { + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['name'], + 'em' => self::EM_NAME, + 'entityClass' => CompositeIntIdEntity::class, + 'identifierFieldNames' => ['id1', 'id2'], + ]); + + $entity = new CompositeIntIdEntity(1, 2, 'Foo'); + + $this->em->persist($entity); + $this->em->flush(); + + $dto = new UpdateCompositeIntIdEntity(1, 2, 'Foo'); + + $this->validator->validate($dto, $constraint); + + $this->assertNoViolation(); + } + + public function testValidateIdentifierMappingOfFieldNames() + { + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['object1' => 'objectOne', 'object2' => 'objectTwo'], + 'em' => self::EM_NAME, + 'entityClass' => CompositeObjectNoToStringIdEntity::class, + 'identifierFieldNames' => ['object1' => 'objectOne', 'object2' => 'objectTwo'], + ]); + + $objectOne = new SingleIntIdNoToStringEntity(1, 'foo'); + $objectTwo = new SingleIntIdNoToStringEntity(2, 'bar'); + + $this->em->persist($objectOne); + $this->em->persist($objectTwo); + $this->em->flush(); + + $entity = new CompositeObjectNoToStringIdEntity($objectOne, $objectTwo); + + $this->em->persist($entity); + $this->em->flush(); + + $dto = new UpdateCompositeObjectNoToStringIdEntity($objectOne, $objectTwo, 'Foo'); + + $this->validator->validate($dto, $constraint); + + $this->assertNoViolation(); + } + + public function testInvalidateMissingIdentifierFieldName() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The "Symfony\Bridge\Doctrine\Tests\Fixtures\CompositeObjectNoToStringIdEntity" entity identifier field names should be "objectOne, objectTwo", not "objectTwo".'); + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['object1' => 'objectOne', 'object2' => 'objectTwo'], + 'em' => self::EM_NAME, + 'entityClass' => CompositeObjectNoToStringIdEntity::class, + 'identifierFieldNames' => ['object2' => 'objectTwo'], + ]); + + $objectOne = new SingleIntIdNoToStringEntity(1, 'foo'); + $objectTwo = new SingleIntIdNoToStringEntity(2, 'bar'); + + $this->em->persist($objectOne); + $this->em->persist($objectTwo); + $this->em->flush(); + + $entity = new CompositeObjectNoToStringIdEntity($objectOne, $objectTwo); + + $this->em->persist($entity); + $this->em->flush(); + + $dto = new UpdateCompositeObjectNoToStringIdEntity($objectOne, $objectTwo, 'Foo'); + $this->validator->validate($dto, $constraint); + } + + public function testUninitializedValueThrowException() + { + $this->expectExceptionMessage('Typed property Symfony\Bridge\Doctrine\Tests\Fixtures\Dto::$foo must not be accessed before initialization'); + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['foo' => 'name'], + 'em' => self::EM_NAME, + 'entityClass' => DoubleNameEntity::class, + ]); + + $entity = new DoubleNameEntity(1, 'Foo', 'Bar'); + $dto = new Dto(); + + $this->em->persist($entity); + $this->em->flush(); + + $this->validator->validate($dto, $constraint); + } + + public function testEntityManagerNullObjectWhenDTO() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('Unable to find the object manager associated with an entity of class "Symfony\Bridge\Doctrine\Tests\Fixtures\Person"'); + $constraint = new UniqueEntity([ + 'message' => 'myMessage', + 'fields' => ['name'], + 'entityClass' => Person::class, + // no "em" option set + ]); + + $this->em = null; + $this->registry = $this->createRegistryMock($this->em); + $this->validator = $this->createValidator(); + $this->validator->initialize($this->context); + + $dto = new HireAnEmployee('Foo'); + + $this->validator->validate($dto, $constraint); + } } diff --git a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntity.php b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntity.php index 02e853016d202..5786d4c224fce 100644 --- a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntity.php +++ b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntity.php @@ -35,6 +35,7 @@ class UniqueEntity extends Constraint public array|string $fields = []; public ?string $errorPath = null; public bool|array|string $ignoreNull = true; + public array $identifierFieldNames = []; /** * @param array|string $fields The combination of fields that must contain unique values or a set of options @@ -54,6 +55,7 @@ public function __construct( ?string $repositoryMethod = null, ?string $errorPath = null, bool|string|array|null $ignoreNull = null, + ?array $identifierFieldNames = null, ?array $groups = null, $payload = null, array $options = [], @@ -73,6 +75,7 @@ public function __construct( $this->repositoryMethod = $repositoryMethod ?? $this->repositoryMethod; $this->errorPath = $errorPath ?? $this->errorPath; $this->ignoreNull = $ignoreNull ?? $this->ignoreNull; + $this->identifierFieldNames = $identifierFieldNames ?? $this->identifierFieldNames; } public function getRequiredOptions(): array diff --git a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php index 4c3216187cb41..25e7bb66ee480 100644 --- a/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php +++ b/src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php @@ -11,8 +11,10 @@ namespace Symfony\Bridge\Doctrine\Validator\Constraints; +use Doctrine\ORM\Mapping\MappingException as ORMMappingException; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\Mapping\ClassMetadata; +use Doctrine\Persistence\Mapping\MappingException as PersistenceMappingException; use Doctrine\Persistence\ObjectManager; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -33,12 +35,10 @@ public function __construct( } /** - * @param object $entity - * * @throws UnexpectedTypeException * @throws ConstraintDefinitionException */ - public function validate(mixed $entity, Constraint $constraint): void + public function validate(mixed $value, Constraint $constraint): void { if (!$constraint instanceof UniqueEntity) { throw new UnexpectedTypeException($constraint, UniqueEntity::class); @@ -58,14 +58,16 @@ public function validate(mixed $entity, Constraint $constraint): void throw new ConstraintDefinitionException('At least one field has to be specified.'); } - if (null === $entity) { + if (null === $value) { return; } - if (!\is_object($entity)) { - throw new UnexpectedValueException($entity, 'object'); + if (!\is_object($value)) { + throw new UnexpectedValueException($value, 'object'); } + $entityClass = $constraint->entityClass ?? $value::class; + if ($constraint->em) { $em = $this->registry->getManager($constraint->em); @@ -73,25 +75,28 @@ public function validate(mixed $entity, Constraint $constraint): void throw new ConstraintDefinitionException(sprintf('Object manager "%s" does not exist.', $constraint->em)); } } else { - $em = $this->registry->getManagerForClass($entity::class); + $em = $this->registry->getManagerForClass($entityClass); if (!$em) { - throw new ConstraintDefinitionException(sprintf('Unable to find the object manager associated with an entity of class "%s".', get_debug_type($entity))); + throw new ConstraintDefinitionException(sprintf('Unable to find the object manager associated with an entity of class "%s".', $entityClass)); } } - $class = $em->getClassMetadata($entity::class); + try { + $em->getRepository($value::class); + $isValueEntity = true; + } catch (ORMMappingException|PersistenceMappingException) { + $isValueEntity = false; + } + + $class = $em->getClassMetadata($entityClass); $criteria = []; $hasIgnorableNullValue = false; - foreach ($fields as $fieldName) { - if (!$class->hasField($fieldName) && !$class->hasAssociation($fieldName)) { - throw new ConstraintDefinitionException(sprintf('The field "%s" is not mapped by Doctrine, so it cannot be validated for uniqueness.', $fieldName)); - } - - $fieldValue = $class->reflFields[$fieldName]->getValue($entity); + $fieldValues = $this->getFieldValues($value, $class, $fields, $isValueEntity); + foreach ($fieldValues as $fieldName => $fieldValue) { if (null === $fieldValue && $this->ignoreNullForField($constraint, $fieldName)) { $hasIgnorableNullValue = true; @@ -128,11 +133,12 @@ public function validate(mixed $entity, Constraint $constraint): void $repository = $em->getRepository($constraint->entityClass); $supportedClass = $repository->getClassName(); - if (!$entity instanceof $supportedClass) { + if ($isValueEntity && !$value instanceof $supportedClass) { + $class = $em->getClassMetadata($value::class); throw new ConstraintDefinitionException(sprintf('The "%s" entity repository does not support the "%s" entity. The entity should be an instance of or extend "%s".', $constraint->entityClass, $class->getName(), $supportedClass)); } } else { - $repository = $em->getRepository($entity::class); + $repository = $em->getRepository($value::class); } $arguments = [$criteria]; @@ -173,12 +179,36 @@ public function validate(mixed $entity, Constraint $constraint): void * which is the same as the entity being validated, the criteria is * unique. */ - if (!$result || (1 === \count($result) && current($result) === $entity)) { + if (!$result || (1 === \count($result) && current($result) === $value)) { return; } - $errorPath = $constraint->errorPath ?? $fields[0]; - $invalidValue = $criteria[$errorPath] ?? $criteria[$fields[0]]; + /* If a single entity matched the query criteria, which is the same as + * the entity being updated by validated object, the criteria is unique. + */ + if (!$isValueEntity && !empty($constraint->identifierFieldNames) && 1 === \count($result)) { + $fieldValues = $this->getFieldValues($value, $class, $constraint->identifierFieldNames); + if (array_values($class->getIdentifierFieldNames()) != array_values($constraint->identifierFieldNames)) { + throw new ConstraintDefinitionException(sprintf('The "%s" entity identifier field names should be "%s", not "%s".', $entityClass, implode(', ', $class->getIdentifierFieldNames()), implode(', ', $constraint->identifierFieldNames))); + } + + $entityMatched = true; + + foreach ($constraint->identifierFieldNames as $identifierFieldName) { + $propertyValue = $this->getPropertyValue($entityClass, $identifierFieldName, current($result)); + if ($fieldValues[$identifierFieldName] !== $propertyValue) { + $entityMatched = false; + break; + } + } + + if ($entityMatched) { + return; + } + } + + $errorPath = $constraint->errorPath ?? current($fields); + $invalidValue = $criteria[$errorPath] ?? $criteria[current($fields)]; $this->context->buildViolation($constraint->message) ->atPath($errorPath) @@ -209,11 +239,11 @@ private function formatWithIdentifiers(ObjectManager $em, ClassMetadata $class, } if ($class->getName() !== $idClass = $value::class) { - // non unique value might be a composite PK that consists of other entity objects + // non-unique value might be a composite PK that consists of other entity objects if ($em->getMetadataFactory()->hasMetadataFor($idClass)) { $identifiers = $em->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 + // 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 = []; } @@ -237,4 +267,36 @@ private function formatWithIdentifiers(ObjectManager $em, ClassMetadata $class, return sprintf('object("%s") identified by (%s)', $idClass, implode(', ', $identifiers)); } + + private function getFieldValues(mixed $object, ClassMetadata $class, array $fields, bool $isValueEntity = false): array + { + if (!$isValueEntity) { + $reflectionObject = new \ReflectionObject($object); + } + + $fieldValues = []; + $objectClass = $object::class; + + foreach ($fields as $objectFieldName => $entityFieldName) { + if (!$class->hasField($entityFieldName) && !$class->hasAssociation($entityFieldName)) { + throw new ConstraintDefinitionException(sprintf('The field "%s" is not mapped by Doctrine, so it cannot be validated for uniqueness.', $entityFieldName)); + } + + $fieldName = \is_int($objectFieldName) ? $entityFieldName : $objectFieldName; + if (!$isValueEntity && !$reflectionObject->hasProperty($fieldName)) { + throw new ConstraintDefinitionException(sprintf('The field "%s" is not a property of class "%s".', $fieldName, $objectClass)); + } + + $fieldValues[$entityFieldName] = $this->getPropertyValue($objectClass, $fieldName, $object); + } + + return $fieldValues; + } + + private function getPropertyValue(string $class, string $name, mixed $object): mixed + { + $property = new \ReflectionProperty($class, $name); + + return $property->getValue($object); + } }