Skip to content

Commit

Permalink
feat(type): Add value-of<BackedEnum> type
Browse files Browse the repository at this point in the history
  • Loading branch information
gsteel committed Aug 15, 2024
1 parent b899ad0 commit ef02dfb
Show file tree
Hide file tree
Showing 6 changed files with 246 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
102 changes: 102 additions & 0 deletions src/Psl/Type/Internal/BackedEnumValueType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace Psl\Type\Internal;

use BackedEnum;
use Psl\Type\Exception\AssertException;
use Psl\Type\Exception\CoercionException;
use Psl\Type\Type;
use ReflectionEnum;

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
*/
public function __construct(
private string $enum
) {
invariant(is_a($this->enum, BackedEnum::class, true), 'Backed enum class-string required');
$reflection = new ReflectionEnum($this->enum);
$this->isStringBacked = $reflection->getBackingType()->getName() === 'string';
}

/**
* @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
21 changes: 21 additions & 0 deletions src/Psl/Type/backed_enum_value.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Psl\Type;

use BackedEnum;

/**
* @psalm-pure
*
* @template T of BackedEnum
*
* @param class-string<T> $enum
*
* @return TypeInterface<value-of<T>>
*/
function backed_enum_value(string $enum): TypeInterface
{
return new Internal\BackedEnumValueType($enum);
}
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)];
}
}
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 ef02dfb

Please sign in to comment.