From 79d7c266ecabc069b11120338ae1e62a8f3dca97 Mon Sep 17 00:00:00 2001 From: Romain Canon Date: Fri, 7 Jan 2022 13:35:10 +0100 Subject: [PATCH] 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']); ``` --- src/Library/Container.php | 15 +- .../CannotResolveObjectTypeFromUnion.php | 20 +++ .../Resolver/Union/UnionObjectNarrower.php | 116 ++++++++++++++ .../Mapping/Fixture/NativeUnionOfObjects.php | 21 +++ .../Object/UnionOfObjectsMappingTest.php | 151 ++++++++++++++++++ 5 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 src/Type/Resolver/Exception/CannotResolveObjectTypeFromUnion.php create mode 100644 src/Type/Resolver/Union/UnionObjectNarrower.php create mode 100644 tests/Integration/Mapping/Fixture/NativeUnionOfObjects.php create mode 100644 tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php diff --git a/src/Library/Container.php b/src/Library/Container.php index fb1cb1b..5455f24 100644 --- a/src/Library/Container.php +++ b/src/Library/Container.php @@ -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, diff --git a/src/Type/Resolver/Exception/CannotResolveObjectTypeFromUnion.php b/src/Type/Resolver/Exception/CannotResolveObjectTypeFromUnion.php new file mode 100644 index 0000000..05e2402 --- /dev/null +++ b/src/Type/Resolver/Exception/CannotResolveObjectTypeFromUnion.php @@ -0,0 +1,20 @@ +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> $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; + } +} diff --git a/tests/Integration/Mapping/Fixture/NativeUnionOfObjects.php b/tests/Integration/Mapping/Fixture/NativeUnionOfObjects.php new file mode 100644 index 0000000..55ffb45 --- /dev/null +++ b/tests/Integration/Mapping/Fixture/NativeUnionOfObjects.php @@ -0,0 +1,21 @@ += 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 */ + public array $objects; +} + +final class UnionOfFooAndAnotherFoo +{ + /** @var array */ + public array $objects; +} + +final class UnionOfFooAndBarAndFoo +{ + /** @var array */ + public array $objects; +} + +final class UnionOfFooAndBarAndFiz +{ + /** @var array */ + 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; +}