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; +}