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; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy