Skip to content

Commit

Permalink
feat: add support for value-of<BackedEnum> type
Browse files Browse the repository at this point in the history
This type can be used as follows:

```php
enum Suit: string
{
    case Hearts = 'H';
    case Diamonds = 'D';
    case Clubs = 'C';
    case Spades = 'S';
}

$suit = (new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(Suit::class, 'D');

// $suit === Suit::Diamonds
```
  • Loading branch information
robchett authored Jul 19, 2024
1 parent 2ff1d02 commit b1017ce
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 0 deletions.
3 changes: 3 additions & 0 deletions docs/pages/usage/type-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ final class SomeClass

/** @var class-string<SomeInterface> */
private string $classStringOfAnInterface,

/** @var value-of<SomeEnum> */
private string $valueOfEnum,
) {}
}
```
Expand Down
21 changes: 21 additions & 0 deletions src/Type/Parser/Exception/Magic/ValueOfClosingBracketMissing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Type\Parser\Exception\Magic;

use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use CuyZ\Valinor\Type\Type;
use RuntimeException;

/** @internal */
final class ValueOfClosingBracketMissing extends RuntimeException implements InvalidType
{
public function __construct(Type $type)
{
parent::__construct(
"The closing bracket is missing for `value-of<{$type->toString()}>`.",
1717702289
);
}
}
21 changes: 21 additions & 0 deletions src/Type/Parser/Exception/Magic/ValueOfIncorrectSubType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Type\Parser\Exception\Magic;

use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use CuyZ\Valinor\Type\Type;
use RuntimeException;

/** @internal */
final class ValueOfIncorrectSubType extends RuntimeException implements InvalidType
{
public function __construct(Type $type)
{
parent::__construct(
"Invalid subtype `value-of<{$type->toString()}>`, it should be a `BackedEnum`.",
1717702683
);
}
}
20 changes: 20 additions & 0 deletions src/Type/Parser/Exception/Magic/ValueOfOpeningBracketMissing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Type\Parser\Exception\Magic;

use CuyZ\Valinor\Type\Parser\Exception\InvalidType;
use RuntimeException;

/** @internal */
final class ValueOfOpeningBracketMissing extends RuntimeException implements InvalidType
{
public function __construct()
{
parent::__construct(
"The opening bracket is missing for `value-of<...>`.",
1717702268
);
}
}
2 changes: 2 additions & 0 deletions src/Type/Parser/Lexer/NativeLexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use CuyZ\Valinor\Type\Parser\Lexer\Token\IterableToken;
use CuyZ\Valinor\Type\Parser\Lexer\Token\ListToken;
use CuyZ\Valinor\Type\Parser\Lexer\Token\NullableToken;
use CuyZ\Valinor\Type\Parser\Lexer\Token\ValueOfToken;
use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningBracketToken;
use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningCurlyBracketToken;
use CuyZ\Valinor\Type\Parser\Lexer\Token\OpeningSquareBracketToken;
Expand Down Expand Up @@ -78,6 +79,7 @@ public function tokenize(string $symbol): Token
'iterable' => IterableToken::get(),
'class-string' => ClassStringToken::get(),
'callable' => CallableToken::get(),
'value-of' => ValueOfToken::get(),

'null' => new TypeToken(NullType::get()),
'true' => new TypeToken(BooleanValueType::true()),
Expand Down
65 changes: 65 additions & 0 deletions src/Type/Parser/Lexer/Token/ValueOfToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Type\Parser\Lexer\Token;

use BackedEnum;
use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfIncorrectSubType;
use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfClosingBracketMissing;
use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfOpeningBracketMissing;
use CuyZ\Valinor\Type\Parser\Lexer\TokenStream;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\EnumType;
use CuyZ\Valinor\Type\Types\Factory\ValueTypeFactory;
use CuyZ\Valinor\Type\Types\UnionType;
use CuyZ\Valinor\Utility\IsSingleton;

use function array_map;
use function array_values;
use function count;
use function is_a;

/** @internal */
final class ValueOfToken implements TraversingToken
{
use IsSingleton;

public function traverse(TokenStream $stream): Type
{
if ($stream->done() || !$stream->forward() instanceof OpeningBracketToken) {
throw new ValueOfOpeningBracketMissing();
}

$subType = $stream->read();

if ($stream->done() || !$stream->forward() instanceof ClosingBracketToken) {
throw new ValueOfClosingBracketMissing($subType);
}

if (! $subType instanceof EnumType) {
throw new ValueOfIncorrectSubType($subType);
}

if (! is_a($subType->className(), BackedEnum::class, true)) {
throw new ValueOfIncorrectSubType($subType);
}

$cases = array_map(
// @phpstan-ignore-next-line / We know it's a BackedEnum
fn (BackedEnum $case) => ValueTypeFactory::from($case->value),
array_values($subType->cases()),
);

if (count($cases) > 1) {
return new UnionType(...$cases);
}

return $cases[0];
}

public function symbol(): string
{
return 'value-of';
}
}
53 changes: 53 additions & 0 deletions tests/Functional/Type/Parser/LexingParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayUnexpectedTokenAfterSealedType;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\ShapedArrayWithoutElementsWithSealedType;
use CuyZ\Valinor\Type\Parser\Exception\Iterable\SimpleArrayClosingBracketMissing;
use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfClosingBracketMissing;
use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfIncorrectSubType;
use CuyZ\Valinor\Type\Parser\Exception\Magic\ValueOfOpeningBracketMissing;
use CuyZ\Valinor\Type\Parser\Exception\MissingClosingQuoteChar;
use CuyZ\Valinor\Type\Parser\Exception\RightIntersectionTypeMissing;
use CuyZ\Valinor\Type\Parser\Exception\RightUnionTypeMissing;
Expand Down Expand Up @@ -1064,6 +1067,16 @@ public static function parse_valid_types_returns_valid_result_data_provider(): i
'transformed' => PureEnum::class . '::*A*',
'type' => EnumType::class,
];
yield 'value-of<BackedStringEnum>' => [
'raw' => "value-of<" . BackedStringEnum::class . ">",
'transformed' => "'foo'|'bar'|'baz'",
'type' => UnionType::class,
];
yield 'value-of<BackedIntegerEnum>' => [
'raw' => "value-of<" . BackedIntegerEnum::class . ">",
'transformed' => "42|404|1337",
'type' => UnionType::class,
];
}

public function test_multiple_union_types_are_parsed(): void
Expand Down Expand Up @@ -1603,6 +1616,46 @@ public function test_duplicated_template_name_throws_exception(): void

$this->parser->parse("$className<int, string>");
}

public function test_value_of_enum_missing_opening_bracket_throws_exception(): void
{
$this->expectException(ValueOfOpeningBracketMissing::class);
$this->expectExceptionCode(1717702268);
$this->expectExceptionMessage('The opening bracket is missing for `value-of<...>`.');

$this->parser->parse('value-of');
}

public function test_value_of_enum_missing_closing_bracket_throws_exception(): void
{
$enumName = BackedStringEnum::class;

$this->expectException(ValueOfClosingBracketMissing::class);
$this->expectExceptionCode(1717702289);
$this->expectExceptionMessage("The closing bracket is missing for `value-of<$enumName>`.");

$this->parser->parse("value-of<$enumName");
}

public function test_value_of_incorrect_type_throws_exception(): void
{
$this->expectException(ValueOfIncorrectSubType::class);
$this->expectExceptionCode(1717702683);
$this->expectExceptionMessage('Invalid subtype `value-of<string>`, it should be a `BackedEnum`.');

$this->parser->parse('value-of<string>');
}

public function test_value_of_unit_enum_type_throws_exception(): void
{
$enumName = PureEnum::class;

$this->expectException(ValueOfIncorrectSubType::class);
$this->expectExceptionCode(1717702683);
$this->expectExceptionMessage("Invalid subtype `value-of<$enumName>`, it should be a `BackedEnum`.");

$this->parser->parse("value-of<$enumName>");
}
}

/**
Expand Down
94 changes: 94 additions & 0 deletions tests/Integration/Mapping/EnumValueOfMappingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Tests\Integration\Mapping;

use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTestCase;

final class EnumValueOfMappingTest extends IntegrationTestCase
{
public function test_can_map_value_of_string_enum(): void
{
try {
$result = $this->mapperBuilder()
->mapper()
->map('value-of<' . SomeStringEnumForValueOf::class . '>', SomeStringEnumForValueOf::FOO->value);
} catch (MappingError $error) {
$this->mappingFail($error);
}

self::assertSame(SomeStringEnumForValueOf::FOO->value, $result);
}

public function test_can_map_value_of_enum_with_one_case(): void
{
try {
$result = $this->mapperBuilder()
->mapper()
->map('value-of<' . SomeStringEnumForValueOfWithOneCase::class . '>', SomeStringEnumForValueOf::FOO->value);
} catch (MappingError $error) {
$this->mappingFail($error);
}

self::assertSame(SomeStringEnumForValueOf::FOO->value, $result); // @phpstan-ignore-line
}

public function test_can_map_value_of_integer_enum(): void
{
try {
$result = $this->mapperBuilder()
->mapper()
->map('value-of<' . SomeIntegerEnumForValueOf::class . '>', SomeIntegerEnumForValueOf::FOO->value);
} catch (MappingError $error) {
$this->mappingFail($error);
}

self::assertSame(SomeIntegerEnumForValueOf::FOO->value, $result);
}

public function test_array_keys_using_value_of(): void
{
try {
$result = $this->mapperBuilder()
->mapper()
->map('array<value-of<' . SomeStringEnumForValueOf::class . '>, string>', [SomeStringEnumForValueOf::FOO->value => 'foo']);
} catch (MappingError $error) {
$this->mappingFail($error);
}

self::assertSame([SomeStringEnumForValueOf::FOO->value => 'foo'], $result);
}

public function test_array_keys_using_value_of_error(): void
{
try {
$this->mapperBuilder()
->mapper()
->map('array<value-of<' . SomeStringEnumForValueOf::class . '>, string>', ['oof' => 'foo']);
} catch (MappingError $exception) {
$error = $exception->node()->children()['oof']->messages()[0];
self::assertSame("Key 'oof' does not match type `'FOO'|'FOZ'|'BAZ'`.", (string)$error);
}
}
}

enum SomeStringEnumForValueOf: string
{
case FOO = 'FOO';
case FOZ = 'FOZ';
case BAZ = 'BAZ';
}

enum SomeStringEnumForValueOfWithOneCase: string
{
case FOO = 'FOO';
}

enum SomeIntegerEnumForValueOf: int
{
case FOO = 42;
case FOZ = 404;
case BAZ = 1337;
}

0 comments on commit b1017ce

Please sign in to comment.