From 8f1b0dfdb743cdbe8e3a1bed6e7b8adfbfea5e9a Mon Sep 17 00:00:00 2001 From: Maxime Steinhausser Date: Fri, 8 Nov 2019 15:27:42 +0100 Subject: [PATCH] [Validator] Allow to define a reusable set of constraints --- src/Symfony/Component/Validator/CHANGELOG.md | 1 + .../Component/Validator/Constraint.php | 14 ++++- .../Validator/Constraints/Compound.php | 52 ++++++++++++++++ .../Constraints/CompoundValidator.php | 35 +++++++++++ .../Test/ConstraintValidatorTestCase.php | 9 +++ .../Tests/Constraints/CompoundTest.php | 60 +++++++++++++++++++ .../Constraints/CompoundValidatorTest.php | 56 +++++++++++++++++ 7 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Component/Validator/Constraints/Compound.php create mode 100644 src/Symfony/Component/Validator/Constraints/CompoundValidator.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/CompoundTest.php create mode 100644 src/Symfony/Component/Validator/Tests/Constraints/CompoundValidatorTest.php diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index c5ad6222ce..78f5463c97 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * added the `Hostname` constraint and validator * added option `alpha3` to `Country` constraint + * allow to define a reusable set of constraints by extending the `Compound` constraint 5.0.0 ----- diff --git a/src/Symfony/Component/Validator/Constraint.php b/src/Symfony/Component/Validator/Constraint.php index b81fb9f5b1..4ad4261941 100644 --- a/src/Symfony/Component/Validator/Constraint.php +++ b/src/Symfony/Component/Validator/Constraint.php @@ -105,6 +105,14 @@ abstract class Constraint */ public function __construct($options = null) { + foreach ($this->normalizeOptions($options) as $name => $value) { + $this->$name = $value; + } + } + + protected function normalizeOptions($options): array + { + $normalizedOptions = []; $defaultOption = $this->getDefaultOption(); $invalidOptions = []; $missingOptions = array_flip((array) $this->getRequiredOptions()); @@ -128,7 +136,7 @@ abstract class Constraint if ($options && \is_array($options) && \is_string(key($options))) { foreach ($options as $option => $value) { if (\array_key_exists($option, $knownOptions)) { - $this->$option = $value; + $normalizedOptions[$option] = $value; unset($missingOptions[$option]); } else { $invalidOptions[] = $option; @@ -140,7 +148,7 @@ abstract class Constraint } if (\array_key_exists($defaultOption, $knownOptions)) { - $this->$defaultOption = $options; + $normalizedOptions[$defaultOption] = $options; unset($missingOptions[$defaultOption]); } else { $invalidOptions[] = $defaultOption; @@ -154,6 +162,8 @@ abstract class Constraint if (\count($missingOptions) > 0) { throw new MissingOptionsException(sprintf('The options "%s" must be set for constraint "%s".', implode('", "', array_keys($missingOptions)), static::class), array_keys($missingOptions)); } + + return $normalizedOptions; } /** diff --git a/src/Symfony/Component/Validator/Constraints/Compound.php b/src/Symfony/Component/Validator/Constraints/Compound.php new file mode 100644 index 0000000000..c6a875d9d4 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Compound.php @@ -0,0 +1,52 @@ + + * + * 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\Validator\Constraint; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; + +/** + * Extend this class to create a reusable set of constraints. + * + * @author Maxime Steinhausser + */ +abstract class Compound extends Composite +{ + /** @var Constraint[] */ + public $constraints = []; + + public function __construct($options = null) + { + if (isset($options[$this->getCompositeOption()])) { + throw new ConstraintDefinitionException(sprintf('You can\'t redefine the "%s" option. Use the %s::getConstraints() method instead.', $this->getCompositeOption(), __CLASS__)); + } + + $this->constraints = $this->getConstraints($this->normalizeOptions($options)); + + parent::__construct($options); + } + + final protected function getCompositeOption() + { + return 'constraints'; + } + + final public function validatedBy() + { + return CompoundValidator::class; + } + + /** + * @return Constraint[] + */ + abstract protected function getConstraints(array $options): array; +} diff --git a/src/Symfony/Component/Validator/Constraints/CompoundValidator.php b/src/Symfony/Component/Validator/Constraints/CompoundValidator.php new file mode 100644 index 0000000000..2ba993f31b --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/CompoundValidator.php @@ -0,0 +1,35 @@ + + * + * 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\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * @author Maxime Steinhausser + */ +class CompoundValidator extends ConstraintValidator +{ + public function validate($value, Constraint $constraint) + { + if (!$constraint instanceof Compound) { + throw new UnexpectedTypeException($constraint, Compound::class); + } + + $context = $this->context; + + $validator = $context->getValidator()->inContext($context); + + $validator->validate($value, $constraint->constraints); + } +} diff --git a/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php b/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php index 8e4fc6ba1b..ae724bc5d6 100644 --- a/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php +++ b/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php @@ -181,6 +181,15 @@ abstract class ConstraintValidatorTestCase extends TestCase ->willReturn($validator); } + protected function expectValidateValue(int $i, $value, array $constraints = [], $group = null) + { + $contextualValidator = $this->context->getValidator()->inContext($this->context); + $contextualValidator->expects($this->at($i)) + ->method('validate') + ->with($value, $constraints, $group) + ->willReturn($contextualValidator); + } + protected function expectValidateValueAt($i, $propertyPath, $value, $constraints, $group = null) { $contextualValidator = $this->context->getValidator()->inContext($this->context); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CompoundTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CompoundTest.php new file mode 100644 index 0000000000..f9e2284089 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/CompoundTest.php @@ -0,0 +1,60 @@ + + * + * 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 PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraints\Compound; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; + +class CompoundTest extends TestCase +{ + public function testItCannotRedefineConstraintsOption() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('You can\'t redefine the "constraints" option. Use the Symfony\Component\Validator\Constraints\Compound::getConstraints() method instead.'); + new EmptyCompound(['constraints' => [new NotBlank()]]); + } + + public function testCanDependOnNormalizedOptions() + { + $constraint = new ForwardingOptionCompound($min = 3); + + $this->assertSame($min, $constraint->constraints[0]->min); + } +} + +class EmptyCompound extends Compound +{ + protected function getConstraints(array $options): array + { + return []; + } +} + +class ForwardingOptionCompound extends Compound +{ + public $min; + + public function getDefaultOption() + { + return 'min'; + } + + protected function getConstraints(array $options): array + { + return [ + new Length(['min' => $options['min'] ?? null]), + ]; + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CompoundValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CompoundValidatorTest.php new file mode 100644 index 0000000000..bcb82fbaa7 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/CompoundValidatorTest.php @@ -0,0 +1,56 @@ + + * + * 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\Compound; +use Symfony\Component\Validator\Constraints\CompoundValidator; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +class CompoundValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator() + { + return new CompoundValidator(); + } + + public function testValidValue() + { + $this->validator->validate('foo', new DummyCompoundConstraint()); + + $this->assertNoViolation(); + } + + public function testValidateWithConstraints() + { + $value = 'foo'; + $constraint = new DummyCompoundConstraint(); + + $this->expectValidateValue(0, $value, $constraint->constraints); + + $this->validator->validate($value, $constraint); + + $this->assertNoViolation(); + } +} + +class DummyCompoundConstraint extends Compound +{ + protected function getConstraints(array $options): array + { + return [ + new NotBlank(), + new Length(['max' => 3]), + ]; + } +}