From 9b7bdc9b1894cca49dca94531b736f2f2f7e2ac5 Mon Sep 17 00:00:00 2001 From: wuchen90 Date: Wed, 9 Jun 2021 12:37:01 +0200 Subject: [PATCH] [Validator] Add the When constraint and validator --- .../FrameworkExtension.php | 5 + .../Resources/config/validator.php | 7 + src/Symfony/Component/Validator/CHANGELOG.md | 1 + .../Component/Validator/Constraints/When.php | 69 ++++++ .../Validator/Constraints/WhenValidator.php | 61 +++++ .../Fixtures/WhenTestWithAttributes.php | 39 ++++ .../Validator/Tests/Constraints/WhenTest.php | 197 ++++++++++++++++ .../Tests/Constraints/WhenValidatorTest.php | 219 ++++++++++++++++++ 8 files changed, 598 insertions(+) create mode 100644 src/Symfony/Component/Validator/Constraints/When.php create mode 100644 src/Symfony/Component/Validator/Constraints/WhenValidator.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithAttributes.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/WhenTest.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/WhenValidatorTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 706773fa60522..b32e6ca69ab63 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -221,6 +221,7 @@ use Symfony\Component\Translation\Translator; use Symfony\Component\Uid\Factory\UuidFactory; use Symfony\Component\Uid\UuidV4; +use Symfony\Component\Validator\Constraints\WhenValidator; use Symfony\Component\Validator\ConstraintValidatorInterface; use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; use Symfony\Component\Validator\ObjectInitializerInterface; @@ -1570,6 +1571,10 @@ private function registerValidationConfiguration(array $config, ContainerBuilder if (!class_exists(ExpressionLanguage::class)) { $container->removeDefinition('validator.expression_language'); } + + if (!class_exists(WhenValidator::class)) { + $container->removeDefinition('validator.when'); + } } private function registerValidatorMapping(ContainerBuilder $container, array $config, array &$files) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php index 3fd066ee37f1f..c397e73d42505 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/validator.php @@ -17,6 +17,7 @@ use Symfony\Component\Validator\Constraints\EmailValidator; use Symfony\Component\Validator\Constraints\ExpressionValidator; use Symfony\Component\Validator\Constraints\NotCompromisedPasswordValidator; +use Symfony\Component\Validator\Constraints\WhenValidator; use Symfony\Component\Validator\ContainerConstraintValidatorFactory; use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader; use Symfony\Component\Validator\Validation; @@ -95,6 +96,12 @@ 'alias' => NotCompromisedPasswordValidator::class, ]) + ->set('validator.when', WhenValidator::class) + ->args([service('validator.expression_language')->nullOnInvalid()]) + ->tag('validator.constraint_validator', [ + 'alias' => WhenValidator::class, + ]) + ->set('validator.property_info_loader', PropertyInfoLoader::class) ->args([ service('property_info'), diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 5d74eba99afb0..6dd7ffcd4bb0b 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 6.2 --- + * Add the `When` constraint and validator * Deprecate the "loose" e-mail validation mode, use "html5" instead * Add the `negate` option to the `Expression` constraint, to inverse the logic of the violation's creation diff --git a/src/Symfony/Component/Validator/Constraints/When.php b/src/Symfony/Component/Validator/Constraints/When.php new file mode 100644 index 0000000000000..12434319c66af --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/When.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\Validator\Exception\LogicException; + +/** + * @Annotation + * @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"}) + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class When extends Composite +{ + public $expression; + public $constraints = []; + public $values = []; + + public function __construct(string|Expression|array $expression, array $constraints = null, array $values = null, array $groups = null, $payload = null, array $options = []) + { + if (!class_exists(ExpressionLanguage::class)) { + throw new LogicException(sprintf('The "symfony/expression-language" component is required to use the "%s" constraint. Try running "composer require symfony/expression-language".', __CLASS__)); + } + + if (\is_array($expression)) { + $options = array_merge($expression, $options); + } else { + $options['expression'] = $expression; + $options['constraints'] = $constraints; + } + + if (null !== $groups) { + $options['groups'] = $groups; + } + + if (null !== $payload) { + $options['payload'] = $payload; + } + + parent::__construct($options); + + $this->values = $values ?? $this->values; + } + + public function getRequiredOptions(): array + { + return ['expression', 'constraints']; + } + + public function getTargets(): string|array + { + return [self::CLASS_CONSTRAINT, self::PROPERTY_CONSTRAINT]; + } + + protected function getCompositeOption(): string + { + return 'constraints'; + } +} diff --git a/src/Symfony/Component/Validator/Constraints/WhenValidator.php b/src/Symfony/Component/Validator/Constraints/WhenValidator.php new file mode 100644 index 0000000000000..505f4fccda982 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/WhenValidator.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\LogicException; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +final class WhenValidator extends ConstraintValidator +{ + private ?ExpressionLanguage $expressionLanguage; + + public function __construct(ExpressionLanguage $expressionLanguage = null) + { + $this->expressionLanguage = $expressionLanguage; + } + + /** + * {@inheritdoc} + */ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof When) { + throw new UnexpectedTypeException($constraint, When::class); + } + + $context = $this->context; + $variables = $constraint->values; + $variables['value'] = $value; + $variables['this'] = $context->getObject(); + + if ($this->getExpressionLanguage()->evaluate($constraint->expression, $variables)) { + $context->getValidator()->inContext($context) + ->validate($value, $constraint->constraints); + } + } + + private function getExpressionLanguage(): ExpressionLanguage + { + if (null !== $this->expressionLanguage) { + return $this->expressionLanguage; + } + + if (!class_exists(ExpressionLanguage::class)) { + throw new LogicException(sprintf('The "symfony/expression-language" component is required to use the "%s" validator. Try running "composer require symfony/expression-language".', __CLASS__)); + } + + return $this->expressionLanguage = new ExpressionLanguage(); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithAttributes.php b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithAttributes.php new file mode 100644 index 0000000000000..b106b414e479f --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithAttributes.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Doctrine\Common\Annotations\AnnotationReader; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraints\Callback; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\When; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Exception\MissingOptionsException; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Validator\Tests\Constraints\Fixtures\WhenTestWithAttributes; + +final class WhenTest extends TestCase +{ + public function testMissingOptionsExceptionIsThrown() + { + $this->expectException(MissingOptionsException::class); + $this->expectExceptionMessage('The options "expression", "constraints" must be set for constraint "Symfony\Component\Validator\Constraints\When".'); + + new When([]); + } + + public function testNonConstraintsAreRejected() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The value "foo" is not an instance of Constraint in constraint "Symfony\Component\Validator\Constraints\When"'); + new When('true', [ + 'foo', + ]); + } + + public function testAnnotations() + { + $loader = new AnnotationLoader(new AnnotationReader()); + $metadata = new ClassMetadata(WhenTestWithAnnotations::class); + + self::assertTrue($loader->loadClassMetadata($metadata)); + + [$classConstraint] = $metadata->getConstraints(); + + self::assertInstanceOf(When::class, $classConstraint); + self::assertSame('true', $classConstraint->expression); + self::assertEquals([ + new Callback([ + 'callback' => 'callback', + 'groups' => ['Default', 'WhenTestWithAnnotations'], + ]), + ], $classConstraint->constraints); + + [$fooConstraint] = $metadata->properties['foo']->getConstraints(); + + self::assertInstanceOf(When::class, $fooConstraint); + self::assertSame('true', $fooConstraint->expression); + self::assertEquals([ + new NotNull([ + 'groups' => ['Default', 'WhenTestWithAnnotations'], + ]), + new NotBlank([ + 'groups' => ['Default', 'WhenTestWithAnnotations'], + ]), + ], $fooConstraint->constraints); + self::assertSame(['Default', 'WhenTestWithAnnotations'], $fooConstraint->groups); + + [$barConstraint] = $metadata->properties['bar']->getConstraints(); + + self::assertInstanceOf(When::class, $fooConstraint); + self::assertSame('false', $barConstraint->expression); + self::assertEquals([ + new NotNull([ + 'groups' => ['foo'], + ]), + new NotBlank([ + 'groups' => ['foo'], + ]), + ], $barConstraint->constraints); + self::assertSame(['foo'], $barConstraint->groups); + + [$bazConstraint] = $metadata->getters['baz']->getConstraints(); + + self::assertInstanceOf(When::class, $bazConstraint); + self::assertSame('true', $bazConstraint->expression); + self::assertEquals([ + new NotNull([ + 'groups' => ['Default', 'WhenTestWithAnnotations'], + ]), + new NotBlank([ + 'groups' => ['Default', 'WhenTestWithAnnotations'], + ]), + ], $bazConstraint->constraints); + self::assertSame(['Default', 'WhenTestWithAnnotations'], $bazConstraint->groups); + } + + /** + * @requires PHP 8.1 + */ + public function testAttributes() + { + $loader = new AnnotationLoader(new AnnotationReader()); + $metadata = new ClassMetadata(WhenTestWithAttributes::class); + + self::assertTrue($loader->loadClassMetadata($metadata)); + + [$classConstraint] = $metadata->getConstraints(); + + self::assertInstanceOf(When::class, $classConstraint); + self::assertSame('true', $classConstraint->expression); + self::assertEquals([ + new Callback([ + 'callback' => 'callback', + 'groups' => ['Default', 'WhenTestWithAttributes'], + ]), + ], $classConstraint->constraints); + + [$fooConstraint] = $metadata->properties['foo']->getConstraints(); + + self::assertInstanceOf(When::class, $fooConstraint); + self::assertSame('true', $fooConstraint->expression); + self::assertEquals([ + new NotNull([ + 'groups' => ['Default', 'WhenTestWithAttributes'], + ]), + new NotBlank([ + 'groups' => ['Default', 'WhenTestWithAttributes'], + ]), + ], $fooConstraint->constraints); + self::assertSame(['Default', 'WhenTestWithAttributes'], $fooConstraint->groups); + + [$barConstraint] = $metadata->properties['bar']->getConstraints(); + + self::assertInstanceOf(When::class, $fooConstraint); + self::assertSame('false', $barConstraint->expression); + self::assertEquals([ + new NotNull([ + 'groups' => ['foo'], + ]), + new NotBlank([ + 'groups' => ['foo'], + ]), + ], $barConstraint->constraints); + self::assertSame(['foo'], $barConstraint->groups); + + [$bazConstraint] = $metadata->getters['baz']->getConstraints(); + + self::assertInstanceOf(When::class, $bazConstraint); + self::assertSame('true', $bazConstraint->expression); + self::assertEquals([ + new NotNull([ + 'groups' => ['Default', 'WhenTestWithAttributes'], + ]), + new NotBlank([ + 'groups' => ['Default', 'WhenTestWithAttributes'], + ]), + ], $bazConstraint->constraints); + self::assertSame(['Default', 'WhenTestWithAttributes'], $bazConstraint->groups); + } +} + +/** + * @When(expression="true", constraints={@Callback("callback")}) + */ +class WhenTestWithAnnotations +{ + /** + * @When(expression="true", constraints={@NotNull, @NotBlank}) + */ + private $foo; + + /** + * @When(expression="false", constraints={@NotNull, @NotBlank}, groups={"foo"}) + */ + private $bar; + + /** + * @When(expression="true", constraints={@NotNull, @NotBlank}) + */ + public function getBaz() + { + return null; + } + + public function callback() + { + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/WhenValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/WhenValidatorTest.php new file mode 100644 index 0000000000000..3fc8ba4a447a8 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/WhenValidatorTest.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Symfony\Component\Validator\Constraints\Blank; +use Symfony\Component\Validator\Constraints\Callback; +use Symfony\Component\Validator\Constraints\NegativeOrZero; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\PositiveOrZero; +use Symfony\Component\Validator\Constraints\When; +use Symfony\Component\Validator\Constraints\WhenValidator; +use Symfony\Component\Validator\ConstraintValidatorInterface; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +final class WhenValidatorTest extends ConstraintValidatorTestCase +{ + public function testConstraintsAreExecuted() + { + $constraints = [ + new NotNull(), + new NotBlank(), + ]; + + $this->expectValidateValue(0, 'Foo', $constraints); + + $this->validator->validate('Foo', new When([ + 'expression' => 'true', + 'constraints' => $constraints, + ])); + } + + public function testConstraintsAreExecutedWithNull() + { + $constraints = [ + new NotNull(), + ]; + + $this->expectValidateValue(0, null, $constraints); + + $this->validator->validate(null, new When([ + 'expression' => 'true', + 'constraints' => $constraints, + ])); + } + + public function testConstraintsAreExecutedWithObject() + { + $number = new \stdClass(); + $number->type = 'positive'; + $number->value = 1; + + $this->setObject($number); + $this->setPropertyPath('value'); + + $constraints = [ + new PositiveOrZero(), + ]; + + $this->expectValidateValue(0, $number->value, $constraints); + + $this->validator->validate($number->value, new When([ + 'expression' => 'this.type === "positive"', + 'constraints' => $constraints, + ])); + } + + public function testConstraintsAreExecutedWithValue() + { + $constraints = [ + new Callback(), + ]; + + $this->expectValidateValue(0, 'foo', $constraints); + + $this->validator->validate('foo', new When([ + 'expression' => 'value === "foo"', + 'constraints' => $constraints, + ])); + } + + public function testConstraintsAreExecutedWithExpressionValues() + { + $constraints = [ + new Callback(), + ]; + + $this->expectValidateValue(0, 'foo', $constraints); + + $this->validator->validate('foo', new When([ + 'expression' => 'activated && value === compared_value', + 'constraints' => $constraints, + 'values' => [ + 'activated' => true, + 'compared_value' => 'foo', + ], + ])); + } + + public function testConstraintsNotExecuted() + { + $constraints = [ + new NotNull(), + new NotBlank(), + ]; + + $this->expectNoValidate(); + + $this->validator->validate('', new When([ + 'expression' => 'false', + 'constraints' => $constraints, + ])); + + $this->assertNoViolation(); + } + + public function testConstraintsNotExecutedWithObject() + { + $number = new \stdClass(); + $number->type = 'positive'; + $number->value = 1; + + $this->setObject($number); + $this->setPropertyPath('value'); + + $constraints = [ + new NegativeOrZero(), + ]; + + $this->expectNoValidate(); + + $this->validator->validate($number->value, new When([ + 'expression' => 'this.type !== "positive"', + 'constraints' => $constraints, + ])); + + $this->assertNoViolation(); + } + + public function testConstraintsNotExecutedWithValue() + { + $constraints = [ + new Callback(), + ]; + + $this->expectNoValidate(); + + $this->validator->validate('foo', new When([ + 'expression' => 'value === null', + 'constraints' => $constraints, + ])); + + $this->assertNoViolation(); + } + + public function testConstraintsNotExecutedWithExpressionValues() + { + $constraints = [ + new Callback(), + ]; + + $this->expectNoValidate(); + + $this->validator->validate('foo', new When([ + 'expression' => 'activated && value === compared_value', + 'constraints' => $constraints, + 'values' => [ + 'activated' => true, + 'compared_value' => 'bar', + ], + ])); + + $this->assertNoViolation(); + } + + public function testConstraintViolations() + { + $constraints = [ + new Blank([ + 'message' => 'my_message', + ]), + ]; + $this->expectFailingValueValidation( + 0, + 'foo', + $constraints, + null, + new ConstraintViolation( + 'my_message', + 'my_message', + [ + '{{ value }}' => 'foo', + ], + null, + '', + null, + null, + Blank::NOT_BLANK_ERROR + ), + ); + + $this->validator->validate('foo', new When('true', $constraints)); + } + + protected function createValidator(): ConstraintValidatorInterface + { + return new WhenValidator(); + } +}