-
-
Notifications
You must be signed in to change notification settings - Fork 9.7k
[Validator] Introduce BackedEnumValue
constraint
#54226
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
base: 7.4
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,10 @@ | ||
CHANGELOG | ||
========= | ||
|
||
7.3 | ||
--- | ||
* Add the `BackedEnumValue` constraint | ||
|
||
7.2 | ||
--- | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,59 @@ | ||||||
<?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\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 <aurelienpillevesse@hotmail.fr> | ||||||
*/ | ||||||
#[\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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. first letter should be uppercase also |
||||||
* @param \BackedEnum[] $except the cases that should be considered invalid | ||||||
*/ | ||||||
#[HasNamedArguments] | ||||||
public function __construct( | ||||||
public string $type, | ||||||
public array $except = [], | ||||||
AurelienPillevesse marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
public string $message = 'The value you selected is not a valid choice.', | ||||||
derrabus marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
public string $typeMessage = 'This value should be of type {{ type }}.', | ||||||
AurelienPillevesse marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
?array $groups = null, | ||||||
mixed $payload = null, | ||||||
) { | ||||||
parent::__construct([], $groups, $payload); | ||||||
|
||||||
if (!is_a($type, \BackedEnum::class, true)) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
throw new ConstraintDefinitionException(\sprintf('The "type" must be a \BackedEnum, got "%s".', get_debug_type($type))); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
} | ||||||
|
||||||
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))); | ||||||
} | ||||||
} | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,75 @@ | ||||||||||||||||||||||||||||||
<?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\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 <aurelienpillevesse@hotmail.fr> | ||||||||||||||||||||||||||||||
*/ | ||||||||||||||||||||||||||||||
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) { | ||||||||||||||||||||||||||||||
derrabus marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||
return; | ||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
try { | ||||||||||||||||||||||||||||||
AurelienPillevesse marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||
$enumTypeValue = $constraint->type::tryFrom($value); | ||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||
} 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)) | ||||||||||||||||||||||||||||||
AurelienPillevesse marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+48
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @derrabus is right, you only need to define what is in the constraint message There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's build in a similar way as Choice constraint. Message :
Validator : symfony/src/Symfony/Component/Validator/Constraints/ChoiceValidator.php Lines 101 to 105 in 78f4d9a
The |
||||||||||||||||||||||||||||||
->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), | ||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||
)); | ||||||||||||||||||||||||||||||
Comment on lines
+67
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
<?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\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 <aurelienpillevesse@hotmail.fr> | ||
*/ | ||
class BackedEnumValueTest extends TestCase | ||
{ | ||
public function testAttributes() | ||
AurelienPillevesse marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
$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'; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,146 @@ | ||
<?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\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 <aurelienpillevesse@hotmail.fr> | ||
*/ | ||
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; | ||
} |
Uh oh!
There was an error while loading. Please reload this page.