diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index ae1ae20da804d..2b190274dd309 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -13,6 +13,7 @@ CHANGELOG * Add support for multiple fields containing nested constraints in `Composite` constraints * Add the `stopOnFirstError` option to the `Unique` constraint to validate all elements * Add support for closures in the `When` constraint + * Add support for reading objects properties with `Unique` constraint `fields` option 7.2 --- diff --git a/src/Symfony/Component/Validator/Constraints/UniqueValidator.php b/src/Symfony/Component/Validator/Constraints/UniqueValidator.php index bd78cac721d1f..fd0c2a0273902 100644 --- a/src/Symfony/Component/Validator/Constraints/UniqueValidator.php +++ b/src/Symfony/Component/Validator/Constraints/UniqueValidator.php @@ -11,8 +11,13 @@ namespace Symfony\Component\Validator\Constraints; +use Symfony\Component\PropertyAccess\Exception\AccessException; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessor; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\LogicException; use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedValueException; @@ -21,6 +26,11 @@ */ class UniqueValidator extends ConstraintValidator { + public function __construct( + private ?PropertyAccessorInterface $propertyAccessor = null, + ) { + } + public function validate(mixed $value, Constraint $constraint): void { if (!$constraint instanceof Unique) { @@ -72,18 +82,43 @@ private function getNormalizer(Unique $unique): callable return $unique->normalizer ?? static fn ($value) => $value; } - private function reduceElementKeys(array $fields, array $element): array + private function reduceElementKeys(array $fields, array|object $element): array { $output = []; foreach ($fields as $field) { if (!\is_string($field)) { throw new UnexpectedTypeException($field, 'string'); } - if (\array_key_exists($field, $element)) { - $output[$field] = $element[$field]; + + $elementAsArray = null; + // handle public object property + if (\is_object($element) && property_exists($element, $field)) { + $elementAsArray = (array) $element; + } elseif (\is_array($element)) { + $elementAsArray = $element; + } + + if ($elementAsArray && \array_key_exists($field, $elementAsArray)) { + $output[$field] = $elementAsArray[$field]; + continue; + } + + try { + $output[$field] = $this->getPropertyAccessor()->getValue($element, $field); + } catch (AccessException) { + // fields are optional } } return $output; } + + private function getPropertyAccessor(): PropertyAccessor + { + if (!class_exists(PropertyAccess::class)) { + throw new LogicException('Property path requires symfony/property-access package to be installed. Try running "composer require symfony/property-access".'); + } + + return $this->propertyAccessor ??= PropertyAccess::createPropertyAccessor(); + } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php index 12efb76982e24..bf5173e9ad91e 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php @@ -34,9 +34,9 @@ public function testExpectsUniqueConstraintCompatibleType() /** * @dataProvider getValidValues */ - public function testValidValues($value) + public function testValidValues($value, array $fields) { - $this->validator->validate($value, new Unique()); + $this->validator->validate($value, new Unique(fields: $fields)); $this->assertNoViolation(); } @@ -44,17 +44,79 @@ public function testValidValues($value) public static function getValidValues() { return [ - yield 'null' => [[null]], - yield 'empty array' => [[]], - yield 'single integer' => [[5]], - yield 'single string' => [['a']], - yield 'single object' => [[new \stdClass()]], - yield 'unique booleans' => [[true, false]], - yield 'unique integers' => [[1, 2, 3, 4, 5, 6]], - yield 'unique floats' => [[0.1, 0.2, 0.3]], - yield 'unique strings' => [['a', 'b', 'c']], - yield 'unique arrays' => [[[1, 2], [2, 4], [4, 6]]], - yield 'unique objects' => [[new \stdClass(), new \stdClass()]], + yield 'null' => [[null], []], + yield 'empty array' => [[], []], + yield 'single integer' => [[5], []], + yield 'single string' => [['a'], []], + yield 'single object' => [[new \stdClass()], []], + yield 'unique booleans' => [[true, false], []], + yield 'unique integers' => [[1, 2, 3, 4, 5, 6], []], + yield 'unique floats' => [[0.1, 0.2, 0.3], []], + yield 'unique strings' => [['a', 'b', 'c'], []], + yield 'unique arrays' => [[[1, 2], [2, 4], [4, 6]], []], + yield 'unique objects' => [[new \stdClass(), new \stdClass()], []], + yield 'unique objects public field' => [ + [ + new class() { + public int $fieldA = 1; + }, + new class() { + public int $fieldA = 2; + }, + ], + ['fieldA'], + ], + yield 'unique objects private field' => [ + [ + new class() { + private int $fieldB = 1; + + public function getFieldB(): int + { + return $this->fieldB; + } + }, + new class() { + private int $fieldB = 2; + + public function getFieldB(): int + { + return $this->fieldB; + } + }, + ], + ['fieldB'], + ], + yield 'unique objects property accessor field' => [ + [ + new class() { + public array $fieldA = ['fieldB' => 1]; + }, + new class() { + public array $fieldA = ['fieldB' => 2]; + }, + ], + ['fieldA[fieldB]'], + ], + 'unique objects polymorph field' => [ + [ + new class() { + private int $fieldB = 1; + + public function getFieldB(): int + { + return $this->fieldB; + } + }, + new class() { + public int $fieldB = 2; + }, + [ + 'fieldB' => 3, + ], + ], + ['fieldB'], + ], ]; } @@ -215,6 +277,42 @@ public function testCollectionFieldsAreOptional() $this->assertNoViolation(); } + public function testCollectionObjectFieldsAreOptional() + { + $this->validator->validate([ + new class() { + public int $value = 5; + }, + new class() { + public int $id = 1; + public int $value = 5; + }, + ], new Unique(fields: 'id')); + + $this->assertNoViolation(); + } + + public function testCollectionObjectPrivateFieldsAreOptional() + { + $this->validator->validate([ + new class() { + private int $id = 2; + public int $value = 5; + }, + new class() { + private int $id = 2; + public int $value = 5; + + public function getId(): int + { + return $this->id; + } + }, + ], new Unique(fields: 'id')); + + $this->assertNoViolation(); + } + /** * @dataProvider getInvalidFieldNames */ @@ -267,6 +365,65 @@ public static function getInvalidCollectionValues(): array ['id' => 1, 'email' => 'bar@email.com'], ['id' => 1, 'email' => 'foo@email.com'], ], ['id'], 'array'], + 'unique object string' => [[ + (object) ['lang' => 'eng', 'translation' => 'hi'], + (object) ['lang' => 'eng', 'translation' => 'hello'], + ], + ['lang'], 'array'], + 'unique objects public field' => [[ + new class() { + public int $fieldA = 1; + }, + new class() { + public int $fieldA = 1; + }, + ], + ['fieldA'], 'array'], + 'unique objects property accessor field' => [[ + new class() { + public array $fieldA = ['fieldB' => 1]; + }, + new class() { + public array $fieldA = ['fieldB' => 1]; + }, + ], + ['fieldA[fieldB]'], 'array'], + 'unique objects private field' => [[ + new class() { + private int $fieldB = 1; + + public function getFieldB(): int + { + return $this->fieldB; + } + }, + new class() { + private int $fieldB = 1; + + public function getFieldB(): int + { + return $this->fieldB; + } + }, + ], + ['fieldB'], 'array'], + 'unique objects polymorph field' => [[ + new class() { + private int $fieldB = 1; + + public function getFieldB(): int + { + return $this->fieldB; + } + }, + new class() { + public int $fieldB = 1; + }, + [ + 'fieldB' => 1, + ], + ], + ['fieldB'], 'array'], 'unique null' => [ [null, null], [],