Commit Graph

47 Commits

Author SHA1 Message Date
Romain Canon
628baf1294 fix: allow mapping iterable to shaped array 2022-05-26 17:56:14 +02:00
Filippo Tessarotto
e0a529a7e5
feat: support mapping to dates with no time 2022-05-25 18:47:24 +02:00
Romain Canon
69ad3f4777 feat: allow injecting a cache implementation that is used by the mapper
The cache implementation that was previously injected in the mapper
builder must now be manually injected. This gives better control on when
the cache should be enabled, especially depending on which environment
the application is running.

The library provides a cache implementation out of the box, which saves
cache entries into the file system.

It is also possible to use any PSR-16 compliant implementation, as long
as it is capable of caching the entries handled by the library.

```php
$cache = new \CuyZ\Valinor\Cache\FileSystemCache('path/to/cache-dir');

(new \CuyZ\Valinor\MapperBuilder())
    ->withCache($cache)
    ->mapper()
    ->map(SomeClass::class, [/* … */]);
```
2022-05-23 20:28:02 +02:00
Abdul Malik Ikhsan
5a50baed86
qa: include and use Rector 2022-05-21 16:37:16 +02:00
Romain Canon
60a6656141 feat!: improve message customization with formatters
The way messages can be customized has been totally revisited, requiring
several breaking changes. All existing error messages have been
rewritten to better fit the actual meaning of the error.

The content of a message can be changed to fit custom use cases; it can
contain placeholders that will be replaced with useful information.

The placeholders below are always available; even more may be used
depending on the original message.

- `{message_code}` — the code of the message
- `{node_name}` — name of the node to which the message is bound
- `{node_path}` — path of the node to which the message is bound
- `{node_type}` — type of the node to which the message is bound
- `{original_value}` — the source value that was given to the node
- `{original_message}` — the original message before being customized

```php
try {
    (new \CuyZ\Valinor\MapperBuilder())
        ->mapper()
        ->map(SomeClass::class, [/* … */]);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
    $messages = new MessagesFlattener($error->node());

    foreach ($messages as $message) {
        if ($message->code() === 'some_code') {
            $message = $message->withBody('new / {original_message}');
        }

        echo $message;
    }
}
```

The messages are formatted using the ICU library, enabling the
placeholders to use advanced syntax to perform proper translations, for
instance currency support.

```php
try {
    (new MapperBuilder())->mapper()->map('int<0, 100>', 1337);
} catch (\CuyZ\Valinor\Mapper\MappingError $error) {
    $message = $error->node()->messages()[0];

    if (is_numeric($message->value())) {
        $message = $message->withBody(
            'Invalid amount {original_value, number, currency}'
        );
    }

    // Invalid amount: $1,337.00
    echo $message->withLocale('en_US');

    // Invalid amount: £1,337.00
    echo $message->withLocale('en_GB');

    // Invalid amount: 1 337,00 €
    echo $message->withLocale('fr_FR');
}
```

If the `intl` extension is not installed, a shim will be available to
replace the placeholders, but it won't handle advanced syntax as
described above.

---

The new formatter `TranslationMessageFormatter` can be used to translate
the content of messages.

The library provides a list of all messages that can be returned; this
list can be filled or modified with custom translations.

```php
TranslationMessageFormatter::default()
    // Create/override a single entry…
    ->withTranslation(
        'fr',
        'some custom message',
        'un message personnalisé'
    )
    // …or several entries.
    ->withTranslations([
        'some custom message' => [
            'en' => 'Some custom message',
            'fr' => 'Un message personnalisé',
            'es' => 'Un mensaje personalizado',
        ],
        'some other message' => [
            // …
        ],
    ])
    ->format($message);
```

It is possible to join several formatters into one formatter by using
the `AggregateMessageFormatter`. This instance can then easily be
injected in a service that will handle messages.

The formatters will be called in the same order they are given to the
aggregate.

```php
(new AggregateMessageFormatter(
    new LocaleMessageFormatter('fr'),
    new MessageMapFormatter([
        // …
    ],
    TranslationMessageFormatter::default(),
))->format($message)
```

BREAKING CHANGE: The method `NodeMessage::format` has been removed,
message formatters should be used instead. If needed, the old behaviour
can be retrieved with the formatter `PlaceHolderMessageFormatter`,
although it is strongly advised to use the new placeholders feature.

BREAKING CHANGE: The signature of the method `MessageFormatter::format`
has changed.
2022-05-21 16:30:24 +02:00
Romain Canon
05cf4a4a4d feat: improve mapping error messages
Enhances most of the messages for the end users.

Two major changes can be noticed:

1. In most cases no class name will be written in the message; it
   prevents users that potentially have no access to the codebase to
   get a useless/unclear information.

2. The input values are now properly formatted; for instance a string
   value will now be written directly instead of the type `string`;
   arrays are also handled with the array shape format, for instance:
   `array{foo: 'some string'}`.
2022-05-21 16:30:24 +02:00
Romain Canon
4ea3f39643 test: require YAML extension for test needing it 2022-05-21 16:30:24 +02:00
Romain Canon
427a291341 test: add test for datetime with key but without format 2022-05-21 16:30:24 +02:00
afcedf9e56
feat: handle literal boolean true / false types
Allows the usage of boolean values, as follows:

```php
class Foo
{
    /** @var int|false */
    public readonly int|bool $value;
}
```
2022-05-09 21:14:46 +02:00
Romain Canon
790df8a3b8 feat: handle float value type
Allows the usage of float values, as follows:

```php
class Foo
{
    /** @var 404.42|1337.42 */
    public readonly float $value;
}
```
2022-05-09 19:17:22 +02:00
Romain Canon
9856c416ee test: add tests for negative integer value 2022-05-09 19:17:22 +02:00
Romain Canon
8443847cb8 misc: bump dev-dependencies 2022-05-06 14:00:43 +02:00
Romain Canon
095e257884 test: add test for native constructor registration 2022-04-06 18:25:40 +02:00
Romain Canon
aab7ae9adb test: add test to cover several value altering functions registration 2022-04-05 19:41:58 +02:00
Nathan Boiron
2f08e1a9b3 fix: call value altering function only if value is accepted 2022-04-05 19:41:58 +02:00
Nathan Boiron
ad51039cc3
feat: introduce a source builder
The `Source` class is a new entry point for sources that are not plain 
array or iterable. It allows accessing other features like camel-case 
keys or custom paths mapping in a convenient way.

It should be used as follows:

```php
$source = \CuyZ\Valinor\Mapper\Source\Source::json($jsonString)
    ->camelCaseKeys()
    ->map([
        'towns' => 'cities',
        'towns.*.label' => 'name',
    ]);

$result = (new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(SomeClass::class, $source);
```
2022-03-24 14:23:03 +01:00
Romain Canon
ecafba3b21 feat!: introduce method to register constructors used during mapping
It is now mandatory to explicitly register custom constructors —
including named constructors — that can be used by the mapper. The
former automatic registration of named constructor feature doesn't
work anymore.

BREAKING CHANGE: existing code must list all named constructors that
were previously automatically used by the mapper, and registerer them
using the method `MapperBuilder::registerConstructor()`.

The method `MapperBuilder::bind()` has been deprecated, the method above
should be used instead.

```php
final class SomeClass
{
    public static function namedConstructor(string $foo): self
    {
        // …
    }
}

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConstructor(
        SomeClass::namedConstructor(...),
        // …or for PHP < 8.1:
        [SomeClass::class, 'namedConstructor'],
    )
    ->mapper()
    ->map(SomeClass::class, [
        // …
    ]);
```
2022-03-24 13:03:55 +01:00
Lucian Olariu
e3592e18c5
test: strengthen datetime mapping tests 2022-03-03 13:02:01 +01:00
Nathan Boiron
b7a7d22993
feat: introduce a path-mapping source modifier
This modifier can be used to change paths in the source data using a dot
notation.

The mapping is done using an associative array of path mappings. This
array must have the source path as key and the target path as value.

The source path uses the dot notation (eg `A.B.C`) and can contain one
`*` for array paths (eg `A.B.*.C`).

```php
final class Country
{
    /** @var City[] */
    public readonly array $cities;
}

final class City
{
    public readonly string $name;
}

$source = new \CuyZ\Valinor\Mapper\Source\Modifier\PathMapping([
    'towns' => [
        ['label' => 'Ankh Morpork'],
        ['label' => 'Minas Tirith'],
    ],
], [
    'towns' => 'cities',
    'towns.*.label' => 'name',
]);

// After modification this is what the source will look like:
[
    'cities' => [
        ['name' => 'Ankh Morpork'],
        ['name' => 'Minas Tirith'],
    ],
];

(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(Country::class, $source);
```
2022-02-26 11:33:50 +01:00
Romain Canon
359e32d03d
fix: transform exception thrown during object binding into a message 2022-02-24 11:27:27 +01:00
Romain Canon
cbf4e11154 fix: remove string keys when unpacking variadic parameter values 2022-02-24 11:23:26 +01:00
Romain Canon
1eb6e61913 feat!: improve interface inferring API
The method `MapperBuilder::infer()` can be used to infer an
implementation for a given interface.

The callback given to this method must return the name of a class that
implements the interface. Any arguments can be required by the callback;
they will be mapped properly using the given source.

```php
$mapper = (new \CuyZ\Valinor\MapperBuilder())
    ->infer(UuidInterface::class, fn () => MyUuid::class)
    ->infer(SomeInterface::class, fn (string $type) => match($type) {
        'first' => FirstImplementation::class,
        'second' => SecondImplementation::class,
        default => throw new DomainException("Unhandled type `$type`.")
    })->mapper();

// Will return an instance of `FirstImplementation`
$mapper->map(SomeInterface::class, [
    'type' => 'first',
    'uuid' => 'a6868d61-acba-406d-bcff-30ecd8c0ceb6',
    'someString' => 'foo',
]);

// Will return an instance of `SecondImplementation`
$mapper->map(SomeInterface::class, [
    'type' => 'second',
    'uuid' => 'a6868d61-acba-406d-bcff-30ecd8c0ceb6',
    'someInt' => 42,
]);

interface SomeInterface {}

final class FirstImplementation implements SomeInterface
{
    public readonly UuidInterface $uuid;

    public readonly string $someString;
}

final class SecondImplementation implements SomeInterface
{
    public readonly UuidInterface $uuid;

    public readonly int $someInt;
}
```
2022-02-24 10:48:49 +01:00
Romain Canon
b6b3296638 feat: handle variadic parameters in constructors
Using variadic parameters is now handled properly by the library,
meaning the following example will run:

```php
final class SomeClass
{
    /** @var string[] */
    private array $values;

    public function __construct(string ...$values)
    {
        $this->values = $values;
    }
}

(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(SomeClass::class, ['foo', 'bar', 'baz']);
```
2022-02-19 20:17:03 +01:00
Romain Canon
c1a884fadd qa: require and include phpstan/phpstan-phpunit rules 2022-02-19 19:58:28 +01:00
Romain Canon
6d427088f7 feat!: improve object binding API
The method `MapperBuilder::bind()` can be used to define a custom way to
build an object during the mapping.

The return type of the callback will be resolved by the mapping to know
when to use it.

The callback can take any arguments, that will automatically be mapped
using the given source. These arguments can then be used to instantiate
the object in the desired way.

Example:

```php
(new \CuyZ\Valinor\MapperBuilder())
    ->bind(function(string $string, OtherClass $otherClass): SomeClass {
        $someClass = new SomeClass($string);
        $someClass->addOtherClass($otherClass);

        return $someClass;
    })
    ->mapper()
    ->map(SomeClass::class, [
        // …
    ]);
```
2022-02-19 19:58:28 +01:00
Romain Canon
422e6a8b27 feat: improve value altering API 2022-02-19 19:58:28 +01:00
Romain Canon
d6e778aff7 refactor: regroup object inferring strategies
Inferring object unions and named constructor are now done using the
same algorithm — in class `ObjectBuilderFilterer` — which is called from
a unique entry point in `ClassNodeBuilder`.
2022-02-19 19:58:28 +01:00
Nathan Boiron
d9465222f4
feat: introduce a camel case source key modifier 2022-02-19 19:47:04 +01:00
Fred-Jan van der Eijken
66aa4d688a
fix: return indexed list of attributes when filtering on type 2022-01-26 18:59:57 +01:00
Romain Canon
718d3c1bc2 feat: introduce automatic named constructor resolution
An object may have several ways of being created — in such cases it is
common to use so-called named constructors, also known as static factory
methods. If one or more are found, they can be called during the mapping
to create an instance of the object.

What defines a named constructor is a method that:

1. is public
2. is static
3. returns an instance of the object
4. has one or more arguments

```php
final class Color
{
    /**
     * @param int<0, 255> $red
     * @param int<0, 255> $green
     * @param int<0, 255> $blue
     */
    private function __construct(
        public readonly int $red,
        public readonly int $green,
        public readonly int $blue
    ) {}

    /**
     * @param int<0, 255> $red
     * @param int<0, 255> $green
     * @param int<0, 255> $blue
     */
    public static function fromRgb(
        int $red,
        int $green,
        int $blue,
    ): self {
        return new self($red, $green, $blue);
    }

    /**
     * @param non-empty-string $hex
     */
    public static function fromHex(string $hex): self
    {
        if (strlen($hex) !== 6) {
            throw new DomainException('Must be 6 characters long');
        }

        /** @var int<0, 255> $red */
        $red = hexdec(substr($hex, 0, 2));
        /** @var int<0, 255> $green */
        $green = hexdec(substr($hex, 2, 2));
        /** @var int<0, 255> $blue */
        $blue = hexdec(substr($hex, 4, 2));

        return new self($red, $green, $blue);
    }
}
```
2022-01-25 18:32:28 +01:00
Romain Canon
79d7c266ec feat: introduce automatic union of objects inferring during mapping
When the mapper needs to map a source to a union of objects, it will try
to guess which object it will map to, based on the needed arguments of
the objects, and the values contained in the source.

```php
final class UnionOfObjects
{
    public readonly SomeFooObject|SomeBarObject $object;
}

final class SomeFooObject
{
    public readonly string $foo;
}

final class SomeBarObject
{
    public readonly string $bar;
}

// Will map to an instance of `SomeFooObject`
(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(UnionOfObjects::class, ['foo' => 'foo']);

// Will map to an instance of `SomeBarObject`
(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(UnionOfObjects::class, ['bar' => 'bar']);
```
2022-01-07 13:41:15 +01:00
Romain Canon
8a74147d4c misc!: allow object builder to yield arguments without source
The `Argument` class must now be instantiated with one of the `required`
or `optional` static constructors.
2022-01-07 13:41:15 +01:00
Romain Canon
a805ba0442 feat!: wrap node messages in proper class
A new class `NodeMessage` is used to wrap messages added to a node
during the mapping. This class will allow further features by giving
access to useful data related to the bound node.

BREAKING CHANGE: as of now every message is wrapped into a `NodeMessage`
it is therefore not possible to check whether the message is an instance
of `Throwable` — a new method `$message->isError()` is now to be used
for such cases.
2022-01-06 14:11:42 +01:00
Romain Canon
b2e810e3ce feat!: allow mapping to any type
Previously, the method `TreeMapper::map` would allow mapping only to an
object. It is now possible to map to any type handled by the library.

It is for instance possible to map to an array of objects:

```php
$objects = (new \CuyZ\Valinor\MapperBuilder())->mapper()->map(
    'array<' . SomeClass::class . '>',
    [/* … */]
);
```

For simple use-cases, an array shape can be used:

```php
$array = (new \CuyZ\Valinor\MapperBuilder())->mapper()->map(
    'array{foo: string, bar: int}',
    [/* … */]
);

echo strtolower($array['foo']);
echo $array['bar'] * 2;
```

This new feature changes the possible behaviour of the mapper, meaning
static analysis tools need help to understand the types correctly. An
extension for PHPStan and a plugin for Psalm are now provided and can be
included in a project to automatically increase the type coverage.
2022-01-02 00:48:01 +01:00
Romain Canon
0144bf084a misc: raise PHPStan version
This enables full PHP 8.1 support 🎉
2021-12-31 13:30:14 +01:00
Romain Canon
54f608e5b1 feat!: add access to root node when error occurs during mapping
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.
2021-12-27 13:52:36 +01:00
Brandon Savage
179ba3df29
feat: handle common database datetime formats (#40) 2021-12-17 17:55:17 +01:00
Romain Canon
9f99a2a1ef feat: handle integer range type
Integer range can be used as follows:

```php
final class SomeClass
{
    /** @var int<42, 1337> */
    public int $intRange; // accepts any int between 42 and 1337

    /** @var int<-1337, 1337> */
    public int $negativeIntRange; // also works with negative values

    /** @var int<min, 1337> */
    public int $minIntRange; // `min` can be used…

    /** @var int<0, max> */
    public int $maxIntRange; // …as well as `max`
}
```

Note that `min` and `max` will check the range with PHP's internal
constants `PHP_INT_MIN` and `PHP_INT_MAX`.
2021-12-07 18:20:25 +01:00
Romain Canon
fa3ce50dfb feat: handle type alias import in class definition
Type aliases can now be imported from another class definition.

Both PHPStan and Psalm syntax are handled.

```php
/**
 * @phpstan-type SomeTypeAlias = array{foo: string}
 */
final class SomeClass
{
    /** @var SomeTypeAlias */
    public array $someTypeAlias;
}

/**
 * @phpstan-import-type SomeTypeAlias from SomeClass
 */
final class SomeOtherClass
{
    /** @var SomeTypeAlias */
    public array $someTypeAlias;
}
```
2021-12-07 18:20:04 +01:00
Romain Canon
56142dea5b feat: handle local type aliasing in class definition
Type aliases can now be added to a class definition.

Both PHPStan and Psalm syntax are handled.

```php
/**
 * @phpstan-type SomeTypeAlias = array{foo: string}
 * @psalm-type SomeOtherTypeAlias = array{bar: int}
 */
final class SomeClass
{
    /** @var SomeTypeAlias */
    public array $someTypeAlias;

    /** @var SomeOtherTypeAlias */
    public array $someOtherTypeAlias;
}
```
2021-12-07 18:20:04 +01:00
Romain Canon
4f561290b1 misc: delete commented code 2021-12-02 22:35:34 +01:00
Romain Canon
5a578ea4c2 fix: do not accept shaped array with excessive key(s)
The mapper will now delete the excessive keys given as input to a shaped
 array.
2021-12-02 22:35:34 +01:00
Romain Canon
d99c59dfb5 feat: handle multiline type declaration
The following type will now be handled:

```php
/**
 * @var array{
 *     foo: string,
 *     bar: int
 * }
 */
public array $foo;
```
2021-12-01 09:45:33 +01:00
Romain Canon
dd4624c5e0 fix: handle correctly iterable source during mapping 2021-11-30 13:04:09 +01:00
Romain Canon
5561d018ab fix: handle shaped array integer key 2021-11-29 12:17:19 +01:00
Romain Canon
1c628b6675 fix: resolve single/double quotes when parsing doc-block type 2021-11-29 11:36:11 +01:00
Romain Canon
396f64a524 feat: initial release
🎉
2021-11-28 18:21:56 +01:00