Skip to content

Commit 4d3393f

Browse files
[Validator] Add support for the otherwise option in the When constraint
1 parent 7e9ecaf commit 4d3393f

File tree

8 files changed

+184
-52
lines changed

8 files changed

+184
-52
lines changed

src/Symfony/Component/Validator/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ CHANGELOG
5656
```
5757
* Add support for ratio checks for SVG files to the `Image` constraint
5858
* Add the `Slug` constraint
59+
* Add support for the `otherwise` option in the `When` constraint
60+
* Add support for multiple fields containing nested constraints in `Composite` constraints
5961

6062
7.2
6163
---

src/Symfony/Component/Validator/Constraints/Composite.php

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -55,57 +55,58 @@ public function __construct(mixed $options = null, ?array $groups = null, mixed
5555

5656
$this->initializeNestedConstraints();
5757

58-
/* @var Constraint[] $nestedConstraints */
59-
$compositeOption = $this->getCompositeOption();
60-
$nestedConstraints = $this->$compositeOption;
58+
foreach ((array) $this->getCompositeOption() as $option) {
59+
/* @var Constraint[] $nestedConstraints */
60+
$nestedConstraints = $this->$option;
6161

62-
if (!\is_array($nestedConstraints)) {
63-
$nestedConstraints = [$nestedConstraints];
64-
}
65-
66-
foreach ($nestedConstraints as $constraint) {
67-
if (!$constraint instanceof Constraint) {
68-
if (\is_object($constraint)) {
69-
$constraint = $constraint::class;
70-
}
71-
72-
throw new ConstraintDefinitionException(\sprintf('The value "%s" is not an instance of Constraint in constraint "%s".', $constraint, static::class));
62+
if (!\is_array($nestedConstraints)) {
63+
$nestedConstraints = [$nestedConstraints];
7364
}
7465

75-
if ($constraint instanceof Valid) {
76-
throw new ConstraintDefinitionException(\sprintf('The constraint Valid cannot be nested inside constraint "%s". You can only declare the Valid constraint directly on a field or method.', static::class));
77-
}
78-
}
66+
foreach ($nestedConstraints as $constraint) {
67+
if (!$constraint instanceof Constraint) {
68+
if (\is_object($constraint)) {
69+
$constraint = get_debug_type($constraint);
70+
}
7971

80-
if (!isset(((array) $this)['groups'])) {
81-
$mergedGroups = [];
72+
throw new ConstraintDefinitionException(\sprintf('The value "%s" is not an instance of Constraint in constraint "%s".', $constraint, get_debug_type($this)));
73+
}
8274

83-
foreach ($nestedConstraints as $constraint) {
84-
foreach ($constraint->groups as $group) {
85-
$mergedGroups[$group] = true;
75+
if ($constraint instanceof Valid) {
76+
throw new ConstraintDefinitionException(\sprintf('The constraint Valid cannot be nested inside constraint "%s". You can only declare the Valid constraint directly on a field or method.', get_debug_type($this)));
8677
}
8778
}
8879

89-
// prevent empty composite constraint to have empty groups
90-
$this->groups = array_keys($mergedGroups) ?: [self::DEFAULT_GROUP];
91-
$this->$compositeOption = $nestedConstraints;
80+
if (!isset(((array) $this)['groups'])) {
81+
$mergedGroups = [];
9282

93-
return;
94-
}
83+
foreach ($nestedConstraints as $constraint) {
84+
foreach ($constraint->groups as $group) {
85+
$mergedGroups[$group] = true;
86+
}
87+
}
88+
89+
// prevent empty composite constraint to have empty groups
90+
$this->groups = array_keys($mergedGroups) ?: [self::DEFAULT_GROUP];
91+
$this->$option = $nestedConstraints;
9592

96-
foreach ($nestedConstraints as $constraint) {
97-
if (isset(((array) $constraint)['groups'])) {
98-
$excessGroups = array_diff($constraint->groups, $this->groups);
93+
continue;
94+
}
9995

100-
if (\count($excessGroups) > 0) {
101-
throw new ConstraintDefinitionException(\sprintf('The group(s) "%s" passed to the constraint "%s" should also be passed to its containing constraint "%s".', implode('", "', $excessGroups), get_debug_type($constraint), static::class));
96+
foreach ($nestedConstraints as $constraint) {
97+
if (isset(((array) $constraint)['groups'])) {
98+
$excessGroups = array_diff($constraint->groups, $this->groups);
99+
100+
if (\count($excessGroups) > 0) {
101+
throw new ConstraintDefinitionException(\sprintf('The group(s) "%s" passed to the constraint "%s" should also be passed to its containing constraint "%s".', implode('", "', $excessGroups), get_debug_type($constraint), get_debug_type($this)));
102+
}
103+
} else {
104+
$constraint->groups = $this->groups;
102105
}
103-
} else {
104-
$constraint->groups = $this->groups;
105106
}
106-
}
107107

108-
$this->$compositeOption = $nestedConstraints;
108+
$this->$option = $nestedConstraints;
109+
}
109110
}
110111

111112
/**
@@ -115,18 +116,20 @@ public function addImplicitGroupName(string $group): void
115116
{
116117
parent::addImplicitGroupName($group);
117118

118-
/** @var Constraint[] $nestedConstraints */
119-
$nestedConstraints = $this->{$this->getCompositeOption()};
119+
foreach ((array) $this->getCompositeOption() as $option) {
120+
/* @var Constraint[] $nestedConstraints */
121+
$nestedConstraints = (array) $this->$option;
120122

121-
foreach ($nestedConstraints as $constraint) {
122-
$constraint->addImplicitGroupName($group);
123+
foreach ($nestedConstraints as $constraint) {
124+
$constraint->addImplicitGroupName($group);
125+
}
123126
}
124127
}
125128

126129
/**
127130
* Returns the name of the property that contains the nested constraints.
128131
*/
129-
abstract protected function getCompositeOption(): string;
132+
abstract protected function getCompositeOption(): array|string;
130133

131134
/**
132135
* @internal Used by metadata
@@ -135,8 +138,12 @@ abstract protected function getCompositeOption(): string;
135138
*/
136139
public function getNestedConstraints(): array
137140
{
138-
/* @var Constraint[] $nestedConstraints */
139-
return $this->{$this->getCompositeOption()};
141+
$constraints = [];
142+
foreach ((array) $this->getCompositeOption() as $option) {
143+
$constraints = array_merge($constraints, (array) $this->$option);
144+
}
145+
146+
return $constraints;
140147
}
141148

142149
/**

src/Symfony/Component/Validator/Constraints/When.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,18 @@ class When extends Composite
2828
public string|Expression $expression;
2929
public array|Constraint $constraints = [];
3030
public array $values = [];
31+
public array|Constraint $otherwise = [];
3132

3233
/**
3334
* @param string|Expression|array<string,mixed> $expression The condition to evaluate, written with the ExpressionLanguage syntax
3435
* @param Constraint[]|Constraint|null $constraints One or multiple constraints that are applied if the expression returns true
3536
* @param array<string,mixed>|null $values The values of the custom variables used in the expression (defaults to [])
3637
* @param string[]|null $groups
3738
* @param array<string,mixed>|null $options
39+
* @param Constraint[]|Constraint $otherwise One or multiple constraints that are applied if the expression returns false
3840
*/
3941
#[HasNamedArguments]
40-
public function __construct(string|Expression|array $expression, array|Constraint|null $constraints = null, ?array $values = null, ?array $groups = null, $payload = null, ?array $options = null)
42+
public function __construct(string|Expression|array $expression, array|Constraint|null $constraints = null, ?array $values = null, ?array $groups = null, $payload = null, ?array $options = null, array|Constraint $otherwise = [])
4143
{
4244
if (!class_exists(ExpressionLanguage::class)) {
4345
throw new LogicException(\sprintf('The "symfony/expression-language" component is required to use the "%s" constraint. Try running "composer require symfony/expression-language".', __CLASS__));
@@ -56,12 +58,17 @@ public function __construct(string|Expression|array $expression, array|Constrain
5658

5759
$options['expression'] = $expression;
5860
$options['constraints'] = $constraints;
61+
$options['otherwise'] = $otherwise;
5962
}
6063

61-
if (isset($options['constraints']) && !\is_array($options['constraints'])) {
64+
if (!\is_array($options['constraints'] ?? [])) {
6265
$options['constraints'] = [$options['constraints']];
6366
}
6467

68+
if (!\is_array($options['otherwise'] ?? [])) {
69+
$options['otherwise'] = [$options['otherwise']];
70+
}
71+
6572
if (null !== $groups) {
6673
$options['groups'] = $groups;
6774
}
@@ -85,8 +92,8 @@ public function getTargets(): string|array
8592
return [self::CLASS_CONSTRAINT, self::PROPERTY_CONSTRAINT];
8693
}
8794

88-
protected function getCompositeOption(): string
95+
protected function getCompositeOption(): array|string
8996
{
90-
return 'constraints';
97+
return ['constraints', 'otherwise'];
9198
}
9299
}

src/Symfony/Component/Validator/Constraints/WhenValidator.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,14 @@ public function validate(mixed $value, Constraint $constraint): void
3535
$variables['this'] = $context->getObject();
3636
$variables['context'] = $context;
3737

38-
if ($this->getExpressionLanguage()->evaluate($constraint->expression, $variables)) {
38+
$result = $this->getExpressionLanguage()->evaluate($constraint->expression, $variables);
39+
40+
if ($result) {
3941
$context->getValidator()->inContext($context)
4042
->validate($value, $constraint->constraints);
43+
} elseif ($constraint->otherwise) {
44+
$context->getValidator()->inContext($context)
45+
->validate($value, $constraint->otherwise);
4146
}
4247
}
4348

src/Symfony/Component/Validator/Tests/Constraints/CompositeTest.php

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\Validator\Constraint;
1616
use Symfony\Component\Validator\Constraints\Composite;
17+
use Symfony\Component\Validator\Constraints\Length;
1718
use Symfony\Component\Validator\Constraints\NotBlank;
1819
use Symfony\Component\Validator\Constraints\NotNull;
1920
use Symfony\Component\Validator\Constraints\Valid;
@@ -23,9 +24,14 @@ class ConcreteComposite extends Composite
2324
{
2425
public array|Constraint $constraints = [];
2526

26-
protected function getCompositeOption(): string
27+
public function __construct(mixed $options = null, public array|Constraint $otherNested = [])
2728
{
28-
return 'constraints';
29+
parent::__construct($options);
30+
}
31+
32+
protected function getCompositeOption(): array
33+
{
34+
return ['constraints', 'otherNested'];
2935
}
3036

3137
public function getDefaultOption(): ?string
@@ -44,11 +50,14 @@ public function testConstraintHasDefaultGroup()
4450
$constraint = new ConcreteComposite([
4551
new NotNull(),
4652
new NotBlank(),
53+
], [
54+
new Length(exactly: 10),
4755
]);
4856

4957
$this->assertEquals(['Default'], $constraint->groups);
5058
$this->assertEquals(['Default'], $constraint->constraints[0]->groups);
5159
$this->assertEquals(['Default'], $constraint->constraints[1]->groups);
60+
$this->assertEquals(['Default'], $constraint->otherNested[0]->groups);
5261
}
5362

5463
public function testNestedCompositeConstraintHasDefaultGroup()
@@ -68,11 +77,14 @@ public function testMergeNestedGroupsIfNoExplicitParentGroup()
6877
$constraint = new ConcreteComposite([
6978
new NotNull(groups: ['Default']),
7079
new NotBlank(groups: ['Default', 'Strict']),
80+
], [
81+
new Length(exactly: 10, groups: ['Default', 'Strict']),
7182
]);
7283

7384
$this->assertEquals(['Default', 'Strict'], $constraint->groups);
7485
$this->assertEquals(['Default'], $constraint->constraints[0]->groups);
7586
$this->assertEquals(['Default', 'Strict'], $constraint->constraints[1]->groups);
87+
$this->assertEquals(['Default', 'Strict'], $constraint->otherNested[0]->groups);
7688
}
7789

7890
public function testSetImplicitNestedGroupsIfExplicitParentGroup()
@@ -82,12 +94,16 @@ public function testSetImplicitNestedGroupsIfExplicitParentGroup()
8294
new NotNull(),
8395
new NotBlank(),
8496
],
97+
'otherNested' => [
98+
new Length(exactly: 10),
99+
],
85100
'groups' => ['Default', 'Strict'],
86101
]);
87102

88103
$this->assertEquals(['Default', 'Strict'], $constraint->groups);
89104
$this->assertEquals(['Default', 'Strict'], $constraint->constraints[0]->groups);
90105
$this->assertEquals(['Default', 'Strict'], $constraint->constraints[1]->groups);
106+
$this->assertEquals(['Default', 'Strict'], $constraint->otherNested[0]->groups);
91107
}
92108

93109
public function testExplicitNestedGroupsMustBeSubsetOfExplicitParentGroups()
@@ -97,12 +113,16 @@ public function testExplicitNestedGroupsMustBeSubsetOfExplicitParentGroups()
97113
new NotNull(groups: ['Default']),
98114
new NotBlank(groups: ['Strict']),
99115
],
116+
'otherNested' => [
117+
new Length(exactly: 10, groups: ['Strict']),
118+
],
100119
'groups' => ['Default', 'Strict'],
101120
]);
102121

103122
$this->assertEquals(['Default', 'Strict'], $constraint->groups);
104123
$this->assertEquals(['Default'], $constraint->constraints[0]->groups);
105124
$this->assertEquals(['Strict'], $constraint->constraints[1]->groups);
125+
$this->assertEquals(['Strict'], $constraint->otherNested[0]->groups);
106126
}
107127

108128
public function testFailIfExplicitNestedGroupsNotSubsetOfExplicitParentGroups()
@@ -116,26 +136,45 @@ public function testFailIfExplicitNestedGroupsNotSubsetOfExplicitParentGroups()
116136
]);
117137
}
118138

139+
public function testFailIfExplicitNestedGroupsNotSubsetOfExplicitParentGroupsInOtherNested()
140+
{
141+
$this->expectException(ConstraintDefinitionException::class);
142+
new ConcreteComposite([
143+
'constraints' => [
144+
new NotNull(groups: ['Default']),
145+
],
146+
'otherNested' => [
147+
new NotNull(groups: ['Default', 'Foobar']),
148+
],
149+
'groups' => ['Default', 'Strict'],
150+
]);
151+
}
152+
119153
public function testImplicitGroupNamesAreForwarded()
120154
{
121155
$constraint = new ConcreteComposite([
122156
new NotNull(groups: ['Default']),
123157
new NotBlank(groups: ['Strict']),
158+
], [
159+
new Length(exactly: 10, groups: ['Default']),
124160
]);
125161

126162
$constraint->addImplicitGroupName('ImplicitGroup');
127163

128164
$this->assertEquals(['Default', 'Strict', 'ImplicitGroup'], $constraint->groups);
129165
$this->assertEquals(['Default', 'ImplicitGroup'], $constraint->constraints[0]->groups);
130166
$this->assertEquals(['Strict'], $constraint->constraints[1]->groups);
167+
$this->assertEquals(['Default', 'ImplicitGroup'], $constraint->otherNested[0]->groups);
131168
}
132169

133170
public function testSingleConstraintsAccepted()
134171
{
135172
$nestedConstraint = new NotNull();
136-
$constraint = new ConcreteComposite($nestedConstraint);
173+
$otherNestedConstraint = new Length(exactly: 10);
174+
$constraint = new ConcreteComposite($nestedConstraint, $otherNestedConstraint);
137175

138176
$this->assertEquals([$nestedConstraint], $constraint->constraints);
177+
$this->assertEquals([$otherNestedConstraint], $constraint->otherNested);
139178
}
140179

141180
public function testFailIfNoConstraint()
@@ -147,6 +186,15 @@ public function testFailIfNoConstraint()
147186
]);
148187
}
149188

189+
public function testFailIfNoConstraintInAnotherNested()
190+
{
191+
$this->expectException(ConstraintDefinitionException::class);
192+
new ConcreteComposite([new NotNull()], [
193+
new NotNull(groups: ['Default']),
194+
'NotBlank',
195+
]);
196+
}
197+
150198
public function testFailIfNoConstraintObject()
151199
{
152200
$this->expectException(ConstraintDefinitionException::class);
@@ -156,11 +204,26 @@ public function testFailIfNoConstraintObject()
156204
]);
157205
}
158206

207+
public function testFailIfNoConstraintObjectInAnotherNested()
208+
{
209+
$this->expectException(ConstraintDefinitionException::class);
210+
new ConcreteComposite([new NotNull()], [
211+
new NotNull(groups: ['Default']),
212+
new \ArrayObject(),
213+
]);
214+
}
215+
159216
public function testValidCantBeNested()
160217
{
161218
$this->expectException(ConstraintDefinitionException::class);
162219
new ConcreteComposite([
163220
new Valid(),
164221
]);
165222
}
223+
224+
public function testValidCantBeNestedInAnotherNested()
225+
{
226+
$this->expectException(ConstraintDefinitionException::class);
227+
new ConcreteComposite([new NotNull()], [new Valid()]);
228+
}
166229
}

0 commit comments

Comments
 (0)