diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ad293..e81a5f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/guide/en/typecasting.md b/docs/guide/en/typecasting.md index e41043b..47ca995 100644 --- a/docs/guide/en/typecasting.md +++ b/docs/guide/en/typecasting.md @@ -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 diff --git a/docs/guide/ru/typecasting.md b/docs/guide/ru/typecasting.md index 3994156..66551ba 100644 --- a/docs/guide/ru/typecasting.md +++ b/docs/guide/ru/typecasting.md @@ -38,6 +38,7 @@ $hydrator = new Hydrator($typeCaster); - `CompositeTypeCaster` позволяет комбинировать несколько классов для приведения типов - `PhpNativeTypeCaster` приведение типов, основанное на PHP типах, определенных в классе - `HydratorTypeCaster` приведение массивов к объектам +- `EnumTypeCaster` приведение значений к перечислениям - `NullTypeCaster` настраиваемый класс для приведения `null`, пустой строки и пустого массива к `null` - `NoTypeCaster` не использовать приведение типов diff --git a/src/TypeCaster/EnumTypeCaster.php b/src/TypeCaster/EnumTypeCaster.php new file mode 100644 index 0000000..b2fe248 --- /dev/null +++ b/src/TypeCaster/EnumTypeCaster.php @@ -0,0 +1,129 @@ +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 $class + */ + private function isEnum(string $class): bool + { + return is_a($class, UnitEnum::class, true); + } + + /** + * @psalm-param class-string $class + * @psalm-assert-if-true class-string $class + */ + private function isBackedEnum(string $class): bool + { + return is_a($class, BackedEnum::class, true); + } + + /** + * @psalm-param class-string $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; + } +} diff --git a/tests/Support/BaseEnum.php b/tests/Support/BaseEnum.php new file mode 100644 index 0000000..690d6c7 --- /dev/null +++ b/tests/Support/BaseEnum.php @@ -0,0 +1,12 @@ + [ + 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()); + } +} diff --git a/tests/TypeCaster/EnumTypeCasterTest.php b/tests/TypeCaster/EnumTypeCasterTest.php new file mode 100644 index 0000000..fdc9dd9 --- /dev/null +++ b/tests/TypeCaster/EnumTypeCasterTest.php @@ -0,0 +1,127 @@ + [ + 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()); + } +}