diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 32ac5c86415ef..337534645ffe2 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * `IbanValidator` accepts IBANs containing non-breaking and narrow non-breaking spaces * Make `PasswordStrengthValidator::estimateStrength()` public * Add the `Yaml` constraint for validating YAML content + * Add `errorPath` to Unique constraint 7.1 --- diff --git a/src/Symfony/Component/Validator/Constraints/Unique.php b/src/Symfony/Component/Validator/Constraints/Unique.php index c759fac634349..7e4d6f626cef7 100644 --- a/src/Symfony/Component/Validator/Constraints/Unique.php +++ b/src/Symfony/Component/Validator/Constraints/Unique.php @@ -25,6 +25,7 @@ class Unique extends Constraint public const IS_NOT_UNIQUE = '7911c98d-b845-4da0-94b7-a8dac36bc55a'; public array|string $fields = []; + public ?string $errorPath = null; protected const ERROR_NAMES = [ self::IS_NOT_UNIQUE => 'IS_NOT_UNIQUE', @@ -46,12 +47,14 @@ public function __construct( ?array $groups = null, mixed $payload = null, array|string|null $fields = null, + ?string $errorPath = null, ) { parent::__construct($options, $groups, $payload); $this->message = $message ?? $this->message; $this->normalizer = $normalizer ?? $this->normalizer; $this->fields = $fields ?? $this->fields; + $this->errorPath = $errorPath ?? $this->errorPath; 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 e62c94179ecb4..e188e3f250101 100644 --- a/src/Symfony/Component/Validator/Constraints/UniqueValidator.php +++ b/src/Symfony/Component/Validator/Constraints/UniqueValidator.php @@ -39,7 +39,7 @@ public function validate(mixed $value, Constraint $constraint): void $collectionElements = []; $normalizer = $this->getNormalizer($constraint); - foreach ($value as $element) { + foreach ($value as $index => $element) { $element = $normalizer($element); if ($fields && !$element = $this->reduceElementKeys($fields, $element)) { @@ -48,6 +48,7 @@ public function validate(mixed $value, Constraint $constraint): void if (\in_array($element, $collectionElements, true)) { $this->context->buildViolation($constraint->message) + ->atPath("[$index]".(null !== $constraint->errorPath ? ".{$constraint->errorPath}" : '')) ->setParameter('{{ value }}', $this->formatValue($value)) ->setCode(Unique::IS_NOT_UNIQUE) ->addViolation(); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php index 3c2dd9f21c98f..0382eb5b6198a 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php @@ -61,7 +61,7 @@ public static function getValidValues() /** * @dataProvider getInvalidValues */ - public function testInvalidValues($value) + public function testInvalidValues($value, string $expectedErrorPath) { $constraint = new Unique([ 'message' => 'myMessage', @@ -71,6 +71,7 @@ public function testInvalidValues($value) $this->buildViolation('myMessage') ->setParameter('{{ value }}', 'array') ->setCode(Unique::IS_NOT_UNIQUE) + ->atPath($expectedErrorPath) ->assertRaised(); } @@ -79,12 +80,12 @@ public static function getInvalidValues() $object = new \stdClass(); return [ - yield 'not unique booleans' => [[true, true]], - yield 'not unique integers' => [[1, 2, 3, 3]], - yield 'not unique floats' => [[0.1, 0.2, 0.1]], - yield 'not unique string' => [['a', 'b', 'a']], - yield 'not unique arrays' => [[[1, 1], [2, 3], [1, 1]]], - yield 'not unique objects' => [[$object, $object]], + yield 'not unique booleans' => [[true, true], 'property.path[1]'], + yield 'not unique integers' => [[1, 2, 3, 3], 'property.path[3]'], + yield 'not unique floats' => [[0.1, 0.2, 0.1], 'property.path[2]'], + yield 'not unique string' => [['a', 'b', 'a'], 'property.path[2]'], + yield 'not unique arrays' => [[[1, 1], [2, 3], [1, 1]], 'property.path[2]'], + yield 'not unique objects' => [[$object, $object], 'property.path[1]'], ]; } @@ -96,6 +97,7 @@ public function testInvalidValueNamed() $this->buildViolation('myMessage') ->setParameter('{{ value }}', 'array') ->setCode(Unique::IS_NOT_UNIQUE) + ->atPath('property.path[3]') ->assertRaised(); } @@ -152,6 +154,7 @@ public function testExpectsNonUniqueObjects($callback) $this->buildViolation('myMessage') ->setParameter('{{ value }}', 'array') ->setCode(Unique::IS_NOT_UNIQUE) + ->atPath('property.path[2]') ->assertRaised(); } @@ -176,6 +179,7 @@ public function testExpectsInvalidNonStrictComparison() $this->buildViolation('myMessage') ->setParameter('{{ value }}', 'array') ->setCode(Unique::IS_NOT_UNIQUE) + ->atPath('property.path[1]') ->assertRaised(); } @@ -202,6 +206,7 @@ public function testExpectsInvalidCaseInsensitiveComparison() $this->buildViolation('myMessage') ->setParameter('{{ value }}', 'array') ->setCode(Unique::IS_NOT_UNIQUE) + ->atPath('property.path[1]') ->assertRaised(); } @@ -246,7 +251,7 @@ public static function getInvalidFieldNames(): array /** * @dataProvider getInvalidCollectionValues */ - public function testInvalidCollectionValues(array $value, array $fields) + public function testInvalidCollectionValues(array $value, array $fields, string $expectedErrorPath) { $this->validator->validate($value, new Unique([ 'message' => 'myMessage', @@ -255,6 +260,7 @@ public function testInvalidCollectionValues(array $value, array $fields) $this->buildViolation('myMessage') ->setParameter('{{ value }}', 'array') ->setCode(Unique::IS_NOT_UNIQUE) + ->atPath($expectedErrorPath) ->assertRaised(); } @@ -264,23 +270,25 @@ public static function getInvalidCollectionValues(): array 'unique string' => [[ ['lang' => 'eng', 'translation' => 'hi'], ['lang' => 'eng', 'translation' => 'hello'], - ], ['lang']], + ], ['lang'], 'property.path[1]'], 'unique floats' => [[ ['latitude' => 51.509865, 'longitude' => -0.118092, 'poi' => 'capital'], ['latitude' => 52.520008, 'longitude' => 13.404954], ['latitude' => 51.509865, 'longitude' => -0.118092], - ], ['latitude', 'longitude']], + ], ['latitude', 'longitude'], 'property.path[2]'], 'unique int' => [[ ['id' => 1, 'email' => 'bar@email.com'], ['id' => 1, 'email' => 'foo@email.com'], - ], ['id']], + ], ['id'], 'property.path[1]'], 'unique null' => [ [null, null], [], + 'property.path[1]', ], 'unique field null' => [ [['nullField' => null], ['nullField' => null]], ['nullField'], + 'property.path[1]', ], ]; } @@ -308,6 +316,90 @@ public function testArrayOfObjectsUnique() $this->assertNoViolation(); } + public function testErrorPath() + { + $array = [ + new DummyClassOne(), + new DummyClassOne(), + new DummyClassOne(), + ]; + + $array[0]->code = 'a1'; + $array[1]->code = 'a2'; + $array[2]->code = 'a1'; + + $this->validator->validate( + $array, + new Unique( + normalizer: [self::class, 'normalizeDummyClassOne'], + fields: 'code', + errorPath: 'code', + ) + ); + + $this->buildViolation('This collection should contain only unique elements.') + ->setParameter('{{ value }}', 'array') + ->setCode(Unique::IS_NOT_UNIQUE) + ->atPath('property.path[2].code') + ->assertRaised(); + } + + public function testErrorPathWithIteratorAggregate() + { + $array = new \ArrayObject([ + new DummyClassOne(), + new DummyClassOne(), + new DummyClassOne(), + ]); + + $array[0]->code = 'a1'; + $array[1]->code = 'a2'; + $array[2]->code = 'a1'; + + $this->validator->validate( + $array, + new Unique( + normalizer: [self::class, 'normalizeDummyClassOne'], + fields: 'code', + errorPath: 'code', + ) + ); + + $this->buildViolation('This collection should contain only unique elements.') + ->setParameter('{{ value }}', 'object') + ->setCode(Unique::IS_NOT_UNIQUE) + ->atPath('property.path[2].code') + ->assertRaised(); + } + + public function testErrorPathWithNonList() + { + $array = [ + 'a' => new DummyClassOne(), + 'b' => new DummyClassOne(), + 'c' => new DummyClassOne(), + ]; + + $array['a']->code = 'a1'; + $array['b']->code = 'a2'; + $array['c']->code = 'a1'; + + $this->validator->validate( + $array, + new Unique( + normalizer: [self::class, 'normalizeDummyClassOne'], + fields: 'code', + errorPath: 'code', + ) + ); + + $this->buildViolation('This collection should contain only unique elements.') + ->setParameter('{{ value }}', 'array') + ->setCode(Unique::IS_NOT_UNIQUE) + ->atPath('property.path[c].code') + ->assertRaised(); + } + public static function normalizeDummyClassOne(DummyClassOne $obj): array { return [