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 option `alpha3` to `Country` constraint
|
||||
* allow to define a reusable set of constraints by extending the `Compound` constraint
|
||||
|
||||
5.0.0
|
||||
-----
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -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