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], [],
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: