From eca1cd77283ac4dee05a2eb7e9f01ae8b70696a3 Mon Sep 17 00:00:00 2001 From: Ninos Ego Date: Tue, 28 Jan 2025 00:38:41 +0100 Subject: [PATCH] [Validator] Add support for enum in `Choice` constraint --- src/Symfony/Component/Validator/CHANGELOG.md | 1 + .../Validator/ConstraintValidator.php | 2 +- .../Validator/Constraints/Choice.php | 28 +++--- .../Validator/Constraints/ChoiceValidator.php | 19 +++- .../Component/Validator/Constraints/Cidr.php | 2 +- .../Tests/ConstraintValidatorTest.php | 8 +- .../Tests/Constraints/ChoiceTest.php | 31 ++++++ .../Tests/Constraints/ChoiceValidatorTest.php | 99 +++++++++++++++++-- .../Tests/Fixtures/TestEnumBackendInteger.php | 21 ++++ .../Tests/Fixtures/TestEnumBackendString.php | 21 ++++ .../{TestEnum.php => TestEnumUnit.php} | 5 +- 11 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 src/Symfony/Component/Validator/Tests/Fixtures/TestEnumBackendInteger.php create mode 100644 src/Symfony/Component/Validator/Tests/Fixtures/TestEnumBackendString.php rename src/Symfony/Component/Validator/Tests/Fixtures/{TestEnum.php => TestEnumUnit.php} (84%) diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index c10797cabfa30..9f57dee356aaa 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -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 7.2 --- diff --git a/src/Symfony/Component/Validator/ConstraintValidator.php b/src/Symfony/Component/Validator/ConstraintValidator.php index 75f3195b8b7ff..6939f8d83adaf 100644 --- a/src/Symfony/Component/Validator/ConstraintValidator.php +++ b/src/Symfony/Component/Validator/ConstraintValidator.php @@ -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; } if (\is_object($value)) { diff --git a/src/Symfony/Component/Validator/Constraints/Choice.php b/src/Symfony/Component/Validator/Constraints/Choice.php index 1435a762b8b7e..8d60325f93dcd 100644 --- a/src/Symfony/Component/Validator/Constraints/Choice.php +++ b/src/Symfony/Component/Validator/Constraints/Choice.php @@ -18,6 +18,7 @@ * Validates that a value is one of a given set of valid choices. * * @author Bernhard Schussek + * @author Ninos Ego */ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Choice extends Constraint @@ -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; @@ -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, @@ -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) { @@ -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; } } diff --git a/src/Symfony/Component/Validator/Constraints/ChoiceValidator.php b/src/Symfony/Component/Validator/Constraints/ChoiceValidator.php index 916c0732a772f..4a65a7685be3c 100644 --- a/src/Symfony/Component/Validator/Constraints/ChoiceValidator.php +++ b/src/Symfony/Component/Validator/Constraints/ChoiceValidator.php @@ -24,6 +24,7 @@ * @author Fabien Potencier * @author Florian Eckerstorfer * @author Bernhard Schussek + * @author Ninos Ego */ class ChoiceValidator extends ConstraintValidator { @@ -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) { @@ -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; } diff --git a/src/Symfony/Component/Validator/Constraints/Cidr.php b/src/Symfony/Component/Validator/Constraints/Cidr.php index a6e47017760e4..06f246c23992d 100644 --- a/src/Symfony/Component/Validator/Constraints/Cidr.php +++ b/src/Symfony/Component/Validator/Constraints/Cidr.php @@ -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 = [ diff --git a/src/Symfony/Component/Validator/Tests/ConstraintValidatorTest.php b/src/Symfony/Component/Validator/Tests/ConstraintValidatorTest.php index d378ba2925dad..1b2619f03f3c4 100644 --- a/src/Symfony/Component/Validator/Tests/ConstraintValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/ConstraintValidatorTest.php @@ -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 { @@ -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); diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ChoiceTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ChoiceTest.php index 9c58dd10714d9..058fe5f10787f 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ChoiceTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ChoiceTest.php @@ -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'); @@ -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); } } @@ -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; } diff --git a/src/Symfony/Component/Validator/Tests/Constraints/ChoiceValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/ChoiceValidatorTest.php index a219e44d864bd..7e8405baafaab 100644 --- a/src/Symfony/Component/Validator/Tests/Constraints/ChoiceValidatorTest.php +++ b/src/Symfony/Component/Validator/Tests/Constraints/ChoiceValidatorTest.php @@ -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() { @@ -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'])]; } @@ -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']])]; } @@ -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', ); @@ -262,6 +265,7 @@ public function testInvalidChoiceMultiple() ->setCode(Choice::NO_SUCH_CHOICE_ERROR) ->assertRaised(); } + /** * @group legacy */ @@ -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(); + } } diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/TestEnumBackendInteger.php b/src/Symfony/Component/Validator/Tests/Fixtures/TestEnumBackendInteger.php new file mode 100644 index 0000000000000..c36bbf367e373 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Fixtures/TestEnumBackendInteger.php @@ -0,0 +1,21 @@ + + * + * 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 + */ +enum TestEnumBackendInteger: int +{ + case FirstCase = 3; + case SecondCase = 4; +} diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/TestEnumBackendString.php b/src/Symfony/Component/Validator/Tests/Fixtures/TestEnumBackendString.php new file mode 100644 index 0000000000000..b051bb8839c28 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Fixtures/TestEnumBackendString.php @@ -0,0 +1,21 @@ + + * + * 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 + */ +enum TestEnumBackendString: string +{ + case FirstCase = 'a'; + case SecondCase = 'b'; +} diff --git a/src/Symfony/Component/Validator/Tests/Fixtures/TestEnum.php b/src/Symfony/Component/Validator/Tests/Fixtures/TestEnumUnit.php similarity index 84% rename from src/Symfony/Component/Validator/Tests/Fixtures/TestEnum.php rename to src/Symfony/Component/Validator/Tests/Fixtures/TestEnumUnit.php index 216d348350000..09c2f36a73c03 100644 --- a/src/Symfony/Component/Validator/Tests/Fixtures/TestEnum.php +++ b/src/Symfony/Component/Validator/Tests/Fixtures/TestEnumUnit.php @@ -11,7 +11,10 @@ namespace Symfony\Component\Validator\Tests\Fixtures; -enum TestEnum +/** + * @author Ninos Ego + */ +enum TestEnumUnit { case FirstCase; case SecondCase;