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
Next Next commit
Handle object field in UniqueValidator
  • Loading branch information
plfort committed Mar 25, 2025
commit 55f49c25cbf09a727f5b246a9a7e531e86000eaa
55 changes: 51 additions & 4 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,7 +26,18 @@
*/
class UniqueValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void

private ?PropertyAccessorInterface $propertyAccessor;

/**
* @param PropertyAccessorInterface|null $propertyAccessor
*/
public function __construct(?PropertyAccessorInterface $propertyAccessor = null)
{
$this->propertyAccessor = $propertyAccessor;
}

public function validate(mixed $value, Constraint $constraint)
{
if (!$constraint instanceof Unique) {
throw new UnexpectedTypeException($constraint, Unique::class);
Expand Down Expand Up @@ -72,18 +88,49 @@ 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, $element)) {
$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 (null === $this->propertyAccessor) {
if (!class_exists(PropertyAccess::class)) {
throw new LogicException(
'Unable to use property path as the Symfony PropertyAccess component is not installed.'
);
}
$this->propertyAccessor = PropertyAccess::createPropertyAccessor();
}

return $this->propertyAccessor;
}
}
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,43 @@ 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 +366,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