From c10ae658ec2ed35902493add31724c782f0a1d50 Mon Sep 17 00:00:00 2001 From: George Steel Date: Tue, 3 Sep 2024 15:09:29 +0100 Subject: [PATCH] feat(type): Add value-of type (#483) --- src/Psl/Internal/Loader.php | 1 + src/Psl/Type/Internal/BackedEnumValueType.php | 134 ++++++++++++++++++ src/Psl/Type/README.md | 19 ++- src/Psl/Type/backed_enum_value.php | 26 ++++ tests/fixture/IntegerEnumWithNoCases.php | 9 ++ tests/fixture/StringEnumWithNoCases.php | 9 ++ .../Type/IntegerBackedEnumValueTypeTest.php | 53 +++++++ .../Type/Internal/BackedEnumValueTypeTest.php | 52 +++++++ .../Type/StringBackedEnumValueTypeTest.php | 51 +++++++ 9 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 src/Psl/Type/Internal/BackedEnumValueType.php create mode 100644 src/Psl/Type/backed_enum_value.php create mode 100644 tests/fixture/IntegerEnumWithNoCases.php create mode 100644 tests/fixture/StringEnumWithNoCases.php create mode 100644 tests/unit/Type/IntegerBackedEnumValueTypeTest.php create mode 100644 tests/unit/Type/Internal/BackedEnumValueTypeTest.php create mode 100644 tests/unit/Type/StringBackedEnumValueTypeTest.php diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index 5b365c449..853f58d2a 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -387,6 +387,7 @@ final class Loader 'Psl\\Type\\is_nan' => 'Psl/Type/is_nan.php', 'Psl\\Type\\literal_scalar' => 'Psl/Type/literal_scalar.php', 'Psl\\Type\\backed_enum' => 'Psl/Type/backed_enum.php', + 'Psl\\Type\\backed_enum_value' => 'Psl/Type/backed_enum_value.php', 'Psl\\Type\\unit_enum' => 'Psl/Type/unit_enum.php', 'Psl\\Type\\converted' => 'Psl/Type/converted.php', 'Psl\\Json\\encode' => 'Psl/Json/encode.php', diff --git a/src/Psl/Type/Internal/BackedEnumValueType.php b/src/Psl/Type/Internal/BackedEnumValueType.php new file mode 100644 index 000000000..9db5ec9ed --- /dev/null +++ b/src/Psl/Type/Internal/BackedEnumValueType.php @@ -0,0 +1,134 @@ +> + * + * @internal + */ +final readonly class BackedEnumValueType extends Type +{ + private bool $isStringBacked; + + /** + * @psalm-mutation-free + * + * @param class-string $enum + * + * @throws RuntimeException If reflection fails. + * @throws InvariantViolationException If the given value is not class-string. + */ + public function __construct( + private string $enum + ) { + $this->isStringBacked = $this->hasStringBackingType($this->enum); + } + + /** + * @param class-string $enum + * + * @throws RuntimeException If reflection fails. + * @throws InvariantViolationException If the given value is not class-string. + */ + private function hasStringBackingType(string $enum): bool + { + invariant(is_a($enum, BackedEnum::class, true), 'A BackedEnum class-string is required'); + + // If the enum has any cases, detect its type by inspecting the first case found + $case = $enum::cases()[0] ?? null; + if ($case !== null) { + return is_string($case->value); + } + + // Fallback to reflection to detect the backing type: + try { + $reflection = new ReflectionEnum($enum); + $type = $reflection->getBackingType(); + invariant($type instanceof ReflectionNamedType, 'Unexpected type'); + return $type->getName() === 'string'; + } catch (ReflectionException $e) { + throw new RuntimeException('Failed to reflect an enum class-string', 0, $e); + } + } + + /** + * @psalm-assert-if-true value-of $value + */ + public function matches(mixed $value): bool + { + return match ($this->isStringBacked) { + true => is_string($value) && $this->enum::tryFrom($value) !== null, + false => is_int($value) && $this->enum::tryFrom($value) !== null, + }; + } + + /** + * @throws CoercionException + * + * @return value-of + * + * @psalm-suppress MismatchingDocblockReturnType,DocblockTypeContradiction + * Psalm has issues with value-of when used with an enum + */ + public function coerce(mixed $value): string|int + { + try { + $case = $this->isStringBacked + ? string()->coerce($value) + : int()->coerce($value); + + if ($this->matches($case)) { + return $case; + } + } catch (CoercionException) { + } + + throw CoercionException::withValue($value, $this->toString()); + } + + /** + * @throws AssertException + * + * @return value-of + * + * @psalm-assert value-of $value + * + * @psalm-suppress MismatchingDocblockReturnType + * Psalm has issues with value-of when used with an enum + */ + public function assert(mixed $value): string|int + { + if ($this->matches($value)) { + return $value; + } + + throw AssertException::withValue($value, $this->toString()); + } + + public function toString(): string + { + return 'value-of<' . $this->enum . '>'; + } +} diff --git a/src/Psl/Type/README.md b/src/Psl/Type/README.md index a33500e0a..58113b324 100644 --- a/src/Psl/Type/README.md +++ b/src/Psl/Type/README.md @@ -77,7 +77,24 @@ Provides a type that can parse backed-enums. Can coerce from: * `string` when `T` is a string-backed enum. -* `int` when `T` is a string-backed enum. +* `int` when `T` is an integer-backed enum. + +--- + +#### [backed_enum_value](backed_enum_value.php) + +```hack +@pure +@template T of BackedEnum +Type\backed_enum_value(class-string $enum): TypeInterface> +``` + +Provides a type that can verify a value matches a backed enum value. + +Can coerce from: + +* `string|int` when `T` is a string-backed enum. +* `int|numeric-string` when `T` is an integer-backed enum. --- diff --git a/src/Psl/Type/backed_enum_value.php b/src/Psl/Type/backed_enum_value.php new file mode 100644 index 000000000..e6804874a --- /dev/null +++ b/src/Psl/Type/backed_enum_value.php @@ -0,0 +1,26 @@ + $enum + * + * @throws RuntimeException If reflection fails. + * @throws InvariantViolationException If the given value is not class-string. + * + * @return TypeInterface> + */ +function backed_enum_value(string $enum): TypeInterface +{ + return new Internal\BackedEnumValueType($enum); +} diff --git a/tests/fixture/IntegerEnumWithNoCases.php b/tests/fixture/IntegerEnumWithNoCases.php new file mode 100644 index 000000000..8564b8518 --- /dev/null +++ b/tests/fixture/IntegerEnumWithNoCases.php @@ -0,0 +1,9 @@ +> + */ +final class IntegerBackedEnumValueTypeTest extends TypeTest +{ + public function getType(): Type\TypeInterface + { + return Type\backed_enum_value(IntegerEnum::class); + } + + public function getValidCoercions(): iterable + { + yield [$this->stringable('1'), IntegerEnum::Foo->value]; + yield [1, IntegerEnum::Foo->value]; + yield ['1', IntegerEnum::Foo->value]; + yield ['2', IntegerEnum::Bar->value]; + yield [2, IntegerEnum::Bar->value]; + } + + /** + * @return iterable + */ + public function getInvalidCoercions(): iterable + { + yield [99]; + yield [null]; + yield [STDIN]; + yield ['hello']; + yield [$this->stringable('bar')]; + yield [new class { + }]; + } + + /** + * @return iterable, 1: string}> + */ + public function getToStringExamples(): iterable + { + yield [Type\backed_enum_value(IntegerEnum::class), Str\format('value-of<%s>', IntegerEnum::class)]; + } +} diff --git a/tests/unit/Type/Internal/BackedEnumValueTypeTest.php b/tests/unit/Type/Internal/BackedEnumValueTypeTest.php new file mode 100644 index 000000000..501c42a65 --- /dev/null +++ b/tests/unit/Type/Internal/BackedEnumValueTypeTest.php @@ -0,0 +1,52 @@ +, 1: bool} + */ + public static function enumDataProvider(): array + { + return [ + [IntegerEnumWithNoCases::class, false], + [StringEnumWithNoCases::class, true], + [IntegerEnum::class, false], + [StringEnum::class, true], + ]; + } + + /** + * @dataProvider enumDataProvider + * + * @param class-string $enum + */ + public function testTheCorrectBackingTypeIsDetected(string $enum, bool $expect): void + { + $type = new BackedEnumValueType($enum); + + $reflection = new ReflectionProperty($type, 'isStringBacked'); + static::assertSame($expect, $reflection->getValue($type)); + } + + public function testReflectionFailsForANonEnumArgument(): void + { + $this->expectException(InvariantViolationException::class); + $this->expectExceptionMessage('A BackedEnum class-string is required'); + + new BackedEnumValueType(self::class); + } +} diff --git a/tests/unit/Type/StringBackedEnumValueTypeTest.php b/tests/unit/Type/StringBackedEnumValueTypeTest.php new file mode 100644 index 000000000..ea936ac16 --- /dev/null +++ b/tests/unit/Type/StringBackedEnumValueTypeTest.php @@ -0,0 +1,51 @@ +> + */ +final class StringBackedEnumValueTypeTest extends TypeTest +{ + public function getType(): Type\TypeInterface + { + return Type\backed_enum_value(StringEnum::class); + } + + public function getValidCoercions(): iterable + { + yield [1, StringEnum::Bar->value]; + yield [$this->stringable('foo'), StringEnum::Foo->value]; + yield ['foo', StringEnum::Foo->value]; + yield ['1', StringEnum::Bar->value]; + } + + /** + * @return iterable + */ + public function getInvalidCoercions(): iterable + { + yield [null]; + yield [STDIN]; + yield ['hello']; + yield [$this->stringable('bar')]; + yield [new class { + }]; + } + + /** + * @return iterable>, 1: string}> + */ + public function getToStringExamples(): iterable + { + yield [Type\backed_enum_value(StringEnum::class), Str\format('value-of<%s>', StringEnum::class)]; + } +}