Skip to content

[Validator] Define which collection keys should be checked for uniqueness #42403

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 12 commits into from
1 change: 1 addition & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 10 additions & 1 deletion src/Symfony/Component/Validator/Constraints/Unique.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
Expand All @@ -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)));
Expand Down
21 changes: 21 additions & 0 deletions src/Symfony/Component/Validator/Constraints/UniqueValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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)) {
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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']],
];
}

Expand Down Expand Up @@ -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
Expand Down