diff --git a/src/Symfony/Component/Validator/Constraints/AtLeastOneOf.php b/src/Symfony/Component/Validator/Constraints/AtLeastOneOf.php new file mode 100644 index 000000000000..b7efac17b886 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/AtLeastOneOf.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +/** + * @Annotation + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) + * + * @author Przemysław Bogusz + */ +class AtLeastOneOf extends Composite +{ + public const AT_LEAST_ONE_ERROR = 'f27e6d6c-261a-4056-b391-6673a623531c'; + + protected static $errorNames = [ + self::AT_LEAST_ONE_ERROR => 'AT_LEAST_ONE_ERROR', + ]; + + public $constraints = []; + public $message = 'This value should satisfy at least one of the following constraints:'; + public $messageCollection = 'Each element of this collection should satisfy its own set of constraints.'; + public $includeInternalMessages = true; + + public function getDefaultOption() + { + return 'constraints'; + } + + public function getRequiredOptions() + { + return ['constraints']; + } + + protected function getCompositeOption() + { + return 'constraints'; + } +} diff --git a/src/Symfony/Component/Validator/Constraints/AtLeastOneOfValidator.php b/src/Symfony/Component/Validator/Constraints/AtLeastOneOfValidator.php new file mode 100644 index 000000000000..9085b89edb78 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/AtLeastOneOfValidator.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\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * @author Przemysław Bogusz + */ +class AtLeastOneOfValidator extends ConstraintValidator +{ + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) + { + if (!$constraint instanceof AtLeastOneOf) { + throw new UnexpectedTypeException($constraint, AtLeastOneOf::class); + } + + $validator = $this->context->getValidator(); + + $messages = [$constraint->message]; + + foreach ($constraint->constraints as $key => $item) { + $violations = $validator->validate($value, $item); + + if (0 === \count($violations)) { + return; + } + + if ($constraint->includeInternalMessages) { + $message = ' ['.($key + 1).'] '; + + if ($item instanceof All || $item instanceof Collection) { + $message .= $constraint->messageCollection; + } else { + $message .= $violations->get(0)->getMessage(); + } + + $messages[] = $message; + } + } + + $this->context->buildViolation(implode('', $messages)) + ->setCode(AtLeastOneOf::AT_LEAST_ONE_ERROR) + ->addViolation() + ; + } +} diff --git a/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php b/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php index ae724bc5d6aa..7e875d1344dd 100644 --- a/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php +++ b/src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php @@ -203,6 +203,25 @@ protected function expectValidateValueAt($i, $propertyPath, $value, $constraints ->willReturn($contextualValidator); } + protected function expectViolationsAt($i, $value, Constraint $constraint) + { + $context = $this->createContext(); + + $validatorClassname = $constraint->validatedBy(); + + $validator = new $validatorClassname(); + $validator->initialize($context); + $validator->validate($value, $constraint); + + $this->context->getValidator() + ->expects($this->at($i)) + ->method('validate') + ->willReturn($context->getViolations()) + ; + + return $context->getViolations(); + } + protected function assertNoViolation() { $this->assertSame(0, $violationsCount = \count($this->context->getViolations()), sprintf('0 violation expected. Got %u.', $violationsCount)); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfTest.php b/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfTest.php new file mode 100644 index 000000000000..b6cb95b259c4 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfTest.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\AtLeastOneOf; +use Symfony\Component\Validator\Constraints\Valid; + +/** + * @author Przemysław Bogusz + */ +class AtLeastOneOfTest extends TestCase +{ + public function testRejectNonConstraints() + { + $this->expectException('Symfony\Component\Validator\Exception\ConstraintDefinitionException'); + new AtLeastOneOf([ + 'foo', + ]); + } + + public function testRejectValidConstraint() + { + $this->expectException('Symfony\Component\Validator\Exception\ConstraintDefinitionException'); + new AtLeastOneOf([ + new Valid(), + ]); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfValidatorTest.php new file mode 100644 index 000000000000..fff5d1015a12 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/AtLeastOneOfValidatorTest.php @@ -0,0 +1,163 @@ + + * + * 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\AtLeastOneOf; +use Symfony\Component\Validator\Constraints\AtLeastOneOfValidator; +use Symfony\Component\Validator\Constraints\Choice; +use Symfony\Component\Validator\Constraints\Count; +use Symfony\Component\Validator\Constraints\Country; +use Symfony\Component\Validator\Constraints\DivisibleBy; +use Symfony\Component\Validator\Constraints\EqualTo; +use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; +use Symfony\Component\Validator\Constraints\IdenticalTo; +use Symfony\Component\Validator\Constraints\Language; +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\LessThan; +use Symfony\Component\Validator\Constraints\Negative; +use Symfony\Component\Validator\Constraints\Range; +use Symfony\Component\Validator\Constraints\Regex; +use Symfony\Component\Validator\Constraints\Unique; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +/** + * @author Przemysław Bogusz + */ +class AtLeastOneOfValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator() + { + return new AtLeastOneOfValidator(); + } + + /** + * @dataProvider getValidCombinations + */ + public function testValidCombinations($value, $constraints) + { + $i = 0; + + foreach ($constraints as $constraint) { + $this->expectViolationsAt($i++, $value, $constraint); + } + + $this->validator->validate($value, new AtLeastOneOf($constraints)); + + $this->assertNoViolation(); + } + + public function getValidCombinations() + { + return [ + ['symfony', [ + new Length(['min' => 10]), + new EqualTo(['value' => 'symfony']), + ]], + [150, [ + new Range(['min' => 10, 'max' => 20]), + new GreaterThanOrEqual(['value' => 100]), + ]], + [7, [ + new LessThan(['value' => 5]), + new IdenticalTo(['value' => 7]), + ]], + [-3, [ + new DivisibleBy(['value' => 4]), + new Negative(), + ]], + ['FOO', [ + new Choice(['choices' => ['bar', 'BAR']]), + new Regex(['pattern' => '/foo/i']), + ]], + ['fr', [ + new Country(), + new Language(), + ]], + [[1, 3, 5], [ + new Count(['min' => 5]), + new Unique(), + ]], + ]; + } + + /** + * @dataProvider getInvalidCombinations + */ + public function testInvalidCombinationsWithDefaultMessage($value, $constraints) + { + $atLeastOneOf = new AtLeastOneOf(['constraints' => $constraints]); + + $message = [$atLeastOneOf->message]; + + $i = 0; + + foreach ($constraints as $constraint) { + $message[] = ' ['.($i + 1).'] '.$this->expectViolationsAt($i++, $value, $constraint)->get(0)->getMessage(); + } + + $this->validator->validate($value, $atLeastOneOf); + + $this->buildViolation(implode('', $message))->setCode(AtLeastOneOf::AT_LEAST_ONE_ERROR)->assertRaised(); + } + + /** + * @dataProvider getInvalidCombinations + */ + public function testInvalidCombinationsWithCustomMessage($value, $constraints) + { + $atLeastOneOf = new AtLeastOneOf(['constraints' => $constraints, 'message' => 'foo', 'includeInternalMessages' => false]); + + $i = 0; + + foreach ($constraints as $constraint) { + $this->expectViolationsAt($i++, $value, $constraint); + } + + $this->validator->validate($value, $atLeastOneOf); + + $this->buildViolation('foo')->setCode(AtLeastOneOf::AT_LEAST_ONE_ERROR)->assertRaised(); + } + + public function getInvalidCombinations() + { + return [ + ['symphony', [ + new Length(['min' => 10]), + new EqualTo(['value' => 'symfony']), + ]], + [70, [ + new Range(['min' => 10, 'max' => 20]), + new GreaterThanOrEqual(['value' => 100]), + ]], + [8, [ + new LessThan(['value' => 5]), + new IdenticalTo(['value' => 7]), + ]], + [3, [ + new DivisibleBy(['value' => 4]), + new Negative(), + ]], + ['F_O_O', [ + new Choice(['choices' => ['bar', 'BAR']]), + new Regex(['pattern' => '/foo/i']), + ]], + ['f_r', [ + new Country(), + new Language(), + ]], + [[1, 3, 3], [ + new Count(['min' => 5]), + new Unique(), + ]], + ]; + } +}