Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

[Validator] Add support for enum in Choice constraint #59633

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

Closed
wants to merge 1 commit into from
Closed
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
1 change: 1 addition & 0 deletions 1 src/Symfony/Component/Validator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ CHANGELOG
```
* Add support for ratio checks for SVG files to the `Image` constraint
* Add the `Slug` constraint
* Add support for enums in `Choice` constraint
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enums are already supported

This PR add supports for using a class-string to specify the list of possible values ( integer or string )


7.2
---
Expand Down
2 changes: 1 addition & 1 deletion 2 src/Symfony/Component/Validator/ConstraintValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ protected function formatValue(mixed $value, int $format = 0): string
}

if ($value instanceof \UnitEnum) {
return $value->name;
$value = $value instanceof \BackedEnum ? $value->value : $value->name;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally don't think this is a good idea.

Enum case names are helpful for developers, scalar equivalent less so.

Moreover, this will have an impact on other constraints, right ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, I don't need this enum functionality in choice constraint (just had an idea & started coding), but this line of code should be fixed upstream. In the BackedEnumNormalizer enums are currently (de)normalized from/to ->value, here it's ->name. Using some {{ value }} parameter in the messages block can confuse some, because input value is not same as displayed.

Btw this should not break anything, may third-party tests depending on this functionality. Haven't seen much enum usages in constraints and tests worked smooth for the other part of code.

PS: Is there any reason why there's no UnitEnumNormalizer, just BackedEnumNormalizer? If it makes sense to implement (for me it does), I'll create a new MR for that :-)

}

if (\is_object($value)) {
Expand Down
28 changes: 17 additions & 11 deletions 28 src/Symfony/Component/Validator/Constraints/Choice.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* Validates that a value is one of a given set of valid choices.
*
* @author Bernhard Schussek <bschussek@gmail.com>
* @author Ninos Ego <me@ninosego.de>
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Choice extends Constraint
Expand All @@ -32,7 +33,8 @@ class Choice extends Constraint
self::TOO_MANY_ERROR => 'TOO_MANY_ERROR',
];

public ?array $choices = null;
/** @var \class-string|array|null */
public string|array|null $choices = null;
/** @var callable|string|null */
public $callback;
public bool $multiple = false;
Expand All @@ -45,25 +47,27 @@ class Choice extends Constraint
public string $maxMessage = 'You must select at most {{ limit }} choice.|You must select at most {{ limit }} choices.';
public bool $match = true;

public ?\Closure $normalizer;

public function getDefaultOption(): ?string
{
return 'choices';
}

/**
* @param array|null $choices An array of choices (required unless a callback is specified)
* @param callable|string|null $callback Callback method to use instead of the choice option to get the choices
* @param bool|null $multiple Whether to expect the value to be an array of valid choices (defaults to false)
* @param bool|null $strict This option defaults to true and should not be used
* @param int<0, max>|null $min Minimum of valid choices if multiple values are expected
* @param positive-int|null $max Maximum of valid choices if multiple values are expected
* @param string[]|null $groups
* @param bool|null $match Whether to validate the values are part of the choices or not (defaults to true)
* @param \class-string|array|null $choices An enum or array of choices (required unless a callback is specified)
* @param callable|string|null $callback Callback method to use instead of the choice option to get the choices
* @param bool|null $multiple Whether to expect the value to be an array of valid choices (defaults to false)
* @param bool|null $strict This option defaults to true and should not be used
* @param int<0, max>|null $min Minimum of valid choices if multiple values are expected
* @param positive-int|null $max Maximum of valid choices if multiple values are expected
* @param string[]|null $groups
* @param bool|null $match Whether to validate the values are part of the choices or not (defaults to true)
*/
#[HasNamedArguments]
public function __construct(
string|array $options = [],
?array $choices = null,
string|array|null $choices = null,
callable|string|null $callback = null,
?bool $multiple = null,
?bool $strict = null,
Expand All @@ -73,11 +77,12 @@ public function __construct(
?string $multipleMessage = null,
?string $minMessage = null,
?string $maxMessage = null,
?callable $normalizer = null,
?array $groups = null,
mixed $payload = null,
?bool $match = null,
) {
if (\is_array($options) && $options && array_is_list($options)) {
if (\is_array($options) && $options && \array_is_list($options)) {
$choices ??= $options;
$options = [];
} elseif (\is_array($options) && [] !== $options) {
Expand All @@ -100,5 +105,6 @@ public function __construct(
$this->minMessage = $minMessage ?? $this->minMessage;
$this->maxMessage = $maxMessage ?? $this->maxMessage;
$this->match = $match ?? $this->match;
$this->normalizer = null !== $normalizer ? $normalizer(...) : null;
}
}
19 changes: 16 additions & 3 deletions 19 src/Symfony/Component/Validator/Constraints/ChoiceValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
* @author Fabien Potencier <fabien@symfony.com>
* @author Florian Eckerstorfer <florian@eckerstorfer.org>
* @author Bernhard Schussek <bschussek@gmail.com>
* @author Ninos Ego <me@ninosego.de>
*/
class ChoiceValidator extends ConstraintValidator
{
Expand All @@ -33,16 +34,24 @@ public function validate(mixed $value, Constraint $constraint): void
throw new UnexpectedTypeException($constraint, Choice::class);
}

if (!\is_array($constraint->choices) && !$constraint->callback) {
if (null === $constraint->choices && !$constraint->callback) {
throw new ConstraintDefinitionException('Either "choices" or "callback" must be specified on constraint Choice.');
}

if (null !== $constraint->choices && !\is_array($constraint->choices) && (!\is_string($constraint->choices) || !\enum_exists($constraint->choices))) {
throw new ConstraintDefinitionException('"choices" must be of type array or enum-class.');
}

if (null === $value) {
return;
}

if ($constraint->multiple && !\is_array($value)) {
throw new UnexpectedValueException($value, 'array');
if (null !== $constraint->normalizer) {
$value = ($constraint->normalizer)($value);
}

if ($constraint->multiple && !\is_array($value) && !$value instanceof \IteratorAggregate) {
throw new UnexpectedValueException($value, 'array|IteratorAggregate');
}

if ($constraint->callback) {
Expand All @@ -56,6 +65,10 @@ public function validate(mixed $value, Constraint $constraint): void
if (!\is_array($choices)) {
throw new ConstraintDefinitionException(\sprintf('The Choice constraint callback "%s" is expected to return an array, but returned "%s".', trim($this->formatValue($constraint->callback), '"'), get_debug_type($choices)));
}
} elseif (\is_string($constraint->choices) && \enum_exists($constraint->choices)) {
$choices = \array_map(static function(\UnitEnum $value): string|int {
return $value instanceof \BackedEnum ? $value->value : $value->name;
}, $constraint->choices::cases());
} else {
$choices = $constraint->choices;
}
Expand Down
2 changes: 1 addition & 1 deletion 2 src/Symfony/Component/Validator/Constraints/Cidr.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class Cidr extends Constraint

protected const ERROR_NAMES = [
self::INVALID_CIDR_ERROR => 'INVALID_CIDR_ERROR',
self::OUT_OF_RANGE_ERROR => 'OUT_OF_RANGE_VIOLATION',
self::OUT_OF_RANGE_ERROR => 'OUT_OF_RANGE_ERROR',
];

private const NET_MAXES = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Tests\Fixtures\TestEnum;
use Symfony\Component\Validator\Tests\Fixtures\TestEnumBackendInteger;
use Symfony\Component\Validator\Tests\Fixtures\TestEnumBackendString;
use Symfony\Component\Validator\Tests\Fixtures\TestEnumUnit;

class ConstraintValidatorTest extends TestCase
{
Expand Down Expand Up @@ -49,7 +51,9 @@ public static function formatValueProvider(): array
[class_exists(\IntlDateFormatter::class) ? static::normalizeIcuSpaces("Feb 2, 1971, 8:00\u{202F}AM") : '1971-02-02 08:00:00', $dateTime, ConstraintValidator::PRETTY_DATE],
[class_exists(\IntlDateFormatter::class) ? static::normalizeIcuSpaces("Jan 1, 1970, 6:00\u{202F}AM") : '1970-01-01 06:00:00', new \DateTimeImmutable('1970-01-01T06:00:00Z'), ConstraintValidator::PRETTY_DATE],
[class_exists(\IntlDateFormatter::class) ? static::normalizeIcuSpaces("Jan 1, 1970, 3:00\u{202F}PM") : '1970-01-01 15:00:00', (new \DateTimeImmutable('1970-01-01T23:00:00'))->setTimezone(new \DateTimeZone('America/New_York')), ConstraintValidator::PRETTY_DATE],
['FirstCase', TestEnum::FirstCase],
['"FirstCase"', TestEnumUnit::FirstCase],
['"a"', TestEnumBackendString::FirstCase],
['3', TestEnumBackendInteger::FirstCase],
];

date_default_timezone_set($defaultTimezone);
Expand Down
31 changes: 31 additions & 0 deletions 31 src/Symfony/Component/Validator/Tests/Constraints/ChoiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,19 @@
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Mapping\Loader\AttributeLoader;
use Symfony\Component\Validator\Tests\Fixtures\ConstraintChoiceWithPreset;
use Symfony\Component\Validator\Tests\Fixtures\TestEnumBackendInteger;
use Symfony\Component\Validator\Tests\Fixtures\TestEnumBackendString;
use Symfony\Component\Validator\Tests\Fixtures\TestEnumUnit;

class ChoiceTest extends TestCase
{
public function testNormalizerCanBeSet()
{
$choice = new Choice(normalizer: 'trim');

$this->assertEquals(trim(...), $choice->normalizer);
}

public function testSetDefaultPropertyChoice()
{
$constraint = new ConstraintChoiceWithPreset('A');
Expand Down Expand Up @@ -52,6 +62,18 @@ public function testAttributes()
/** @var Choice $stringIndexedConstraint */
[$stringIndexedConstraint] = $metadata->properties['stringIndexed']->getConstraints();
self::assertSame(['one' => 1, 'two' => 2], $stringIndexedConstraint->choices);

/** @var Choice $enumUnitConstraint */
[$enumUnitConstraint] = $metadata->properties['enumUnit']->getConstraints();
self::assertSame(TestEnumUnit::class, $enumUnitConstraint->choices);

/** @var Choice $enumBackendStringConstraint */
[$enumBackendStringConstraint] = $metadata->properties['enumBackendString']->getConstraints();
self::assertSame(TestEnumBackendString::class, $enumBackendStringConstraint->choices);

/** @var Choice $enumBackendIntegerConstraint */
[$enumBackendIntegerConstraint] = $metadata->properties['enumBackendInteger']->getConstraints();
self::assertSame(TestEnumBackendInteger::class, $enumBackendIntegerConstraint->choices);
}
}

Expand All @@ -68,4 +90,13 @@ class ChoiceDummy

#[Choice(choices: ['one' => 1, 'two' => 2])]
private $stringIndexed;

#[Choice(choices: TestEnumUnit::class)]
private $enumUnit;

#[Choice(choices: TestEnumBackendString::class)]
private $enumBackendString;

#[Choice(choices: TestEnumBackendInteger::class)]
private $enumBackendInteger;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
use Symfony\Component\Validator\Tests\Fixtures\TestEnumBackendInteger;
use Symfony\Component\Validator\Tests\Fixtures\TestEnumBackendString;
use Symfony\Component\Validator\Tests\Fixtures\TestEnumUnit;

function choice_callback()
{
Expand Down Expand Up @@ -119,8 +122,8 @@ public function testValidChoiceCallbackFunction(Choice $constraint)

public static function provideConstraintsWithCallbackFunction(): iterable
{
yield 'named arguments, namespaced function' => [new Choice(callback: __NAMESPACE__.'\choice_callback')];
yield 'named arguments, closure' => [new Choice(callback: fn () => ['foo', 'bar'])];
yield 'named arguments, namespaced function' => [new Choice(callback: __NAMESPACE__ . '\choice_callback')];
yield 'named arguments, closure' => [new Choice(callback: fn() => ['foo', 'bar'])];
yield 'named arguments, static method' => [new Choice(callback: [__CLASS__, 'staticCallback'])];
}

Expand All @@ -137,9 +140,9 @@ public function testValidChoiceCallbackFunctionDoctrineStyle(Choice $constraint)

public static function provideLegacyConstraintsWithCallbackFunctionDoctrineStyle(): iterable
{
yield 'doctrine style, namespaced function' => [new Choice(['callback' => __NAMESPACE__.'\choice_callback'])];
yield 'doctrine style, namespaced function' => [new Choice(['callback' => __NAMESPACE__ . '\choice_callback'])];
yield 'doctrine style, closure' => [new Choice([
'callback' => fn () => ['foo', 'bar'],
'callback' => fn() => ['foo', 'bar'],
])];
yield 'doctrine style, static method' => [new Choice(['callback' => [__CLASS__, 'staticCallback']])];
}
Expand Down Expand Up @@ -232,8 +235,8 @@ public function testInvalidChoiceDoctrineStyle()
public function testInvalidChoiceEmptyChoices()
{
$constraint = new Choice(
// May happen when the choices are provided dynamically, e.g. from
// the DB or the model
// May happen when the choices are provided dynamically, e.g. from
// the DB or the model
choices: [],
message: 'myMessage',
);
Expand Down Expand Up @@ -262,6 +265,7 @@ public function testInvalidChoiceMultiple()
->setCode(Choice::NO_SUCH_CHOICE_ERROR)
->assertRaised();
}

/**
* @group legacy
*/
Expand Down Expand Up @@ -443,4 +447,87 @@ public function testMatchFalseWithMultiple()
->setInvalidValue('bar')
->assertRaised();
}

public function testValidEnumUnit()
{
$this->validator->validate('FirstCase', new Choice(
choices: TestEnumUnit::class,
message: 'myMessage',
));

$this->assertNoViolation();
}

public function testInvalidEnumUnit()
{
$this->validator->validate('NoneCase', new Choice(
choices: TestEnumUnit::class,
message: 'myMessage',
));

$this->buildViolation('myMessage')
->setParameter('{{ value }}', '"NoneCase"')
->setParameter('{{ choices }}', '"FirstCase", "SecondCase"')
->setCode(Choice::NO_SUCH_CHOICE_ERROR)
->assertRaised();
}

public function testValidEnumBackendString()
{
$this->validator->validate('a', new Choice(
choices: TestEnumBackendString::class,
message: 'myMessage',
));

$this->assertNoViolation();
}

public function testInvalidEnumBackendString()
{
$this->validator->validate('none', new Choice(
choices: TestEnumBackendString::class,
message: 'myMessage',
));

$this->buildViolation('myMessage')
->setParameter('{{ value }}', '"none"')
->setParameter('{{ choices }}', '"a", "b"')
->setCode(Choice::NO_SUCH_CHOICE_ERROR)
->assertRaised();
}

public function testValidEnumBackendInteger()
{
$this->validator->validate(3, new Choice(
choices: TestEnumBackendInteger::class,
message: 'myMessage',
));

$this->assertNoViolation();
}

public function testValidEnumBackendIntegerNormalize()
{
$this->validator->validate('3', new Choice(
choices: TestEnumBackendInteger::class,
message: 'myMessage',
normalizer: 'intval',
));

$this->assertNoViolation();
}

public function testInvalidEnumBackendInteger()
{
$this->validator->validate(9, new Choice(
choices: TestEnumBackendInteger::class,
message: 'myMessage',
));

$this->buildViolation('myMessage')
->setParameter('{{ value }}', '9')
->setParameter('{{ choices }}', '3, 4')
->setCode(Choice::NO_SUCH_CHOICE_ERROR)
->assertRaised();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?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\Fixtures;

/**
* @author Ninos Ego <me@ninosego.de>
*/
enum TestEnumBackendInteger: int
{
case FirstCase = 3;
case SecondCase = 4;
}
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.