diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 219babdb675b9..120a838e557f6 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -3,7 +3,7 @@ CHANGELOG 5.3 --- - + * Add the `normalizer` option to the `Unique` constraint * Add `Validation::createIsValidCallable()` that returns true/false instead of throwing exceptions 5.2.0 diff --git a/src/Symfony/Component/Validator/Constraints/Unique.php b/src/Symfony/Component/Validator/Constraints/Unique.php index ee50eed95feda..6280e9771fd6a 100644 --- a/src/Symfony/Component/Validator/Constraints/Unique.php +++ b/src/Symfony/Component/Validator/Constraints/Unique.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Validator\Constraints; use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Exception\InvalidArgumentException; /** * @Annotation @@ -29,15 +30,22 @@ class Unique extends Constraint ]; public $message = 'This collection should contain only unique elements.'; + public $normalizer; public function __construct( array $options = null, string $message = null, + callable $normalizer = null, array $groups = null, $payload = null ) { parent::__construct($options, $groups, $payload); $this->message = $message ?? $this->message; + $this->normalizer = $normalizer ?? $this->normalizer; + + 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))); + } } } diff --git a/src/Symfony/Component/Validator/Constraints/UniqueValidator.php b/src/Symfony/Component/Validator/Constraints/UniqueValidator.php index ce98cd73477c1..2758a3faa11f6 100644 --- a/src/Symfony/Component/Validator/Constraints/UniqueValidator.php +++ b/src/Symfony/Component/Validator/Constraints/UniqueValidator.php @@ -39,7 +39,10 @@ public function validate($value, Constraint $constraint) } $collectionElements = []; + $normalizer = $this->getNormalizer($constraint); foreach ($value as $element) { + $element = $normalizer($element); + if (\in_array($element, $collectionElements, true)) { $this->context->buildViolation($constraint->message) ->setParameter('{{ value }}', $this->formatValue($value)) @@ -51,4 +54,15 @@ public function validate($value, Constraint $constraint) $collectionElements[] = $element; } } + + private function getNormalizer(Unique $unique): callable + { + if (null === $unique->normalizer) { + return static function ($value) { + return $value; + }; + } + + return $unique->normalizer; + } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php index 54494b465695d..60c0b682c6fc0 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php @@ -13,14 +13,15 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Unique; +use Symfony\Component\Validator\Exception\InvalidArgumentException; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; -/** - * @requires PHP 8 - */ class UniqueTest extends TestCase { + /** + * @requires PHP 8 + */ public function testAttributes() { $metadata = new ClassMetadata(UniqueDummy::class); @@ -34,6 +35,23 @@ public function testAttributes() [$cConstraint] = $metadata->properties['c']->getConstraints(); self::assertSame(['my_group'], $cConstraint->groups); self::assertSame('some attached data', $cConstraint->payload); + + [$dConstraint] = $metadata->properties['d']->getConstraints(); + self::assertSame('intval', $dConstraint->normalizer); + } + + public function testInvalidNormalizerThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "normalizer" option must be a valid callable ("string" given).'); + new Unique(['normalizer' => 'Unknown Callable']); + } + + public function testInvalidNormalizerObjectThrowsException() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "normalizer" option must be a valid callable ("stdClass" given).'); + new Unique(['normalizer' => new \stdClass()]); } } @@ -47,4 +65,7 @@ class UniqueDummy #[Unique(groups: ['my_group'], payload: 'some attached data')] private $c; + + #[Unique(normalizer: 'intval')] + private $d; } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php index da36214036bf7..6b892cb0a5ca5 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php @@ -99,4 +99,136 @@ public function testInvalidValueNamed() ->setCode(Unique::IS_NOT_UNIQUE) ->assertRaised(); } + + /** + * @dataProvider getCallback + */ + public function testExpectsUniqueObjects($callback) + { + $object1 = new \stdClass(); + $object1->name = 'Foo'; + $object1->email = 'foo@email.com'; + + $object2 = new \stdClass(); + $object2->name = 'Foo'; + $object2->email = 'foobar@email.com'; + + $object3 = new \stdClass(); + $object3->name = 'Bar'; + $object3->email = 'foo@email.com'; + + $value = [$object1, $object2, $object3]; + + $this->validator->validate($value, new Unique([ + 'normalizer' => $callback, + ])); + + $this->assertNoViolation(); + } + + /** + * @dataProvider getCallback + */ + public function testExpectsNonUniqueObjects($callback) + { + $object1 = new \stdClass(); + $object1->name = 'Foo'; + $object1->email = 'bar@email.com'; + + $object2 = new \stdClass(); + $object2->name = 'Foo'; + $object2->email = 'foo@email.com'; + + $object3 = new \stdClass(); + $object3->name = 'Foo'; + $object3->email = 'foo@email.com'; + + $value = [$object1, $object2, $object3]; + + $this->validator->validate($value, new Unique([ + 'message' => 'myMessage', + 'normalizer' => $callback, + ])); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', 'array') + ->setCode(Unique::IS_NOT_UNIQUE) + ->assertRaised(); + } + + public function getCallback() + { + return [ + yield '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']], + ]; + } + + public function testExpectsInvalidNonStrictComparison() + { + $this->validator->validate([1, '1', 1.0, '1.0'], new Unique([ + 'message' => 'myMessage', + 'normalizer' => 'intval', + ])); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', 'array') + ->setCode(Unique::IS_NOT_UNIQUE) + ->assertRaised(); + } + + public function testExpectsValidNonStrictComparison() + { + $callback = static function ($item) { + return (int) $item; + }; + + $this->validator->validate([1, '2', 3, '4.0'], new Unique([ + 'normalizer' => $callback, + ])); + + $this->assertNoViolation(); + } + + public function testExpectsInvalidCaseInsensitiveComparison() + { + $callback = static function ($item) { + return mb_strtolower($item); + }; + + $this->validator->validate(['Hello', 'hello', 'HELLO', 'hellO'], new Unique([ + 'message' => 'myMessage', + 'normalizer' => $callback, + ])); + + $this->buildViolation('myMessage') + ->setParameter('{{ value }}', 'array') + ->setCode(Unique::IS_NOT_UNIQUE) + ->assertRaised(); + } + + public function testExpectsValidCaseInsensitiveComparison() + { + $callback = static function ($item) { + return mb_strtolower($item); + }; + + $this->validator->validate(['Hello', 'World'], new Unique([ + 'normalizer' => $callback, + ])); + + $this->assertNoViolation(); + } +} + +class CallableClass +{ + public static function execute(\stdClass $object) + { + return [$object->name, $object->email]; + } }
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: