Skip to content

Commit

Permalink
feat(type): Add value-of<BackedEnum> type (#483)
Browse files Browse the repository at this point in the history
  • Loading branch information
gsteel authored Sep 3, 2024
1 parent 8e8ca30 commit c10ae65
Show file tree
Hide file tree
Showing 9 changed files with 353 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/Psl/Internal/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
134 changes: 134 additions & 0 deletions src/Psl/Type/Internal/BackedEnumValueType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

declare(strict_types=1);

namespace Psl\Type\Internal;

use BackedEnum;
use Psl\Exception\InvariantViolationException;
use Psl\Exception\RuntimeException;
use Psl\Type\Exception\AssertException;
use Psl\Type\Exception\CoercionException;
use Psl\Type\Type;
use ReflectionEnum;
use ReflectionException;
use ReflectionNamedType;

use function is_a;
use function is_int;
use function is_string;
use function Psl\invariant;
use function Psl\Type\int;
use function Psl\Type\string;

/**
* @template T of BackedEnum
*
* @extends Type<value-of<T>>
*
* @internal
*/
final readonly class BackedEnumValueType extends Type
{
private bool $isStringBacked;

/**
* @psalm-mutation-free
*
* @param class-string<T> $enum
*
* @throws RuntimeException If reflection fails.
* @throws InvariantViolationException If the given value is not class-string<BackedEnum>.
*/
public function __construct(
private string $enum
) {
$this->isStringBacked = $this->hasStringBackingType($this->enum);
}

/**
* @param class-string<T> $enum
*
* @throws RuntimeException If reflection fails.
* @throws InvariantViolationException If the given value is not class-string<BackedEnum>.
*/
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<T> $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<T>
*
* @psalm-suppress MismatchingDocblockReturnType,DocblockTypeContradiction
* Psalm has issues with value-of<T> 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<T>
*
* @psalm-assert value-of<T> $value
*
* @psalm-suppress MismatchingDocblockReturnType
* Psalm has issues with value-of<T> 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 . '>';
}
}
19 changes: 18 additions & 1 deletion src/Psl/Type/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> $enum): TypeInterface<value-of<T>>
```

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.

---

Expand Down
26 changes: 26 additions & 0 deletions src/Psl/Type/backed_enum_value.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Psl\Type;

use BackedEnum;
use Psl\Exception\InvariantViolationException;
use Psl\Exception\RuntimeException;

/**
* @psalm-pure
*
* @template T of BackedEnum
*
* @param class-string<T> $enum
*
* @throws RuntimeException If reflection fails.
* @throws InvariantViolationException If the given value is not class-string<BackedEnum>.
*
* @return TypeInterface<value-of<T>>
*/
function backed_enum_value(string $enum): TypeInterface
{
return new Internal\BackedEnumValueType($enum);
}
9 changes: 9 additions & 0 deletions tests/fixture/IntegerEnumWithNoCases.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Psl\Tests\Fixture;

enum IntegerEnumWithNoCases: int
{
}
9 changes: 9 additions & 0 deletions tests/fixture/StringEnumWithNoCases.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Psl\Tests\Fixture;

enum StringEnumWithNoCases: string
{
}
53 changes: 53 additions & 0 deletions tests/unit/Type/IntegerBackedEnumValueTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Psl\Tests\Unit\Type;

use Psl\Str;
use Psl\Tests\Fixture\IntegerEnum;
use Psl\Type;

use const STDIN;

/**
* @extends TypeTest<value-of<IntegerEnum>>
*/
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<array{0: mixed}>
*/
public function getInvalidCoercions(): iterable
{
yield [99];
yield [null];
yield [STDIN];
yield ['hello'];
yield [$this->stringable('bar')];
yield [new class {
}];
}

/**
* @return iterable<array{0: Type\Type<mixed>, 1: string}>
*/
public function getToStringExamples(): iterable
{
yield [Type\backed_enum_value(IntegerEnum::class), Str\format('value-of<%s>', IntegerEnum::class)];
}
}
52 changes: 52 additions & 0 deletions tests/unit/Type/Internal/BackedEnumValueTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Psl\Tests\Unit\Type\Internal;

use BackedEnum;
use PHPUnit\Framework\TestCase;
use Psl\Exception\InvariantViolationException;
use Psl\Tests\Fixture\IntegerEnum;
use Psl\Tests\Fixture\IntegerEnumWithNoCases;
use Psl\Tests\Fixture\StringEnum;
use Psl\Tests\Fixture\StringEnumWithNoCases;
use Psl\Type\Internal\BackedEnumValueType;
use ReflectionProperty;

class BackedEnumValueTypeTest extends TestCase
{
/**
* @return list<array{0: class-string<BackedEnum>, 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<BackedEnum> $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);
}
}
51 changes: 51 additions & 0 deletions tests/unit/Type/StringBackedEnumValueTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Psl\Tests\Unit\Type;

use Psl\Str;
use Psl\Tests\Fixture\StringEnum;
use Psl\Type;

use const STDIN;

/**
* @extends TypeTest<value-of<StringEnum>>
*/
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<array{0: mixed}>
*/
public function getInvalidCoercions(): iterable
{
yield [null];
yield [STDIN];
yield ['hello'];
yield [$this->stringable('bar')];
yield [new class {
}];
}

/**
* @return iterable<array{0: Type\Type<value-of<StringEnum>>, 1: string}>
*/
public function getToStringExamples(): iterable
{
yield [Type\backed_enum_value(StringEnum::class), Str\format('value-of<%s>', StringEnum::class)];
}
}

0 comments on commit c10ae65

Please sign in to comment.