Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add EnumTypeCaster #96

Merged
merged 1 commit into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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());
}
}
Loading