8000 feature #34334 [Validator] Allow to define a reusable set of constrai… · symfony/symfony@f53ea3d · GitHub
[go: up one dir, main page]

Skip to content

Commit f53ea3d

Browse files
committed
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 ------- 8f1b0df [Validator] Allow to define a reusable set of constraints
2 parents f4490a6 + 8f1b0df commit f53ea3d

File tree

7 files changed

+225
-2
lines changed

7 files changed

+225
-2
lines changed

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ CHANGELOG
66

77
* added the `Hostname` constraint and validator
88
* added option `alpha3` to `Country` constraint
9+
* allow to define a reusable set of constraints by extending the `Compound` constraint
910

1011
5.0.0
1112
-----

src/Symfony/Component/Validator/Constraint.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ public static function getErrorName($errorCode)
105105
*/
106106
public function __construct($options = null)
107107
{
108+
foreach ($this->normalizeOptions($options) as $name => $value) {
109+
$this->$name = $value;
110+
}
111+
}
112+
113+
protected function normalizeOptions($options): array
114+
{
115+
$normalizedOptions = [];
108116
$defaultOption = $this->getDefaultOption();
109117
$invalidOptions = [];
110118
$missingOptions = array_flip((array) $this->getRequiredOptions());
@@ -128,7 +136,7 @@ public function __construct($options = null)
128136
if ($options && \is_array($options) && \is_string(key($options))) {
129137
foreach ($options as $option => $value) {
130138
if (\array_key_exists($option, $knownOptions)) {
131-
$this->$option = $value;
139+
$normalizedOptions[$option] = $value;
132140
unset($missingOptions[$option]);
133141
} else {
134142
$invalidOptions[] = $option;
@@ -140,7 +148,7 @@ public function __construct($options = null)
140148
}
141149

142150
if (\array_key_exists($defaultOption, $knownOptions)) {
143-
$this->$defaultOption = $options;
151+
$normalizedOptions[$defaultOption] = $options;
144152
unset($missingOptions[$defaultOption]);
145153
} else {
146154
$invalidOptions[] = $defaultOption;
@@ -154,6 +162,8 @@ public function __construct($options = null)
154162
if (\count($missingOptions) > 0) {
155163
throw new MissingOptionsException(sprintf('The options "%s" must be set for constraint "%s".', implode('", "', array_keys($missingOptions)), static::class), array_keys($missingOptions));
156164
}
165+
166+
return $normalizedOptions;
157167
}
158168

159169
/**
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
16+
17+
/**
18+
* Extend this class to create a reusable set of constraints.
19+
*
20+
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
21+
*/
22+
abstract class Compound extends Composite
23+
{
24+
/** @var Constraint[] */
25+
public $constraints = [];
26+
27+
public function __construct($options = null)
28+
{
29+
if (isset($options[$this->getCompositeOption()])) {
30+
throw new ConstraintDefinitionException(sprintf('You can\'t redefine the "%s" option. Use the %s::getConstraints() method instead.', $this->getCompositeOption(), __CLASS__));
31+
}
32+
33+
$this->constraints = $this->getConstraints($this->normalizeOptions($options));
34+
35+
parent::__construct($options);
36+
}
37+
38+
final protected function getCompositeOption()
39+
{
40+
return 'constraints';
41+
}
42+
43+
final public function validatedBy()
44+
{
45+
return CompoundValidator::class;
46+
}
47+
48+
/**
49+
* @return Constraint[]
50+
*/
51+
abstract protected function getConstraints(array $options): array;
52+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\ConstraintValidator;
16+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
17+
18+
/**
19+
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
20+
*/
21+
class CompoundValidator extends ConstraintValidator
22+
{
23+
public function validate($value, Constraint $constraint)
24+
{
25+
if (!$constraint instanceof Compound) {
26+
throw new UnexpectedTypeException($constraint, Compound::class);
27+
}
28+
29+
$context = $this->context;
30+
31+
$validator = $context->getValidator()->inContext($context);
32+
33+
$validator->validate($value, $constraint->constraints);
34+
}
35+
}

src/Symfony/Component/Validator/Test/ConstraintValidatorTestCase.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ protected function expectValidateAt($i, $propertyPath, $value, $group)
181181
->willReturn($validator);
182182
}
183183

184+
protected function expectValidateValue(int $i, $value, array $constraints = [], $group = null)
185+
{
186+
$contextualValidator = $this->context->getValidator()->inContext($this->context);
187+
$contextualValidator->expects($this->at($i))
188+
->method('validate')
189+
->with($value, $constraints, $group)
190+
->willReturn($contextualValidator);
191+
}
192+
184193
protected function expectValidateValueAt($i, $propertyPath, $value, $constraints, $group = null)
185194
{
186195
$contextualValidator = $this->context->getValidator()->inContext($this->context);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Validator\Constraints\Compound;
16+
use Symfony\Component\Validator\Constraints\Length;
17+
use Symfony\Component\Validator\Constraints\NotBlank;
18+
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
19+
20+
class CompoundTest extends TestCase
21+
{
22+
public function testItCannotRedefineConstraintsOption()
23+
{
24+
$this->expectException(ConstraintDefinitionException::class);
25+
$this->expectExceptionMessage('You can\'t redefine the "constraints" option. Use the Symfony\Component\Validator\Constraints\Compound::getConstraints() method instead.');
26+
new EmptyCompound(['constraints' => [new NotBlank()]]);
27+
}
28+
29+
public function testCanDependOnNormalizedOptions()
30+
{
31+
$constraint = new ForwardingOptionCompound($min = 3);
32+
33+
$this->assertSame($min, $constraint->constraints[0]->min);
34+
}
35+
}
36+
37+
class EmptyCompound extends Compound
38+
{
39+
protected function getConstraints(array $options): array
40+
{
41+
return [];
42+
}
43+
}
44+
45+
class ForwardingOptionCompound extends Compound
46+
{
47+
public $min;
48+
49+
public function getDefaultOption()
50+
{
51+
return 'min';
52+
}
53+
54+
protected function getConstraints(array $options): array
55+
{
56+
return [
57+
new Length(['min' => $options['min'] ?? null]),
58+
];
59+
}
60+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Validator\Tests\Constraints;
13+
14+
use Symfony\Component\Validator\Constraints\Compound;
15+
use Symfony\Component\Validator\Constraints\CompoundValidator;
16+
use Symfony\Component\Validator\Constraints\Length;
17+
use Symfony\Component\Validator\Constraints\NotBlank;
18+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
19+
20+
class CompoundValidatorTest extends ConstraintValidatorTestCase
21+
{
22+
protected function createValidator()
23+
{
24+
return new CompoundValidator();
25+
}
26+
27+
public function testValidValue()
28+
{
29+
$this->validator->validate('foo', new DummyCompoundConstraint());
30+
31+
$this->assertNoViolation();
32+
}
33+
34+
public function testValidateWithConstraints()
35+
{
36+
$value = 'foo';
37+
$constraint = new DummyCompoundConstraint();
38+
39+
$this->expectValidateValue(0, $value, $constraint->constraints);
40+
41+
$this->validator->validate($value, $constraint);
42+
43+
$this->assertNoViolation();
44+
}
45+
}
46+
47+
class DummyCompoundConstraint extends Compound
48+
{
49+
protected function getConstraints(array $options): array
50+
{
51+
return [
52+
new NotBlank(),
53+
new Length(['max' => 3]),
54+
];
55+
}
56+
}

0 commit comments

Comments
 (0)
0