Skip to content

Commit

Permalink
Add EnumTypeCaster (#96)
Browse files Browse the repository at this point in the history
  • Loading branch information
vjik authored Sep 17, 2024
1 parent 494a7e4 commit d0d4986
Show file tree
Hide file tree
Showing 7 changed files with 336 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 1.4.1 under development

- New #96: Add `EnumTypeCaster` (@vjik)
- Bug #95: Fix populating readonly properties from parent classes (@vjik)

## 1.4.0 August 23, 2024
Expand Down
1 change: 1 addition & 0 deletions docs/guide/en/typecasting.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Out of the box, the following type-casters are available:
- `CompositeTypeCaster` allows combining multiple type-casters
- `PhpNativeTypeCaster` casts based on PHP types defined in the class
- `HydratorTypeCaster` casts arrays to objects
- `EnumTypeCaster` casts values to enumerations
- `NullTypeCaster` configurable type caster for casting `null`, empty string and empty array to `null`
- `NoTypeCaster` does not cast anything

Expand Down
1 change: 1 addition & 0 deletions docs/guide/ru/typecasting.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ $hydrator = new Hydrator($typeCaster);
- `CompositeTypeCaster` позволяет комбинировать несколько классов для приведения типов
- `PhpNativeTypeCaster` приведение типов, основанное на PHP типах, определенных в классе
- `HydratorTypeCaster` приведение массивов к объектам
- `EnumTypeCaster` приведение значений к перечислениям
- `NullTypeCaster` настраиваемый класс для приведения `null`, пустой строки и пустого массива к `null`
- `NoTypeCaster` не использовать приведение типов

Expand Down
129 changes: 129 additions & 0 deletions src/TypeCaster/EnumTypeCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Hydrator\TypeCaster;

use BackedEnum;
use ReflectionEnum;
use ReflectionNamedType;
use ReflectionUnionType;
use Stringable;
use UnitEnum;
use Yiisoft\Hydrator\Result;

use function is_a;
use function is_scalar;

/**
* Casts values to enumerations.
*/
final class EnumTypeCaster implements TypeCasterInterface
{
public function cast(mixed $value, TypeCastContext $context): Result
{
$type = $context->getReflectionType();

if ($type instanceof ReflectionNamedType) {
return $this->castInternal($value, $type);
}

if (!$type instanceof ReflectionUnionType) {
return Result::fail();
}

foreach ($type->getTypes() as $t) {
if (!$t instanceof ReflectionNamedType) {
continue;
}

$result = $this->castInternal($value, $t);
if ($result->isResolved()) {
return $result;
}
}

return Result::fail();
}

private function castInternal(mixed $value, ReflectionNamedType $type): Result
{
$enumClass = $type->getName();
if (!$this->isEnum($enumClass)) {
return Result::fail();
}

if ($value instanceof $enumClass) {
return Result::success($value);
}

if (!$this->isBackedEnum($enumClass)) {
return Result::fail();
}

$enumValue = $this->isStringEnum($enumClass)
? $this->tryCastToString($value)
: $this->tryCastToInt($value);
if ($enumValue === null) {
return Result::fail();
}

$enum = $enumClass::tryFrom($enumValue);
if ($enum === null) {
return Result::fail();
}

return Result::success($enum);
}

/**
* @psalm-assert-if-true class-string<UnitEnum> $class
*/
private function isEnum(string $class): bool
{
return is_a($class, UnitEnum::class, true);
}

/**
* @psalm-param class-string<UnitEnum> $class
* @psalm-assert-if-true class-string<BackedEnum> $class
*/
private function isBackedEnum(string $class): bool
{
return is_a($class, BackedEnum::class, true);
}

/**
* @psalm-param class-string<BackedEnum> $class
*/
private function isStringEnum(string $class): bool
{
$reflection = new ReflectionEnum($class);

/**
* @var ReflectionNamedType $type
*/
$type = $reflection->getBackingType();

return $type->getName() === 'string';
}

private function tryCastToString(mixed $value): ?string
{
if (is_scalar($value) || $value === null || $value instanceof Stringable) {
return (string) $value;
}
return null;
}

private function tryCastToInt(mixed $value): ?int
{
if (is_scalar($value) || $value === null) {
return (int) $value;
}
if ($value instanceof Stringable) {
return (int) (string) $value;
}
return null;
}
}
12 changes: 12 additions & 0 deletions tests/Support/BaseEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Yiisoft\Hydrator\Tests\Support;

enum BaseEnum
{
case A;
case B;
case C;
}
65 changes: 65 additions & 0 deletions tests/TestEnvironments/Php82/TypeCaster/EnumTypeCasterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace TestEnvironments\Php82\TypeCaster;

use Countable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Yiisoft\Hydrator\Result;
use Yiisoft\Hydrator\Tests\Support\IntegerEnum;
use Yiisoft\Hydrator\Tests\Support\StringEnum;
use Yiisoft\Hydrator\Tests\Support\TestHelper;
use Yiisoft\Hydrator\TypeCaster\EnumTypeCaster;
use Yiisoft\Hydrator\TypeCaster\TypeCastContext;

final class EnumTypeCasterTest extends TestCase
{
public static function dataBase(): array
{
return [
'enum to enum|intersection' => [
Result::success(StringEnum::A),
StringEnum::A,
TestHelper::createTypeCastContext(static fn(StringEnum|(IntegerEnum&Countable) $a) => null),
],
'string to enum|intersection' => [
Result::success(StringEnum::A),
'one',
TestHelper::createTypeCastContext(static fn(StringEnum|(IntegerEnum&Countable) $a) => null),
],
'string to intersection|enum' => [
Result::success(StringEnum::A),
'one',
TestHelper::createTypeCastContext(static fn((IntegerEnum&Countable)|StringEnum $a) => null),
],
'int to enum|intersection' => [
Result::success(IntegerEnum::B),
2,
TestHelper::createTypeCastContext(static fn(IntegerEnum|(StringEnum&Countable) $a) => null),
],
'int to intersection|enum' => [
Result::success(IntegerEnum::B),
2,
TestHelper::createTypeCastContext(static fn((StringEnum&Countable)|IntegerEnum $a) => null),
],
'enum to (another enum)|intersection' => [
Result::fail(),
IntegerEnum::B,
TestHelper::createTypeCastContext(static fn(StringEnum|(IntegerEnum&Countable) $a) => null),
],
];
}

#[DataProvider('dataBase')]
public function testBase(Result $expected, mixed $value, TypeCastContext $context): void
{
$typeCaster = new EnumTypeCaster();

$result = $typeCaster->cast($value, $context);

$this->assertSame($expected->isResolved(), $result->isResolved());
$this->assertEquals($expected->getValue(), $result->getValue());
}
}
127 changes: 127 additions & 0 deletions tests/TypeCaster/EnumTypeCasterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

namespace TypeCaster;

use Countable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Yiisoft\Hydrator\Result;
use Yiisoft\Hydrator\Tests\Support\BaseEnum;
use Yiisoft\Hydrator\Tests\Support\IntegerEnum;
use Yiisoft\Hydrator\Tests\Support\StringableObject;
use Yiisoft\Hydrator\Tests\Support\StringEnum;
use Yiisoft\Hydrator\Tests\Support\TestHelper;
use Yiisoft\Hydrator\TypeCaster\EnumTypeCaster;
use Yiisoft\Hydrator\TypeCaster\TypeCastContext;

final class EnumTypeCasterTest extends TestCase
{
public static function dataBase(): array
{
return [
'enum to not enum' => [
Result::fail(),
IntegerEnum::A,
TestHelper::createTypeCastContext(static fn(int $a) => null),
],
'enum to no type' => [
Result::fail(),
IntegerEnum::A,
TestHelper::createTypeCastContext(static fn($a) => null),
],
'enum to enum' => [
Result::success(IntegerEnum::A),
IntegerEnum::A,
TestHelper::createTypeCastContext(static fn(IntegerEnum $a) => null),
],
'enum to another enum' => [
Result::fail(),
IntegerEnum::A,
TestHelper::createTypeCastContext(static fn(StringEnum $a) => null),
],
'int to enum' => [
Result::success(IntegerEnum::A),
1,
TestHelper::createTypeCastContext(static fn(IntegerEnum $a) => null),
],
'int as string to enum' => [
Result::success(IntegerEnum::A),
'1',
TestHelper::createTypeCastContext(static fn(IntegerEnum $a) => null),
],
'stringable int to enum' => [
Result::success(IntegerEnum::A),
new StringableObject('1'),
TestHelper::createTypeCastContext(static fn(IntegerEnum $a) => null),
],
'invalid int to enum' => [
Result::fail(),
5,
TestHelper::createTypeCastContext(static fn(IntegerEnum $a) => null),
],
'string to enum' => [
Result::success(StringEnum::B),
'two',
TestHelper::createTypeCastContext(static fn(StringEnum $a) => null),
],
'stringable to enum' => [
Result::success(StringEnum::B),
new StringableObject('two'),
TestHelper::createTypeCastContext(static fn(StringEnum $a) => null),
],
'invalid string to enum' => [
Result::fail(),
'five',
TestHelper::createTypeCastContext(static fn(StringEnum $a) => null),
],
'enum to nulled enum' => [
Result::success(StringEnum::B),
'two',
TestHelper::createTypeCastContext(static fn(?StringEnum $a) => null),
],
'enum to union with enum' => [
Result::success(StringEnum::B),
'two',
TestHelper::createTypeCastContext(static fn(string|StringEnum $a) => null),
],
'enum to union without enum' => [
Result::fail(),
'two',
TestHelper::createTypeCastContext(static fn(string|int $a) => null),
],
'enum to intersection type' => [
Result::fail(),
IntegerEnum::A,
TestHelper::createTypeCastContext(static fn(IntegerEnum&Countable $a) => null),
],
'base enum' => [
Result::success(BaseEnum::A),
BaseEnum::A,
TestHelper::createTypeCastContext(static fn(BaseEnum $a) => null),
],
'base enum to union with enum' => [
Result::success(BaseEnum::A),
BaseEnum::A,
TestHelper::createTypeCastContext(static fn(IntegerEnum|BaseEnum $a) => null),
],
'string to base enum' => [
Result::fail(),
'A',
TestHelper::createTypeCastContext(static fn(BaseEnum $a) => null),
],
];
}

#[DataProvider('dataBase')]
public function testBase(Result $expected, mixed $value, TypeCastContext $context): void
{
$typeCaster = new EnumTypeCaster();

$result = $typeCaster->cast($value, $context);

$this->assertSame($expected->isResolved(), $result->isResolved());
$this->assertEquals($expected->getValue(), $result->getValue());
}
}

0 comments on commit d0d4986

Please sign in to comment.