diff --git a/docs/component/type.md b/docs/component/type.md index 3ab40e1f..60f603c3 100644 --- a/docs/component/type.md +++ b/docs/component/type.md @@ -15,6 +15,7 @@ - [array_key](./../../src/Psl/Type/array_key.php#L10) - [backed_enum](./../../src/Psl/Type/backed_enum.php#L16) - [bool](./../../src/Psl/Type/bool.php#L10) +- [converted](./../../src/Psl/Type/converted.php#L21) - [dict](./../../src/Psl/Type/dict.php#L16) - [f32](./../../src/Psl/Type/f32.php#L12) - [f64](./../../src/Psl/Type/f64.php#L12) diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index db448961..d5423783 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -351,6 +351,7 @@ final class Loader 'Psl\\Type\\literal_scalar' => 'Psl/Type/literal_scalar.php', 'Psl\\Type\\backed_enum' => 'Psl/Type/backed_enum.php', 'Psl\\Type\\unit_enum' => 'Psl/Type/unit_enum.php', + 'Psl\\Type\\converted' => 'Psl/Type/converted.php', 'Psl\\Json\\encode' => 'Psl/Json/encode.php', 'Psl\\Json\\decode' => 'Psl/Json/decode.php', 'Psl\\Json\\typed' => 'Psl/Json/typed.php', @@ -633,6 +634,7 @@ final class Loader 'Psl\\Type\\Internal\\VectorType' => 'Psl/Type/Internal/VectorType.php', 'Psl\\Type\\Internal\\MutableVectorType' => 'Psl/Type/Internal/MutableVectorType.php', 'Psl\\Type\\Internal\\BoolType' => 'Psl/Type/Internal/BoolType.php', + 'Psl\\Type\\Internal\\ConvertedType' => 'Psl/Type/Internal/ConvertedType.php', 'Psl\\Type\\Internal\\FloatType' => 'Psl/Type/Internal/FloatType.php', 'Psl\\Type\\Internal\\IntersectionType' => 'Psl/Type/Internal/IntersectionType.php', 'Psl\\Type\\Internal\\IntType' => 'Psl/Type/Internal/IntType.php', diff --git a/src/Psl/Type/Exception/CoercionException.php b/src/Psl/Type/Exception/CoercionException.php index 3c2c35f2..46651dd4 100644 --- a/src/Psl/Type/Exception/CoercionException.php +++ b/src/Psl/Type/Exception/CoercionException.php @@ -5,6 +5,7 @@ namespace Psl\Type\Exception; use Psl\Str; +use Throwable; use function get_debug_type; @@ -12,10 +13,16 @@ final class CoercionException extends Exception { private string $target; - public function __construct(string $actual, string $target, TypeTrace $typeTrace) + public function __construct(string $actual, string $target, TypeTrace $typeTrace, string $additionalInfo = '') { parent::__construct( - Str\format('Could not coerce "%s" to type "%s".', $actual, $target), + Str\format( + 'Could not coerce "%s" to type "%s"%s%s', + $actual, + $target, + $additionalInfo ? ': ' : '.', + $additionalInfo + ), $actual, $typeTrace, ); @@ -35,4 +42,18 @@ public static function withValue( ): self { return new self(get_debug_type($value), $target, $typeTrace); } + + public static function withConversionFailureOnValue( + mixed $value, + string $target, + TypeTrace $typeTrace, + Throwable $failure, + ): self { + return new self( + get_debug_type($value), + $target, + $typeTrace, + $failure->getMessage() + ); + } } diff --git a/src/Psl/Type/Internal/ConvertedType.php b/src/Psl/Type/Internal/ConvertedType.php new file mode 100644 index 00000000..80075ed9 --- /dev/null +++ b/src/Psl/Type/Internal/ConvertedType.php @@ -0,0 +1,82 @@ + + * + * @extends Type\Type + * + * @internal + */ +final class ConvertedType extends Type\Type +{ + /** + * @param TypeInterface $from + * @param TypeInterface $into + * @param (\Closure(I): O) $converter + */ + public function __construct( + private TypeInterface $from, + private TypeInterface $into, + private Closure $converter + ) { + } + + /** + * @throws CoercionException + * + * @ara-return O + * + * @return O + */ + public function coerce(mixed $value): mixed + { + if ($this->into->matches($value)) { + return $value; + } + + $coercedInput = $this->from->coerce($value); + + try { + $converted = ($this->converter)($coercedInput); + } catch (Throwable $failure) { + throw CoercionException::withConversionFailureOnValue($value, $this->toString(), $this->getTrace(), $failure); + } + + return $this->into->coerce($converted); + } + + /** + * @ara-assert O $value + * + * @psalm-assert O $value + * + * @throws AssertException + * + * @ara-return O + * + * @return O + */ + public function assert(mixed $value): mixed + { + return $this->into->assert($value); + } + + public function toString(): string + { + return $this->into->toString(); + } +} diff --git a/src/Psl/Type/converted.php b/src/Psl/Type/converted.php new file mode 100644 index 00000000..87ed1544 --- /dev/null +++ b/src/Psl/Type/converted.php @@ -0,0 +1,24 @@ + $from + * @param TypeInterface $into + * @param (Closure(I): O) $converter + * + * @ara-return TypeInterface + * + * @return TypeInterface + */ +function converted(TypeInterface $from, TypeInterface $into, Closure $converter): TypeInterface +{ + return new Internal\ConvertedType($from, $into, $converter); +} diff --git a/tests/unit/Type/ConvertedTypeTest.php b/tests/unit/Type/ConvertedTypeTest.php new file mode 100644 index 00000000..cfda2ad8 --- /dev/null +++ b/tests/unit/Type/ConvertedTypeTest.php @@ -0,0 +1,64 @@ + + DateTimeImmutable::createFromFormat(self::DATE_FORMAT, $value) + ?: throw new RuntimeException('Unable to parse date format'), + ); + } + + public function getValidCoercions(): iterable + { + yield ['2023-04-27 08:28:00', DateTimeImmutable::createFromFormat(self::DATE_FORMAT, '2023-04-27 08:28:00')]; + yield [$this->stringable('2023-04-27 08:28:00'), DateTimeImmutable::createFromFormat(self::DATE_FORMAT, '2023-04-27 08:28:00')]; + } + + public function getInvalidCoercions(): iterable + { + yield [1]; + yield [false]; + yield ['']; + yield ['2023-04-27']; + yield ['2023-04-27 08:26']; + yield ['27/04/2023']; + yield [$this->stringable('2023-04-27')]; + } + + /** + * @param DateTimeImmutable|mixed $a + * @param DateTimeImmutable|mixed $b + */ + protected function equals($a, $b): bool + { + if (Type\instance_of(DateTimeImmutable::class)->matches($a)) { + $a = $a->format(self::DATE_FORMAT); + } + + if (Type\instance_of(DateTimeImmutable::class)->matches($b)) { + $b = $b->format(self::DATE_FORMAT); + } + + return parent::equals($a, $b); + } + + public function getToStringExamples(): iterable + { + yield [$this->getType(), DateTimeImmutable::class]; + } +} diff --git a/tests/unit/Type/Exception/TypeCoercionExceptionTest.php b/tests/unit/Type/Exception/TypeCoercionExceptionTest.php index a764f269..5f033106 100644 --- a/tests/unit/Type/Exception/TypeCoercionExceptionTest.php +++ b/tests/unit/Type/Exception/TypeCoercionExceptionTest.php @@ -9,6 +9,7 @@ use Psl\Iter; use Psl\Str; use Psl\Type; +use RuntimeException; final class TypeCoercionExceptionTest extends TestCase { @@ -63,4 +64,34 @@ public function testIncorrectResourceType(): void static::assertCount(0, $frames); } } + + public function testConversionFailure(): void + { + $type = Type\converted( + Type\int(), + Type\string(), + static fn (int $i): string => throw new RuntimeException('not possible') + ); + + try { + $type->coerce(1); + + static::fail(Str\format( + 'Expected "%s" exception to be thrown.', + Type\Exception\CoercionException::class + )); + } catch (Type\Exception\CoercionException $e) { + static::assertSame('string', $e->getTargetType()); + static::assertSame('int', $e->getActualType()); + static::assertSame(Str\format( + 'Could not coerce "int" to type "string": not possible', + Collection\Map::class + ), $e->getMessage()); + + $trace = $e->getTypeTrace(); + $frames = $trace->getFrames(); + + static::assertCount(0, $frames); + } + } }