diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 78f5463c97de..d3bebec61e0c 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -7,6 +7,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 + * added `Sequentially` constraint, to sequentially validate a set of constraints (any violation raised will prevent further validation of the nested constraints) 5.0.0 ----- diff --git a/src/Symfony/Component/Validator/Constraints/Sequentially.php b/src/Symfony/Component/Validator/Constraints/Sequentially.php new file mode 100644 index 000000000000..c291daf55054 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/Sequentially.php @@ -0,0 +1,41 @@ + + * + * 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 this constraint to sequentially validate nested constraints. + * Validation for the nested constraints collection will stop at first violation. + * + * @Annotation + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) + * + * @author Maxime Steinhausser + */ +class Sequentially extends Composite +{ + public $constraints = []; + + public function getDefaultOption() + { + return 'constraints'; + } + + public function getRequiredOptions() + { + return ['constraints']; + } + + protected function getCompositeOption() + { + return 'constraints'; + } +} diff --git a/src/Symfony/Component/Validator/Constraints/SequentiallyValidator.php b/src/Symfony/Component/Validator/Constraints/SequentiallyValidator.php new file mode 100644 index 000000000000..434d2aba22fd --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/SequentiallyValidator.php @@ -0,0 +1,44 @@ + + * + * 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 SequentiallyValidator extends ConstraintValidator +{ + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) + { + if (!$constraint instanceof Sequentially) { + throw new UnexpectedTypeException($constraint, Sequentially::class); + } + + $context = $this->context; + + $validator = $context->getValidator()->inContext($context); + + $originalCount = $validator->getViolations()->count(); + + foreach ($constraint->constraints as $c) { + if ($originalCount !== $validator->validate($value, $c)->getViolations()->count()) { + break; + } + } + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyTest.php new file mode 100644 index 000000000000..f699a5abdc71 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyTest.php @@ -0,0 +1,38 @@ + + * + * 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\Sequentially; +use Symfony\Component\Validator\Constraints\Valid; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; + +class SequentiallyTest extends TestCase +{ + public function testRejectNonConstraints() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The value foo is not an instance of Constraint in constraint Symfony\Component\Validator\Constraints\Sequentially'); + new Sequentially([ + 'foo', + ]); + } + + public function testRejectValidConstraint() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The constraint Valid cannot be nested inside constraint Symfony\Component\Validator\Constraints\Sequentially'); + new Sequentially([ + new Valid(), + ]); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyValidatorTest.php new file mode 100644 index 000000000000..e6993ff20dc8 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/SequentiallyValidatorTest.php @@ -0,0 +1,87 @@ + + * + * 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\NotEqualTo; +use Symfony\Component\Validator\Constraints\Range; +use Symfony\Component\Validator\Constraints\Regex; +use Symfony\Component\Validator\Constraints\Sequentially; +use Symfony\Component\Validator\Constraints\SequentiallyValidator; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +class SequentiallyValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator() + { + return new SequentiallyValidator(); + } + + public function testWalkThroughConstraints() + { + $constraints = [ + new Type('number'), + new Range(['min' => 4]), + ]; + + $value = 6; + + $contextualValidator = $this->context->getValidator()->inContext($this->context); + $contextualValidator->expects($this->any())->method('getViolations')->willReturn($this->context->getViolations()); + $contextualValidator->expects($this->exactly(2)) + ->method('validate') + ->withConsecutive( + [$value, $constraints[0]], + [$value, $constraints[1]] + ) + ->willReturn($contextualValidator); + + $this->validator->validate($value, new Sequentially($constraints)); + + $this->assertNoViolation(); + } + + public function testStopsAtFirstConstraintWithViolations() + { + $constraints = [ + new Type('string'), + new Regex(['pattern' => '[a-z]']), + new NotEqualTo('Foo'), + ]; + + $value = 'Foo'; + + $contextualValidator = $this->context->getValidator()->inContext($this->context); + $contextualValidator->expects($this->any())->method('getViolations')->willReturn($this->context->getViolations()); + $contextualValidator->expects($this->exactly(2)) + ->method('validate') + ->withConsecutive( + [$value, $constraints[0]], + [$value, $constraints[1]] + ) + ->will($this->onConsecutiveCalls( + // Noop, just return the validator: + $this->returnValue($contextualValidator), + // Add violation on second call: + $this->returnCallback(function () use ($contextualValidator) { + $this->context->getViolations()->add($violation = new ConstraintViolation('regex error', null, [], null, '', null, null, 'regex')); + + return $contextualValidator; + } + ))); + + $this->validator->validate($value, new Sequentially($constraints)); + + $this->assertCount(1, $this->context->getViolations()); + } +}