diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 297904b5e7293..8e06d5873506a 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 6.1 --- + * Add the `fields` option to the `Unique` constraint, to define which collection keys should be checked for uniqueness * Deprecate `Constraint::$errorNames`, use `Constraint::ERROR_NAMES` instead * Deprecate constraint `ExpressionLanguageSyntax`, use `ExpressionSyntax` instead * Add method `__toString()` to `ConstraintViolationInterface` & `ConstraintViolationListInterface` diff --git a/src/Symfony/Component/Validator/Constraints/Unique.php b/src/Symfony/Component/Validator/Constraints/Unique.php index a18807e15239c..83c961d6174bf 100644 --- a/src/Symfony/Component/Validator/Constraints/Unique.php +++ b/src/Symfony/Component/Validator/Constraints/Unique.php @@ -25,6 +25,8 @@ class Unique extends Constraint { public const IS_NOT_UNIQUE = '7911c98d-b845-4da0-94b7-a8dac36bc55a'; + public array|string $fields = []; + protected const ERROR_NAMES = [ self::IS_NOT_UNIQUE => 'IS_NOT_UNIQUE', ]; @@ -37,17 +39,24 @@ class Unique extends Constraint public $message = 'This collection should contain only unique elements.'; public $normalizer; + /** + * {@inheritdoc} + * + * @param array|string $fields the combination of fields that must contain unique values or a set of options + */ public function __construct( array $options = null, string $message = null, callable $normalizer = null, array $groups = null, - mixed $payload = null + mixed $payload = null, + array|string $fields = null, ) { parent::__construct($options, $groups, $payload); $this->message = $message ?? $this->message; $this->normalizer = $normalizer ?? $this->normalizer; + $this->fields = $fields ?? $this->fields; if (null !== $this->normalizer && !\is_callable($this->normalizer)) { throw new InvalidArgumentException(sprintf('The "normalizer" option must be a valid callable ("%s" given).', get_debug_type($this->normalizer))); diff --git a/src/Symfony/Component/Validator/Constraints/UniqueValidator.php b/src/Symfony/Component/Validator/Constraints/UniqueValidator.php index c132f497709e4..c47c63bb6e6a8 100644 --- a/src/Symfony/Component/Validator/Constraints/UniqueValidator.php +++ b/src/Symfony/Component/Validator/Constraints/UniqueValidator.php @@ -30,6 +30,8 @@ public function validate(mixed $value, Constraint $constraint) throw new UnexpectedTypeException($constraint, Unique::class); } + $fields = (array) $constraint->fields; + if (null === $value) { return; } @@ -41,6 +43,10 @@ public function validate(mixed $value, Constraint $constraint) $collectionElements = []; $normalizer = $this->getNormalizer($constraint); foreach ($value as $element) { + if ($fields && !$element = $this->reduceElementKeys($fields, $element)) { + continue; + } + $element = $normalizer($element); if (\in_array($element, $collectionElements, true)) { @@ -65,4 +71,19 @@ private function getNormalizer(Unique $unique): callable return $unique->normalizer; } + + private function reduceElementKeys(array $fields, array $element): array + { + $output = []; + foreach ($fields as $field) { + if (!\is_string($field)) { + throw new UnexpectedTypeException($field, 'string'); + } + if (isset($element[$field])) { + $output[$field] = $element[$field]; + } + } + + return $output; + } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php index 3448dc2387bd2..2f045ccf64ff7 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php @@ -13,12 +13,13 @@ use Symfony\Component\Validator\Constraints\Unique; use Symfony\Component\Validator\Constraints\UniqueValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; use Symfony\Component\Validator\Exception\UnexpectedValueException; use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; class UniqueValidatorTest extends ConstraintValidatorTestCase { - protected function createValidator() + protected function createValidator(): UniqueValidator { return new UniqueValidator(); } @@ -153,15 +154,15 @@ public function testExpectsNonUniqueObjects($callback) ->assertRaised(); } - public function getCallback() + public function getCallback(): array { return [ - yield 'static function' => [static function (\stdClass $object) { + 'static function' => [static function (\stdClass $object) { return [$object->name, $object->email]; }], - yield 'callable with string notation' => ['Symfony\Component\Validator\Tests\Constraints\CallableClass::execute'], - yield 'callable with static notation' => [[CallableClass::class, 'execute']], - yield 'callable with object' => [[new CallableClass(), 'execute']], + 'callable with string notation' => ['Symfony\Component\Validator\Tests\Constraints\CallableClass::execute'], + 'callable with static notation' => [[CallableClass::class, 'execute']], + 'callable with object' => [[new CallableClass(), 'execute']], ]; } @@ -220,6 +221,67 @@ public function testExpectsValidCaseInsensitiveComparison() $this->assertNoViolation(); } + + public function testCollectionFieldsAreOptional() + { + $this->validator->validate([['value' => 5], ['id' => 1, 'value' => 6]], new Unique(fields: 'id')); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getInvalidFieldNames + */ + public function testCollectionFieldNamesMustBeString(string $type, mixed $field) + { + $this->expectException(UnexpectedTypeException::class); + $this->expectExceptionMessage(sprintf('Expected argument of type "string", "%s" given', $type)); + + $this->validator->validate([['value' => 5], ['id' => 1, 'value' => 6]], new Unique(fields: [$field])); + } + + public function getInvalidFieldNames(): array + { + return [ + ['stdClass', new \stdClass()], + ['int', 2], + ['bool', false], + ]; + } + + /** + * @dataProvider getInvalidCollectionValues + */ + public function testInvalidCollectionValues(array $value, array $fields) + { + $this->validator->validate($value, new Unique([ + 'message' => 'myMessage', + ], fields: $fields)); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', 'array') + ->setCode(Unique::IS_NOT_UNIQUE) + ->assertRaised(); + } + + public function getInvalidCollectionValues(): array + { + return [ + 'unique string' => [[ + ['lang' => 'eng', 'translation' => 'hi'], + ['lang' => 'eng', 'translation' => 'hello'], + ], ['lang']], + 'unique floats' => [[ + ['latitude' => 51.509865, 'longitude' => -0.118092, 'poi' => 'capital'], + ['latitude' => 52.520008, 'longitude' => 13.404954], + ['latitude' => 51.509865, 'longitude' => -0.118092], + ], ['latitude', 'longitude']], + 'unique int' => [[ + ['id' => 1, 'email' => 'bar@email.com'], + ['id' => 1, 'email' => 'foo@email.com'], + ], ['id']], + ]; + } } class CallableClass