diff --git a/README.md b/README.md index 252031dc..5751e767 100644 --- a/README.md +++ b/README.md @@ -113,20 +113,15 @@ public function getThread(int $id): Thread $rawJson = $this->client->request("https://example.com/thread/$id"); try { - return (new \CuyZ\Valinor\MapperBuilder()) - ->mapper() - ->map( - Thread::class, - new \CuyZ\Valinor\Mapper\Source\JsonSource($rawJson) - ); - } catch (\CuyZ\Valinor\Mapper\MappingError $error) { - $this->logger->error( - 'Invalid JSON returned by API', - $error->describe() // This gives more information about what was wrong - ); - - throw $error; - } + return (new \CuyZ\Valinor\MapperBuilder()) + ->mapper() + ->map( + Thread::class, + new \CuyZ\Valinor\Mapper\Source\JsonSource($rawJson) + ); + } catch (\CuyZ\Valinor\Mapper\MappingError $error) { + // Do something… + } } ``` @@ -147,12 +142,17 @@ More specific validation should be done in the constructor of the value object, by throwing an exception if something is wrong with the given data. A good practice would be to use lightweight validation tools like [Webmozart Assert]. +When the mapping fails, the exception gives access to the root node. This +recursive object allows retrieving all needed information through the whole +mapping tree: path, values, types and messages, including the issues that caused +the exception. + ```php final class SomeClass { - public function __construct(private string $value) + public function __construct(private string $someValue) { - Assert::startsWith($value, 'foo_'); + Assert::startsWith($someValue, 'foo_'); } } @@ -161,12 +161,42 @@ try { ->mapper() ->map( SomeClass::class, - ['value' => 'bar_baz'] + ['someValue' => 'bar_baz'] ); } catch (\CuyZ\Valinor\Mapper\MappingError $error) { - // Contains an error similar to: + $node = $error->node()->children()['someValue']; + + // Should print something similar to: // > Expected a value to start with "foo_". Got: "bar_baz" - var_dump($error->describe()); + var_dump($node->messages()[0]); + + // The name of a node can be accessed + $name = $node->name(); + + // The logical path of a node contains dot separated names of its parents + $path = $node->path(); + + // The type of the node can be cast to string to enhance suggestion messages + $type = (string)$node->type(); + + // It is important to check if a node is valid before getting its value + if ($node->isValid()) { + // The processed value of the node can be different from original input + $value = $node->value(); + } + + // All messages bound to the node can be accessed + foreach ($node->messages() as $message) { + // Errors can be retrieved by filtering like below: + if ($message instanceof Throwable) { + // Do something… + } + } + + // If the node is a branch, its children can be recursively accessed + foreach ($node->children() as $child) { + // Do something… + } } ``` diff --git a/src/Mapper/Exception/CannotMapObject.php b/src/Mapper/Exception/CannotMapObject.php deleted file mode 100644 index 2d94c83a..00000000 --- a/src/Mapper/Exception/CannotMapObject.php +++ /dev/null @@ -1,56 +0,0 @@ -> */ - private array $errors; - - public function __construct(Node $node) - { - $this->errors = iterator_to_array($this->errors($node)); - - parent::__construct( - "Could not map an object of type `{$node->type()}` with the given source.", - 1617193185 - ); - } - - public function describe(): array - { - return $this->errors; - } - - /** - * @return Iterator> - */ - private function errors(Node $node): Iterator - { - $errors = array_filter( - $node->messages(), - static fn (Message $message) => $message instanceof Throwable - ); - - if (! empty($errors)) { - yield $node->path() => array_values($errors); - } - - foreach ($node->children() as $child) { - yield from $this->errors($child); - } - } -} diff --git a/src/Mapper/MappingError.php b/src/Mapper/MappingError.php index 321fd5a4..73e189f3 100644 --- a/src/Mapper/MappingError.php +++ b/src/Mapper/MappingError.php @@ -4,12 +4,25 @@ namespace CuyZ\Valinor\Mapper; -use Throwable; +use CuyZ\Valinor\Mapper\Tree\Node; +use RuntimeException; -interface MappingError extends Throwable +final class MappingError extends RuntimeException { - /** - * @return array> - */ - public function describe(): array; + private Node $node; + + public function __construct(Node $node) + { + $this->node = $node; + + parent::__construct( + "Could not map an object of type `{$node->type()}` with the given source.", + 1617193185 + ); + } + + public function node(): Node + { + return $this->node; + } } diff --git a/src/Mapper/Tree/Node.php b/src/Mapper/Tree/Node.php index 59cee8b5..e58a4399 100644 --- a/src/Mapper/Tree/Node.php +++ b/src/Mapper/Tree/Node.php @@ -19,7 +19,7 @@ final class Node /** @var mixed */ private $value; - /** @var array */ + /** @var array */ private array $children = []; /** @var array */ @@ -128,7 +128,7 @@ public function value() } /** - * @return array + * @return array */ public function children(): array { diff --git a/src/Mapper/TreeMapperContainer.php b/src/Mapper/TreeMapperContainer.php index f7893821..d5af17d5 100644 --- a/src/Mapper/TreeMapperContainer.php +++ b/src/Mapper/TreeMapperContainer.php @@ -4,7 +4,6 @@ namespace CuyZ\Valinor\Mapper; -use CuyZ\Valinor\Mapper\Exception\CannotMapObject; use CuyZ\Valinor\Mapper\Exception\InvalidMappingType; use CuyZ\Valinor\Mapper\Exception\InvalidMappingTypeSignature; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; @@ -31,7 +30,7 @@ public function map(string $signature, $source): object $node = $this->node($signature, $source); if (! $node->isValid()) { - throw new CannotMapObject($node); + throw new MappingError($node); } return $node->value(); // @phpstan-ignore-line diff --git a/tests/Integration/IntegrationTest.php b/tests/Integration/IntegrationTest.php index 108d5e8c..36a388c2 100644 --- a/tests/Integration/IntegrationTest.php +++ b/tests/Integration/IntegrationTest.php @@ -5,13 +5,14 @@ namespace CuyZ\Valinor\Tests\Integration; use CuyZ\Valinor\Mapper\MappingError; +use CuyZ\Valinor\Mapper\Tree\Node; use CuyZ\Valinor\MapperBuilder; use FilesystemIterator; use PHPUnit\Framework\TestCase; use Throwable; -use function array_map; use function implode; +use function iterator_to_array; abstract class IntegrationTest extends TestCase { @@ -47,17 +48,30 @@ protected function tearDown(): void */ protected function mappingFail(MappingError $error) { - $errors = []; + $errorFinder = static function (Node $node, callable $errorFinder) { + if ($node->isValid()) { + return; + } - foreach ($error->describe() as $path => $messages) { - $list = array_map( - static fn (Throwable $message) => $message->getMessage(), - $messages - ); + $errors = []; - $errors[] = "$path: " . implode(' / ', $list); - } + foreach ($node->messages() as $message) { + if ($message instanceof Throwable) { + $errors[] = $message->getMessage(); + } + } + + if (count($errors) > 0) { + yield $node->path() => "{$node->path()}: " . implode(' / ', $errors); + } + + foreach ($node->children() as $child) { + yield from $errorFinder($child, $errorFinder); + } + }; + + $list = iterator_to_array($errorFinder($error->node(), $errorFinder)); - self::fail(implode(' — ', $errors)); + self::fail(implode(' — ', $list)); } } diff --git a/tests/Integration/Mapping/Type/ArrayValuesMappingTest.php b/tests/Integration/Mapping/Type/ArrayValuesMappingTest.php index 3563e809..13af61d4 100644 --- a/tests/Integration/Mapping/Type/ArrayValuesMappingTest.php +++ b/tests/Integration/Mapping/Type/ArrayValuesMappingTest.php @@ -10,6 +10,7 @@ use CuyZ\Valinor\Tests\Integration\IntegrationTest; use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject; use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject as SimpleObjectAlias; +use Throwable; final class ArrayValuesMappingTest extends IntegrationTest { @@ -75,7 +76,9 @@ public function test_empty_array_in_non_empty_array_throws_exception(): void 'nonEmptyArraysOfStrings' => [], ]); } catch (MappingError $exception) { - $error = $exception->describe()['nonEmptyArraysOfStrings'][0]; + $error = $exception->node()->children()['nonEmptyArraysOfStrings']->messages()[0]; + + assert($error instanceof Throwable); self::assertInstanceOf(InvalidNodeValue::class, $error); self::assertSame(1630678334, $error->getCode()); @@ -90,7 +93,9 @@ public function test_value_that_cannot_be_casted_throws_exception(): void 'integers' => ['foo'], ]); } catch (MappingError $exception) { - $error = $exception->describe()['integers.0'][0]; + $error = $exception->node()->children()['integers']->children()[0]->messages()[0]; + + assert($error instanceof Throwable); self::assertInstanceOf(CannotCastToScalarValue::class, $error); self::assertSame(1618736242, $error->getCode()); diff --git a/tests/Integration/Mapping/Type/DateTimeMappingTest.php b/tests/Integration/Mapping/Type/DateTimeMappingTest.php index f6174bbc..e7ab1850 100644 --- a/tests/Integration/Mapping/Type/DateTimeMappingTest.php +++ b/tests/Integration/Mapping/Type/DateTimeMappingTest.php @@ -12,6 +12,7 @@ use DateTime; use DateTimeImmutable; use DateTimeInterface; +use Throwable; final class DateTimeMappingTest extends IntegrationTest { @@ -68,7 +69,9 @@ public function test_invalid_datetime_throws_exception(): void 'dateTime' => 'invalid datetime', ]); } catch (MappingError $exception) { - $error = $exception->describe()['dateTime'][0]; + $error = $exception->node()->children()['dateTime']->messages()[0]; + + assert($error instanceof Throwable); self::assertInstanceOf(CannotParseToDateTime::class, $error); self::assertSame(1630686564, $error->getCode()); @@ -88,7 +91,9 @@ public function test_invalid_datetime_from_array_throws_exception(): void ], ]); } catch (MappingError $exception) { - $error = $exception->describe()['dateTime'][0]; + $error = $exception->node()->children()['dateTime']->messages()[0]; + + assert($error instanceof Throwable); self::assertInstanceOf(CannotParseToDateTime::class, $error); self::assertSame(1630686564, $error->getCode()); @@ -107,7 +112,7 @@ public function test_invalid_array_source_throws_exception(): void ], ]); } catch (MappingError $exception) { - $error = $exception->describe()['dateTime.datetime'][0]; + $error = $exception->node()->children()['dateTime']->children()['datetime']->messages()[0]; self::assertInstanceOf(UnionTypeDoesNotAllowNull::class, $error); } diff --git a/tests/Integration/Mapping/Type/ListValuesMappingTest.php b/tests/Integration/Mapping/Type/ListValuesMappingTest.php index b68a64cf..23b2cd6e 100644 --- a/tests/Integration/Mapping/Type/ListValuesMappingTest.php +++ b/tests/Integration/Mapping/Type/ListValuesMappingTest.php @@ -11,6 +11,8 @@ use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject; use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject as SimpleObjectAlias; +use Throwable; + use function array_values; final class ListValuesMappingTest extends IntegrationTest @@ -75,7 +77,9 @@ public function test_empty_list_in_non_empty_list_throws_exception(): void 'nonEmptyListOfStrings' => [], ]); } catch (MappingError $exception) { - $error = $exception->describe()['nonEmptyListOfStrings'][0]; + $error = $exception->node()->children()['nonEmptyListOfStrings']->messages()[0]; + + assert($error instanceof Throwable); self::assertInstanceOf(InvalidNodeValue::class, $error); self::assertSame(1630678334, $error->getCode()); @@ -90,7 +94,9 @@ public function test_value_that_cannot_be_casted_throws_exception(): void 'integers' => ['foo'], ]); } catch (MappingError $exception) { - $error = $exception->describe()['integers.0'][0]; + $error = $exception->node()->children()['integers']->children()['0']->messages()[0]; + + assert($error instanceof Throwable); self::assertInstanceOf(CannotCastToScalarValue::class, $error); self::assertSame(1618736242, $error->getCode()); diff --git a/tests/Integration/Mapping/Type/ScalarValuesMappingTest.php b/tests/Integration/Mapping/Type/ScalarValuesMappingTest.php index 1780696d..bf39947c 100644 --- a/tests/Integration/Mapping/Type/ScalarValuesMappingTest.php +++ b/tests/Integration/Mapping/Type/ScalarValuesMappingTest.php @@ -4,7 +4,6 @@ namespace CuyZ\Valinor\Tests\Integration\Mapping\Type; -use CuyZ\Valinor\Mapper\Exception\CannotMapObject; use CuyZ\Valinor\Mapper\MappingError; use CuyZ\Valinor\Mapper\Tree\Exception\CannotCastToScalarValue; use CuyZ\Valinor\Tests\Integration\IntegrationTest; @@ -14,6 +13,7 @@ use DateTimeInterface; use stdClass; use stdClass as ObjectAlias; +use Throwable; final class ScalarValuesMappingTest extends IntegrationTest { @@ -71,7 +71,9 @@ public function test_value_that_cannot_be_casted_throws_exception(): void 'value' => new stdClass(), ]); } catch (MappingError $exception) { - $error = $exception->describe()['value'][0]; + $error = $exception->node()->children()['value']->messages()[0]; + + assert($error instanceof Throwable); self::assertInstanceOf(CannotCastToScalarValue::class, $error); self::assertSame(1618736242, $error->getCode()); @@ -81,22 +83,18 @@ public function test_value_that_cannot_be_casted_throws_exception(): void public function test_empty_mandatory_value_throws_exception(): void { - $this->expectException(CannotMapObject::class); - $this->expectExceptionCode(1617193185); - $this->expectExceptionMessage('Could not map an object of type `' . SimpleObject::class . '` with the given source.'); - try { $this->mapperBuilder->mapper()->map(SimpleObject::class, [ 'value' => null, ]); } catch (MappingError $exception) { - $error = $exception->describe()['value'][0]; + $error = $exception->node()->children()['value']->messages()[0]; + + assert($error instanceof Throwable); self::assertInstanceOf(CannotCastToScalarValue::class, $error); self::assertSame(1618736242, $error->getCode()); self::assertSame('Cannot be empty and must be filled with a value of type `string`.', $error->getMessage()); - - throw $exception; } } } diff --git a/tests/Integration/Mapping/Type/ShapedArrayValuesMappingTest.php b/tests/Integration/Mapping/Type/ShapedArrayValuesMappingTest.php index e5e3e8bf..571338aa 100644 --- a/tests/Integration/Mapping/Type/ShapedArrayValuesMappingTest.php +++ b/tests/Integration/Mapping/Type/ShapedArrayValuesMappingTest.php @@ -9,6 +9,7 @@ use CuyZ\Valinor\Tests\Integration\IntegrationTest; use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject; use stdClass; +use Throwable; final class ShapedArrayValuesMappingTest extends IntegrationTest { @@ -25,7 +26,7 @@ public function test_values_are_mapped_properly(): void ], 'basicShapedArrayWithIntegerKeys' => [ 0 => 'fiz', - 1 => 42.404 + 1 => 42.404, ], 'shapedArrayWithObject' => [ 'foo' => ['value' => 'bar'], @@ -69,11 +70,13 @@ public function test_value_that_cannot_be_casted_throws_exception(): void $this->mapperBuilder->mapper()->map(ShapedArrayValues::class, [ 'basicShapedArrayWithStringKeys' => [ 'foo' => new stdClass(), - 'bar' => 42 + 'bar' => 42, ], ]); } catch (MappingError $exception) { - $error = $exception->describe()['basicShapedArrayWithStringKeys.foo'][0]; + $error = $exception->node()->children()['basicShapedArrayWithStringKeys']->children()['foo']->messages()[0]; + + assert($error instanceof Throwable); self::assertInstanceOf(CannotCastToScalarValue::class, $error); self::assertSame(1618736242, $error->getCode()); diff --git a/tests/Integration/Mapping/VisitorMappingTest.php b/tests/Integration/Mapping/VisitorMappingTest.php index 3ae5687a..b75c28cd 100644 --- a/tests/Integration/Mapping/VisitorMappingTest.php +++ b/tests/Integration/Mapping/VisitorMappingTest.php @@ -35,7 +35,7 @@ public function test_visitors_are_called_during_mapping(): void ->mapper() ->map(SimpleObject::class, ['value' => 'foo']); } catch (MappingError $exception) { - self::assertSame(['' => [$error]], $exception->describe()); + self::assertSame($error, $exception->node()->messages()[0]); } self::assertSame(['#1', '#2'], $visits); diff --git a/tests/Unit/Mapper/Exception/CannotMapObjectTest.php b/tests/Unit/Mapper/Exception/CannotMapObjectTest.php deleted file mode 100644 index a774c734..00000000 --- a/tests/Unit/Mapper/Exception/CannotMapObjectTest.php +++ /dev/null @@ -1,40 +0,0 @@ -child('foo', FakeType::thatWillAccept('foo'), 'foo'); - $childB = $shell->child('bar', FakeType::thatWillAccept('bar'), 'bar'); - - $children = [ - Node::leaf($childA, 'foo')->withMessage($message)->withMessage($error)->withMessage($message), - Node::leaf($childB, 'bar')->withMessage($message)->withMessage($error)->withMessage($message), - ]; - - $node = Node::branch($shell, [], $children); - - $errors = (new CannotMapObject($node))->describe(); - - self::assertSame([ - 'foo' => [$error], - 'bar' => [$error], - ], $errors); - } -} diff --git a/tests/Unit/Mapper/MappingErrorTest.php b/tests/Unit/Mapper/MappingErrorTest.php new file mode 100644 index 00000000..9e0e3b81 --- /dev/null +++ b/tests/Unit/Mapper/MappingErrorTest.php @@ -0,0 +1,27 @@ +node()); + } +}