Repository: spatie/data-transfer-object (php)

Issue:
Union type support
On caster class instantiation there's no check if object property has union type:
https://github.com/spatie/data-transfer-object/blob/d101e5185844eb92bf5fb8d0d1a200f1778e9802/src/Reflection/DataTransferObjectProperty.php#L85-L88
In that case it fails because ReflectionUnionType doesn't have getName() method.

Pull request description:
Support union type in casters
Closes #209 

Patch:
diff --git a/.gitignore b/.gitignore
index 1bb8f39..f6fc932 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,6 @@ composer.lock
 docs
 vendor
 coverage
+.idea
 .php-cs-fixer.cache
 .phpunit.result.cache
diff --git a/README.md b/README.md
index adaf8ed..6c99ed6 100644
--- a/README.md
+++ b/README.md
@@ -192,7 +192,7 @@ For example:
     public array $collectionWithoutNamedArguments;
 ```
 
-Note that the first argument passed to the caster constructor is always the type of the value being casted.
+Note that the first argument passed to the caster constructor is always the array with type(s) of the value being casted.
 All other arguments will be the ones passed as extra arguments in the `CastWith` attribute.
 
 ## Validation
diff --git a/src/Casters/ArrayCaster.php b/src/Casters/ArrayCaster.php
index 8b00c2a..1ec18e2 100644
--- a/src/Casters/ArrayCaster.php
+++ b/src/Casters/ArrayCaster.php
@@ -10,25 +10,27 @@ use Traversable;
 class ArrayCaster implements Caster
 {
     public function __construct(
-        private string $type,
+        private array $types,
         private string $itemType,
     ) {
     }
 
     public function cast(mixed $value): array | ArrayAccess
     {
-        if ($this->type == 'array') {
-            return $this->mapInto(
-                destination: [],
-                items: $value
-            );
-        }
+        foreach ($this->types as $type) {
+            if ($type == 'array') {
+                return $this->mapInto(
+                    destination: [],
+                    items: $value
+                );
+            }
 
-        if (is_subclass_of($this->type, ArrayAccess::class)) {
-            return $this->mapInto(
-                destination: new $this->type(),
-                items: $value
-            );
+            if (is_subclass_of($type, ArrayAccess::class)) {
+                return $this->mapInto(
+                    destination: new $type(),
+                    items: $value
+                );
+            }
         }
 
         throw new LogicException(
diff --git a/src/Casters/DataTransferObjectCaster.php b/src/Casters/DataTransferObjectCaster.php
index f4504cd..4f1913e 100644
--- a/src/Casters/DataTransferObjectCaster.php
+++ b/src/Casters/DataTransferObjectCaster.php
@@ -8,16 +8,18 @@ use Spatie\DataTransferObject\DataTransferObject;
 class DataTransferObjectCaster implements Caster
 {
     public function __construct(
-        private string $className
+        private array $classNames
     ) {
     }
 
     public function cast(mixed $value): DataTransferObject
     {
-        if ($value instanceof $this->className) {
-            return $value;
+        foreach ($this->classNames as $className) {
+            if ($value instanceof $className) {
+                return $value;
+            }
         }
 
-        return new $this->className(...$value);
+        return new $this->classNames[0](...$value);
     }
 }
diff --git a/src/Reflection/DataTransferObjectProperty.php b/src/Reflection/DataTransferObjectProperty.php
index 0d456f8..312cfd8 100644
--- a/src/Reflection/DataTransferObjectProperty.php
+++ b/src/Reflection/DataTransferObjectProperty.php
@@ -83,26 +83,14 @@ class DataTransferObjectProperty
         $attribute = $attributes[0]->newInstance();
 
         return new $attribute->casterClass(
-            $this->reflectionProperty->getType()?->getName(),
-            ...$attribute->args,
+            array_map(fn ($type) => $type->getName(), $this->extractTypes()),
+            ...$attribute->args
         );
     }
 
     private function resolveCasterFromType(): array
     {
-        $type = $this->reflectionProperty->getType();
-
-        if (! $type) {
-            return [];
-        }
-
-        /** @var ReflectionNamedType[]|null $types */
-        $types = match ($type::class) {
-            ReflectionNamedType::class => [$type],
-            ReflectionUnionType::class => $type->getTypes(),
-        };
-
-        foreach ($types as $type) {
+        foreach ($this->extractTypes() as $type) {
             if (! class_exists($type->getName())) {
                 continue;
             }
@@ -150,4 +138,21 @@ class DataTransferObjectProperty
 
         return null;
     }
+
+    /**
+     * @return ReflectionNamedType[]
+     */
+    private function extractTypes(): array
+    {
+        $type = $this->reflectionProperty->getType();
+
+        if (! $type) {
+            return [];
+        }
+
+        return match ($type::class) {
+            ReflectionNamedType::class => [$type],
+            ReflectionUnionType::class => $type->getTypes(),
+        };
+    }
 }


Test patch:
diff --git a/tests/Dummy/RoundingCaster.php b/tests/Dummy/RoundingCaster.php
new file mode 100644
index 0000000..80bf0ae
--- /dev/null
+++ b/tests/Dummy/RoundingCaster.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Spatie\DataTransferObject\Tests\Dummy;
+
+use Spatie\DataTransferObject\Caster;
+
+class RoundingCaster implements Caster
+{
+    public function cast(mixed $value): float | int
+    {
+        return is_int($value) ? $value : round($value, 2);
+    }
+}
diff --git a/tests/UnionDtoTest.php b/tests/UnionDtoTest.php
index 9b81877..3485676 100644
--- a/tests/UnionDtoTest.php
+++ b/tests/UnionDtoTest.php
@@ -2,7 +2,9 @@
 
 namespace Spatie\DataTransferObject\Tests;
 
+use Spatie\DataTransferObject\Attributes\CastWith;
 use Spatie\DataTransferObject\DataTransferObject;
+use Spatie\DataTransferObject\Tests\Dummy\RoundingCaster;
 
 class UnionDtoTest extends TestCase
 {
@@ -13,9 +15,69 @@ class UnionDtoTest extends TestCase
 
         $this->assertEquals(1, $dto->foo);
     }
+
+    /** @test */
+    public function union_types_rounding_float()
+    {
+        $dto = new UnionDtoWithCast(bar: 123.456);
+
+        $this->assertEquals(123.46, $dto->bar);
+    }
+
+    /** @test */
+    public function union_types_rounding_integer()
+    {
+        $dto = new UnionDtoWithCast(bar: 123);
+
+        $this->assertIsInt($dto->bar);
+        $this->assertEquals(123, $dto->bar);
+    }
+
+    /** @test */
+    public function complex_union_types_fallback()
+    {
+        $dto = new ComplexUnionDto([
+            'baz' => ['value' => 3],
+        ]);
+
+        $this->assertInstanceOf(Dto1::class, $dto->baz);
+        $this->assertEquals(3, $dto->baz->value);
+    }
+
+    /** @test */
+    public function complex_union_types_force()
+    {
+        $dto = new ComplexUnionDto(
+            baz: new Dto2(value: 3),
+        );
+
+        $this->assertInstanceOf(Dto2::class, $dto->baz);
+        $this->assertEquals(3, $dto->baz->value);
+    }
 }
 
 class UnionDto extends DataTransferObject
 {
     public string | int $foo;
 }
+
+class UnionDtoWithCast extends DataTransferObject
+{
+    #[CastWith(RoundingCaster::class)]
+    public float | int $bar;
+}
+
+class ComplexUnionDto extends DataTransferObject
+{
+    public Dto1 | Dto2 $baz;
+}
+
+class Dto1 extends DataTransferObject
+{
+    public int $value = 1;
+}
+
+class Dto2 extends DataTransferObject
+{
+    public int $value = 2;
+}