diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index c10797cabfa30..960195c95da4b 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -56,6 +56,8 @@ CHANGELOG ``` * Add support for ratio checks for SVG files to the `Image` constraint * Add the `Slug` constraint + * Add support for the `otherwise` option in the `When` constraint + * Add support for multiple fields containing nested constraints in `Composite` constraints 7.2 --- diff --git a/src/Symfony/Component/Validator/Constraints/Composite.php b/src/Symfony/Component/Validator/Constraints/Composite.php index 8cd0edde7db7b..deac22cc5570d 100644 --- a/src/Symfony/Component/Validator/Constraints/Composite.php +++ b/src/Symfony/Component/Validator/Constraints/Composite.php @@ -55,57 +55,58 @@ public function __construct(mixed $options = null, ?array $groups = null, mixed $this->initializeNestedConstraints(); - /* @var Constraint[] $nestedConstraints */ - $compositeOption = $this->getCompositeOption(); - $nestedConstraints = $this->$compositeOption; + foreach ((array) $this->getCompositeOption() as $option) { + /* @var Constraint[] $nestedConstraints */ + $nestedConstraints = $this->$option; - if (!\is_array($nestedConstraints)) { - $nestedConstraints = [$nestedConstraints]; - } - - foreach ($nestedConstraints as $constraint) { - if (!$constraint instanceof Constraint) { - if (\is_object($constraint)) { - $constraint = $constraint::class; - } - - throw new ConstraintDefinitionException(\sprintf('The value "%s" is not an instance of Constraint in constraint "%s".', $constraint, static::class)); + if (!\is_array($nestedConstraints)) { + $nestedConstraints = [$nestedConstraints]; } - if ($constraint instanceof Valid) { - 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)); - } - } + foreach ($nestedConstraints as $constraint) { + if (!$constraint instanceof Constraint) { + if (\is_object($constraint)) { + $constraint = get_debug_type($constraint); + } - if (!isset(((array) $this)['groups'])) { - $mergedGroups = []; + throw new ConstraintDefinitionException(\sprintf('The value "%s" is not an instance of Constraint in constraint "%s".', $constraint, get_debug_type($this))); + } - foreach ($nestedConstraints as $constraint) { - foreach ($constraint->groups as $group) { - $mergedGroups[$group] = true; + if ($constraint instanceof Valid) { + 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))); } } - // prevent empty composite constraint to have empty groups - $this->groups = array_keys($mergedGroups) ?: [self::DEFAULT_GROUP]; - $this->$compositeOption = $nestedConstraints; + if (!isset(((array) $this)['groups'])) { + $mergedGroups = []; - return; - } + foreach ($nestedConstraints as $constraint) { + foreach ($constraint->groups as $group) { + $mergedGroups[$group] = true; + } + } + + // prevent empty composite constraint to have empty groups + $this->groups = array_keys($mergedGroups) ?: [self::DEFAULT_GROUP]; + $this->$option = $nestedConstraints; - foreach ($nestedConstraints as $constraint) { - if (isset(((array) $constraint)['groups'])) { - $excessGroups = array_diff($constraint->groups, $this->groups); + continue; + } - if (\count($excessGroups) > 0) { - 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)); + foreach ($nestedConstraints as $constraint) { + if (isset(((array) $constraint)['groups'])) { + $excessGroups = array_diff($constraint->groups, $this->groups); + + if (\count($excessGroups) > 0) { + 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))); + } + } else { + $constraint->groups = $this->groups; } - } else { - $constraint->groups = $this->groups; } - } - $this->$compositeOption = $nestedConstraints; + $this->$option = $nestedConstraints; + } } /** @@ -115,18 +116,20 @@ public function addImplicitGroupName(string $group): void { parent::addImplicitGroupName($group); - /** @var Constraint[] $nestedConstraints */ - $nestedConstraints = $this->{$this->getCompositeOption()}; + foreach ((array) $this->getCompositeOption() as $option) { + /* @var Constraint[] $nestedConstraints */ + $nestedConstraints = (array) $this->$option; - foreach ($nestedConstraints as $constraint) { - $constraint->addImplicitGroupName($group); + foreach ($nestedConstraints as $constraint) { + $constraint->addImplicitGroupName($group); + } } } /** * Returns the name of the property that contains the nested constraints. */ - abstract protected function getCompositeOption(): string; + abstract protected function getCompositeOption(): array|string; /** * @internal Used by metadata @@ -135,8 +138,12 @@ abstract protected function getCompositeOption(): string; */ public function getNestedConstraints(): array { - /* @var Constraint[] $nestedConstraints */ - return $this->{$this->getCompositeOption()}; + $constraints = []; + foreach ((array) $this->getCompositeOption() as $option) { + $constraints = array_merge($constraints, (array) $this->$option); + } + + return $constraints; } /** diff --git a/src/Symfony/Component/Validator/Constraints/When.php b/src/Symfony/Component/Validator/Constraints/When.php index 12d5e7cc3eb42..5fe83ab53e12f 100644 --- a/src/Symfony/Component/Validator/Constraints/When.php +++ b/src/Symfony/Component/Validator/Constraints/When.php @@ -28,6 +28,7 @@ class When extends Composite public string|Expression $expression; public array|Constraint $constraints = []; public array $values = []; + public array|Constraint $otherwise = []; /** * @param string|Expression|array $expression The condition to evaluate, written with the ExpressionLanguage syntax @@ -35,9 +36,10 @@ class When extends Composite * @param array|null $values The values of the custom variables used in the expression (defaults to []) * @param string[]|null $groups * @param array|null $options + * @param Constraint[]|Constraint $otherwise One or multiple constraints that are applied if the expression returns false */ #[HasNamedArguments] - public function __construct(string|Expression|array $expression, array|Constraint|null $constraints = null, ?array $values = null, ?array $groups = null, $payload = null, ?array $options = null) + 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 = []) { if (!class_exists(ExpressionLanguage::class)) { 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 $options['expression'] = $expression; $options['constraints'] = $constraints; + $options['otherwise'] = $otherwise; } - if (isset($options['constraints']) && !\is_array($options['constraints'])) { + if (!\is_array($options['constraints'] ?? [])) { $options['constraints'] = [$options['constraints']]; } + if (!\is_array($options['otherwise'] ?? [])) { + $options['otherwise'] = [$options['otherwise']]; + } + if (null !== $groups) { $options['groups'] = $groups; } @@ -85,8 +92,8 @@ public function getTargets(): string|array return [self::CLASS_CONSTRAINT, self::PROPERTY_CONSTRAINT]; } - protected function getCompositeOption(): string + protected function getCompositeOption(): array|string { - return 'constraints'; + return ['constraints', 'otherwise']; } } diff --git a/src/Symfony/Component/Validator/Constraints/WhenValidator.php b/src/Symfony/Component/Validator/Constraints/WhenValidator.php index b41ba83ff48df..272bd86e90f24 100644 --- a/src/Symfony/Component/Validator/Constraints/WhenValidator.php +++ b/src/Symfony/Component/Validator/Constraints/WhenValidator.php @@ -35,9 +35,14 @@ public function validate(mixed $value, Constraint $constraint): void $variables['this'] = $context->getObject(); $variables['context'] = $context; - if ($this->getExpressionLanguage()->evaluate($constraint->expression, $variables)) { + $result = $this->getExpressionLanguage()->evaluate($constraint->expression, $variables); + + if ($result) { $context->getValidator()->inContext($context) ->validate($value, $constraint->constraints); + } elseif ($constraint->otherwise) { + $context->getValidator()->inContext($context) + ->validate($value, $constraint->otherwise); } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/CompositeTest.php b/src/Symfony/Component/Validator/Tests/Constraints/CompositeTest.php index a769a68e40809..9329ef1a2a022 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/CompositeTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/CompositeTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Composite; +use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\Constraints\Valid; @@ -23,9 +24,14 @@ class ConcreteComposite extends Composite { public array|Constraint $constraints = []; - protected function getCompositeOption(): string + public function __construct(mixed $options = null, public array|Constraint $otherNested = []) { - return 'constraints'; + parent::__construct($options); + } + + protected function getCompositeOption(): array + { + return ['constraints', 'otherNested']; } public function getDefaultOption(): ?string @@ -44,11 +50,14 @@ public function testConstraintHasDefaultGroup() $constraint = new ConcreteComposite([ new NotNull(), new NotBlank(), + ], [ + new Length(exactly: 10), ]); $this->assertEquals(['Default'], $constraint->groups); $this->assertEquals(['Default'], $constraint->constraints[0]->groups); $this->assertEquals(['Default'], $constraint->constraints[1]->groups); + $this->assertEquals(['Default'], $constraint->otherNested[0]->groups); } public function testNestedCompositeConstraintHasDefaultGroup() @@ -68,11 +77,14 @@ public function testMergeNestedGroupsIfNoExplicitParentGroup() $constraint = new ConcreteComposite([ new NotNull(groups: ['Default']), new NotBlank(groups: ['Default', 'Strict']), + ], [ + new Length(exactly: 10, groups: ['Default', 'Strict']), ]); $this->assertEquals(['Default', 'Strict'], $constraint->groups); $this->assertEquals(['Default'], $constraint->constraints[0]->groups); $this->assertEquals(['Default', 'Strict'], $constraint->constraints[1]->groups); + $this->assertEquals(['Default', 'Strict'], $constraint->otherNested[0]->groups); } public function testSetImplicitNestedGroupsIfExplicitParentGroup() @@ -82,12 +94,16 @@ public function testSetImplicitNestedGroupsIfExplicitParentGroup() new NotNull(), new NotBlank(), ], + 'otherNested' => [ + new Length(exactly: 10), + ], 'groups' => ['Default', 'Strict'], ]); $this->assertEquals(['Default', 'Strict'], $constraint->groups); $this->assertEquals(['Default', 'Strict'], $constraint->constraints[0]->groups); $this->assertEquals(['Default', 'Strict'], $constraint->constraints[1]->groups); + $this->assertEquals(['Default', 'Strict'], $constraint->otherNested[0]->groups); } public function testExplicitNestedGroupsMustBeSubsetOfExplicitParentGroups() @@ -97,12 +113,16 @@ public function testExplicitNestedGroupsMustBeSubsetOfExplicitParentGroups() new NotNull(groups: ['Default']), new NotBlank(groups: ['Strict']), ], + 'otherNested' => [ + new Length(exactly: 10, groups: ['Strict']), + ], 'groups' => ['Default', 'Strict'], ]); $this->assertEquals(['Default', 'Strict'], $constraint->groups); $this->assertEquals(['Default'], $constraint->constraints[0]->groups); $this->assertEquals(['Strict'], $constraint->constraints[1]->groups); + $this->assertEquals(['Strict'], $constraint->otherNested[0]->groups); } public function testFailIfExplicitNestedGroupsNotSubsetOfExplicitParentGroups() @@ -116,11 +136,27 @@ public function testFailIfExplicitNestedGroupsNotSubsetOfExplicitParentGroups() ]); } + public function testFailIfExplicitNestedGroupsNotSubsetOfExplicitParentGroupsInOtherNested() + { + $this->expectException(ConstraintDefinitionException::class); + new ConcreteComposite([ + 'constraints' => [ + new NotNull(groups: ['Default']), + ], + 'otherNested' => [ + new NotNull(groups: ['Default', 'Foobar']), + ], + 'groups' => ['Default', 'Strict'], + ]); + } + public function testImplicitGroupNamesAreForwarded() { $constraint = new ConcreteComposite([ new NotNull(groups: ['Default']), new NotBlank(groups: ['Strict']), + ], [ + new Length(exactly: 10, groups: ['Default']), ]); $constraint->addImplicitGroupName('ImplicitGroup'); @@ -128,14 +164,17 @@ public function testImplicitGroupNamesAreForwarded() $this->assertEquals(['Default', 'Strict', 'ImplicitGroup'], $constraint->groups); $this->assertEquals(['Default', 'ImplicitGroup'], $constraint->constraints[0]->groups); $this->assertEquals(['Strict'], $constraint->constraints[1]->groups); + $this->assertEquals(['Default', 'ImplicitGroup'], $constraint->otherNested[0]->groups); } public function testSingleConstraintsAccepted() { $nestedConstraint = new NotNull(); - $constraint = new ConcreteComposite($nestedConstraint); + $otherNestedConstraint = new Length(exactly: 10); + $constraint = new ConcreteComposite($nestedConstraint, $otherNestedConstraint); $this->assertEquals([$nestedConstraint], $constraint->constraints); + $this->assertEquals([$otherNestedConstraint], $constraint->otherNested); } public function testFailIfNoConstraint() @@ -147,6 +186,15 @@ public function testFailIfNoConstraint() ]); } + public function testFailIfNoConstraintInAnotherNested() + { + $this->expectException(ConstraintDefinitionException::class); + new ConcreteComposite([new NotNull()], [ + new NotNull(groups: ['Default']), + 'NotBlank', + ]); + } + public function testFailIfNoConstraintObject() { $this->expectException(ConstraintDefinitionException::class); @@ -156,6 +204,15 @@ public function testFailIfNoConstraintObject() ]); } + public function testFailIfNoConstraintObjectInAnotherNested() + { + $this->expectException(ConstraintDefinitionException::class); + new ConcreteComposite([new NotNull()], [ + new NotNull(groups: ['Default']), + new \ArrayObject(), + ]); + } + public function testValidCantBeNested() { $this->expectException(ConstraintDefinitionException::class); @@ -163,4 +220,10 @@ public function testValidCantBeNested() new Valid(), ]); } + + public function testValidCantBeNestedInAnotherNested() + { + $this->expectException(ConstraintDefinitionException::class); + new ConcreteComposite([new NotNull()], [new Valid()]); + } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithAttributes.php b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithAttributes.php index a683eb3c67940..31258dc0d192d 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithAttributes.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/Fixtures/WhenTestWithAttributes.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Validator\Tests\Constraints\Fixtures; use Symfony\Component\Validator\Constraints\Callback; +use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\Constraints\When; @@ -36,6 +37,9 @@ class WhenTestWithAttributes #[When(expression: 'true', constraints: new NotNull(), groups: ['foo'])] private $qux; + #[When(expression: 'true', constraints: new NotNull(), otherwise: new Length(exactly: 10), groups: ['foo'])] + private $quux; + #[When(expression: 'true', constraints: [ new NotNull(), new NotBlank(), diff --git a/src/Symfony/Component/Validator/Tests/Constraints/WhenTest.php b/src/Symfony/Component/Validator/Tests/Constraints/WhenTest.php index fa71de02e85d5..6516a10148080 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/WhenTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/WhenTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints\Callback; +use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotNull; use Symfony\Component\Validator\Constraints\When; @@ -59,6 +60,7 @@ public function testAttributes() groups: ['Default', 'WhenTestWithAttributes'], ), ], $classConstraint->constraints); + self::assertEmpty($classConstraint->otherwise); [$fooConstraint] = $metadata->properties['foo']->getConstraints(); @@ -68,6 +70,7 @@ public function testAttributes() new NotNull(groups: ['Default', 'WhenTestWithAttributes']), new NotBlank(groups: ['Default', 'WhenTestWithAttributes']), ], $fooConstraint->constraints); + self::assertEmpty($fooConstraint->otherwise); self::assertSame(['Default', 'WhenTestWithAttributes'], $fooConstraint->groups); [$barConstraint] = $metadata->properties['bar']->getConstraints(); @@ -78,6 +81,7 @@ public function testAttributes() new NotNull(groups: ['foo']), new NotBlank(groups: ['foo']), ], $barConstraint->constraints); + self::assertEmpty($barConstraint->otherwise); self::assertSame(['foo'], $barConstraint->groups); [$quxConstraint] = $metadata->properties['qux']->getConstraints(); @@ -85,6 +89,7 @@ public function testAttributes() self::assertInstanceOf(When::class, $quxConstraint); self::assertSame('true', $quxConstraint->expression); self::assertEquals([new NotNull(groups: ['foo'])], $quxConstraint->constraints); + self::assertEmpty($quxConstraint->otherwise); self::assertSame(['foo'], $quxConstraint->groups); [$bazConstraint] = $metadata->getters['baz']->getConstraints(); @@ -95,6 +100,15 @@ public function testAttributes() new NotNull(groups: ['Default', 'WhenTestWithAttributes']), new NotBlank(groups: ['Default', 'WhenTestWithAttributes']), ], $bazConstraint->constraints); + self::assertEmpty($bazConstraint->otherwise); self::assertSame(['Default', 'WhenTestWithAttributes'], $bazConstraint->groups); + + [$quuxConstraint] = $metadata->properties['quux']->getConstraints(); + + self::assertInstanceOf(When::class, $quuxConstraint); + self::assertSame('true', $quuxConstraint->expression); + self::assertEquals([new NotNull(groups: ['foo'])], $quuxConstraint->constraints); + self::assertEquals([new Length(exactly: 10, groups: ['foo'])], $quuxConstraint->otherwise); + self::assertSame(['foo'], $quuxConstraint->groups); } } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/WhenValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/WhenValidatorTest.php index 019ec828f4aac..501398d2a53a9 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/WhenValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/WhenValidatorTest.php @@ -13,6 +13,7 @@ use Symfony\Component\Validator\Constraints\Blank; use Symfony\Component\Validator\Constraints\Callback; +use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NegativeOrZero; use Symfony\Component\Validator\Constraints\NotBlank; use Symfony\Component\Validator\Constraints\NotNull; @@ -50,6 +51,20 @@ public function testConstraintIsExecuted() )); } + public function testOtherwiseIsExecutedWhenFalse() + { + $constraint = new NotNull(); + $otherwise = new Length(exactly: 10); + + $this->expectValidateValue(0, 'Foo', [$otherwise]); + + $this->validator->validate('Foo', new When( + expression: 'false', + constraints: $constraint, + otherwise: $otherwise, + )); + } + public function testConstraintsAreExecutedWithNull() { $constraints = [ @@ -159,6 +174,21 @@ public function testConstraintsNotExecuted() $this->assertNoViolation(); } + public function testOtherwiseIsExecutedWhenTrue() + { + $constraints = [new NotNull()]; + + $this->expectValidateValue(0, '', $constraints); + + $this->validator->validate('', new When( + expression: 'true', + constraints: $constraints, + otherwise: new Length(exactly: 10), + )); + + $this->assertNoViolation(); + } + public function testConstraintsNotExecutedWithObject() { $number = new \stdClass();