Skip to content

Commit

Permalink
feature #35744 [Validator] Add AtLeastOne constraint and validator (p…
Browse files Browse the repository at this point in the history
…rzemyslaw-bogusz)

This PR was squashed before being merged into the 5.1-dev branch.

Discussion
----------

[Validator] Add AtLeastOne constraint and validator

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| License       | MIT
| Doc PR        | TODO

This constraint allows you to apply a collection of constraints to a value, and it will be considered valid, if it satisfies at least one of the constraints from the collection.

Some examples:

```php
    /**
     * @Assert\AtLeastOne({
     *     @Assert\Length(min=5),
     *     @Assert\EqualTo("bar")
     * })
     */
    public $name = 'foo';

    /**
     * @Assert\AtLeastOne({
     *     @Assert\All({@Assert\GreaterThanOrEqual(10)}),
     *     @Assert\Count(20)
     * })
     */
    public $numbers = ['3', '5'];

    /**
     * @Assert\All({
     *     @Assert\AtLeastOne({
     *          @Assert\GreaterThanOrEqual(5),
     *          @Assert\LessThanOrEqual(3)
     *     })
     * })
     */
    public $otherNumbers = ['4', '5'];
```

The respective default messages would be:
`name: This value should satisfy at least one of the following constraints: [1] This value is too short. It should have 5 characters or more. [2] This value should be equal to "bar".`

`numbers: This value should satisfy at least one of the following constraints: [1] Each element of this collection should satisfy its own set of constraints. [2] This collection should contain exactly 20 elements.`

`otherNumbers[0]: This value should satisfy at least one of the following constraints: [1] This value should be greater than or equal to 5. [2] This value should be less than or equal to 3.`

But of course you could also create a simple custom message like `None of the constraints are satisfied`.

Commits
-------

e6209a6 [Validator] Add AtLeastOne constraint and validator
  • Loading branch information
fabpot committed Mar 16, 2020
2 parents 06a1a1b + e6209a6 commit b4f03d0
Show file tree
Hide file tree
Showing 5 changed files with 328 additions and 0 deletions.
47 changes: 47 additions & 0 deletions src/Symfony/Component/Validator/Constraints/AtLeastOneOf.php
@@ -0,0 +1,47 @@
<?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;

/**
* @Annotation
* @Target({"PROPERTY", "METHOD", "ANNOTATION"})
*
* @author Przemysław Bogusz <przemyslaw.bogusz@tubotax.pl>
*/
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';
}
}
@@ -0,0 +1,61 @@
<?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 Przemysław Bogusz <przemyslaw.bogusz@tubotax.pl>
*/
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()
;
}
}
Expand Up @@ -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));
Expand Down
@@ -0,0 +1,38 @@
<?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\AtLeastOneOf;
use Symfony\Component\Validator\Constraints\Valid;

/**
* @author Przemysław Bogusz <przemyslaw.bogusz@tubotax.pl>
*/
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(),
]);
}
}
@@ -0,0 +1,163 @@
<?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\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 <przemyslaw.bogusz@tubotax.pl>
*/
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(),
]],
];
}
}

0 comments on commit b4f03d0

Please sign in to comment.