Skip to content

Commit f4cb9a2

Browse files
committed
feat(mapper): support unions
1 parent 22cfe96 commit f4cb9a2

15 files changed

+304
-6
lines changed

packages/mapper/src/CasterFactory.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Closure;
88
use Tempest\Mapper\Casters\DtoCaster;
99
use Tempest\Reflection\PropertyReflector;
10+
use Tempest\Reflection\TypeReflector;
1011

1112
use function Tempest\get;
1213

@@ -64,4 +65,15 @@ public function forProperty(PropertyReflector $property): ?Caster
6465

6566
return null;
6667
}
68+
69+
public function forType(TypeReflector $type): ?Caster
70+
{
71+
foreach ($this->casters as [$for, $casterClass]) {
72+
if (is_string($for) && $type->matches($for) && ! \is_callable($for)) {
73+
return get($casterClass);
74+
}
75+
}
76+
77+
return null;
78+
}
6779
}

packages/mapper/src/CasterFactoryInitializer.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
use Tempest\Mapper\Casters\JsonToArrayCaster;
2222
use Tempest\Mapper\Casters\NativeDateTimeCaster;
2323
use Tempest\Mapper\Casters\ObjectCaster;
24+
use Tempest\Mapper\Casters\StringCaster;
25+
use Tempest\Mapper\Casters\UnionCaster;
2426
use Tempest\Reflection\PropertyReflector;
2527
use UnitEnum;
2628

@@ -29,21 +31,30 @@ final class CasterFactoryInitializer implements Initializer
2931
#[Singleton]
3032
public function initialize(Container $container): CasterFactory
3133
{
32-
return new CasterFactory()
34+
$casterFactory = new CasterFactory();
35+
36+
$casterFactory
3337
->addCaster('array', JsonToArrayCaster::class)
3438
->addCaster('bool', BooleanCaster::class)
3539
->addCaster('boolean', BooleanCaster::class)
3640
->addCaster('int', IntegerCaster::class)
3741
->addCaster('integer', IntegerCaster::class)
3842
->addCaster('float', FloatCaster::class)
3943
->addCaster('double', FloatCaster::class)
40-
->addCaster(fn (PropertyReflector $property) => $property->getIterableType() !== null, fn (PropertyReflector $property) => new ArrayToObjectCollectionCaster($property))
44+
->addCaster('string', StringCaster::class)
45+
->addCaster(
46+
fn (PropertyReflector $property) => $property->getIterableType() !== null,
47+
fn (PropertyReflector $property) => new ArrayToObjectCollectionCaster($property, $casterFactory),
48+
)
4149
->addCaster(fn (PropertyReflector $property) => $property->getType()->isClass(), fn (PropertyReflector $property) => new ObjectCaster($property->getType()))
50+
->addCaster(fn (PropertyReflector $property) => $property->getType()->isUnion(), fn (PropertyReflector $property) => new UnionCaster($property))
4251
->addCaster(UnitEnum::class, fn (PropertyReflector $property) => new EnumCaster($property->getType()->getName()))
4352
->addCaster(DateTimeInterface::class, DateTimeCaster::fromProperty(...))
4453
->addCaster(NativeDateTimeImmutable::class, NativeDateTimeCaster::fromProperty(...))
4554
->addCaster(NativeDateTime::class, NativeDateTimeCaster::fromProperty(...))
4655
->addCaster(NativeDateTimeInterface::class, NativeDateTimeCaster::fromProperty(...))
4756
->addCaster(DateTime::class, DateTimeCaster::fromProperty(...));
57+
58+
return $casterFactory;
4859
}
4960
}

packages/mapper/src/Casters/ArrayToObjectCollectionCaster.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,27 @@
55
namespace Tempest\Mapper\Casters;
66

77
use Tempest\Mapper\Caster;
8+
use Tempest\Mapper\CasterFactory;
89
use Tempest\Reflection\PropertyReflector;
910
use Tempest\Support\Json;
1011

1112
final readonly class ArrayToObjectCollectionCaster implements Caster
1213
{
1314
public function __construct(
1415
private PropertyReflector $property,
16+
private CasterFactory $casterFactory,
1517
) {}
1618

1719
public function cast(mixed $input): mixed
1820
{
1921
$values = [];
2022
$iterableType = $this->property->getIterableType();
2123

22-
$caster = $iterableType->isEnum()
23-
? new EnumCaster($iterableType->getName())
24-
: new ObjectCaster($iterableType);
24+
$caster = match (true) {
25+
$iterableType->isEnum() => new EnumCaster($iterableType->getName()),
26+
$iterableType->isBuiltIn() => $this->casterFactory->forType($iterableType),
27+
default => new ObjectCaster($iterableType),
28+
};
2529

2630
if (Json\is_valid($input)) {
2731
$input = Json\decode($input);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Mapper\Casters;
6+
7+
use Tempest\Mapper\Caster;
8+
9+
class StringCaster implements Caster
10+
{
11+
public function cast(mixed $input): string
12+
{
13+
return (string) $input;
14+
}
15+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Mapper\Casters;
6+
7+
use Tempest\Mapper\Caster;
8+
use Tempest\Reflection\PropertyReflector;
9+
10+
use function Tempest\map;
11+
12+
class UnionCaster implements Caster
13+
{
14+
public function __construct(
15+
private PropertyReflector $property,
16+
) {}
17+
18+
public function cast(mixed $input): mixed
19+
{
20+
$propertyType = $this->property->getDocType() ?? $this->property->getType();
21+
22+
// for native types that already match, return early
23+
foreach ($propertyType->split() as $type) {
24+
if ($type->accepts($input)) {
25+
return $input;
26+
}
27+
}
28+
29+
$lastException = null;
30+
31+
// as last resort, try to map to any of the union types
32+
foreach ($propertyType->split() as $type) {
33+
try {
34+
return map($input)->to($type->getName());
35+
} catch (\Throwable $e) {
36+
$lastException = $e;
37+
}
38+
}
39+
40+
throw $lastException;
41+
}
42+
}

packages/reflection/src/PropertyReflector.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,23 @@ public function getIterableType(): ?TypeReflector
107107
return new TypeReflector(ltrim($match[1], '\\'));
108108
}
109109

110+
public function getDocType(): ?TypeReflector
111+
{
112+
$doc = $this->reflectionProperty->getDocComment();
113+
114+
if (! $doc) {
115+
return null;
116+
}
117+
118+
preg_match('/@var ([^\s]+)/', $doc, $match);
119+
120+
if (! isset($match[1])) {
121+
return null;
122+
}
123+
124+
return new TypeReflector($match[1]);
125+
}
126+
110127
public function isUninitialized(object $object): bool
111128
{
112129
return ! $this->reflectionProperty->isInitialized($object);

packages/reflection/src/TypeReflector.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,19 +110,41 @@ public function accepts(mixed $input): bool
110110
return is_iterable($input);
111111
}
112112

113-
if (str_contains($this->definition, '|')) {
113+
if ($this->isUnion()) {
114114
return array_any($this->split(), static fn ($type) => $type->accepts($input));
115115
}
116116

117117
if (str_contains($this->definition, '&')) {
118118
return array_all($this->split(), static fn ($type) => $type->accepts($input));
119119
}
120120

121+
// non-native array types, e.g. string[] or Type[]
122+
if (\str_contains($this->definition, '[]')) {
123+
if (! \is_iterable($input)) {
124+
return false;
125+
}
126+
127+
$typeName = str_replace('[]', '', $this->definition);
128+
$itemType = new self($typeName);
129+
130+
foreach ($input as $item) {
131+
if (! $itemType->accepts($item)) {
132+
return false;
133+
}
134+
}
135+
136+
return true;
137+
}
138+
121139
return false;
122140
}
123141

124142
public function matches(string $className): bool
125143
{
144+
if ($this->isBuiltIn()) {
145+
return $this->cleanDefinition === $className;
146+
}
147+
126148
return is_a($this->cleanDefinition, $className, true);
127149
}
128150

@@ -168,6 +190,11 @@ public function isBackedEnum(): bool
168190
return $this->matches(BackedEnum::class);
169191
}
170192

193+
public function isUnion(): bool
194+
{
195+
return str_contains($this->definition, '|');
196+
}
197+
171198
// TODO: should be refactored outside of the reflector component
172199
public function isRelation(): bool
173200
{
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Reflection\Tests\Fixtures;
6+
7+
class ClassWithIterableProperty
8+
{
9+
/**
10+
* @var string[]
11+
*/
12+
public array $items;
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Reflection\Tests\Fixtures;
6+
7+
class ClassWithUnionOfStringAndArray
8+
{
9+
/**
10+
* @var string|string[]|null
11+
*/
12+
public string|array|null $items;
13+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Tempest\Reflection\Tests;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Tempest\Reflection\ClassReflector;
7+
use Tempest\Reflection\Tests\Fixtures\ClassWithIterableProperty;
8+
use Tempest\Reflection\Tests\Fixtures\ClassWithUnionOfStringAndArray;
9+
10+
final class PropertyReflectorTest extends TestCase
11+
{
12+
public function test_get_iterable_type(): void
13+
{
14+
$reflector = new ClassReflector(ClassWithIterableProperty::class)->getProperty('items');
15+
16+
$iterableType = $reflector->getIterableType();
17+
18+
$this->assertEquals('string', $iterableType->getName());
19+
}
20+
21+
public function test_get_union_of_string_and_array(): void
22+
{
23+
$reflector = new ClassReflector(ClassWithUnionOfStringAndArray::class)->getProperty('items');
24+
25+
$unionType = $reflector->getDocType();
26+
27+
$this->assertTrue($unionType->isUnion());
28+
$this->assertCount(3, $unionType->split());
29+
$this->assertTrue($unionType->accepts('a'));
30+
$this->assertTrue($unionType->accepts(['a', 'b', 'c']));
31+
$this->assertTrue($unionType->accepts([]));
32+
$this->assertTrue($unionType->accepts(null));
33+
34+
$this->assertFalse($unionType->accepts(123));
35+
$this->assertFalse($unionType->accepts([123]));
36+
$this->assertFalse($unionType->accepts([null]));
37+
}
38+
}

0 commit comments

Comments
 (0)