Skip to content

[Validator] Handle object properties in Unique validator #48951

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

Open
wants to merge 11 commits into
base: 7.4
Choose a base branch
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 @@ -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
---
Expand Down
41 changes: 38 additions & 3 deletions src/Symfony/Component/Validator/Constraints/UniqueValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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) {
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,89 @@ 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();
}

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'],
],
];
}

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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],
[],
Expand Down
Loading