mirror of
https://github.com/danog/Valinor.git
synced 2025-01-10 14:48:20 +01:00
90dc586018
The mapper is now more type-sensitive and will fail in the following situations: - When a value does not match exactly the awaited scalar type, for instance a string `"42"` given to a node that awaits an integer. - When unnecessary array keys are present, for instance mapping an array `['foo' => …, 'bar' => …, 'baz' => …]` to an object that needs only `foo` and `bar`. - When permissive types like `mixed` or `object` are encountered. These limitations can be bypassed by enabling the flexible mode: ```php (new \CuyZ\Valinor\MapperBuilder()) ->flexible() ->mapper(); ->map('array{foo: int, bar: bool}', [ 'foo' => '42', // Will be cast from `string` to `int` 'bar' => 'true', // Will be cast from `string` to `bool` 'baz' => '…', // Will be ignored ]); ``` When using this library for a provider application — for instance an API endpoint that can be called with a JSON payload — it is recommended to use the strict mode. This ensures that the consumers of the API provide the exact awaited data structure, and prevents unknown values to be passed. When using this library as a consumer of an external source, it can make sense to enable the flexible mode. This allows for instance to convert string numeric values to integers or to ignore data that is present in the source but not needed in the application. --- All these changes led to a new check that runs on all registered object constructors. If a collision is found between several constructors that have the same signature (the same parameter names), an exception will be thrown. ```php final class SomeClass { public static function constructorA(string $foo, string $bar): self { // … } public static function constructorB(string $foo, string $bar): self { // … } } (new \CuyZ\Valinor\MapperBuilder()) ->registerConstructor( SomeClass::constructorA(...), SomeClass::constructorB(...), ) ->mapper(); ->map(SomeClass::class, [ 'foo' => 'foo', 'bar' => 'bar', ]); // Exception: A collision was detected […] ```
214 lines
6.2 KiB
PHP
214 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace CuyZ\Valinor\Tests\Integration\Mapping\Other;
|
|
|
|
use CuyZ\Valinor\Mapper\MappingError;
|
|
use CuyZ\Valinor\MapperBuilder;
|
|
use CuyZ\Valinor\Tests\Fixture\Enum\BackedIntegerEnum;
|
|
use CuyZ\Valinor\Tests\Fixture\Enum\BackedStringEnum;
|
|
use CuyZ\Valinor\Tests\Fixture\Object\StringableObject;
|
|
use CuyZ\Valinor\Tests\Integration\IntegrationTest;
|
|
use DateTime;
|
|
use stdClass;
|
|
|
|
final class FlexibleMappingTest extends IntegrationTest
|
|
{
|
|
public function test_array_of_scalars_is_mapped_properly(): void
|
|
{
|
|
$source = ['foo', 42, 1337.404];
|
|
|
|
try {
|
|
$result = (new MapperBuilder())->flexible()->mapper()->map('string[]', $source);
|
|
} catch (MappingError $error) {
|
|
$this->mappingFail($error);
|
|
}
|
|
|
|
self::assertSame(['foo', '42', '1337.404'], $result);
|
|
}
|
|
|
|
public function test_shaped_array_is_mapped_correctly(): void
|
|
{
|
|
$source = [
|
|
'foo',
|
|
'foo' => '42',
|
|
];
|
|
|
|
try {
|
|
$result = (new MapperBuilder())->flexible()->mapper()->map('array{string, foo: int, bar?: float}', $source);
|
|
} catch (MappingError $error) {
|
|
$this->mappingFail($error);
|
|
}
|
|
|
|
self::assertSame(['foo', 'foo' => 42], $result);
|
|
}
|
|
|
|
/**
|
|
* @requires PHP >= 8.1
|
|
*/
|
|
public function test_string_enum_is_cast_correctly(): void
|
|
{
|
|
try {
|
|
$result = (new MapperBuilder())->flexible()->mapper()->map(BackedStringEnum::class, new StringableObject('foo'));
|
|
} catch (MappingError $error) {
|
|
$this->mappingFail($error);
|
|
}
|
|
|
|
self::assertSame(BackedStringEnum::FOO, $result);
|
|
}
|
|
|
|
/**
|
|
* @requires PHP >= 8.1
|
|
*/
|
|
public function test_integer_enum_is_cast_correctly(): void
|
|
{
|
|
try {
|
|
$result = (new MapperBuilder())->flexible()->mapper()->map(BackedIntegerEnum::class, '42');
|
|
} catch (MappingError $error) {
|
|
$this->mappingFail($error);
|
|
}
|
|
|
|
self::assertSame(BackedIntegerEnum::FOO, $result);
|
|
}
|
|
|
|
public function test_superfluous_shaped_array_values_are_mapped_properly_in_flexible_mode(): void
|
|
{
|
|
$source = [
|
|
'foo' => 'foo',
|
|
'bar' => 42,
|
|
'fiz' => 1337.404,
|
|
];
|
|
|
|
foreach (['array{foo: string, bar: int}', 'array{bar: int, fiz:float}'] as $signature) {
|
|
try {
|
|
$result = (new MapperBuilder())->flexible()->mapper()->map($signature, $source);
|
|
} catch (MappingError $error) {
|
|
$this->mappingFail($error);
|
|
}
|
|
|
|
self::assertSame(42, $result['bar']);
|
|
}
|
|
}
|
|
|
|
public function test_object_with_no_argument_build_with_non_array_source_in_flexible_mode_works_as_expected(): void
|
|
{
|
|
try {
|
|
$result = (new MapperBuilder())->flexible()->mapper()->map(stdClass::class, 'foo');
|
|
} catch (MappingError $error) {
|
|
$this->mappingFail($error);
|
|
}
|
|
|
|
self::assertFalse(isset($result->foo));
|
|
}
|
|
|
|
public function test_source_matching_two_unions_maps_the_one_with_most_arguments(): void
|
|
{
|
|
try {
|
|
$result = (new MapperBuilder())->flexible()->mapper()->map(UnionOfBarAndFizAndFoo::class, [
|
|
['foo' => 'foo', 'bar' => 'bar', 'fiz' => 'fiz'],
|
|
]);
|
|
} catch (MappingError $error) {
|
|
$this->mappingFail($error);
|
|
}
|
|
|
|
$object = $result->objects[0];
|
|
|
|
self::assertInstanceOf(SomeBarAndFizObject::class, $object);
|
|
self::assertSame('bar', $object->bar);
|
|
self::assertSame('fiz', $object->fiz);
|
|
}
|
|
|
|
public function test_can_map_to_mixed_type_in_flexible_mode(): void
|
|
{
|
|
try {
|
|
$result = (new MapperBuilder())->flexible()->mapper()->map('mixed[]', [
|
|
'foo' => 'foo',
|
|
'bar' => 'bar',
|
|
]);
|
|
} catch (MappingError $error) {
|
|
$this->mappingFail($error);
|
|
}
|
|
|
|
self::assertSame(['foo' => 'foo', 'bar' => 'bar'], $result);
|
|
}
|
|
|
|
public function test_can_map_to_undefined_object_type_in_flexible_mode(): void
|
|
{
|
|
$source = [new stdClass(), new DateTime()];
|
|
|
|
try {
|
|
$result = (new MapperBuilder())->flexible()->mapper()->map('object[]', $source);
|
|
} catch (MappingError $error) {
|
|
$this->mappingFail($error);
|
|
}
|
|
|
|
self::assertSame($source, $result);
|
|
}
|
|
|
|
public function test_value_that_cannot_be_cast_throws_exception(): void
|
|
{
|
|
try {
|
|
(new MapperBuilder())->flexible()->mapper()->map('int', 'foo');
|
|
} catch (MappingError $exception) {
|
|
$error = $exception->node()->messages()[0];
|
|
|
|
self::assertSame('1618736242', $error->code());
|
|
self::assertSame("Cannot cast 'foo' to `int`.", (string)$error);
|
|
}
|
|
}
|
|
|
|
public function test_null_that_cannot_be_cast_throws_exception(): void
|
|
{
|
|
try {
|
|
(new MapperBuilder())->flexible()->mapper()->map('int', null);
|
|
} catch (MappingError $exception) {
|
|
$error = $exception->node()->messages()[0];
|
|
|
|
self::assertSame('1618736242', $error->code());
|
|
self::assertSame('Cannot be empty and must be filled with a value matching type `int`.', (string)$error);
|
|
}
|
|
}
|
|
|
|
public function test_missing_value_throws_exception(): void
|
|
{
|
|
$class = new class () {
|
|
public string $foo;
|
|
|
|
public string $bar;
|
|
};
|
|
|
|
try {
|
|
(new MapperBuilder())->flexible()->mapper()->map(get_class($class), [
|
|
'foo' => 'foo',
|
|
]);
|
|
} catch (MappingError $exception) {
|
|
$error = $exception->node()->children()['bar']->messages()[0];
|
|
|
|
self::assertSame('1655449641', $error->code());
|
|
self::assertSame('Cannot be empty and must be filled with a value matching type `string`.', (string)$error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// @PHP8.1 Readonly properties
|
|
final class UnionOfBarAndFizAndFoo
|
|
{
|
|
/** @var array<SomeBarAndFizObject|SomeFooObject> */
|
|
public array $objects;
|
|
}
|
|
|
|
// @PHP8.1 Readonly properties
|
|
final class SomeFooObject
|
|
{
|
|
public string $foo;
|
|
}
|
|
|
|
// @PHP8.1 Readonly properties
|
|
final class SomeBarAndFizObject
|
|
{
|
|
public string $bar;
|
|
|
|
public string $fiz;
|
|
}
|