Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit f6d7694

Browse filesBrowse files
committed
feature #42403 [Validator] Define which collection keys should be checked for uniqueness (wkania)
This PR was squashed before being merged into the 6.1 branch. Discussion ---------- [Validator] Define which collection keys should be checked for uniqueness | Q | A | ------------- | --- | Branch? | 5.4 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Fix #9888 | License | MIT | Doc PR | symfony/symfony-docs#16713 Currently, the validator checks each element of the collection as a whole. We already have a custom normalizer (which is great), but it would be nice to be able to check for uniqueness certain [collection](https://symfony.com/doc/current/reference/constraints/Collection.html) keys. For example, some fields in the collection element can be identifiers. They should be unique within the collection, even when the rest of the element data are different. Current state: - validates that all the elements of the given collection are unique New state: - preserve the current state, - all old tests pass (no changes in them), - no breaking changes, - define which collection fields should be checked for uniqueness (optional), - fields are optional in each element of the collection. Use [collection constraints](https://symfony.com/doc/current/reference/constraints/Collection.html) if they are required Examples: 1. Basic example. Each translation of the same resource must be in a different language. ```php use Symfony\Component\Validator\Constraints as Assert; /** * @Assert\Count(min=1), * @Assert\Unique(fields={"language"}), * @Assert\Collection( * fields = { * "language" = { * @Assert\NotBlank, * @Assert\Length(min = 2, max = 2), * @Assert\Language * }, * "title" = { * @Assert\NotBlank, * @Assert\Length(max = 255) * }, * "description" = { * @Assert\NotBlank, * @Assert\Length(max = 255) * } * } * ) */ public array $translations = []; ``` 2. An example where Optional is recognizable. Items with the id are changed and without are new. ```php use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints\Optional; /** * @Assert\Unique(fields={"id"}), * @Assert\Collection( * fields = { * "id" = @Assert\Optional({ * @Assert\Uuid * }), * "name" = { * @Assert\NotBlank, * @Assert\Length(max = 255) * } * } * ) */ public array $items = []; ``` 3. An example with composite uniqueness ```php use Symfony\Component\Validator\Constraints as Assert; /** * @Assert\Unique(fields={"latitude", "longitude"}), * @Assert\Collection( * fields = { * "latitude" = { * @Assert\NotBlank * }, * "longitude" = { * @Assert\NotBlank * }, * "poi" = { * @Assert\Length(max = 255) * } * } * ) */ public array $coordinates = []; ``` Commits ------- 0e8f4ce [Validator] Define which collection keys should be checked for uniqueness
2 parents f1ce841 + 0e8f4ce commit f6d7694
Copy full SHA for f6d7694

File tree

4 files changed

+100
-7
lines changed
Filter options

4 files changed

+100
-7
lines changed

‎src/Symfony/Component/Validator/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
6.1
55
---
66

7+
* Add the `fields` option to the `Unique` constraint, to define which collection keys should be checked for uniqueness
78
* Deprecate `Constraint::$errorNames`, use `Constraint::ERROR_NAMES` instead
89
* Deprecate constraint `ExpressionLanguageSyntax`, use `ExpressionSyntax` instead
910
* Add method `__toString()` to `ConstraintViolationInterface` & `ConstraintViolationListInterface`

‎src/Symfony/Component/Validator/Constraints/Unique.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/Constraints/Unique.php
+10-1Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ class Unique extends Constraint
2525
{
2626
public const IS_NOT_UNIQUE = '7911c98d-b845-4da0-94b7-a8dac36bc55a';
2727

28+
public array|string $fields = [];
29+
2830
protected const ERROR_NAMES = [
2931
self::IS_NOT_UNIQUE => 'IS_NOT_UNIQUE',
3032
];
@@ -37,17 +39,24 @@ class Unique extends Constraint
3739
public $message = 'This collection should contain only unique elements.';
3840
public $normalizer;
3941

42+
/**
43+
* {@inheritdoc}
44+
*
45+
* @param array|string $fields the combination of fields that must contain unique values or a set of options
46+
*/
4047
public function __construct(
4148
array $options = null,
4249
string $message = null,
4350
callable $normalizer = null,
4451
array $groups = null,
45-
mixed $payload = null
52+
mixed $payload = null,
53+
array|string $fields = null,
4654
) {
4755
parent::__construct($options, $groups, $payload);
4856

4957
$this->message = $message ?? $this->message;
5058
$this->normalizer = $normalizer ?? $this->normalizer;
59+
$this->fields = $fields ?? $this->fields;
5160

5261
if (null !== $this->normalizer && !\is_callable($this->normalizer)) {
5362
throw new InvalidArgumentException(sprintf('The "normalizer" option must be a valid callable ("%s" given).', get_debug_type($this->normalizer)));

‎src/Symfony/Component/Validator/Constraints/UniqueValidator.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/Constraints/UniqueValidator.php
+21Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ public function validate(mixed $value, Constraint $constraint)
3030
throw new UnexpectedTypeException($constraint, Unique::class);
3131
}
3232

33+
$fields = (array) $constraint->fields;
34+
3335
if (null === $value) {
3436
return;
3537
}
@@ -41,6 +43,10 @@ public function validate(mixed $value, Constraint $constraint)
4143
$collectionElements = [];
4244
$normalizer = $this->getNormalizer($constraint);
4345
foreach ($value as $element) {
46+
if ($fields && !$element = $this->reduceElementKeys($fields, $element)) {
47+
continue;
48+
}
49+
4450
$element = $normalizer($element);
4551

4652
if (\in_array($element, $collectionElements, true)) {
@@ -65,4 +71,19 @@ private function getNormalizer(Unique $unique): callable
6571

6672
return $unique->normalizer;
6773
}
74+
75+
private function reduceElementKeys(array $fields, array $element): array
76+
{
77+
$output = [];
78+
foreach ($fields as $field) {
79+
if (!\is_string($field)) {
80+
throw new UnexpectedTypeException($field, 'string');
81+
}
82+
if (isset($element[$field])) {
83+
$output[$field] = $element[$field];
84+
}
85+
}
86+
87+
return $output;
88+
}
6889
}

‎src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php
+68-6Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313

1414
use Symfony\Component\Validator\Constraints\Unique;
1515
use Symfony\Component\Validator\Constraints\UniqueValidator;
16+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
1617
use Symfony\Component\Validator\Exception\UnexpectedValueException;
1718
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
1819

1920
class UniqueValidatorTest extends ConstraintValidatorTestCase
2021
{
21-
protected function createValidator()
22+
protected function createValidator(): UniqueValidator
2223
{
2324
return new UniqueValidator();
2425
}
@@ -153,15 +154,15 @@ public function testExpectsNonUniqueObjects($callback)
153154
->assertRaised();
154155
}
155156

156-
public function getCallback()
157+
public function getCallback(): array
157158
{
158159
return [
159-
yield 'static function' => [static function (\stdClass $object) {
160+
'static function' => [static function (\stdClass $object) {
160161
return [$object->name, $object->email];
161162
}],
162-
yield 'callable with string notation' => ['Symfony\Component\Validator\Tests\Constraints\CallableClass::execute'],
163-
yield 'callable with static notation' => [[CallableClass::class, 'execute']],
164-
yield 'callable with object' => [[new CallableClass(), 'execute']],
163+
'callable with string notation' => ['Symfony\Component\Validator\Tests\Constraints\CallableClass::execute'],
164+
'callable with static notation' => [[CallableClass::class, 'execute']],
165+
'callable with object' => [[new CallableClass(), 'execute']],
165166
];
166167
}
167168

@@ -220,6 +221,67 @@ public function testExpectsValidCaseInsensitiveComparison()
220221

221222
$this->assertNoViolation();
222223
}
224+
225+
public function testCollectionFieldsAreOptional()
226+
{
227+
$this->validator->validate([['value' => 5], ['id' => 1, 'value' => 6]], new Unique(fields: 'id'));
228+
229+
$this->assertNoViolation();
230+
}
231+
232+
/**
233+
* @dataProvider getInvalidFieldNames
234+
*/
235+
public function testCollectionFieldNamesMustBeString(string $type, mixed $field)
236+
{
237+
$this->expectException(UnexpectedTypeException::class);
238+
$this->expectExceptionMessage(sprintf('Expected argument of type "string", "%s" given', $type));
239+
240+
$this->validator->validate([['value' => 5], ['id' => 1, 'value' => 6]], new Unique(fields: [$field]));
241+
}
242+
243+
public function getInvalidFieldNames(): array
244+
{
245+
return [
246+
['stdClass', new \stdClass()],
247+
['int', 2],
248+
['bool', false],
249+
];
250+
}
251+
252+
/**
253+
* @dataProvider getInvalidCollectionValues
254+
*/
255+
public function testInvalidCollectionValues(array $value, array $fields)
256+
{
257+
$this->validator->validate($value, new Unique([
258+
'message' => 'myMessage',
259+
], fields: $fields));
260+
261+
$this->buildViolation('myMessage')
262+
->setParameter('{{ value }}', 'array')
263+
->setCode(Unique::IS_NOT_UNIQUE)
264+
->assertRaised();
265+
}
266+
267+
public function getInvalidCollectionValues(): array
268+
{
269+
return [
270+
'unique string' => [[
271+
['lang' => 'eng', 'translation' => 'hi'],
272+
['lang' => 'eng', 'translation' => 'hello'],
273+
], ['lang']],
274+
'unique floats' => [[
275+
['latitude' => 51.509865, 'longitude' => -0.118092, 'poi' => 'capital'],
276+
['latitude' => 52.520008, 'longitude' => 13.404954],
277+
['latitude' => 51.509865, 'longitude' => -0.118092],
278+
], ['latitude', 'longitude']],
279+
'unique int' => [[
280+
['id' => 1, 'email' => 'bar@email.com'],
281+
['id' => 1, 'email' => 'foo@email.com'],
282+
], ['id']],
283+
];
284+
}
223285
}
224286

225287
class CallableClass

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.