Repository: CuyZ/Valinor (php)

Issue:
No MappingError anymore on invalid input (since v1.14.3)
Copied from [here](https://github.com/eliashaeussler/version-bumper/pull/32#issuecomment-2672549009):

> In both [failing test cases](https://github.com/eliashaeussler/version-bumper/actions/runs/13378833447/job/37363580385?pr=32), I map an [invalid file](https://github.com/eliashaeussler/version-bumper/blob/c8ed7f87855b3f913d072ac46f2539000abde9b2/tests/src/Fixtures/ConfigFiles/invalid-config.json) to a [value object](https://github.com/eliashaeussler/version-bumper/blob/0b0c1b04d1397a3bd6684208d13942359246b418/src/Config/VersionBumperConfig.php) which should throw a `MappingError`. This worked fine until valinor 1.14.2. Starting with v1.14.3, no error is thrown anymore.

Thanks again for all your efforts! ❤ 

Pull request:
fix: properly handle superfluous keys when source is an iterable
Fixes #612

Patch:
diff --git a/src/Mapper/Object/ArgumentsValues.php b/src/Mapper/Object/ArgumentsValues.php
index 6742482..d75a0ea 100644
--- a/src/Mapper/Object/ArgumentsValues.php
+++ b/src/Mapper/Object/ArgumentsValues.php
@@ -13,8 +13,6 @@ use Traversable;
 use function array_key_exists;
 use function count;
 use function is_array;
-use function is_iterable;
-use function iterator_to_array;
 
 /**
  * @internal
@@ -83,10 +81,6 @@ final class ArgumentsValues implements IteratorAggregate
     {
         $value = $shell->value();
 
-        if (is_iterable($value) && ! is_array($value)) {
-            $value = iterator_to_array($value);
-        }
-
         $transformedValue = $this->transformValueForSingleArgument($value, $shell->allowSuperfluousKeys());
 
         if (! is_array($transformedValue)) {
diff --git a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
index 88310e8..cb0484e 100644
--- a/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
+++ b/src/Mapper/Tree/Builder/InterfaceNodeBuilder.php
@@ -50,6 +50,8 @@ final class InterfaceNodeBuilder implements NodeBuilder
 
         if ($shell->enableFlexibleCasting() && $shell->value() === null) {
             $shell = $shell->withValue([]);
+        } else {
+            $shell = $shell->transformIteratorToArray();
         }
 
         $className = $type->className();
diff --git a/src/Mapper/Tree/Builder/ObjectNodeBuilder.php b/src/Mapper/Tree/Builder/ObjectNodeBuilder.php
index bd4db46..e154cb7 100644
--- a/src/Mapper/Tree/Builder/ObjectNodeBuilder.php
+++ b/src/Mapper/Tree/Builder/ObjectNodeBuilder.php
@@ -43,6 +43,8 @@ final class ObjectNodeBuilder implements NodeBuilder
 
         if ($shell->enableFlexibleCasting() && $shell->value() === null) {
             $shell = $shell->withValue([]);
+        } else {
+            $shell = $shell->transformIteratorToArray();
         }
 
         $class = $this->classDefinitionRepository->for($type);
diff --git a/src/Mapper/Tree/Shell.php b/src/Mapper/Tree/Shell.php
index aa79349..0438522 100644
--- a/src/Mapper/Tree/Shell.php
+++ b/src/Mapper/Tree/Shell.php
@@ -13,6 +13,8 @@ use CuyZ\Valinor\Type\Types\UnresolvableType;
 
 use function assert;
 use function implode;
+use function is_array;
+use function is_iterable;
 
 /** @internal */
 final class Shell
@@ -155,6 +157,18 @@ final class Shell
         return $this->allowedSuperfluousKeys;
     }
 
+    public function transformIteratorToArray(): self
+    {
+        if (is_iterable($this->value) && ! is_array($this->value)) {
+            $self = clone $this;
+            $self->value = iterator_to_array($this->value);
+
+            return $self;
+        }
+
+        return $this;
+    }
+
     public function path(): string
     {
         if ($this->isRoot()) {


Test patch:
diff --git a/tests/Integration/Mapping/InterfaceInferringMappingTest.php b/tests/Integration/Mapping/InterfaceInferringMappingTest.php
index 7d1b8b2..ccfd53c 100644
--- a/tests/Integration/Mapping/InterfaceInferringMappingTest.php
+++ b/tests/Integration/Mapping/InterfaceInferringMappingTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace CuyZ\Valinor\Tests\Integration\Mapping;
 
 use CuyZ\Valinor\Mapper\MappingError;
+use CuyZ\Valinor\Mapper\Source\Source;
 use CuyZ\Valinor\Mapper\Tree\Exception\CannotResolveObjectType;
 use CuyZ\Valinor\Mapper\Tree\Exception\InvalidResolvedImplementationValue;
 use CuyZ\Valinor\Mapper\Tree\Exception\MissingObjectImplementationRegistration;
@@ -65,6 +66,27 @@ final class InterfaceInferringMappingTest extends IntegrationTestCase
         self::assertSame('fooB', $resultB->valueB);
     }
 
+    public function test_infer_interface_with_iterable_but_not_array_source(): void
+    {
+        try {
+            $result = $this->mapperBuilder()
+                ->infer(
+                    SomeInterface::class,
+                    /** @return class-string<SomeClassThatInheritsInterfaceA> */
+                    fn (string $foo): string => SomeClassThatInheritsInterfaceA::class
+                )
+                ->mapper()
+                ->map(SomeInterface::class, Source::iterable([
+                    'foo' => 'bar',
+                    'valueA' => 'value',
+                ]));
+        } catch (MappingError $error) {
+            $this->mappingFail($error);
+        }
+
+        self::assertInstanceOf(SomeClassThatInheritsInterfaceA::class, $result);
+    }
+
     public function test_infer_interface_with_union_of_class_string_works_properly(): void
     {
         try {
diff --git a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php
index badfeca..b08ea80 100644
--- a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php
+++ b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace CuyZ\Valinor\Tests\Integration\Mapping\Object;
 
 use CuyZ\Valinor\Mapper\MappingError;
+use CuyZ\Valinor\Mapper\Source\Source;
 use CuyZ\Valinor\Tests\Integration\IntegrationTestCase;
 use CuyZ\Valinor\Tests\Integration\Mapping\Fixture\SimpleObject;
 use stdClass;
@@ -62,6 +63,22 @@ final class ObjectValuesMappingTest extends IntegrationTestCase
         }
     }
 
+    public function test_superfluous_values_throws_exception_when_source_is_iterable_but_not_array(): void
+    {
+        try {
+            $this->mapperBuilder()->mapper()->map(ObjectWithTwoProperties::class, Source::iterable([
+                'stringA' => 'fooA',
+                'stringB' => 'fooB',
+                'unexpectedValue' => 'foo',
+                42 => 'baz',
+            ]));
+        } catch (MappingError $exception) {
+            self::assertMappingErrors($exception, [
+                '*root*' => "[1655117782] Unexpected key(s) `unexpectedValue`, `42`, expected `stringA`, `stringB`.",
+            ]);
+        }
+    }
+
     public function test_object_with_no_argument_build_with_non_array_source_throws_exception(): void
     {
         try {
diff --git a/tests/Unit/Mapper/Tree/ShellTest.php b/tests/Unit/Mapper/Tree/ShellTest.php
index 99e69f4..4202585 100644
--- a/tests/Unit/Mapper/Tree/ShellTest.php
+++ b/tests/Unit/Mapper/Tree/ShellTest.php
@@ -87,4 +87,18 @@ final class ShellTest extends TestCase
         self::assertSame($value, $child->value());
         self::assertSame($attributes, $child->attributes());
     }
+
+    public function test_can_transform_iterator_to_array(): void
+    {
+        $value = (function () {
+            yield 'foo' => 'foo';
+            yield 'bar' => 'bar';
+        })();
+
+        $shellA = Shell::root(new Settings(), new FakeType(), $value);
+        $shellB = $shellA->transformIteratorToArray();
+
+        self::assertNotSame($shellA, $shellB);
+        self::assertSame(['foo' => 'foo', 'bar' => 'bar'], $shellB->value());
+    }
 }