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

[type] introduce converted type. #405

Merged
merged 1 commit into from
May 17, 2023
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 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);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we try to coerce it into the into type first? maybe that is possible, even if it doesn't match

Copy link
Collaborator Author

@veewee veewee Apr 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'dd say that would be a strange limitation. If you explicitly pass a converter function, it is strange to do some auto-conversion internal first?

For example: It would also block this case:

if you want to have a string that has a specific format, you could parse it like this now:

$scalarEmailType = Type\converted(
    Type\string(),
    Type\non_empty_string(),
    static function (string $value): string {
         if (!Regex\matches($value, $emailPattern)) {
               throw new \RuntimeException('The provided email is not valid');
         }
         return $value;
    }
);

Copy link
Collaborator Author

@veewee veewee Apr 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way, it could solve scenarios like these:
#404

$customBoolType = Type\converted(
    Type\string(),
    Type\bool(),
    static function (string $value): bool {
        return !in_array($value, ['', 'False', 'No', '0'], true);
    }
);


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);
}
}
}