From 1afc34906b95eb979c75144e7e8dc4559e8e89f6 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 27 Mar 2024 15:51:40 +0100 Subject: [PATCH] Introduce nested type exceptions with paths --- docs/component/type.md | 4 +- src/Psl/Internal/Loader.php | 1 - src/Psl/Type/Exception/AssertException.php | 27 +++- src/Psl/Type/Exception/CoercionException.php | 34 ++-- src/Psl/Type/Exception/Exception.php | 32 +++- src/Psl/Type/Exception/PathExpression.php | 49 ++++++ src/Psl/Type/Internal/ConvertedType.php | 2 +- src/Psl/Type/Internal/DictType.php | 66 ++++++-- src/Psl/Type/Internal/ShapeType.php | 85 +++++++--- src/Psl/Type/Internal/VecType.php | 50 ++++-- tests/unit/Type/DictTypeTest.php | 98 ++++++++++++ .../Type/Exception/PathExpressionTest.php | 41 +++++ .../Exception/TypeAssertExceptionTest.php | 46 +++++- .../Exception/TypeCoercionExceptionTest.php | 51 +++++- tests/unit/Type/ShapeTypeTest.php | 151 ++++++++++++++++-- tests/unit/Type/VecTypeTest.php | 88 ++++++++++ 16 files changed, 725 insertions(+), 100 deletions(-) create mode 100644 src/Psl/Type/Exception/PathExpression.php create mode 100644 tests/unit/Type/Exception/PathExpressionTest.php diff --git a/docs/component/type.md b/docs/component/type.md index ee753958..bf342125 100644 --- a/docs/component/type.md +++ b/docs/component/type.md @@ -63,10 +63,10 @@ #### `Interfaces` -- [TypeInterface](./../../src/Psl/Type/TypeInterface.php#L14) +- [TypeInterface](./../../src/Psl/Type/TypeInterface.php#L13) #### `Classes` -- [Type](./../../src/Psl/Type/Type.php#L15) +- [Type](./../../src/Psl/Type/Type.php#L14) diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index 8c384e2b..bc3c9a92 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -690,7 +690,6 @@ final class Loader 'Psl\\Type\\Internal\\LiteralScalarType' => 'Psl/Type/Internal/LiteralScalarType.php', 'Psl\\Type\\Internal\\BackedEnumType' => 'Psl/Type/Internal/BackedEnumType.php', 'Psl\\Type\\Internal\\UnitEnumType' => 'Psl/Type/Internal/UnitEnumType.php', - 'Psl\\Type\\Exception\\TypeTrace' => 'Psl/Type/Exception/TypeTrace.php', 'Psl\\Type\\Exception\\AssertException' => 'Psl/Type/Exception/AssertException.php', 'Psl\\Type\\Exception\\CoercionException' => 'Psl/Type/Exception/CoercionException.php', 'Psl\\Type\\Exception\\Exception' => 'Psl/Type/Exception/Exception.php', diff --git a/src/Psl/Type/Exception/AssertException.php b/src/Psl/Type/Exception/AssertException.php index 853f2017..1f34e68d 100644 --- a/src/Psl/Type/Exception/AssertException.php +++ b/src/Psl/Type/Exception/AssertException.php @@ -5,6 +5,8 @@ namespace Psl\Type\Exception; use Psl\Str; +use Psl\Vec; +use Throwable; use function get_debug_type; @@ -12,9 +14,24 @@ final class AssertException extends Exception { private string $expected; - public function __construct(string $actual, string $expected) + /** + * @param list $paths + */ + public function __construct(string $actual, string $expected, array $paths = [], ?Throwable $previous = null) { - parent::__construct(Str\format('Expected "%s", got "%s".', $expected, $actual), $actual); + $first = $previous instanceof Exception ? $previous->getFirstFailingActualType() : $actual; + + parent::__construct( + Str\format( + 'Expected "%s", got "%s"%s.', + $expected, + $first, + $paths ? ' at path "' . Str\join($paths, '.') . '"' : '' + ), + $actual, + $paths, + $previous + ); $this->expected = $expected; } @@ -27,7 +44,11 @@ public function getExpectedType(): string public static function withValue( mixed $value, string $expected_type, + ?string $path = null, + ?Throwable $previous = null ): self { - return new self(get_debug_type($value), $expected_type); + $paths = $previous instanceof Exception ? [$path, ...$previous->getPaths()] : [$path]; + + return new self(get_debug_type($value), $expected_type, Vec\filter_nulls($paths), $previous); } } diff --git a/src/Psl/Type/Exception/CoercionException.php b/src/Psl/Type/Exception/CoercionException.php index 8f2874dc..87bee7ca 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 Psl\Vec; use Throwable; use function get_debug_type; @@ -13,17 +14,24 @@ final class CoercionException extends Exception { private string $target; - public function __construct(string $actual, string $target, string $additionalInfo = '') + /** + * @param list $paths + */ + public function __construct(string $actual, string $target, array $paths = [], ?Throwable $previous = null) { + $first = $previous instanceof Exception ? $previous->getFirstFailingActualType() : $actual; + parent::__construct( Str\format( - 'Could not coerce "%s" to type "%s"%s%s', - $actual, + 'Could not coerce "%s" to type "%s"%s%s.', + $first, $target, - $additionalInfo ? ': ' : '.', - $additionalInfo + $paths ? ' at path "' . Str\join($paths, '.') . '"' : '', + $previous && !$previous instanceof self ? ': ' . $previous->getMessage() : '', ), $actual, + $paths, + $previous ); $this->target = $target; @@ -37,19 +45,11 @@ public function getTargetType(): string public static function withValue( mixed $value, string $target, + ?string $path = null, + ?Throwable $previous = null ): self { - return new self(get_debug_type($value), $target); - } + $paths = $previous instanceof Exception ? [$path, ...$previous->getPaths()] : [$path]; - public static function withConversionFailureOnValue( - mixed $value, - string $target, - Throwable $failure, - ): self { - return new self( - get_debug_type($value), - $target, - $failure->getMessage() - ); + return new self(get_debug_type($value), $target, Vec\filter_nulls($paths), $previous); } } diff --git a/src/Psl/Type/Exception/Exception.php b/src/Psl/Type/Exception/Exception.php index 9bff586c..89becff3 100644 --- a/src/Psl/Type/Exception/Exception.php +++ b/src/Psl/Type/Exception/Exception.php @@ -5,22 +5,50 @@ namespace Psl\Type\Exception; use Psl\Exception\RuntimeException; +use Throwable; abstract class Exception extends RuntimeException implements ExceptionInterface { private string $actual; + /** + * @var list + */ + private array $paths; + + private string $first; + + /** + * @param list $paths + */ public function __construct( string $message, string $actual, + array $paths, + ?Throwable $previous = null ) { - parent::__construct($message); + parent::__construct($message, 0, $previous); + + $this->paths = $paths; + $this->first = $previous instanceof self ? $previous->first : $actual; + $this->actual = $actual; + } - $this->actual = $actual; + /** + * @return list + */ + public function getPaths(): array + { + return $this->paths; } public function getActualType(): string { return $this->actual; } + + public function getFirstFailingActualType(): string + { + return $this->first; + } } diff --git a/src/Psl/Type/Exception/PathExpression.php b/src/Psl/Type/Exception/PathExpression.php new file mode 100644 index 00000000..15e66691 --- /dev/null +++ b/src/Psl/Type/Exception/PathExpression.php @@ -0,0 +1,49 @@ + $path ? 'true' : 'false', + is_scalar($path) => (string) $path, + default => get_debug_type($path), + }; + } + + /** + * @pure + */ + public static function expression(string $expression, mixed $path): string + { + return Str\format($expression, self::path($path)); + } + + /** + * @pure + */ + public static function iteratorKey(mixed $key): string + { + return self::expression('key(%s)', $key); + } + + /** + * @pure + */ + public static function iteratorError(mixed $previousKey): string + { + return self::expression($previousKey === null ? 'first()' : '%s.next()', $previousKey); + } +} diff --git a/src/Psl/Type/Internal/ConvertedType.php b/src/Psl/Type/Internal/ConvertedType.php index f4a5ce1f..590c7061 100644 --- a/src/Psl/Type/Internal/ConvertedType.php +++ b/src/Psl/Type/Internal/ConvertedType.php @@ -53,7 +53,7 @@ public function coerce(mixed $value): mixed try { $converted = ($this->converter)($coercedInput); } catch (Throwable $failure) { - throw CoercionException::withConversionFailureOnValue($value, $this->toString(), $failure); + throw CoercionException::withValue($value, $this->toString(), previous: $failure); } return $this->into->coerce($converted); diff --git a/src/Psl/Type/Internal/DictType.php b/src/Psl/Type/Internal/DictType.php index c8d7b768..3d3ec4c7 100644 --- a/src/Psl/Type/Internal/DictType.php +++ b/src/Psl/Type/Internal/DictType.php @@ -7,6 +7,8 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Psl\Type\Exception\PathExpression; +use Throwable; use function is_array; use function is_iterable; @@ -42,17 +44,35 @@ public function coerce(mixed $value): array throw CoercionException::withValue($value, $this->toString()); } + $result = []; $key_type = $this->key_type; $value_type = $this->value_type; - $result = []; - - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $result[$key_type->coerce($k)] = $value_type->coerce($v); + $k = $v = null; + $trying_key = true; + $iterating = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $iterating = false; + $trying_key = true; + $k_result = $key_type->coerce($k); + $trying_key = false; + $v_result = $value_type->coerce($v); + + $result[$k_result] = $v_result; + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e), + $trying_key => CoercionException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + !$trying_key => CoercionException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } return $result; @@ -71,17 +91,31 @@ public function assert(mixed $value): array throw AssertException::withValue($value, $this->toString()); } + $result = []; $key_type = $this->key_type; $value_type = $this->value_type; - $result = []; - - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - $result[$key_type->assert($k)] = $value_type->assert($v); + $k = $v = null; + $trying_key = true; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $trying_key = true; + $k_result = $key_type->assert($k); + $trying_key = false; + $v_result = $value_type->assert($v); + + $result[$k_result] = $v_result; + } + } catch (AssertException $e) { + throw match ($trying_key) { + true => AssertException::withValue($k, $this->toString(), PathExpression::iteratorKey($k), $e), + false => AssertException::withValue($v, $this->toString(), PathExpression::path($k), $e) + }; } return $result; diff --git a/src/Psl/Type/Internal/ShapeType.php b/src/Psl/Type/Internal/ShapeType.php index be3bb791..9e9fddac 100644 --- a/src/Psl/Type/Internal/ShapeType.php +++ b/src/Psl/Type/Internal/ShapeType.php @@ -8,7 +8,9 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Psl\Type\Exception\PathExpression; use stdClass; +use Throwable; use function array_diff_key; use function array_filter; @@ -106,29 +108,47 @@ private function coerceIterable(mixed $value): array $arrayKeyType = Type\array_key(); $array = []; - /** - * @var Tk $k - * @var Tv $v - */ - foreach ($value as $k => $v) { - if ($arrayKeyType->matches($k)) { - $array[$k] = $v; + $k = null; + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + if ($arrayKeyType->matches($k)) { + $array[$k] = $v; + } } + } catch (Throwable $e) { + throw CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($k), $e); } + $result = []; - foreach ($this->elements_types as $element => $type) { - if (Iter\contains_key($array, $element)) { - $result[$element] = $type->coerce($array[$element]); + $element = null; + $element_value_found = false; - continue; - } + try { + foreach ($this->elements_types as $element => $type) { + $element_value_found = false; + if (Iter\contains_key($array, $element)) { + $element_value_found = true; + $result[$element] = $type->coerce($array[$element]); - if ($type->isOptional()) { - continue; - } + continue; + } - throw CoercionException::withValue($value, $this->toString()); + if ($type->isOptional()) { + continue; + } + + throw CoercionException::withValue(null, $this->toString(), PathExpression::path($element)); + } + } catch (CoercionException $e) { + throw match (true) { + $element_value_found => CoercionException::withValue($array[$element] ?? null, $this->toString(), PathExpression::path($element), $e), + default => $e + }; } if ($this->allow_unknown_fields) { @@ -157,18 +177,30 @@ public function assert(mixed $value): array } $result = []; - foreach ($this->elements_types as $element => $type) { - if (Iter\contains_key($value, $element)) { - $result[$element] = $type->assert($value[$element]); + $element = null; + $element_value_found = false; - continue; - } + try { + foreach ($this->elements_types as $element => $type) { + $element_value_found = false; + if (Iter\contains_key($value, $element)) { + $element_value_found = true; + $result[$element] = $type->assert($value[$element]); - if ($type->isOptional()) { - continue; - } + continue; + } - throw AssertException::withValue($value, $this->toString()); + if ($type->isOptional()) { + continue; + } + + throw AssertException::withValue(null, $this->toString(), PathExpression::path($element)); + } + } catch (AssertException $e) { + throw match (true) { + $element_value_found => AssertException::withValue($value[$element] ?? null, $this->toString(), PathExpression::path($element), $e), + default => $e + }; } /** @@ -181,8 +213,9 @@ public function assert(mixed $value): array $result[$k] = $v; } else { throw AssertException::withValue( - $value, + $v, $this->toString(), + PathExpression::path($k) ); } } diff --git a/src/Psl/Type/Internal/VecType.php b/src/Psl/Type/Internal/VecType.php index 3bd345f7..9a42b071 100644 --- a/src/Psl/Type/Internal/VecType.php +++ b/src/Psl/Type/Internal/VecType.php @@ -7,6 +7,8 @@ use Psl\Type; use Psl\Type\Exception\AssertException; use Psl\Type\Exception\CoercionException; +use Psl\Type\Exception\PathExpression; +use Throwable; use function array_is_list; use function is_array; @@ -58,17 +60,29 @@ public function coerce(mixed $value): iterable throw CoercionException::withValue($value, $this->toString()); } - /** @var Type\Type $value_type */ - $value_type = $this->value_type; - /** * @var list $entries */ $result = []; - - /** @var Tv $v */ - foreach ($value as $v) { - $result[] = $value_type->coerce($v); + $value_type = $this->value_type; + $i = $v = null; + $iterating = true; + + try { + /** + * @var Tv $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $iterating = false; + $result[] = $value_type->coerce($v); + $iterating = true; + } + } catch (Throwable $e) { + throw match (true) { + $iterating => CoercionException::withValue(null, $this->toString(), PathExpression::iteratorError($i), $e), + default => CoercionException::withValue($v, $this->toString(), PathExpression::path($i), $e) + }; } return $result; @@ -87,16 +101,20 @@ public function assert(mixed $value): array throw AssertException::withValue($value, $this->toString()); } - /** @var Type\Type $value_type */ - $value_type = $this->value_type; - $result = []; - - /** - * @var Tv $v - */ - foreach ($value as $v) { - $result[] = $value_type->assert($v); + $value_type = $this->value_type; + $i = $v = null; + + try { + /** + * @var Tv $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $result[] = $value_type->assert($v); + } + } catch (AssertException $e) { + throw AssertException::withValue($v, $this->toString(), PathExpression::path($i), $e); } return $result; diff --git a/tests/unit/Type/DictTypeTest.php b/tests/unit/Type/DictTypeTest.php index f1b69173..a9e3bb00 100644 --- a/tests/unit/Type/DictTypeTest.php +++ b/tests/unit/Type/DictTypeTest.php @@ -10,6 +10,7 @@ use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** * @extends TypeTest> @@ -99,4 +100,101 @@ public function getToStringExamples(): iterable 'dict' ]; } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion key' => [ + Type\dict(Type\int(), Type\int()), + ['nope' => 1], + 'Expected "dict", got "string" at path "key(nope)".' + ]; + yield 'invalid assertion value' => [ + Type\dict(Type\int(), Type\int()), + [0 => 'nope'], + 'Expected "dict", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\dict(Type\int(), Type\dict(Type\int(), Type\int())), + [0 => ['nope' => 'nope'],], + 'Expected "dict>", got "string" at path "0.key(nope)".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion key' => [ + Type\dict(Type\int(), Type\int()), + ['nope' => 1], + 'Could not coerce "string" to type "dict" at path "key(nope)".' + ]; + yield 'invalid coercion value' => [ + Type\dict(Type\int(), Type\int()), + [0 => 'nope'], + 'Could not coerce "string" to type "dict" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + yield 0 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "dict" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + yield 0 => 0; + yield 1 => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "dict" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + throw new RuntimeException('whoops'); + yield; + })(), + 'Could not coerce "null" to type "dict" at path "first()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "null" to type "dict" at path "key(null)".' + ]; + yield 'iterator yielding object key' => [ + Type\dict(Type\int(), Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "class@anonymous" to type "dict" at path "key(class@anonymous)".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/Exception/PathExpressionTest.php b/tests/unit/Type/Exception/PathExpressionTest.php new file mode 100644 index 00000000..2c032d25 --- /dev/null +++ b/tests/unit/Type/Exception/PathExpressionTest.php @@ -0,0 +1,41 @@ +getExpectedType()); static::assertSame('string', $e->getActualType()); static::assertSame('Expected "int", got "string".', $e->getMessage()); + static::assertSame(0, $e->getCode()); + static::assertSame([], $e->getPaths()); } } @@ -41,6 +42,49 @@ public function testIncorrectResourceType(): void static::assertSame('resource (curl)', $e->getExpectedType()); static::assertSame('resource (stream)', $e->getActualType()); static::assertSame('Expected "resource (curl)", got "resource (stream)".', $e->getMessage()); + static::assertSame(0, $e->getCode()); + static::assertSame([], $e->getPaths()); + } + } + + public function testIncorrectNestedType() + { + $type = Type\shape([ + 'child' => Type\shape([ + 'name' => Type\string(), + ]) + ]); + + try { + $type->assert(['child' => ['name' => 123]]); + + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame('array{\'child\': array{\'name\': string}}', $e->getExpectedType()); + static::assertSame('array', $e->getActualType()); + static::assertSame('int', $e->getFirstFailingActualType()); + static::assertSame('Expected "array{\'child\': array{\'name\': string}}", got "int" at path "child.name".', $e->getMessage()); + static::assertSame(0, $e->getCode()); + static::assertSame(['child', 'name'], $e->getPaths()); + + $previous = $e->getPrevious(); + static::assertInstanceOf(Type\Exception\AssertException::class, $previous); + static::assertSame('Expected "array{\'name\': string}", got "int" at path "name".', $previous->getMessage()); + static::assertSame('int', $previous->getActualType()); + static::assertSame('int', $previous->getFirstFailingActualType()); + static::assertSame(0, $previous->getCode()); + static::assertSame(['name'], $previous->getpaths()); + + $previous = $previous->getPrevious(); + static::assertInstanceOf(Type\Exception\AssertException::class, $previous); + static::assertSame('Expected "string", got "int".', $previous->getMessage()); + static::assertSame('int', $previous->getActualType()); + static::assertSame('int', $previous->getFirstFailingActualType()); + static::assertSame(0, $previous->getCode()); + static::assertSame([], $previous->getpaths()); + + $previous = $previous->getPrevious(); + static::assertNull($previous); } } } diff --git a/tests/unit/Type/Exception/TypeCoercionExceptionTest.php b/tests/unit/Type/Exception/TypeCoercionExceptionTest.php index c7021431..fdf283db 100644 --- a/tests/unit/Type/Exception/TypeCoercionExceptionTest.php +++ b/tests/unit/Type/Exception/TypeCoercionExceptionTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\TestCase; use Psl\Collection; -use Psl\Iter; use Psl\Str; use Psl\Type; use RuntimeException; @@ -30,6 +29,8 @@ public function testIncorrectIterableKey(): void static::assertSame('bool', $e->getTargetType()); static::assertSame('int', $e->getActualType()); static::assertSame('Could not coerce "int" to type "bool".', $e->getMessage()); + static::assertSame(0, $e->getCode()); + static::assertSame([], $e->getPaths()); } } @@ -47,10 +48,12 @@ public function testIncorrectResourceType(): void } catch (Type\Exception\CoercionException $e) { static::assertSame('resource (curl)', $e->getTargetType()); static::assertSame(Collection\Map::class, $e->getActualType()); + static::assertSame(0, $e->getCode()); static::assertSame(Str\format( 'Could not coerce "%s" to type "resource (curl)".', Collection\Map::class ), $e->getMessage()); + static::assertSame([], $e->getPaths()); } } @@ -72,10 +75,54 @@ public function testConversionFailure(): void } catch (Type\Exception\CoercionException $e) { static::assertSame('string', $e->getTargetType()); static::assertSame('int', $e->getActualType()); + static::assertSame(0, $e->getCode()); static::assertSame(Str\format( - 'Could not coerce "int" to type "string": not possible', + 'Could not coerce "int" to type "string": not possible.', Collection\Map::class ), $e->getMessage()); + static::assertSame([], $e->getPaths()); + } + } + + public function testIncorrectNestedType() + { + $type = Type\shape([ + 'child' => Type\shape([ + 'name' => Type\string(), + ]) + ]); + + try { + $type->coerce(['child' => ['name' => new class () { + }]]); + + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame('array{\'child\': array{\'name\': string}}', $e->getTargetType()); + static::assertSame('array', $e->getActualType()); + static::assertSame('class@anonymous', $e->getFirstFailingActualType()); + static::assertSame('Could not coerce "class@anonymous" to type "array{\'child\': array{\'name\': string}}" at path "child.name".', $e->getMessage()); + static::assertSame(0, $e->getCode()); + static::assertSame(['child', 'name'], $e->getPaths()); + + $previous = $e->getPrevious(); + static::assertInstanceOf(Type\Exception\CoercionException::class, $previous); + static::assertSame('Could not coerce "class@anonymous" to type "array{\'name\': string}" at path "name".', $previous->getMessage()); + static::assertSame('class@anonymous', $previous->getActualType()); + static::assertSame('class@anonymous', $previous->getFirstFailingActualType()); + static::assertSame(0, $previous->getCode()); + static::assertSame(['name'], $previous->getpaths()); + + $previous = $previous->getPrevious(); + static::assertInstanceOf(Type\Exception\CoercionException::class, $previous); + static::assertSame('Could not coerce "class@anonymous" to type "string".', $previous->getMessage()); + static::assertSame('class@anonymous', $previous->getActualType()); + static::assertSame('class@anonymous', $previous->getFirstFailingActualType()); + static::assertSame(0, $previous->getCode()); + static::assertSame([], $previous->getpaths()); + + $previous = $previous->getPrevious(); + static::assertNull($previous); } } } diff --git a/tests/unit/Type/ShapeTypeTest.php b/tests/unit/Type/ShapeTypeTest.php index 43809d17..ac6a2339 100644 --- a/tests/unit/Type/ShapeTypeTest.php +++ b/tests/unit/Type/ShapeTypeTest.php @@ -7,7 +7,9 @@ use ArrayIterator; use Psl\Collection; use Psl\Iter; +use Psl\Str; use Psl\Type; +use RuntimeException; /** * @extends TypeTest @@ -30,19 +32,6 @@ public function getType(): Type\TypeInterface ]); } - public function testInvalidAssertionExtraKey(): void - { - $this->expectException(Type\Exception\AssertException::class); - - $this->getType()->assert([ - 'name' => 'saif', - 'articles' => [ - ['title' => 'Foo', 'content' => 'Bar', 'likes' => 0, 'dislikes' => 5], - ['title' => 'Baz', 'content' => 'Qux', 'likes' => 13, 'dislikes' => 3], - ] - ]); - } - public function testWillConsiderUnknownIterableFieldsWhenCoercing(): void { static::assertEquals( @@ -209,4 +198,140 @@ protected function equals($a, $b): bool return parent::equals($a, $b); } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'extra key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + [ + 'name' => 'saif', + 'extra' => 123, + ], + 'Expected "array{\'name\': string}", got "int" at path "extra".' + ]; + yield 'missing key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + [], + 'Expected "array{\'name\': string}", got "null" at path "name".' + ]; + yield 'invalid key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + ['name' => 123], + 'Expected "array{\'name\': string}", got "int" at path "name".' + ]; + yield 'nested' => [ + Type\shape([ + 'item' => Type\shape([ + 'name' => Type\string(), + ]), + ]), + [ + 'item' => [ + 'name' => 123, + ] + ], + 'Expected "array{\'item\': array{\'name\': string}}", got "int" at path "item.name".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'missing key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + [], + 'Could not coerce "null" to type "array{\'name\': string}" at path "name".' + ]; + yield 'invalid key' => [ + Type\shape([ + 'name' => Type\string(), + ]), + [ + 'name' => new class () { + }, + ], + 'Could not coerce "class@anonymous" to type "array{\'name\': string}" at path "name".', + ]; + yield 'invalid iterator first item' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + yield 'id' => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "array{\'id\': int}" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + yield 'id' => 1; + yield 'next' => Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "array{\'id\': int}" at path "id.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + throw new RuntimeException('whoops'); + yield; + })(), + 'Could not coerce "null" to type "array{\'id\': int}" at path "first()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "null" to type "array{\'id\': int}" at path "id".' + ]; + yield 'iterator yielding object key' => [ + Type\shape([ + 'id' => Type\int(), + ]), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "null" to type "array{\'id\': int}" at path "id".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } } diff --git a/tests/unit/Type/VecTypeTest.php b/tests/unit/Type/VecTypeTest.php index c2a7fc2d..d316ab15 100644 --- a/tests/unit/Type/VecTypeTest.php +++ b/tests/unit/Type/VecTypeTest.php @@ -10,6 +10,7 @@ use Psl\Str; use Psl\Type; use Psl\Vec; +use RuntimeException; /** * @extends TypeTest> @@ -94,4 +95,91 @@ public function getType(): Type\TypeInterface { return Type\vec(Type\int()); } + + public static function provideAssertExceptionExpectations(): iterable + { + yield 'invalid assertion value' => [ + Type\vec(Type\int()), + ['nope'], + 'Expected "vec", got "string" at path "0".' + ]; + yield 'nested' => [ + Type\vec(Type\vec(Type\int())), + [['nope']], + 'Expected "vec>", got "string" at path "0.0".', + ]; + } + + public static function provideCoerceExceptionExpectations(): iterable + { + yield 'invalid coercion value' => [ + Type\vec(Type\int()), + ['nope'], + 'Could not coerce "string" to type "vec" at path "0".' + ]; + yield 'invalid iterator first item' => [ + Type\vec(Type\int()), + (static function () { + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "vec" at path "first()".' + ]; + yield 'invalid iterator second item' => [ + Type\vec(Type\int()), + (static function () { + yield 0; + yield Type\int()->coerce('nope'); + })(), + 'Could not coerce "string" to type "vec" at path "0.next()".' + ]; + yield 'iterator throwing exception' => [ + Type\vec(Type\int()), + (static function () { + yield 0; + throw new RuntimeException('whoops'); + })(), + 'Could not coerce "null" to type "vec" at path "0.next()": whoops.' + ]; + yield 'iterator yielding null key' => [ + Type\vec(Type\int()), + (static function () { + yield null => 'nope'; + })(), + 'Could not coerce "string" to type "vec" at path "null".' + ]; + yield 'iterator yielding object key' => [ + Type\vec(Type\int()), + (static function () { + yield (new class () { + }) => 'nope'; + })(), + 'Could not coerce "string" to type "vec" at path "class@anonymous".' + ]; + } + + /** + * @dataProvider provideAssertExceptionExpectations + */ + public function testInvalidAssertionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->assert($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\AssertException::class)); + } catch (Type\Exception\AssertException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } + + /** + * @dataProvider provideCoerceExceptionExpectations + */ + public function testInvalidCoercionTypeExceptions(Type\TypeInterface $type, mixed $data, string $expectedMessage): void + { + try { + $type->coerce($data); + static::fail(Str\format('Expected "%s" exception to be thrown.', Type\Exception\CoercionException::class)); + } catch (Type\Exception\CoercionException $e) { + static::assertSame($expectedMessage, $e->getMessage()); + } + } }