[Validator] Allow to define a reusable set of constraints
authorMaxime Steinhausser <maxime.steinhausser@elao.com>
Fri, 8 Nov 2019 14:27:42 +0000 (15:27 +0100)
committerFabien Potencier <fabien@potencier.org>
Sat, 8 Feb 2020 07:09:33 +0000 (08:09 +0100)
src/Symfony/Component/Validator/CHANGELOG.md
src/Symfony/Component/Validator/Constraint.php
src/Symfony/Component/Validator/Constraints/Compound.php [new file with mode: 0644]
src/Symfony/Component/Validator/Constraints/CompoundValidator.php [new file with mode: 0644]
src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php
src/Symfony/Component/Validator/Tests/Constraints/CompoundTest.php [new file with mode: 0644]
src/Symfony/Component/Validator/Tests/Constraints/CompoundValidatorTest.php [new file with mode: 0644]

index c5ad622..78f5463 100644 (file)
@@ -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
 -----
index b81fb9f..4ad4261 100644 (file)
@@ -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 (file)
index 0000000..c6a875d
--- /dev/null
@@ -0,0 +1,52 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * 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 <maxime.steinhausser@gmail.com>
+ */
+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 (file)
index 0000000..2ba993f
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * 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 <maxime.steinhausser@gmail.com>
+ */
+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);
+    }
+}
index 8e4fc6b..ae724bc 100644 (file)
@@ -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 (file)
index 0000000..f9e2284
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * 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 (file)
index 0000000..bcb82fb
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * 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]),
+        ];
+    }
+}