8000 [Validator] Add support for the `otherwise` option in the `When` constraint by alexandre-daubois · Pull Request #59634 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Validator] Add support for the otherwise option in the When constraint #59634

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Symfony/Component/Validator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
95 changes: 51 additions & 44 deletions src/Symfony/Component/Validator/Constraints/Composite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

/**
Expand All @@ -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
Expand All @@ -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;
}

/**
Expand Down
15 changes: 11 additions & 4 deletions src/Symfony/Component/Validator/Constraints/When.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,18 @@ class When extends Composite
public string|Expression $expression;
public array|Constraint $constraints = [];
public array $values = [];
public array|Constraint $otherwise = [];

/**
* @param string|Expression|array<string,mixed> $expression The condition to evaluate, written with the ExpressionLanguage syntax
* @param Constraint[]|Constraint|null $constraints One or multiple constraints that are applied if the expression returns true
* @param array<string,mixed>|null $values The values of the custom variables used in the expression (defaults to [])
* @param string[]|null $groups
* @param array<string,mixed>|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__));
Expand All @@ -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;
}
Expand All @@ -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'];
}
}
6D40
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -116,26 +136,45 @@ 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');

$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()
Expand All @@ -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);
Expand All @@ -156,11 +204,26 @@ 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);
new ConcreteComposite([
new Valid(),
]);
}

public function testValidCantBeNestedInAnotherNested()
{
$this->expectException(ConstraintDefinitionException::class);
new ConcreteComposite([new NotNull()], [new Valid()]);
}
}
Loading
Loading
0