Skip to content

Commit

Permalink
feat: introduce automatic union of objects inferring during mapping
Browse files Browse the repository at this point in the history
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']);
```
  • Loading branch information
romm committed Jan 7, 2022
1 parent 8a74147 commit 79d7c26
Show file tree
Hide file tree
Showing 5 changed files with 319 additions and 4 deletions.
15 changes: 11 additions & 4 deletions src/Library/Container.php
Expand Up @@ -21,7 +21,6 @@
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;
Expand All @@ -31,12 +30,13 @@
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;
Expand All @@ -49,6 +49,7 @@
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;
Expand Down Expand Up @@ -91,7 +92,13 @@ public function __construct(Settings $settings)
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,
Expand Down
20 changes: 20 additions & 0 deletions src/Type/Resolver/Exception/CannotResolveObjectTypeFromUnion.php
@@ -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
);
}
}
116 changes: 116 additions & 0 deletions src/Type/Resolver/Union/UnionObjectNarrower.php
@@ -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;
}
}
21 changes: 21 additions & 0 deletions tests/Integration/Mapping/Fixture/NativeUnionOfObjects.php
@@ -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;
}
151 changes: 151 additions & 0 deletions tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php
@@ -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;
}

0 comments on commit 79d7c26

Please sign in to comment.