From 44e1e8bc9b7f1ece7f8e33e347e1fdf6c59c9cce Mon Sep 17 00:00:00 2001 From: Andrii Popov Date: Thu, 8 Oct 2020 22:05:27 +0300 Subject: [PATCH] [Validator] Add normalizer option to Unique constraint --- src/Symfony/Component/Validator/CHANGELOG.md | 2 +- .../Validator/Constraints/Unique.php | 8 ++ .../Validator/Constraints/UniqueValidator.php | 14 ++ .../Tests/Constraints/UniqueTest.php | 27 +++- .../Tests/Constraints/UniqueValidatorTest.php | 132 ++++++++++++++++++ 5 files changed, 179 insertions(+), 4 deletions(-) diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 219babdb67..120a838e55 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 ee50eed95f..6280e9771f 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 ce98cd7347..2758a3faa1 100644 --- a/src/Symfony/Component/Validator/Constraints/UniqueValidator.php +++ b/src/Symfony/Component/Validator/Constraints/UniqueValidator.php @@ -39,7 +39,10 @@ class UniqueValidator extends ConstraintValidator } $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 @@ class UniqueValidator extends ConstraintValidator $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 54494b4656..60c0b682c6 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UniqueTest.php @@ -13,14 +13,15 @@ namespace Symfony\Component\Validator\Tests\Constraints; 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 @@ class UniqueTest extends TestCase [$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 da36214036..6b892cb0a5 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/UniqueValidatorTest.php @@ -99,4 +99,136 @@ class UniqueValidatorTest extends ConstraintValidatorTestCase ->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]; + } }