Skip to content

Commit

Permalink
Introduce a converted type.
Browse files Browse the repository at this point in the history
  • Loading branch information
veewee committed Apr 27, 2023
1 parent 0b1eac3 commit 72aea10
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 2 deletions.
1 change: 1 addition & 0 deletions docs/component/type.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/Psl/Internal/Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
25 changes: 23 additions & 2 deletions src/Psl/Type/Exception/CoercionException.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,24 @@
namespace Psl\Type\Exception;

use Psl\Str;
use Throwable;

use function get_debug_type;

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,
);
Expand All @@ -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()
);
}
}
82 changes: 82 additions & 0 deletions src/Psl/Type/Internal/ConvertedType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace Psl\Type\Internal;

use Closure;
use Psl\Type;
use Psl\Type\Exception\AssertException;
use Psl\Type\Exception\CoercionException;
use Psl\Type\TypeInterface;
use Throwable;

/**
* @template I
* @template O
*
* @ara-extends Type\Type<O>
*
* @extends Type\Type<O>
*
* @internal
*/
final class ConvertedType extends Type\Type
{
/**
* @param TypeInterface<I> $from
* @param TypeInterface<O> $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();
}
}
24 changes: 24 additions & 0 deletions src/Psl/Type/converted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Psl\Type;

use Closure;

/**
* @template I
* @template O
*
* @param TypeInterface<I> $from
* @param TypeInterface<O> $into
* @param (Closure(I): O) $converter
*
* @ara-return TypeInterface<O>
*
* @return TypeInterface<O>
*/
function converted(TypeInterface $from, TypeInterface $into, Closure $converter): TypeInterface
{
return new Internal\ConvertedType($from, $into, $converter);
}
64 changes: 64 additions & 0 deletions tests/unit/Type/ConvertedTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Psl\Tests\Unit\Type;

use DateTimeImmutable;
use Psl\Type;
use RuntimeException;

final class ConvertedTypeTest extends TypeTest
{
private const DATE_FORMAT = 'Y-m-d H:i:s';

public function getType(): Type\TypeInterface
{
return Type\converted(
Type\string(),
Type\instance_of(DateTimeImmutable::class),
static fn (string $value): DateTimeImmutable =>
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];
}
}
31 changes: 31 additions & 0 deletions tests/unit/Type/Exception/TypeCoercionExceptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Psl\Iter;
use Psl\Str;
use Psl\Type;
use RuntimeException;

final class TypeCoercionExceptionTest extends TestCase
{
Expand Down Expand Up @@ -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);
}
}
}

0 comments on commit 72aea10

Please sign in to comment.