Repository: CuyZ/Valinor (php)

Issue:
CannotResolveObjectType: Impossible to resolve an implementation
Hi, since 040b9093f16a7446f6e678ae992754c8170bcad9 the following no longer works:

```php
// test.php

<?php

require_once __DIR__ . '/vendor/autoload.php';

interface Foo {}
final readonly class Baz implements Foo {}
final readonly class Bar implements Foo {}

final readonly class Dto
{
    public function __construct(
        public Foo|string|null $foo = null,
    ) {}
}

$mapper = (new \CuyZ\Valinor\MapperBuilder())->mapper();
$foo = $mapper->map(Dto::class, ['foo' => 'baz']);

var_dump($foo);
```

I get the following result when calling `php test.php`:

```
Fatal error: Uncaught CuyZ\Valinor\Mapper\Tree\Exception\CannotResolveObjectType: Impossible to resolve an implementation for `Foo`. in /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php:59
Stack trace:
#0 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/IterableNodeBuilder.php(26): CuyZ\Valinor\Mapper\Tree\Builder\InterfaceNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#1 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/RootNodeBuilder.php(25): CuyZ\Valinor\Mapper\Tree\Builder\IterableNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#2 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/UnionNodeBuilder.php(43): CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell))
#3 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/TypeNodeBuilder.php(73): CuyZ\Valinor\Mapper\Tree\Builder\UnionNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#4 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php(40): CuyZ\Valinor\Mapper\Tree\Builder\TypeNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#5 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/IterableNodeBuilder.php(26): CuyZ\Valinor\Mapper\Tree\Builder\InterfaceNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#6 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/RootNodeBuilder.php(25): CuyZ\Valinor\Mapper\Tree\Builder\IterableNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#7 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php(107): CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell))
#8 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/ObjectNodeBuilder.php(62): CuyZ\Valinor\Mapper\Tree\Builder\ObjectNodeBuilder->children(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Object\ArgumentsValues), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#9 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/TypeNodeBuilder.php(73): CuyZ\Valinor\Mapper\Tree\Builder\ObjectNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#10 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php(62): CuyZ\Valinor\Mapper\Tree\Builder\TypeNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#11 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/IterableNodeBuilder.php(26): CuyZ\Valinor\Mapper\Tree\Builder\InterfaceNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#12 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/RootNodeBuilder.php(25): CuyZ\Valinor\Mapper\Tree\Builder\IterableNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell), Object(CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder))
#13 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/TypeTreeMapper.php(48): CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder->build(Object(CuyZ\Valinor\Mapper\Tree\Shell))
#14 /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/TypeTreeMapper.php(28): CuyZ\Valinor\Mapper\TypeTreeMapper->node('Dto', Array)
#15 /Users/elias/test/valinor-error/test.php(17): CuyZ\Valinor\Mapper\TypeTreeMapper->map('Dto', Array)
#16 {main}
  thrown in /Users/elias/test/valinor-error/vendor/cuyz/valinor/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php on line 59
```

This is the result when using the previous commit (f8eedc944978ecfaaa0487ac3a9068be1e7f8747):

```
object(Dto)#135 (1) {
  ["foo"]=>
  string(3) "baz"
}
```

Pull request description:
fix: properly handle interface with no implementation in union type
Fixes #611

Patch:
diff --git a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
index cb0484e..cb49e5c 100644
--- a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
+++ b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
@@ -40,6 +40,10 @@ final class InterfaceNodeBuilder implements NodeBuilder
             return $this->delegate->build($shell, $rootBuilder);
         }
 
+        if ($type->accepts($shell->value())) {
+            return TreeNode::leaf($shell, $shell->value());
+        }
+
         if ($this->constructorRegisteredFor($type)) {
             if ($this->implementations->has($type->className())) {
                 throw new InterfaceHasBothConstructorAndInfer($type->className());
diff --git a/src/Mapper/Tree/Builder/UnionNodeBuilder.php b/src/Mapper/Tree/Builder/UnionNodeBuilder.php
index 166ed4c..bc934d1 100644
--- a/src/Mapper/Tree/Builder/UnionNodeBuilder.php
+++ b/src/Mapper/Tree/Builder/UnionNodeBuilder.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace CuyZ\Valinor\Mapper\Tree\Builder;
 
+use CuyZ\Valinor\Mapper\Tree\Exception\CannotResolveObjectType;
 use CuyZ\Valinor\Mapper\Tree\Exception\CannotResolveTypeFromUnion;
 use CuyZ\Valinor\Mapper\Tree\Exception\TooManyResolvedTypesFromUnion;
 use CuyZ\Valinor\Mapper\Tree\Shell;
@@ -40,7 +41,14 @@ final class UnionNodeBuilder implements NodeBuilder
                 return TreeNode::leaf($shell, null);
             }
 
-            $node = $rootBuilder->build($shell->withType($subType));
+            try {
+                $node = $rootBuilder->build($shell->withType($subType));
+            } catch (CannotResolveObjectType) {
+                // We catch a special case where an interface type from the
+                // union has no implementation. In this case, we just ignore the
+                // exception and let the other types handle the value.
+                continue;
+            }
 
             if (! $node->isValid()) {
                 continue;
diff --git a/src/Mapper/Tree/Message/DefaultMessage.php b/src/Mapper/Tree/Message/DefaultMessage.php
index 3fdb96b..f1c4c8f 100644
--- a/src/Mapper/Tree/Message/DefaultMessage.php
+++ b/src/Mapper/Tree/Message/DefaultMessage.php
@@ -77,11 +77,11 @@ interface DefaultMessage
         'Invalid value {source_value}.' => [
             'en' => 'Invalid value {source_value}.',
         ],
-        'Invalid value {source_value}, it matches at least two types from union.' => [
-            'en' => 'Invalid value {source_value}, it matches at least two types from union.',
+        'Invalid value {source_value}, it matches two or more types from union: cannot take a decision.' => [
+            'en' => 'Invalid value {source_value}, it matches two or more types from union: cannot take a decision.',
         ],
-        'Invalid value {source_value}, it matches at least two types from {allowed_types}.' => [
-            'en' => 'Invalid value {source_value}, it matches at least two types from {allowed_types}.',
+        'Invalid value {source_value}, it matches two or more types from {allowed_types}: cannot take a decision.' => [
+            'en' => 'Invalid value {source_value}, it matches two or more types from {allowed_types}: cannot take a decision.',
         ],
         'Invalid sequential key {key}, expected {expected}.' => [
             'en' => 'Invalid sequential key {key}, expected {expected}.',


Test patch:
diff --git a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php
index b08ea80..e5db054 100644
--- a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php
+++ b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php
@@ -30,6 +30,15 @@ final class ObjectValuesMappingTest extends IntegrationTestCase
         }
     }
 
+    public function test_interface_with_no_infer_is_mapped_when_an_object_implementing_this_interface_is_given(): void
+    {
+        $source = new SomeClassImplementingSomeInterface();
+
+        $result = $this->mapperBuilder()->mapper()->map(SomeInterfaceWithOneImplementation::class, $source);
+
+        self::assertSame($source, $result);
+    }
+
     public function test_invalid_iterable_source_throws_exception(): void
     {
         $source = 'foo';
@@ -113,3 +122,7 @@ final class ObjectWithTwoProperties
 
     public string $stringB;
 }
+
+interface SomeInterfaceWithOneImplementation {}
+
+final class SomeClassImplementingSomeInterface implements SomeInterfaceWithOneImplementation {}
diff --git a/tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php b/tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php
index 72345e4..33559b9 100644
--- a/tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php
+++ b/tests/Integration/Mapping/Object/UnionOfObjectsMappingTest.php
@@ -49,6 +49,19 @@ final class UnionOfObjectsMappingTest extends IntegrationTestCase
         self::assertSame('fiz', $result->fiz);
     }
 
+    public function test_mapping_to_union_of_interface_and_scalar_when_interface_has_no_implementation_uses_string(): void
+    {
+        try {
+            $result = $this->mapperBuilder()
+                ->mapper()
+                ->map(SomeInterfaceWithNoImplementation::class . '|string', 'foo');
+        } catch (MappingError $error) {
+            $this->mappingFail($error);
+        }
+
+        self::assertSame('foo', $result);
+    }
+
     public static function mapping_error_when_cannot_resolve_union_data_provider(): iterable
     {
         yield [
@@ -134,3 +147,5 @@ final class SomeObjectWithBazAndFiz
 
     public string $fiz;
 }
+
+interface SomeInterfaceWithNoImplementation {}