From c143df1426aa05da3936c3cb601e281a38780524 Mon Sep 17 00:00:00 2001 From: ndm2 Date: Thu, 12 Oct 2023 15:03:18 +0200 Subject: [PATCH 1/5] Adds support for validating backed enums. --- psalm-baseline.xml | 8 +++- src/Validation/Validation.php | 45 +++++++++++++++++++ src/Validation/Validator.php | 40 +++++++++++++++++ tests/TestCase/Validation/ValidationTest.php | 23 ++++++++++ tests/TestCase/Validation/ValidatorTest.php | 40 +++++++++++++++++ .../test_app/TestApp/Model/Enum/NonBacked.php | 20 +++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tests/test_app/TestApp/Model/Enum/NonBacked.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 34183b16da7..ab7ffb92778 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + $iterator @@ -165,6 +165,12 @@ is_array($_list) + + + + + }]]> diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php index d915ea445da..9d6e66ab2b8 100644 --- a/src/Validation/Validation.php +++ b/src/Validation/Validation.php @@ -16,6 +16,7 @@ */ namespace Cake\Validation; +use BackedEnum; use Cake\Chronos\ChronosDate; use Cake\Core\Exception\CakeException; use Cake\I18n\DateTime; @@ -25,6 +26,8 @@ use InvalidArgumentException; use NumberFormatter; use Psr\Http\Message\UploadedFileInterface; +use ReflectionEnum; +use ReflectionException; use RuntimeException; use UnhandledMatchError; @@ -781,6 +784,48 @@ public static function email(mixed $check, ?bool $deep = false, ?string $regex = return false; } + /** + * Checks that the value is a valid backed enum instance or value. + * + * @param mixed $check Value to check + * @param class-string<\BackedEnum> $enumClassName The valid backed enum class name + * @return bool Success + * @since 5.1.0 + */ + public static function enum(mixed $check, string $enumClassName): bool + { + if ( + $check instanceof $enumClassName && + $check instanceof BackedEnum + ) { + return true; + } + + if ( + !is_string($check) && + !is_int($check) + ) { + return false; + } + + try { + $reflectionEnum = new ReflectionEnum($enumClassName); + $backingType = (string)$reflectionEnum->getBackingType(); + } catch (ReflectionException) { + return false; + } + + if (get_debug_type($check) !== $backingType) { + return false; + } + + if ($enumClassName::tryFrom($check) !== null) { + return true; + } + + return false; + } + /** * Checks that value is exactly $comparedTo. * diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index e44000336fe..c386db5ec3f 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -18,6 +18,7 @@ use ArrayAccess; use ArrayIterator; +use BackedEnum; use Closure; use Countable; use InvalidArgumentException; @@ -2033,6 +2034,45 @@ public function email( ]); } + /** + * Add a backed enum validation rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param class-string<\BackedEnum> $enumClassName The valid backed enum class name. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @return $this + * @see \Cake\Validation\Validation::enum() + * @since 5.1.0 + */ + public function enum( + string $field, + string $enumClassName, + ?string $message = null, + Closure|string|null $when = null + ) { + if (!in_array(BackedEnum::class, (array)class_implements($enumClassName), true)) { + throw new InvalidArgumentException( + 'The `$enumClassName` argument must be the classname of a valid backed enum.' + ); + } + + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be a `\%s` enum instance or value', $enumClassName); + } else { + $message = __d('cake', 'The provided value must be a `\{0}` enum instance or value', $enumClassName); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'enum', $extra + [ + 'rule' => ['enum', $enumClassName], + ]); + } + /** * Add an IP validation rule to a field. * diff --git a/tests/TestCase/Validation/ValidationTest.php b/tests/TestCase/Validation/ValidationTest.php index c844f8daa7a..551da8450c9 100644 --- a/tests/TestCase/Validation/ValidationTest.php +++ b/tests/TestCase/Validation/ValidationTest.php @@ -30,6 +30,9 @@ use Laminas\Diactoros\UploadedFile; use Locale; use stdClass; +use TestApp\Model\Enum\ArticleStatus; +use TestApp\Model\Enum\NonBacked; +use TestApp\Model\Enum\Priority; require_once __DIR__ . '/stubs.php'; @@ -2014,6 +2017,26 @@ public function testEmailCustomRegex(): void $this->assertFalse(Validation::email('abc.efg@com.caphpkeinvalid', null, '/^[A-Z0-9._%-]+@[A-Z0-9.-]+\\.[A-Z]{2,4}$/i')); } + public function testEnum(): void + { + $this->assertTrue(Validation::enum(ArticleStatus::PUBLISHED, ArticleStatus::class)); + $this->assertTrue(Validation::enum('Y', ArticleStatus::class)); + + $this->assertTrue(Validation::enum(Priority::LOW, Priority::class)); + $this->assertTrue(Validation::enum(1, Priority::class)); + + $this->assertFalse(Validation::enum(Priority::LOW, ArticleStatus::class)); + $this->assertFalse(Validation::enum(1, ArticleStatus::class)); + $this->assertFalse(Validation::enum('non-existent', ArticleStatus::class)); + + $this->assertFalse(Validation::enum(ArticleStatus::PUBLISHED, Priority::class)); + $this->assertFalse(Validation::enum('wrong type', Priority::class)); + $this->assertFalse(Validation::enum(123, Priority::class)); + + $this->assertFalse(Validation::enum(NonBacked::Basic, NonBacked::class)); + $this->assertFalse(Validation::enum('non-enum class', TestCase::class)); + } + /** * testEqualTo method */ diff --git a/tests/TestCase/Validation/ValidatorTest.php b/tests/TestCase/Validation/ValidatorTest.php index 8bd715d351f..19dc8281422 100644 --- a/tests/TestCase/Validation/ValidatorTest.php +++ b/tests/TestCase/Validation/ValidatorTest.php @@ -25,6 +25,9 @@ use InvalidArgumentException; use Laminas\Diactoros\UploadedFile; use stdClass; +use TestApp\Model\Enum\ArticleStatus; +use TestApp\Model\Enum\NonBacked; +use TestApp\Model\Enum\Priority; use Traversable; /** @@ -2704,6 +2707,43 @@ public function testEmail(): void $this->assertValidationMessage($fieldName, $rule, $expectedMessage, $checkMx); } + public function testEnum(): void + { + $validator = new Validator(); + $validator->enum('status', ArticleStatus::class); + + $this->assertEmpty($validator->validate(['status' => ArticleStatus::PUBLISHED])); + $this->assertEmpty($validator->validate(['status' => 'Y'])); + + $this->assertNotEmpty($validator->validate(['status' => Priority::LOW])); + $this->assertNotEmpty($validator->validate(['status' => 'wrong type'])); + $this->assertNotEmpty($validator->validate(['status' => 123])); + $this->assertNotEmpty($validator->validate(['status' => NonBacked::Basic])); + + $fieldName = 'status'; + $rule = 'enum'; + $expectedMessage = 'The provided value must be a `\TestApp\Model\Enum\ArticleStatus` enum instance or value'; + $this->assertValidationMessage($fieldName, $rule, $expectedMessage, ArticleStatus::class); + } + + public function testEnumNonBacked(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `$enumClassName` argument must be the classname of a valid backed enum.'); + + $validator = new Validator(); + $validator->enum('status', NonBacked::class); + } + + public function testEnumNonEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `$enumClassName` argument must be the classname of a valid backed enum.'); + + $validator = new Validator(); + $validator->enum('status', TestCase::class); + } + /** * Tests the integer proxy method */ diff --git a/tests/test_app/TestApp/Model/Enum/NonBacked.php b/tests/test_app/TestApp/Model/Enum/NonBacked.php new file mode 100644 index 00000000000..39d93a00c72 --- /dev/null +++ b/tests/test_app/TestApp/Model/Enum/NonBacked.php @@ -0,0 +1,20 @@ + Date: Sat, 14 Oct 2023 14:13:56 +0200 Subject: [PATCH 2/5] Simplify final value evaluation. Co-authored-by: Mark Story --- src/Validation/Validation.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php index 9d6e66ab2b8..8d267d57f00 100644 --- a/src/Validation/Validation.php +++ b/src/Validation/Validation.php @@ -819,11 +819,7 @@ public static function enum(mixed $check, string $enumClassName): bool return false; } - if ($enumClassName::tryFrom($check) !== null) { - return true; - } - - return false; + return $enumClassName::tryFrom($check) !== null; } /** From c6d09c82c8f0f0a36760ab9f39d2ad2d7161fd96 Mon Sep 17 00:00:00 2001 From: Oliver Nowak Date: Thu, 19 Oct 2023 15:36:00 +0200 Subject: [PATCH 3/5] Better user facing messages. Co-authored-by: Mark Story --- src/Validation/Validator.php | 6 ++++-- tests/TestCase/Validation/ValidatorTest.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index c386db5ec3f..f8e2e79653a 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -2059,10 +2059,12 @@ public function enum( } if ($message === null) { + $cases = array_map(fn ($case) => $case->value, $enumClassName::cases()); + $caseOptions = implode('`, `', $cases); if (!$this->_useI18n) { - $message = sprintf('The provided value must be a `\%s` enum instance or value', $enumClassName); + $message = sprintf('The provided value must be one of `%s`', $caseOptions); } else { - $message = __d('cake', 'The provided value must be a `\{0}` enum instance or value', $enumClassName); + $message = __d('cake', 'The provided value must be one of `{0}`', $caseOptions); } } diff --git a/tests/TestCase/Validation/ValidatorTest.php b/tests/TestCase/Validation/ValidatorTest.php index 19dc8281422..015752c3515 100644 --- a/tests/TestCase/Validation/ValidatorTest.php +++ b/tests/TestCase/Validation/ValidatorTest.php @@ -2722,7 +2722,7 @@ public function testEnum(): void $fieldName = 'status'; $rule = 'enum'; - $expectedMessage = 'The provided value must be a `\TestApp\Model\Enum\ArticleStatus` enum instance or value'; + $expectedMessage = 'The provided value must be one of `Y`, `N`'; $this->assertValidationMessage($fieldName, $rule, $expectedMessage, ArticleStatus::class); } From 5ee5c937c6b552baef258af3d0b1d1d6af4d3342 Mon Sep 17 00:00:00 2001 From: ndm2 Date: Thu, 19 Oct 2023 19:32:41 +0200 Subject: [PATCH 4/5] Throw exception on misconfiguration in `Validation::enum()` too. --- src/Validation/Validation.php | 19 +++++++++---------- tests/TestCase/Validation/ValidationTest.php | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php index 8d267d57f00..023f4edf9e6 100644 --- a/src/Validation/Validation.php +++ b/src/Validation/Validation.php @@ -801,21 +801,20 @@ public static function enum(mixed $check, string $enumClassName): bool return true; } - if ( - !is_string($check) && - !is_int($check) - ) { - return false; - } - + $backingType = null; try { $reflectionEnum = new ReflectionEnum($enumClassName); - $backingType = (string)$reflectionEnum->getBackingType(); + $backingType = $reflectionEnum->getBackingType(); } catch (ReflectionException) { - return false; } - if (get_debug_type($check) !== $backingType) { + if ($backingType === null) { + throw new InvalidArgumentException( + 'The `$enumClassName` argument must be the classname of a valid backed enum.' + ); + } + + if (get_debug_type($check) !== (string)$backingType) { return false; } diff --git a/tests/TestCase/Validation/ValidationTest.php b/tests/TestCase/Validation/ValidationTest.php index 551da8450c9..967d15cc89b 100644 --- a/tests/TestCase/Validation/ValidationTest.php +++ b/tests/TestCase/Validation/ValidationTest.php @@ -2032,9 +2032,22 @@ public function testEnum(): void $this->assertFalse(Validation::enum(ArticleStatus::PUBLISHED, Priority::class)); $this->assertFalse(Validation::enum('wrong type', Priority::class)); $this->assertFalse(Validation::enum(123, Priority::class)); + } + + public function testEnumNonBacked(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `$enumClassName` argument must be the classname of a valid backed enum.'); + + Validation::enum(NonBacked::Basic, NonBacked::class); + } + + public function testEnumNonEnum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The `$enumClassName` argument must be the classname of a valid backed enum.'); - $this->assertFalse(Validation::enum(NonBacked::Basic, NonBacked::class)); - $this->assertFalse(Validation::enum('non-enum class', TestCase::class)); + Validation::enum('non-enum class', TestCase::class); } /** From ee94828482fcdb4d7b5f7d361747d8d34ff7bb40 Mon Sep 17 00:00:00 2001 From: ndm2 Date: Fri, 3 Nov 2023 17:15:32 +0100 Subject: [PATCH 5/5] Adjust `@since` tags. --- src/Validation/Validation.php | 2 +- src/Validation/Validator.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php index 023f4edf9e6..413dd460e2c 100644 --- a/src/Validation/Validation.php +++ b/src/Validation/Validation.php @@ -790,7 +790,7 @@ public static function email(mixed $check, ?bool $deep = false, ?string $regex = * @param mixed $check Value to check * @param class-string<\BackedEnum> $enumClassName The valid backed enum class name * @return bool Success - * @since 5.1.0 + * @since 5.0.3 */ public static function enum(mixed $check, string $enumClassName): bool { diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index f8e2e79653a..5ffa450a16f 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -2044,7 +2044,7 @@ public function email( * true when the validation rule should be applied. * @return $this * @see \Cake\Validation\Validation::enum() - * @since 5.1.0 + * @since 5.0.3 */ public function enum( string $field,