From ccb5c112cef22b5a39679ad44d505be04514f142 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 | 25 +++++++- src/Psl/Type/Exception/CoercionException.php | 30 +++++---- src/Psl/Type/Exception/Exception.php | 24 ++++++- src/Psl/Type/Internal/ConvertedType.php | 2 +- src/Psl/Type/Internal/DictType.php | 62 +++++++++++++----- src/Psl/Type/Internal/ShapeType.php | 63 +++++++++++++------ src/Psl/Type/Internal/VecType.php | 48 +++++++++----- .../Exception/TypeAssertExceptionTest.php | 36 ++++++++++- .../Exception/TypeCoercionExceptionTest.php | 40 +++++++++++- 11 files changed, 257 insertions(+), 78 deletions(-) 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..8cfa6b58 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,22 @@ 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); + parent::__construct( + Str\format( + 'Expected "%s", got "%s"%s.', + $expected, + $actual, + $paths ? ' at path "' . Str\join($paths, '.') . '"' : '' + ), + $actual, + $paths, + $previous + ); $this->expected = $expected; } @@ -27,7 +42,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..14ec5003 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,22 @@ 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) { parent::__construct( Str\format( - 'Could not coerce "%s" to type "%s"%s%s', + 'Could not coerce "%s" to type "%s"%s%s.', $actual, $target, - $additionalInfo ? ': ' : '.', - $additionalInfo + $paths ? ' at path "' . Str\join($paths, '.') . '"' : '', + $previous && !$previous instanceof self ? ': ' . $previous->getMessage() : '', ), $actual, + $paths, + $previous ); $this->target = $target; @@ -37,19 +43,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..5fd8fb0c 100644 --- a/src/Psl/Type/Exception/Exception.php +++ b/src/Psl/Type/Exception/Exception.php @@ -5,18 +5,38 @@ 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; + + /** + * @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->actual = $actual; + } - $this->actual = $actual; + /** + * @return list + */ + public function getPaths(): array + { + return $this->paths; } public function getActualType(): string 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..8d72f8a2 100644 --- a/src/Psl/Type/Internal/DictType.php +++ b/src/Psl/Type/Internal/DictType.php @@ -42,17 +42,32 @@ 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; + + try { + /** + * @var Tk $k + * @var Tv $v + */ + foreach ($value as $k => $v) { + $trying_key = true; + $k_result = $key_type->coerce($k); + $trying_key = false; + $v_result = $value_type->coerce($v); + + $result[$k_result] = $v_result; + } + } catch (CoercionException $e) { + throw match (true) { + $k === null => $e, + $trying_key => CoercionException::withValue($k, $this->toString(), 'key(' . (string) $k . ')', $e), + !$trying_key => CoercionException::withValue($v, $this->toString(), (string) $k, $e) + }; } return $result; @@ -71,17 +86,32 @@ 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 (true) { + $k === null => $e, + $trying_key => AssertException::withValue($k, $this->toString(), 'key(' . (string) $k . ')', $e), + !$trying_key => AssertException::withValue($v, $this->toString(), (string) $k, $e) + }; } return $result; diff --git a/src/Psl/Type/Internal/ShapeType.php b/src/Psl/Type/Internal/ShapeType.php index be3bb791..4faaa53f 100644 --- a/src/Psl/Type/Internal/ShapeType.php +++ b/src/Psl/Type/Internal/ShapeType.php @@ -117,18 +117,31 @@ private function coerceIterable(mixed $value): array } $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($array, $this->toString(), (string) $element); + } + } catch (CoercionException $e) { + throw match (true) { + $element === null => $e, + $element_value_found => CoercionException::withValue($array[$element] ?? null, $this->toString(), (string) $element, $e), + default => $e + }; } if ($this->allow_unknown_fields) { @@ -157,18 +170,31 @@ 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($value, $this->toString(), (string) $element); + } + } catch (AssertException $e) { + throw match (true) { + $element === null => $e, + $element_value_found => AssertException::withValue($value[$element] ?? null, $this->toString(), (string) $element, $e), + default => $e + }; } /** @@ -183,6 +209,7 @@ public function assert(mixed $value): array throw AssertException::withValue( $value, $this->toString(), + (string) $k ); } } diff --git a/src/Psl/Type/Internal/VecType.php b/src/Psl/Type/Internal/VecType.php index 3bd345f7..3e70a4d9 100644 --- a/src/Psl/Type/Internal/VecType.php +++ b/src/Psl/Type/Internal/VecType.php @@ -58,17 +58,26 @@ 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; + + try { + /** + * @var Tv $v + * @var array-key $i + */ + foreach ($value as $i => $v) { + $result[] = $value_type->coerce($v); + } + } catch (CoercionException $e) { + throw match (true) { + $i === null => $e, + default => CoercionException::withValue($v, $this->toString(), (string) $i, $e) + }; } return $result; @@ -87,16 +96,23 @@ 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 match (true) { + $i === null => $e, + default => AssertException::withValue($v, $this->toString(), (string) $i, $e) + }; } return $result; diff --git a/tests/unit/Type/Exception/TypeAssertExceptionTest.php b/tests/unit/Type/Exception/TypeAssertExceptionTest.php index f06bb047..fd3533fc 100644 --- a/tests/unit/Type/Exception/TypeAssertExceptionTest.php +++ b/tests/unit/Type/Exception/TypeAssertExceptionTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\TestCase; use Psl\Collection; -use Psl\Iter; use Psl\Str; use Psl\Type; @@ -26,6 +25,7 @@ public function testIncorrectIterableKey(): void static::assertSame('int', $e->getExpectedType()); static::assertSame('string', $e->getActualType()); static::assertSame('Expected "int", got "string".', $e->getMessage()); + static::assertSame([], $e->getPaths()); } } @@ -41,6 +41,40 @@ 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([], $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('Expected "array{\'child\': array{\'name\': string}}", got "array" at path "child.name".', $e->getMessage()); + 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(['name'], $previous->getpaths()); + + $previous = $previous->getPrevious(); + static::assertInstanceOf(Type\Exception\AssertException::class, $previous); + static::assertSame('Expected "string", got "int".', $previous->getMessage()); + 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..51006100 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,7 @@ 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([], $e->getPaths()); } } @@ -51,6 +51,7 @@ public function testIncorrectResourceType(): void 'Could not coerce "%s" to type "resource (curl)".', Collection\Map::class ), $e->getMessage()); + static::assertSame([], $e->getPaths()); } } @@ -73,9 +74,44 @@ public function testConversionFailure(): void static::assertSame('string', $e->getTargetType()); static::assertSame('int', $e->getActualType()); 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('Could not coerce "array" to type "array{\'child\': array{\'name\': string}}" at path "child.name".', $e->getMessage()); + 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(['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([], $previous->getpaths()); + + $previous = $previous->getPrevious(); + static::assertNull($previous); } } }