feature #34334 [Validator] Allow to define a reusable set of constraints (ogizanagi)
This PR was squashed before being merged into the 5.1-dev branch (closes #34334).
Discussion
----------
[Validator] Allow to define a reusable set of constraints
| Q | A
| ------------- | ---
| Branch? | 5.1 <!-- see below -->
| Bug fix? | no
| New feature? | yes <!-- please update src/**/CHANGELOG.md files -->
| Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Tickets | N/A <!-- prefix each issue number with "Fix #", if any -->
| License | MIT
| Doc PR | TODO
The goal of this feature is to simplify writing a set of validation constraints to be reused consistently across the application. Which is especially useful with DTOs, as a same set of constraints can be used in different places.
For instance, given multiple DTOs containing the new user password in for different use-cases (register, forgot pwd, change pwd), the same rules apply on the property. Hence with this PR, you can write a single constraint class to be reused:
```php
/**
* @Annotation
*/
class MatchesPasswordRequirements extends Compound
{
protected function getConstraints(array $options): array
{
return [
new NotBlank(),
new Type('string'),
new Length(['min' => 12]),
new NotCompromisedPassword(),
];
}
}
```
I'm open to better naming and ways to expose the options to the `Compound::getConstraints` method, so options can be forwarded to the nested constraints for most specific use-cases.
Commits
-------
8f1b0dfdb7
[Validator] Allow to define a reusable set of constraints
This commit is contained in:
commit
f53ea3d04f
@ -6,6 +6,7 @@ CHANGELOG
|
|||||||
|
|
||||||
* added the `Hostname` constraint and validator
|
* added the `Hostname` constraint and validator
|
||||||
* added option `alpha3` to `Country` constraint
|
* added option `alpha3` to `Country` constraint
|
||||||
|
* allow to define a reusable set of constraints by extending the `Compound` constraint
|
||||||
|
|
||||||
5.0.0
|
5.0.0
|
||||||
-----
|
-----
|
||||||
|
@ -105,6 +105,14 @@ abstract class Constraint
|
|||||||
*/
|
*/
|
||||||
public function __construct($options = null)
|
public function __construct($options = null)
|
||||||
{
|
{
|
||||||
|
foreach ($this->normalizeOptions($options) as $name => $value) {
|
||||||
|
$this->$name = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeOptions($options): array
|
||||||
|
{
|
||||||
|
$normalizedOptions = [];
|
||||||
$defaultOption = $this->getDefaultOption();
|
$defaultOption = $this->getDefaultOption();
|
||||||
$invalidOptions = [];
|
$invalidOptions = [];
|
||||||
$missingOptions = array_flip((array) $this->getRequiredOptions());
|
$missingOptions = array_flip((array) $this->getRequiredOptions());
|
||||||
@ -128,7 +136,7 @@ abstract class Constraint
|
|||||||
if ($options && \is_array($options) && \is_string(key($options))) {
|
if ($options && \is_array($options) && \is_string(key($options))) {
|
||||||
foreach ($options as $option => $value) {
|
foreach ($options as $option => $value) {
|
||||||
if (\array_key_exists($option, $knownOptions)) {
|
if (\array_key_exists($option, $knownOptions)) {
|
||||||
$this->$option = $value;
|
$normalizedOptions[$option] = $value;
|
||||||
unset($missingOptions[$option]);
|
unset($missingOptions[$option]);
|
||||||
} else {
|
} else {
|
||||||
$invalidOptions[] = $option;
|
$invalidOptions[] = $option;
|
||||||
@ -140,7 +148,7 @@ abstract class Constraint
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (\array_key_exists($defaultOption, $knownOptions)) {
|
if (\array_key_exists($defaultOption, $knownOptions)) {
|
||||||
$this->$defaultOption = $options;
|
$normalizedOptions[$defaultOption] = $options;
|
||||||
unset($missingOptions[$defaultOption]);
|
unset($missingOptions[$defaultOption]);
|
||||||
} else {
|
} else {
|
||||||
$invalidOptions[] = $defaultOption;
|
$invalidOptions[] = $defaultOption;
|
||||||
@ -154,6 +162,8 @@ abstract class Constraint
|
|||||||
if (\count($missingOptions) > 0) {
|
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));
|
throw new MissingOptionsException(sprintf('The options "%s" must be set for constraint "%s".', implode('", "', array_keys($missingOptions)), static::class), array_keys($missingOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $normalizedOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
52
src/Symfony/Component/Validator/Constraints/Compound.php
Normal file
52
src/Symfony/Component/Validator/Constraints/Compound.php
Normal file
@ -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;
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -181,6 +181,15 @@ abstract class ConstraintValidatorTestCase extends TestCase
|
|||||||
->willReturn($validator);
|
->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)
|
protected function expectValidateValueAt($i, $propertyPath, $value, $constraints, $group = null)
|
||||||
{
|
{
|
||||||
$contextualValidator = $this->context->getValidator()->inContext($this->context);
|
$contextualValidator = $this->context->getValidator()->inContext($this->context);
|
||||||
|
@ -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]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -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]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user