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..413dd460e2c 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,43 @@ 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.0.3 + */ + public static function enum(mixed $check, string $enumClassName): bool + { + if ( + $check instanceof $enumClassName && + $check instanceof BackedEnum + ) { + return true; + } + + $backingType = null; + try { + $reflectionEnum = new ReflectionEnum($enumClassName); + $backingType = $reflectionEnum->getBackingType(); + } catch (ReflectionException) { + } + + 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; + } + + return $enumClassName::tryFrom($check) !== null; + } + /** * Checks that value is exactly $comparedTo. * diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index e44000336fe..5ffa450a16f 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,47 @@ 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.0.3 + */ + 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) { + $cases = array_map(fn ($case) => $case->value, $enumClassName::cases()); + $caseOptions = implode('`, `', $cases); + if (!$this->_useI18n) { + $message = sprintf('The provided value must be one of `%s`', $caseOptions); + } else { + $message = __d('cake', 'The provided value must be one of `{0}`', $caseOptions); + } + } + + $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..967d15cc89b 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,39 @@ 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)); + } + + 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.'); + + 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..015752c3515 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 one of `Y`, `N`'; + $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 @@ +