diff --git a/src/Symfony/Component/Validator/CHANGELOG.md b/src/Symfony/Component/Validator/CHANGELOG.md index 6b5be184c0101..b2de989ed8197 100644 --- a/src/Symfony/Component/Validator/CHANGELOG.md +++ b/src/Symfony/Component/Validator/CHANGELOG.md @@ -1,6 +1,10 @@ CHANGELOG ========= +7.3 +--- + * Add the `BackedEnumValue` constraint + 7.2 --- diff --git a/src/Symfony/Component/Validator/Constraints/BackedEnumValue.php b/src/Symfony/Component/Validator/Constraints/BackedEnumValue.php new file mode 100644 index 0000000000000..e3838696001e1 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/BackedEnumValue.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Attribute\HasNamedArguments; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; + +/** + * Validates that a backed enum can be hydrated from a value. + * + * @author Aurélien Pillevesse + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class BackedEnumValue extends Constraint +{ + public const NO_SUCH_VALUE_ERROR = '53dcc1b1-a8dd-4813-baa5-b8486ff56447'; + public const INVALID_TYPE_ERROR = 'aa0374f4-b3ab-4362-b48d-b5ecf0f1a02d'; + + protected const ERROR_NAMES = [ + self::NO_SUCH_VALUE_ERROR => 'NO_SUCH_VALUE_ERROR', + self::INVALID_TYPE_ERROR => 'INVALID_TYPE_ERROR', + ]; + + /** + * @param class-string<\BackedEnum> $type the type of the enum + * @param \BackedEnum[] $except the cases that should be considered invalid + */ + #[HasNamedArguments] + public function __construct( + public string $type, + public array $except = [], + public string $message = 'The value you selected is not a valid choice.', + public string $typeMessage = 'This value should be of type {{ type }}.', + ?array $groups = null, + mixed $payload = null, + ) { + parent::__construct([], $groups, $payload); + + if (!is_a($type, \BackedEnum::class, true)) { + throw new ConstraintDefinitionException(\sprintf('The "type" must be a \BackedEnum, got "%s".', get_debug_type($type))); + } + + foreach ($except as $exceptValue) { + if (!is_a($exceptValue, $type)) { + throw new ConstraintDefinitionException(\sprintf('The "except" values must be cases of enum "%s", got "%s".', $type, get_debug_type($exceptValue))); + } + } + } +} diff --git a/src/Symfony/Component/Validator/Constraints/BackedEnumValueValidator.php b/src/Symfony/Component/Validator/Constraints/BackedEnumValueValidator.php new file mode 100644 index 0000000000000..6f63bd55a6e16 --- /dev/null +++ b/src/Symfony/Component/Validator/Constraints/BackedEnumValueValidator.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Constraints; + +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; + +/** + * BackedEnumValueValidator validates that a backed enum case can be hydrated from a value. + * + * @author Aurélien Pillevesse + */ +class BackedEnumValueValidator extends ConstraintValidator +{ + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof BackedEnumValue) { + throw new UnexpectedTypeException($constraint, BackedEnumValue::class); + } + + if (null === $value || '' === $value) { + return; + } + + try { + $enumTypeValue = $constraint->type::tryFrom($value); + } catch (\TypeError) { + $this->context->buildViolation($constraint->typeMessage) + ->setParameter('{{ type }}', $this->formatValue((string) (new \ReflectionEnum($constraint->type))->getBackingType())) + ->setCode(BackedEnumValue::INVALID_TYPE_ERROR) + ->addViolation(); + + return; + } + + if (null === $enumTypeValue) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($value)) + ->setParameter('{{ choices }}', $this->formatValidCases($constraint)) + ->setCode(BackedEnumValue::NO_SUCH_VALUE_ERROR) + ->addViolation(); + + return; + } + + if (\count($constraint->except) > 0 && \in_array($enumTypeValue, $constraint->except, true)) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $this->formatValue($enumTypeValue->value)) + ->setParameter('{{ choices }}', $this->formatValidCases($constraint)) + ->setCode(BackedEnumValue::NO_SUCH_VALUE_ERROR) + ->addViolation(); + } + } + + private function formatValidCases(BackedEnumValue $constraint): string + { + return $this->formatValues(array_map( + static fn (\BackedEnum $case) => $case->value, + array_filter( + $constraint->type::cases(), + static fn (\BackedEnum $currentValue) => !\in_array($currentValue, $constraint->except, true), + ) + )); + } +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/BackedEnumValueTest.php b/src/Symfony/Component/Validator/Tests/Constraints/BackedEnumValueTest.php new file mode 100644 index 0000000000000..bb6bdb23b77eb --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/BackedEnumValueTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\Constraints\BackedEnumValue; +use Symfony\Component\Validator\Mapping\ClassMetadata; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +/** + * @author Aurélien Pillevesse + */ +class BackedEnumValueTest extends TestCase +{ + public function testAttributes() + { + $metadata = new ClassMetadata(EnumDummy::class); + $loader = new AttributeLoader(); + self::assertTrue($loader->loadClassMetadata($metadata)); + + /** @var BackedEnumValue $aConstraint */ + [$aConstraint] = $metadata->properties['a']->getConstraints(); + self::assertSame(MyStringEnum::class, $aConstraint->type); + + /** @var BackedEnumValue $bConstraint */ + [$bConstraint] = $metadata->properties['b']->getConstraints(); + self::assertSame(MyStringEnum::class, $aConstraint->type); + self::assertSame('myMessage', $bConstraint->message); + + /** @var BackedEnumValue $cConstraint */ + [$cConstraint] = $metadata->properties['c']->getConstraints(); + self::assertSame(MyStringEnum::class, $aConstraint->type); + self::assertSame(['my_group'], $cConstraint->groups); + self::assertSame('some attached data', $cConstraint->payload); + + /** @var BackedEnumValue $dConstraint */ + [$dConstraint] = $metadata->properties['d']->getConstraints(); + self::assertSame(MyStringEnum::class, $dConstraint->type); + self::assertSame([MyStringEnum::YES], $dConstraint->except); + } +} + +class EnumDummy +{ + #[BackedEnumValue(type: MyStringEnum::class)] + private $a; + + #[BackedEnumValue(type: MyStringEnum::class, message: 'myMessage')] + private $b; + + #[BackedEnumValue(type: MyStringEnum::class, groups: ['my_group'], payload: 'some attached data')] + private $c; + + #[BackedEnumValue(type: MyStringEnum::class, except: [MyStringEnum::YES])] + private $d; +} + +enum MyStringEnum: string +{ + case YES = 'yes'; + case NO = 'no'; +} diff --git a/src/Symfony/Component/Validator/Tests/Constraints/BackedEnumValueValidatorTest.php b/src/Symfony/Component/Validator/Tests/Constraints/BackedEnumValueValidatorTest.php new file mode 100644 index 0000000000000..549414ba24ba7 --- /dev/null +++ b/src/Symfony/Component/Validator/Tests/Constraints/BackedEnumValueValidatorTest.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Tests\Constraints; + +use Symfony\Component\Validator\Constraints\BackedEnumValue; +use Symfony\Component\Validator\Constraints\BackedEnumValueValidator; +use Symfony\Component\Validator\Exception\ConstraintDefinitionException; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +/** + * @author Aurélien Pillevesse + */ +class BackedEnumValueValidatorTest extends ConstraintValidatorTestCase +{ + protected function createValidator(): BackedEnumValueValidator + { + return new BackedEnumValueValidator(); + } + + public function testExpectEnumForTypeAttribute() + { + $this->expectException(ConstraintDefinitionException::class); + $this->expectExceptionMessage('The "type" must be a \BackedEnum, got "string".'); + new BackedEnumValue( + type: self::class + ); + } + + public function testNullIsValid() + { + $this->validator->validate( + null, + new BackedEnumValue( + type: MyStringBackedEnum::class + ) + ); + + $this->assertNoViolation(); + } + + public function testEmptyStringIsValid() + { + $this->validator->validate( + '', + new BackedEnumValue( + type: MyStringBackedEnum::class + ) + ); + + $this->assertNoViolation(); + } + + public function testStringEnumValid() + { + $this->validator->validate( + 'yes', + new BackedEnumValue( + type: MyStringBackedEnum::class + ) + ); + + $this->assertNoViolation(); + } + + public function testStringEnumWrongValue() + { + $this->validator->validate('wrongvalue', new BackedEnumValue(type: MyStringBackedEnum::class)); + + $this->buildViolation('The value you selected is not a valid choice.') + ->setParameter('{{ value }}', '"wrongvalue"') + ->setParameter('{{ choices }}', '"yes", "no"') + ->setCode(BackedEnumValue::NO_SUCH_VALUE_ERROR) + ->assertRaised(); + } + + public function testStringEnumWrongValueWithExcept() + { + $this->validator->validate('no', new BackedEnumValue(type: MyStringBackedEnum::class, except: [MyStringBackedEnum::NO])); + + $this->buildViolation('The value you selected is not a valid choice.') + ->setParameter('{{ value }}', '"no"') + ->setParameter('{{ choices }}', '"yes"') + ->setCode(BackedEnumValue::NO_SUCH_VALUE_ERROR) + ->assertRaised(); + } + + public function testIntEnumValid() + { + $this->validator->validate( + 1, + new BackedEnumValue( + type: MyIntBackedEnum::class + ) + ); + + $this->assertNoViolation(); + } + + public function testIntEnumWithStringIntSubmitted() + { + $this->validator->validate( + '1', + new BackedEnumValue( + type: MyIntBackedEnum::class + ) + ); + + $this->assertNoViolation(); + } + + public function testIntEnumNotValidWithBoolValue() + { + $this->validator->validate( + 'bonjour', + new BackedEnumValue( + type: MyIntBackedEnum::class + ) + ); + + $this->buildViolation('This value should be of type {{ type }}.') + ->setParameter('{{ type }}', '"int"') + ->setCode(BackedEnumValue::INVALID_TYPE_ERROR) + ->assertRaised(); + } +} + +enum MyStringBackedEnum: string +{ + case YES = 'yes'; + case NO = 'no'; +} + +enum MyIntBackedEnum: int +{ + case YES = 1; + case NO = 0; +} 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