Skip to content

Commit

Permalink
feat!: add access to root node when error occurs during mapping
Browse files Browse the repository at this point in the history
When an error occurs during mapping, the root instance of `Node` can now
be accessed from the exception. 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.

It can be used like the following:

```php
try {
   (new \CuyZ\Valinor\MapperBuilder())
       ->mapper()
       ->map(SomeClass::class, [/* ... */]);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
    // Do something with `$error->node()`
    // See README for more information
}
```

This change removes the method `MappingError::describe()` which provided
a flattened view of messages of all the errors that were encountered
during the mapping. The same behaviour can still be retrieved, see the
example below:

```php
use CuyZ\Valinor\Mapper\Tree\Message\Message;
use CuyZ\Valinor\Mapper\Tree\Node;

/**
 * @implements \IteratorAggregate<string, array<\Throwable&Message>>
 */
final class MappingErrorList implements \IteratorAggregate
{
    private Node $node;

    public function __construct(Node $node)
    {
        $this->node = $node;
    }

    /**
     * @return \Traversable<string, array<\Throwable&Message>>
     */
    public function getIterator(): \Traversable
    {
        yield from $this->errors($this->node);
    }

    /**
     * @return \Traversable<string, array<\Throwable&Message>>
     */
    private function errors(Node $node): \Traversable
    {
        $errors = array_filter(
            $node->messages(),
            static fn (Message $m) => $m instanceof \Throwable
        );

        if (! empty($errors)) {
            yield $node->path() => array_values($errors);
        }

        foreach ($node->children() as $child) {
            yield from $this->errors($child);
        }
    }
}

try {
   (new \CuyZ\Valinor\MapperBuilder())
       ->mapper()
       ->map(SomeClass::class, [/* ... */]);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
    $errors = iterator_to_array(new MappingErrorList($error->node()));
}
```

The class `CannotMapObject` is deleted, as it does not provide any
value; this means that `MappingError` which was previously an interface
becomes a class.
  • Loading branch information
romm committed Dec 27, 2021
1 parent f12ef39 commit 54f608e
Show file tree
Hide file tree
Showing 14 changed files with 159 additions and 155 deletions.
68 changes: 49 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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…
}
}
```

Expand All @@ -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_');
}
}

Expand All @@ -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…
}
}
```

Expand Down
56 changes: 0 additions & 56 deletions src/Mapper/Exception/CannotMapObject.php

This file was deleted.

25 changes: 19 additions & 6 deletions src/Mapper/MappingError.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array<Throwable>>
*/
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;
}
}
4 changes: 2 additions & 2 deletions src/Mapper/Tree/Node.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ final class Node
/** @var mixed */
private $value;

/** @var array<string, Node> */
/** @var array<Node> */
private array $children = [];

/** @var array<Message> */
Expand Down Expand Up @@ -128,7 +128,7 @@ public function value()
}

/**
* @return array<string, Node>
* @return array<Node>
*/
public function children(): array
{
Expand Down
3 changes: 1 addition & 2 deletions src/Mapper/TreeMapperContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
34 changes: 24 additions & 10 deletions tests/Integration/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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));
}
}
9 changes: 7 additions & 2 deletions tests/Integration/Mapping/Type/ArrayValuesMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down
11 changes: 8 additions & 3 deletions tests/Integration/Mapping/Type/DateTimeMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Throwable;

final class DateTimeMappingTest extends IntegrationTest
{
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand All @@ -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);
}
Expand Down
10 changes: 8 additions & 2 deletions tests/Integration/Mapping/Type/ListValuesMappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand All @@ -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());
Expand Down
Loading

0 comments on commit 54f608e

Please sign in to comment.