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']);
```
This commit is contained in:
Romain Canon 2022-01-07 13:35:10 +01:00
parent 8a74147d4c
commit 79d7c266ec
5 changed files with 319 additions and 4 deletions

View File

@ -21,7 +21,6 @@ use CuyZ\Valinor\Mapper\Object\Factory\AttributeObjectBuilderFactory;
use CuyZ\Valinor\Mapper\Object\Factory\BasicObjectBuilderFactory;
use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory;
use CuyZ\Valinor\Mapper\Tree\Builder\ArrayNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\VisitorNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\CasterNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\ClassNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\EnumNodeBuilder;
@ -31,12 +30,13 @@ use CuyZ\Valinor\Mapper\Tree\Builder\NodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\ScalarNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\ShapedArrayNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\ValueAlteringNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\ShellVisitorNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\ValueAlteringNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Builder\VisitorNodeBuilder;
use CuyZ\Valinor\Mapper\Tree\Visitor\AggregateShellVisitor;
use CuyZ\Valinor\Mapper\Tree\Visitor\AttributeShellVisitor;
use CuyZ\Valinor\Mapper\Tree\Visitor\ObjectBindingShellVisitor;
use CuyZ\Valinor\Mapper\Tree\Visitor\InterfaceShellVisitor;
use CuyZ\Valinor\Mapper\Tree\Visitor\ObjectBindingShellVisitor;
use CuyZ\Valinor\Mapper\Tree\Visitor\ShellVisitor;
use CuyZ\Valinor\Mapper\Tree\Visitor\UnionShellVisitor;
use CuyZ\Valinor\Mapper\TreeMapper;
@ -49,6 +49,7 @@ use CuyZ\Valinor\Type\Parser\Template\BasicTemplateParser;
use CuyZ\Valinor\Type\Parser\Template\TemplateParser;
use CuyZ\Valinor\Type\Parser\TypeParser;
use CuyZ\Valinor\Type\Resolver\Union\UnionNullNarrower;
use CuyZ\Valinor\Type\Resolver\Union\UnionObjectNarrower;
use CuyZ\Valinor\Type\Resolver\Union\UnionScalarNarrower;
use CuyZ\Valinor\Type\ScalarType;
use CuyZ\Valinor\Type\Types\ArrayType;
@ -91,7 +92,13 @@ final class Container
ShellVisitor::class => function () use ($settings): ShellVisitor {
return new AggregateShellVisitor(
new UnionShellVisitor(
new UnionNullNarrower(new UnionScalarNarrower())
new UnionNullNarrower(
new UnionObjectNarrower(
new UnionScalarNarrower(),
$this->get(ClassDefinitionRepository::class),
$this->get(ObjectBuilderFactory::class),
)
)
),
new InterfaceShellVisitor(
$settings->interfaceMapping,

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Type\Resolver\Exception;
use CuyZ\Valinor\Mapper\Tree\Message\Message;
use CuyZ\Valinor\Type\Types\UnionType;
use RuntimeException;
final class CannotResolveObjectTypeFromUnion extends RuntimeException implements Message
{
public function __construct(UnionType $unionType)
{
parent::__construct(
"Impossible to resolve the object type from the union `$unionType`.",
1641406600
);
}
}

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Type\Resolver\Union;
use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository;
use CuyZ\Valinor\Mapper\Object\Argument;
use CuyZ\Valinor\Mapper\Object\Factory\ObjectBuilderFactory;
use CuyZ\Valinor\Type\ObjectType;
use CuyZ\Valinor\Type\Resolver\Exception\CannotResolveObjectTypeFromUnion;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\UnionType;
use function array_map;
use function array_pop;
use function array_shift;
use function count;
use function in_array;
use function is_array;
use function ksort;
final class UnionObjectNarrower implements UnionNarrower
{
private UnionNarrower $delegate;
private ClassDefinitionRepository $classDefinitionRepository;
private ObjectBuilderFactory $objectBuilderFactory;
public function __construct(
UnionNarrower $delegate,
ClassDefinitionRepository $classDefinitionRepository,
ObjectBuilderFactory $objectBuilderFactory
) {
$this->delegate = $delegate;
$this->classDefinitionRepository = $classDefinitionRepository;
$this->objectBuilderFactory = $objectBuilderFactory;
}
public function narrow(UnionType $unionType, $source): Type
{
if (! is_array($source)) {
return $this->delegate->narrow($unionType, $source);
}
$isIncremental = true;
$types = [];
$argumentsList = [];
foreach ($unionType->types() as $type) {
if (! $type instanceof ObjectType) {
return $this->delegate->narrow($unionType, $source);
}
$class = $this->classDefinitionRepository->for($type->signature());
$objectBuilder = $this->objectBuilderFactory->for($class);
$arguments = [...$objectBuilder->describeArguments()];
foreach ($arguments as $argument) {
if (! isset($source[$argument->name()]) && $argument->isRequired()) {
continue 2;
}
}
$count = count($arguments);
if (isset($types[$count])) {
$isIncremental = false;
/** @infection-ignore-all */
break;
}
$types[$count] = $type;
$argumentsList[$count] = $arguments;
}
ksort($types);
ksort($argumentsList);
if ($isIncremental && count($types) >= 1 && $this->argumentsAreSharedAcrossList($argumentsList)) {
return array_pop($types);
}
throw new CannotResolveObjectTypeFromUnion($unionType);
}
/**
* @param array<int, array<Argument>> $argumentsList
*/
private function argumentsAreSharedAcrossList(array $argumentsList): bool
{
$namesList = [];
foreach ($argumentsList as $arguments) {
$namesList[] = array_map(fn (Argument $argument) => $argument->name(), $arguments);
}
while ($current = array_shift($namesList)) {
if (count($namesList) === 0) {
/** @infection-ignore-all */
break;
}
foreach ($current as $name) {
foreach ($namesList as $other) {
if (! in_array($name, $other, true)) {
return false;
}
}
}
}
return true;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Fixture;
// @PHP8.0 move inside \CuyZ\Valinor\Tests\Integration\Mapping\Object\UnionOfObjectsMappingTest
final class NativeUnionOfObjects
{
public SomeFooObject|SomeBarObject $object;
}
final class SomeFooObject
{
public string $foo;
}
final class SomeBarObject
{
public string $bar;
}

View File

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Tests\Integration\IntegrationTest;
use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\NativeUnionOfObjects;
final class UnionOfObjectsMappingTest extends IntegrationTest
{
/**
* @requires PHP >= 8
*/
public function test_object_type_is_narrowed_correctly_for_simple_case(): void
{
try {
$resultFoo = $this->mapperBuilder->mapper()->map(NativeUnionOfObjects::class, [
'foo' => 'foo'
]);
$resultBar = $this->mapperBuilder->mapper()->map(NativeUnionOfObjects::class, [
'bar' => 'bar'
]);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertInstanceOf(\CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SomeFooObject::class, $resultFoo->object);
self::assertInstanceOf(\CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SomeBarObject::class, $resultBar->object);
}
public function test_object_type_is_narrowed_correctly_for_simple_array_case(): void
{
try {
$result = $this->mapperBuilder->mapper()->map(UnionOfFooAndBar::class, [
'foo' => ['foo' => 'foo'],
'bar' => ['bar' => 'bar'],
]);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertInstanceOf(SomeFooObject::class, $result->objects['foo']);
self::assertInstanceOf(SomeBarObject::class, $result->objects['bar']);
}
public function test_objects_sharing_one_property_are_resolved_correctly(): void
{
try {
$result = $this->mapperBuilder->mapper()->map(UnionOfFooAndBarAndFoo::class, [
['foo' => 'foo'],
['foo' => 'foo', 'bar' => 'bar'],
]);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertInstanceOf(SomeFooObject::class, $result->objects[0]);
self::assertInstanceOf(SomeFooAndBarObject::class, $result->objects[1]);
}
/**
*
* @dataProvider mapping_error_when_cannot_resolve_union_data_provider
*
* @param class-string $className
* @param mixed[] $source
*/
public function test_mapping_error_when_cannot_resolve_union(string $className, array $source): void
{
try {
$this->mapperBuilder->mapper()->map($className, $source);
self::fail('No mapping error when one was expected');
} catch (MappingError $exception) {
$error = $exception->node()->children()['objects']->children()[0]->messages()[0];
self::assertSame('1641406600', $error->code());
}
}
public function mapping_error_when_cannot_resolve_union_data_provider(): iterable
{
yield [
'className' => UnionOfFooAndBar::class,
'source' => [['foo' => 'foo', 'bar' => 'bar']],
];
yield [
'className' => UnionOfFooAndBarAndFiz::class,
'source' => [['foo' => 'foo', 'bar' => 'bar', 'fiz' => 'fiz']],
];
yield [
'className' => UnionOfFooAndAnotherFoo::class,
'source' => [['foo' => 'foo']],
];
}
}
final class UnionOfFooAndBar
{
/** @var array<SomeFooObject|SomeBarObject> */
public array $objects;
}
final class UnionOfFooAndAnotherFoo
{
/** @var array<SomeFooObject|SomeOtherFooObject> */
public array $objects;
}
final class UnionOfFooAndBarAndFoo
{
/** @var array<SomeFooAndBarObject|SomeFooObject> */
public array $objects;
}
final class UnionOfFooAndBarAndFiz
{
/** @var array<SomeFooObject|SomeBarAndFizObject> */
public array $objects;
}
final class SomeFooObject
{
public string $foo;
}
final class SomeOtherFooObject
{
public string $foo;
}
final class SomeBarObject
{
public string $bar;
}
final class SomeFooAndBarObject
{
public string $foo;
public string $bar;
}
final class SomeBarAndFizObject
{
public string $bar;
public string $fiz;
}