Skip to content

Commit 07bfc5f

Browse files
author
Stephan Six
committed
[DoctrineBridge] Add comparator option to UniqueEntity constraint and enforce use of only identifierFieldNames or comparator
The `comparator` allows the `UniqueEntityValidator` to delegate the equality check to a user-defined callback. This helps in edge-cases where a simple equality check (after casting to string) of all `identifierFieldNames` is not enough.
1 parent d3a0df0 commit 07bfc5f

File tree

4 files changed

+85
-20
lines changed

4 files changed

+85
-20
lines changed

src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
16+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
1617
use Symfony\Component\Validator\Mapping\ClassMetadata;
1718
use Symfony\Component\Validator\Mapping\Loader\AttributeLoader;
1819

@@ -70,6 +71,18 @@ public function testValueOptionConfiguresFields()
7071

7172
$this->assertSame('email', $constraint->fields);
7273
}
74+
75+
public function testOnlyOneOfIdentifierFielsOrComparatorAreAllowed()
76+
{
77+
$this->expectException(ConstraintDefinitionException::class);
78+
$this->expectExceptionMessage('Only "identifierFieldNames" or "comparator" can be used at the same time.');
79+
80+
new UniqueEntity(
81+
fields: ['field'],
82+
identifierFieldNames: ['identifier'],
83+
comparator: function () {},
84+
);
85+
}
7386
}
7487

7588
#[UniqueEntity(['email'], message: 'myMessage')]

src/Symfony/Bridge/Doctrine/Tests/Validator/Constraints/UniqueEntityValidatorTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,4 +1448,41 @@ public function testUuidIdentifierWithSameValueDifferentInstanceDoesNotCauseViol
14481448

14491449
$this->assertNoViolation();
14501450
}
1451+
1452+
public function testCheckForUniquenessIsDelegatedToComparator()
1453+
{
1454+
$comparatorAsBeenCalled = false;
1455+
1456+
$entity = new Person(1, 'Foo');
1457+
1458+
$this->em->persist($entity);
1459+
$this->em->flush();
1460+
1461+
$dto = new UpdateEmployeeProfile(2, 'Foo');
1462+
1463+
$constraint = new UniqueEntity(
1464+
fields: ['name'],
1465+
message: 'myMessage',
1466+
em: self::EM_NAME,
1467+
entityClass: Person::class,
1468+
comparator: function ($value, $foundEntity) use ($dto, $entity, &$comparatorAsBeenCalled) {
1469+
$comparatorAsBeenCalled = true;
1470+
1471+
$this->assertSame($value, $dto);
1472+
$this->assertSame($foundEntity, $entity);
1473+
1474+
// Usually, using `'identifierFieldNames' => ['id'],` would fail validation
1475+
// because the ids don't match. The comparator specifically allows for
1476+
// overwriting this behavior.
1477+
1478+
return true;
1479+
},
1480+
);
1481+
1482+
$this->validator->validate($dto, $constraint);
1483+
1484+
$this->assertTrue($comparatorAsBeenCalled);
1485+
1486+
$this->assertNoViolation();
1487+
}
14511488
}

src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntity.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\Validator\Attribute\HasNamedArguments;
1515
use Symfony\Component\Validator\Constraint;
16+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
1617

1718
/**
1819
* Constraint for the Unique Entity validator.
@@ -37,6 +38,7 @@ class UniqueEntity extends Constraint
3738
public ?string $errorPath = null;
3839
public bool|array|string $ignoreNull = true;
3940
public array $identifierFieldNames = [];
41+
public ?\Closure $comparator = null;
4042

4143
/**
4244
* @param array|string $fields The combination of fields that must contain unique values or a set of options
@@ -46,6 +48,9 @@ class UniqueEntity extends Constraint
4648
* @param string|null $repositoryMethod The repository method to check uniqueness instead of findBy. The method will receive as its argument
4749
* a fieldName => value associative array according to the fields option configuration
4850
* @param string|null $errorPath Bind the constraint violation to this field instead of the first one in the fields option configuration
51+
* @param callable|null $comparator A custom callback to check the uniqueness of the found entity. The first parameter will
52+
* be the object this constraint is applied to, the second parameter will be the found entity. The callback
53+
* should return true if both are considered to be the same.
4954
*/
5055
#[HasNamedArguments]
5156
public function __construct(
@@ -58,6 +63,7 @@ public function __construct(
5863
?string $errorPath = null,
5964
bool|string|array|null $ignoreNull = null,
6065
?array $identifierFieldNames = null,
66+
?callable $comparator = null,
6167
?array $groups = null,
6268
$payload = null,
6369
?array $options = null,
@@ -89,6 +95,11 @@ public function __construct(
8995
$this->errorPath = $errorPath ?? $this->errorPath;
9096
$this->ignoreNull = $ignoreNull ?? $this->ignoreNull;
9197
$this->identifierFieldNames = $identifierFieldNames ?? $this->identifierFieldNames;
98+
$this->comparator = $comparator === null ? $this->comparator : $comparator(...);
99+
100+
if ($this->identifierFieldNames !== [] && $this->comparator !== null) {
101+
throw new ConstraintDefinitionException('Only "identifierFieldNames" or "comparator" can be used at the same time.');
102+
}
92103
}
93104

94105
/**

src/Symfony/Bridge/Doctrine/Validator/Constraints/UniqueEntityValidator.php

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -186,29 +186,33 @@ public function validate(mixed $value, Constraint $constraint): void
186186
/* If a single entity matched the query criteria, which is the same as
187187
* the entity being updated by validated object, the criteria is unique.
188188
*/
189-
if (!$isValueEntity && !empty($constraint->identifierFieldNames) && 1 === \count($result)) {
190-
$fieldValues = $this->getFieldValues($value, $class, $constraint->identifierFieldNames);
191-
if (array_values($class->getIdentifierFieldNames()) != array_values($constraint->identifierFieldNames)) {
192-
throw new ConstraintDefinitionException(\sprintf('The "%s" entity identifier field names should be "%s", not "%s".', $entityClass, implode(', ', $class->getIdentifierFieldNames()), implode(', ', $constraint->identifierFieldNames)));
193-
}
194-
195-
$entityMatched = true;
196-
197-
foreach ($constraint->identifierFieldNames as $identifierFieldName) {
198-
$propertyValue = $this->getPropertyValue($entityClass, $identifierFieldName, current($result));
199-
if ($fieldValues[$identifierFieldName] instanceof \Stringable) {
200-
$fieldValues[$identifierFieldName] = (string) $fieldValues[$identifierFieldName];
201-
}
202-
if ($propertyValue instanceof \Stringable) {
203-
$propertyValue = (string) $propertyValue;
189+
if (!$isValueEntity && 1 === \count($result)) {
190+
if (!empty($constraint->identifierFieldNames)) {
191+
$fieldValues = $this->getFieldValues($value, $class, $constraint->identifierFieldNames);
192+
if (array_values($class->getIdentifierFieldNames()) != array_values($constraint->identifierFieldNames)) {
193+
throw new ConstraintDefinitionException(\sprintf('The "%s" entity identifier field names should be "%s", not "%s".', $entityClass, implode(', ', $class->getIdentifierFieldNames()), implode(', ', $constraint->identifierFieldNames)));
204194
}
205-
if ($fieldValues[$identifierFieldName] !== $propertyValue) {
206-
$entityMatched = false;
207-
break;
195+
196+
$entityMatched = true;
197+
198+
foreach ($constraint->identifierFieldNames as $identifierFieldName) {
199+
$propertyValue = $this->getPropertyValue($entityClass, $identifierFieldName, current($result));
200+
if ($fieldValues[$identifierFieldName] instanceof \Stringable) {
201+
$fieldValues[$identifierFieldName] = (string) $fieldValues[$identifierFieldName];
202+
}
203+
if ($propertyValue instanceof \Stringable) {
204+
$propertyValue = (string) $propertyValue;
205+
}
206+
if ($fieldValues[$identifierFieldName] !== $propertyValue) {
207+
$entityMatched = false;
208+
break;
209+
}
208210
}
209-
}
210211

211-
if ($entityMatched) {
212+
if ($entityMatched) {
213+
return;
214+
}
215+
} elseif (!empty($constraint->comparator) && ($constraint->comparator)($value, current($result))) {
212216
return;
213217
}
214218
}

0 commit comments

Comments
 (0)