Repository: laminas/laminas-validator (php)

Issue:
Enum-Validator 
### Feature Request

I would like to have an enum validator. 

|    Q        |   A
|------------ | ------
| New Feature | yes
| RFC         | no
| BC Break    | may be, it depends

#### Summary

Like I have a value and if it is in the specific enum it is valid. 

Of course this would be a php > 8.0 feature. So I just want to know if you're open for that or if it is not in the scope of this package. 

I you're open for it I will try to add a pull request :-) 


Pull request description:
Add `EnumCase` and `BackedEnumValue` Validators
|    Q          |   A
|-------------- | ------
| Documentation | yes
| New Feature   | yes

### Description

Adds 2 new validators to check that a given value is either a valid enum case, or a valid value for a backed enum.

Rather than expand the `InArray` validator, I prefer a more explicit approach which also keeps the `InArray` validator simple, and therefore:

- Closes #168
- Closes #167


Patch:
diff --git a/docs/book/v3/set.md b/docs/book/v3/set.md
index e5adb322..ad0e38f0 100644
--- a/docs/book/v3/set.md
+++ b/docs/book/v3/set.md
@@ -2,6 +2,7 @@
 
 The following validators come with the laminas-validator distribution.
 
+- [BackedEnumValue](validators/backed-enum-value.md)
 - [Barcode](validators/barcode.md)
 - [Callback](validators/callback.md)
 - [CreditCard](validators/credit-card.md)
@@ -9,6 +10,7 @@ The following validators come with the laminas-validator distribution.
 - [DateComparison](validators/date-comparison.md)
 - [Digits](validators/digits.md)
 - [EmailAddress](validators/email-address.md)
+- [EnumCase](validators/enum-case.md)
 - [Explode](validators/explode.md)
 - [File Validation Classes](validators/file/intro.md)
 - [Hex](validators/hex.md)
diff --git a/docs/book/v3/validators/backed-enum-value.md b/docs/book/v3/validators/backed-enum-value.md
new file mode 100644
index 00000000..cc88fe4b
--- /dev/null
+++ b/docs/book/v3/validators/backed-enum-value.md
@@ -0,0 +1,33 @@
+# Backed Enum Value Validator
+
+`Laminas\Validator\BackedEnumValue` allows you to validate that a string or numeric value is a valid value for a specified [enum](https://www.php.net/manual/language.enumerations.php).
+
+## Supported options
+
+The following options are supported for `Laminas\Validator\BackedEnumValue`:
+
+- `enum`: The backed enum class you wish to test against
+
+## Basic usage
+
+```php
+enum MyEnum: string {
+    case Something = 'Some Value';
+    case OtherThing = 'Other Value';
+}
+
+$validator = new Laminas\Validator\BackedEnumValue([
+    'enum' => MyEnum::class,
+]);
+
+if ($validator->isValid('Some Value')) {
+    // $value is a valid value for `MyEnum`
+} else {
+    // $value is not a known value in `MyEnum`
+    foreach ($validator->getMessages() as $message) {
+        echo "$message\n";
+    }
+}
+```
+
+[Unit enums](https://www.php.net/manual/language.enumerations.basics.php) are not supported by this validator. To validate enum cases as opposed to _values_, see the [`EnumCase` validator](enum-case.md). 
diff --git a/docs/book/v3/validators/enum-case.md b/docs/book/v3/validators/enum-case.md
new file mode 100644
index 00000000..c1941d46
--- /dev/null
+++ b/docs/book/v3/validators/enum-case.md
@@ -0,0 +1,33 @@
+# Enum Case Validator
+
+`Laminas\Validator\EnumCase` allows you to validate that a string is a valid case for a specified [enum](https://www.php.net/manual/language.enumerations.php).
+
+## Supported options
+
+The following options are supported for `Laminas\Validator\BackedEnumValue`:
+
+- `enum`: The backed or unit enum class you wish to test against
+
+## Basic usage
+
+```php
+enum MyEnum {
+    case Something;
+    case OtherThing;
+}
+
+$validator = new Laminas\Validator\EnumCase([
+    'enum' => MyEnum::class,
+]);
+
+if ($validator->isValid('Something')) {
+    // $value is a valid case for `MyEnum`
+} else {
+    // $value is not a known case in `MyEnum`
+    foreach ($validator->getMessages() as $message) {
+        echo "$message\n";
+    }
+}
+```
+
+To validate against the _values_ of a backed enum, see the [`BackedEnumValue` validator](backed-enum-value.md). 
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index ef848083..90bea34f 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -1,5 +1,10 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <files psalm-version="5.25.0@01a8eb06b9e9cc6cfb6a320bf9fb14331919d505">
+  <file src="src/BackedEnumValue.php">
+    <PossiblyUnusedProperty>
+      <code><![CDATA[$type]]></code>
+    </PossiblyUnusedProperty>
+  </file>
   <file src="src/Barcode.php">
     <PossiblyUnusedProperty>
       <code><![CDATA[$length]]></code>
@@ -41,6 +46,11 @@
       <code><![CDATA[UConverter::transcode($localPart, 'UTF-8', 'UTF-8') === false]]></code>
     </TypeDoesNotContainType>
   </file>
+  <file src="src/EnumCase.php">
+    <PossiblyUnusedProperty>
+      <code><![CDATA[$type]]></code>
+    </PossiblyUnusedProperty>
+  </file>
   <file src="src/Explode.php">
     <MixedInferredReturnType>
       <code><![CDATA[ValidatorInterface]]></code>
@@ -189,6 +199,11 @@
       <code><![CDATA[$message]]></code>
     </MixedAssignment>
   </file>
+  <file src="test/BackedEnumValueTest.php">
+    <PossiblyUnusedMethod>
+      <code><![CDATA[basicProvider]]></code>
+    </PossiblyUnusedMethod>
+  </file>
   <file src="test/BarcodeTest.php">
     <PossiblyUnusedMethod>
       <code><![CDATA[invalidAdapterArguments]]></code>
@@ -256,6 +271,11 @@
       <code><![CDATA[validEmailAddresses]]></code>
     </PossiblyUnusedMethod>
   </file>
+  <file src="test/EnumCaseTest.php">
+    <PossiblyUnusedMethod>
+      <code><![CDATA[basicProvider]]></code>
+    </PossiblyUnusedMethod>
+  </file>
   <file src="test/File/BytesTest.php">
     <PossiblyUnusedMethod>
       <code><![CDATA[bytesToSiUnitDataProvider]]></code>
diff --git a/src/BackedEnumValue.php b/src/BackedEnumValue.php
new file mode 100644
index 00000000..b2417c7d
--- /dev/null
+++ b/src/BackedEnumValue.php
@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Laminas\Validator;
+
+use BackedEnum;
+use Laminas\Translator\TranslatorInterface;
+use Laminas\Validator\Exception\InvalidArgumentException;
+use ReflectionEnum;
+use ReflectionNamedType;
+
+use function assert;
+use function get_debug_type;
+use function is_a;
+use function is_int;
+use function is_scalar;
+use function is_string;
+use function sprintf;
+
+/**
+ * @psalm-type OptionsArgument = array{
+ *     enum: class-string<BackedEnum>,
+ *     messages?: array<string, string>,
+ *     translator?: TranslatorInterface|null,
+ *     translatorTextDomain?: string|null,
+ *     translatorEnabled?: bool,
+ *     valueObscured?: bool,
+ * }
+ */
+final class BackedEnumValue extends AbstractValidator
+{
+    public const ERR_INVALID_TYPE  = 'errInvalidType';
+    public const ERR_INVALID_VALUE = 'errInvalidValue';
+
+    /** @var array<string, string> */
+    protected array $messageTemplates = [
+        self::ERR_INVALID_TYPE  => 'Expected a string or numeric value but received %type%',
+        self::ERR_INVALID_VALUE => '"%value%" is not a valid enum case',
+    ];
+
+    /** @var class-string<BackedEnum> */
+    private readonly string $enum;
+    private readonly bool $isStringBacked;
+
+    protected ?string $type = null;
+
+    /** @var array<string, string|array<string, string>> */
+    protected array $messageVariables = [
+        'type' => 'type',
+    ];
+
+    /** @param OptionsArgument $options */
+    public function __construct(array $options)
+    {
+        $enum = $options['enum'];
+        if (! is_a($enum, BackedEnum::class, true)) {
+            throw new InvalidArgumentException(sprintf(
+                'Expected the `enum` option to be a backed enum class-string but "%s" received',
+                $enum,
+            ));
+        }
+
+        $this->enum = $enum;
+
+        $reflection     = new ReflectionEnum($this->enum);
+        $reflectionType = $reflection->getBackingType();
+        assert($reflectionType instanceof ReflectionNamedType);
+        $this->isStringBacked = $reflectionType->getName() === 'string';
+
+        parent::__construct($options);
+    }
+
+    public function isValid(mixed $value): bool
+    {
+        $this->setValue(is_scalar($value) ? (string) $value : get_debug_type($value));
+        $this->type = get_debug_type($value);
+
+        if (! is_string($value) && ! is_int($value)) {
+            $this->error(self::ERR_INVALID_TYPE);
+
+            return false;
+        }
+
+        if ($this->isStringBacked && ! is_string($value)) {
+            $this->error(self::ERR_INVALID_TYPE);
+
+            return false;
+        }
+
+        if (! $this->isStringBacked && (string) (int) $value !== (string) $value) {
+            $this->error(self::ERR_INVALID_TYPE);
+
+            return false;
+        }
+
+        $value = $this->isStringBacked ? $value : (int) $value;
+
+        $enum = $this->enum::tryFrom($value);
+        if ($enum === null) {
+            $this->error(self::ERR_INVALID_VALUE);
+
+            return false;
+        }
+
+        return true;
+    }
+}
diff --git a/src/EnumCase.php b/src/EnumCase.php
new file mode 100644
index 00000000..972850e6
--- /dev/null
+++ b/src/EnumCase.php
@@ -0,0 +1,91 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Laminas\Validator;
+
+use BackedEnum;
+use Laminas\Translator\TranslatorInterface;
+use Laminas\Validator\Exception\InvalidArgumentException;
+use UnitEnum;
+
+use function array_map;
+use function get_debug_type;
+use function in_array;
+use function is_a;
+use function is_scalar;
+use function is_string;
+use function sprintf;
+
+/**
+ * @psalm-type OptionsArgument = array{
+ *     enum: class-string<UnitEnum>|class-string<BackedEnum>,
+ *     messages?: array<string, string>,
+ *     translator?: TranslatorInterface|null,
+ *     translatorTextDomain?: string|null,
+ *     translatorEnabled?: bool,
+ *     valueObscured?: bool,
+ * }
+ */
+final class EnumCase extends AbstractValidator
+{
+    public const ERR_INVALID_TYPE  = 'errInvalidType';
+    public const ERR_INVALID_VALUE = 'errInvalidValue';
+
+    /** @var array<string, string> */
+    protected array $messageTemplates = [
+        self::ERR_INVALID_TYPE  => 'Expected a string but received %type%',
+        self::ERR_INVALID_VALUE => '"%value%" is not a valid enum case',
+    ];
+
+    /** @var class-string<UnitEnum>|class-string<BackedEnum> */
+    private readonly string $enum;
+
+    protected ?string $type = null;
+
+    /** @var array<string, string|array<string, string>> */
+    protected array $messageVariables = [
+        'type' => 'type',
+    ];
+
+    /** @param OptionsArgument $options */
+    public function __construct(array $options)
+    {
+        $enum = $options['enum'];
+        if (! is_a($enum, BackedEnum::class, true) && ! is_a($enum, UnitEnum::class, true)) {
+            throw new InvalidArgumentException(sprintf(
+                'Expected the `enum` option to be a unit or backed enum class-string but "%s" received',
+                $enum,
+            ));
+        }
+
+        $this->enum = $enum;
+
+        parent::__construct($options);
+    }
+
+    public function isValid(mixed $value): bool
+    {
+        $this->setValue(is_scalar($value) ? (string) $value : get_debug_type($value));
+        $this->type = get_debug_type($value);
+
+        if (! is_string($value)) {
+            $this->error(self::ERR_INVALID_TYPE);
+
+            return false;
+        }
+
+        $match = array_map(
+            static fn (UnitEnum|BackedEnum $case): string => $case->name,
+            $this->enum::cases(),
+        );
+
+        if (! in_array($value, $match, true)) {
+            $this->error(self::ERR_INVALID_VALUE);
+
+            return false;
+        }
+
+        return true;
+    }
+}
diff --git a/src/ValidatorPluginManager.php b/src/ValidatorPluginManager.php
index 8e219ce6..fb362a7c 100644
--- a/src/ValidatorPluginManager.php
+++ b/src/ValidatorPluginManager.php
@@ -22,6 +22,7 @@ final class ValidatorPluginManager extends AbstractSingleInstancePluginManager
 {
     private const DEFAULT_CONFIGURATION = [
         'factories' => [
+            BackedEnumValue::class           => InvokableFactory::class,
             Barcode::class                   => InvokableFactory::class,
             Bitwise::class                   => InvokableFactory::class,
             BusinessIdentifierCode::class    => InvokableFactory::class,
@@ -32,6 +33,7 @@ final class ValidatorPluginManager extends AbstractSingleInstancePluginManager
             DateComparison::class            => InvokableFactory::class,
             Digits::class                    => InvokableFactory::class,
             EmailAddress::class              => InvokableFactory::class,
+            EnumCase::class                  => InvokableFactory::class,
             Explode::class                   => InvokableFactory::class,
             File\Count::class                => InvokableFactory::class,
             File\ExcludeExtension::class     => InvokableFactory::class,


Test patch:
diff --git a/test/BackedEnumValueTest.php b/test/BackedEnumValueTest.php
new file mode 100644
index 00000000..af850ce1
--- /dev/null
+++ b/test/BackedEnumValueTest.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace LaminasTest\Validator;
+
+use BackedEnum;
+use Laminas\Validator\BackedEnumValue;
+use Laminas\Validator\Exception\InvalidArgumentException;
+use LaminasTest\Validator\TestAsset\ExampleIntBackedEnum;
+use LaminasTest\Validator\TestAsset\ExampleStringBackedEnum;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+
+class BackedEnumValueTest extends TestCase
+{
+    /**
+     * @return iterable<int, array{
+     *     0: class-string<BackedEnum>,
+     *     1: mixed,
+     *     2: bool,
+     *     3: string|null,
+     * }>
+     */
+    public static function basicProvider(): iterable
+    {
+        foreach (ExampleIntBackedEnum::cases() as $case) {
+            yield [ExampleIntBackedEnum::class, $case->value, true, null];
+            yield [ExampleIntBackedEnum::class, $case, false, BackedEnumValue::ERR_INVALID_TYPE];
+        }
+
+        foreach (ExampleIntBackedEnum::cases() as $case) {
+            yield [ExampleIntBackedEnum::class, (string) $case->value, true, null];
+        }
+
+        foreach (ExampleStringBackedEnum::cases() as $case) {
+            yield [ExampleStringBackedEnum::class, $case->value, true, null];
+            yield [ExampleStringBackedEnum::class, $case, false, BackedEnumValue::ERR_INVALID_TYPE];
+        }
+
+        yield from [
+            [ExampleStringBackedEnum::class, 'Not There', false, BackedEnumValue::ERR_INVALID_VALUE],
+            [ExampleStringBackedEnum::class, 10, false, BackedEnumValue::ERR_INVALID_TYPE],
+            [ExampleStringBackedEnum::class, ['foo'], false, BackedEnumValue::ERR_INVALID_TYPE],
+            [ExampleIntBackedEnum::class, 123, false, BackedEnumValue::ERR_INVALID_VALUE],
+            [ExampleIntBackedEnum::class, '123', false, BackedEnumValue::ERR_INVALID_VALUE],
+            [ExampleIntBackedEnum::class, ['foo'], false, BackedEnumValue::ERR_INVALID_TYPE],
+            [ExampleIntBackedEnum::class, 'foo', false, BackedEnumValue::ERR_INVALID_TYPE],
+        ];
+    }
+
+    /** @param class-string<BackedEnum> $enum */
+    #[DataProvider('basicProvider')]
+    public function testBasic(string $enum, mixed $value, bool $expect, string|null $errorKey): void
+    {
+        $validator = new BackedEnumValue(['enum' => $enum]);
+
+        self::assertSame($expect, $validator->isValid($value));
+
+        if ($errorKey === null) {
+            return;
+        }
+
+        self::assertArrayHasKey($errorKey, $validator->getMessages());
+    }
+
+    public function testANonEnumClassOptionWillCauseAnException(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Expected the `enum` option to be a backed enum class-string');
+
+        /** @psalm-suppress ArgumentTypeCoercion - Intentionally invalid value */
+        new BackedEnumValue(['enum' => 'Foo']);
+    }
+
+    public function testErrorMessageVariables(): void
+    {
+        $validator = new BackedEnumValue(['enum' => ExampleStringBackedEnum::class]);
+
+        self::assertFalse($validator->isValid([]));
+        $message = $validator->getMessages()[BackedEnumValue::ERR_INVALID_TYPE] ?? null;
+        self::assertIsString($message);
+        self::assertSame('Expected a string or numeric value but received array', $message);
+
+        self::assertFalse($validator->isValid('Not Valid'));
+        $message = $validator->getMessages()[BackedEnumValue::ERR_INVALID_VALUE] ?? null;
+        self::assertIsString($message);
+        self::assertSame('"Not Valid" is not a valid enum case', $message);
+    }
+}
diff --git a/test/EnumCaseTest.php b/test/EnumCaseTest.php
new file mode 100644
index 00000000..540d4d77
--- /dev/null
+++ b/test/EnumCaseTest.php
@@ -0,0 +1,91 @@
+<?php
+
+declare(strict_types=1);
+
+namespace LaminasTest\Validator;
+
+use BackedEnum;
+use Laminas\Validator\EnumCase;
+use Laminas\Validator\Exception\InvalidArgumentException;
+use LaminasTest\Validator\TestAsset\ExampleIntBackedEnum;
+use LaminasTest\Validator\TestAsset\ExampleStringBackedEnum;
+use LaminasTest\Validator\TestAsset\ExampleUnitEnum;
+use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\TestCase;
+use UnitEnum;
+
+class EnumCaseTest extends TestCase
+{
+    /**
+     * @return iterable<int, array{
+     *     0: class-string<BackedEnum>|class-string<UnitEnum>,
+     *     1: mixed,
+     *     2: bool,
+     *     3: string|null,
+     * }>
+     */
+    public static function basicProvider(): iterable
+    {
+        foreach (ExampleIntBackedEnum::cases() as $case) {
+            yield [ExampleIntBackedEnum::class, $case->name, true, null];
+            yield [ExampleIntBackedEnum::class, $case, false, EnumCase::ERR_INVALID_TYPE];
+        }
+
+        foreach (ExampleStringBackedEnum::cases() as $case) {
+            yield [ExampleStringBackedEnum::class, $case->name, true, null];
+            yield [ExampleStringBackedEnum::class, $case, false, EnumCase::ERR_INVALID_TYPE];
+        }
+
+        foreach (ExampleUnitEnum::cases() as $case) {
+            yield [ExampleUnitEnum::class, $case->name, true, null];
+            yield [ExampleUnitEnum::class, $case, false, EnumCase::ERR_INVALID_TYPE];
+        }
+
+        yield from [
+            [ExampleStringBackedEnum::class, 'Not There', false, EnumCase::ERR_INVALID_VALUE],
+            [ExampleStringBackedEnum::class, 10, false, EnumCase::ERR_INVALID_TYPE],
+            [ExampleStringBackedEnum::class, ['foo'], false, EnumCase::ERR_INVALID_TYPE],
+            [ExampleIntBackedEnum::class, 'Foo', false, EnumCase::ERR_INVALID_VALUE],
+            [ExampleUnitEnum::class, 'Foo', false, EnumCase::ERR_INVALID_VALUE],
+        ];
+    }
+
+    /** @param class-string<BackedEnum>|class-string<UnitEnum> $enum */
+    #[DataProvider('basicProvider')]
+    public function testBasic(string $enum, mixed $value, bool $expect, string|null $errorKey): void
+    {
+        $validator = new EnumCase(['enum' => $enum]);
+
+        self::assertSame($expect, $validator->isValid($value));
+
+        if ($errorKey === null) {
+            return;
+        }
+
+        self::assertArrayHasKey($errorKey, $validator->getMessages());
+    }
+
+    public function testANonEnumClassOptionWillCauseAnException(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Expected the `enum` option to be a unit or backed enum class-string');
+
+        /** @psalm-suppress ArgumentTypeCoercion - Intentionally invalid value */
+        new EnumCase(['enum' => 'Foo']);
+    }
+
+    public function testErrorMessageVariables(): void
+    {
+        $validator = new EnumCase(['enum' => ExampleStringBackedEnum::class]);
+
+        self::assertFalse($validator->isValid([]));
+        $message = $validator->getMessages()[EnumCase::ERR_INVALID_TYPE] ?? null;
+        self::assertIsString($message);
+        self::assertSame('Expected a string but received array', $message);
+
+        self::assertFalse($validator->isValid('Not a Case'));
+        $message = $validator->getMessages()[EnumCase::ERR_INVALID_VALUE] ?? null;
+        self::assertIsString($message);
+        self::assertSame('"Not a Case" is not a valid enum case', $message);
+    }
+}
diff --git a/test/TestAsset/ExampleIntBackedEnum.php b/test/TestAsset/ExampleIntBackedEnum.php
new file mode 100644
index 00000000..b2ae7a80
--- /dev/null
+++ b/test/TestAsset/ExampleIntBackedEnum.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace LaminasTest\Validator\TestAsset;
+
+enum ExampleIntBackedEnum: int
+{
+    case LifeMeaning = 42;
+    case AllTheTwos  = 22;
+    case CupOfTea    = 3;
+    case PickAndMix  = 26;
+}
diff --git a/test/TestAsset/ExampleStringBackedEnum.php b/test/TestAsset/ExampleStringBackedEnum.php
new file mode 100644
index 00000000..a0beee0d
--- /dev/null
+++ b/test/TestAsset/ExampleStringBackedEnum.php
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace LaminasTest\Validator\TestAsset;
+
+enum ExampleStringBackedEnum: string
+{
+    case Clubs    = 'Clubs';
+    case Diamonds = 'Diamonds';
+    case Hearts   = 'Hearts';
+    case Spades   = 'Spades';
+}
diff --git a/test/TestAsset/ExampleUnitEnum.php b/test/TestAsset/ExampleUnitEnum.php
new file mode 100644
index 00000000..4e953855
--- /dev/null
+++ b/test/TestAsset/ExampleUnitEnum.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace LaminasTest\Validator\TestAsset;
+
+enum ExampleUnitEnum
+{
+    case Gonzo;
+    case MissPiggy;
+    case Kermit;
+    case FozzieBear;
+    case RowlfTheDog;
+    case Scooter;
+    case Animal;
+}