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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy