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 2, 2024
1 parent b899ad0 commit 85162ff
Show file tree
Hide file tree
Showing 6 changed files with 249 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
105 changes: 105 additions & 0 deletions src/Psl/Type/Internal/BackedEnumValueType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?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 function array_map;
use function in_array;
use function is_string;
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
) {
$this->isStringBacked = is_string($this->enum::cases()[0]->value);
}

/**
* @psalm-assert-if-true value-of<T> $value
*/
public function matches(mixed $value): bool
{
if (! is_string($value) && ! is_int($value)) {
return false;
}

$values = array_map(
static fn (BackedEnum $enum): string|int => $enum->value,
$this->enum::cases(),
);

return in_array($value, $values, true);
}

/**
* @throws CoercionException
*
* @return value-of<T>
*/
public function coerce(mixed $value): string|int
{
if ($this->isStringBacked) {
try {
$str_value = string()->coerce($value);
if ($this->matches($str_value)) {
return $str_value;
}
} catch (CoercionException) {
}
} else {
try {
$int_value = int()->coerce($value);
if ($this->matches($int_value)) {
return $int_value;
}
} catch (CoercionException) {
}
}

throw CoercionException::withValue($value, $this->toString());
}

/**
* @throws AssertException
*
* @return value-of<T>
*
* @psalm-assert value-of<T> $value
*/
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 85162ff

Please sign in to comment.