Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/mapper/src/CasterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Closure;
use Tempest\Mapper\Casters\DtoCaster;
use Tempest\Reflection\PropertyReflector;
use Tempest\Reflection\TypeReflector;

use function Tempest\get;

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

return null;
}

public function forType(TypeReflector $type): ?Caster
{
foreach ($this->casters as [$for, $casterClass]) {
if (is_string($for) && $type->matches($for) && is_string($casterClass)) {
return get($casterClass);
}
}

return null;
}
}
15 changes: 13 additions & 2 deletions packages/mapper/src/CasterFactoryInitializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
use Tempest\Mapper\Casters\JsonToArrayCaster;
use Tempest\Mapper\Casters\NativeDateTimeCaster;
use Tempest\Mapper\Casters\ObjectCaster;
use Tempest\Mapper\Casters\StringCaster;
use Tempest\Mapper\Casters\UnionCaster;
use Tempest\Reflection\PropertyReflector;
use UnitEnum;

Expand All @@ -29,21 +31,30 @@ final class CasterFactoryInitializer implements Initializer
#[Singleton]
public function initialize(Container $container): CasterFactory
{
return new CasterFactory()
$casterFactory = new CasterFactory();

$casterFactory
->addCaster('array', JsonToArrayCaster::class)
->addCaster('bool', BooleanCaster::class)
->addCaster('boolean', BooleanCaster::class)
->addCaster('int', IntegerCaster::class)
->addCaster('integer', IntegerCaster::class)
->addCaster('float', FloatCaster::class)
->addCaster('double', FloatCaster::class)
->addCaster(fn (PropertyReflector $property) => $property->getIterableType() !== null, fn (PropertyReflector $property) => new ArrayToObjectCollectionCaster($property))
->addCaster('string', StringCaster::class)
->addCaster(
fn (PropertyReflector $property) => $property->getIterableType() !== null,
fn (PropertyReflector $property) => new ArrayToObjectCollectionCaster($property, $casterFactory),
)
->addCaster(fn (PropertyReflector $property) => $property->getType()->isClass(), fn (PropertyReflector $property) => new ObjectCaster($property->getType()))
->addCaster(fn (PropertyReflector $property) => $property->getType()->isUnion(), fn (PropertyReflector $property) => new UnionCaster($property))
->addCaster(UnitEnum::class, fn (PropertyReflector $property) => new EnumCaster($property->getType()->getName()))
->addCaster(DateTimeInterface::class, DateTimeCaster::fromProperty(...))
->addCaster(NativeDateTimeImmutable::class, NativeDateTimeCaster::fromProperty(...))
->addCaster(NativeDateTime::class, NativeDateTimeCaster::fromProperty(...))
->addCaster(NativeDateTimeInterface::class, NativeDateTimeCaster::fromProperty(...))
->addCaster(DateTime::class, DateTimeCaster::fromProperty(...));

return $casterFactory;
}
}
11 changes: 8 additions & 3 deletions packages/mapper/src/Casters/ArrayToObjectCollectionCaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,28 @@
namespace Tempest\Mapper\Casters;

use Tempest\Mapper\Caster;
use Tempest\Mapper\CasterFactory;
use Tempest\Reflection\PropertyReflector;
use Tempest\Support\Json;

final readonly class ArrayToObjectCollectionCaster implements Caster
{
public function __construct(
private PropertyReflector $property,
private CasterFactory $casterFactory,
) {}

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

$caster = $iterableType->isEnum()
? new EnumCaster($iterableType->getName())
: new ObjectCaster($iterableType);
$caster = match (true) {
$iterableType->isEnum() => new EnumCaster($iterableType->getName()),
$iterableType->getName() === 'mixed' => new MixedCaster(),
$iterableType->isBuiltIn() => $this->casterFactory->forType($iterableType),
default => new ObjectCaster($iterableType),
};

if (Json\is_valid($input)) {
$input = Json\decode($input);
Expand Down
15 changes: 15 additions & 0 deletions packages/mapper/src/Casters/MixedCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Mapper\Casters;

use Tempest\Mapper\Caster;

class MixedCaster implements Caster
{
public function cast(mixed $input): mixed
{
return $input;
}
}
15 changes: 15 additions & 0 deletions packages/mapper/src/Casters/StringCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Mapper\Casters;

use Tempest\Mapper\Caster;

class StringCaster implements Caster
{
public function cast(mixed $input): string
{
return (string) $input;
}
}
42 changes: 42 additions & 0 deletions packages/mapper/src/Casters/UnionCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace Tempest\Mapper\Casters;

use Tempest\Mapper\Caster;
use Tempest\Reflection\PropertyReflector;

use function Tempest\map;

class UnionCaster implements Caster
{
public function __construct(
private PropertyReflector $property,
) {}

public function cast(mixed $input): mixed
{
$propertyType = $this->property->getDocType() ?? $this->property->getType();

// for native types that already match, return early
foreach ($propertyType->split() as $type) {
if ($type->accepts($input)) {
return $input;
}
}

$lastException = null;

// as last resort, try to map to any of the union types
foreach ($propertyType->split() as $type) {
try {
return map($input)->to($type->getName());
} catch (\Throwable $e) {
$lastException = $e;
}
}

throw $lastException;
}
}
17 changes: 17 additions & 0 deletions packages/reflection/src/PropertyReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ public function getIterableType(): ?TypeReflector
return new TypeReflector(ltrim($match[1], '\\'));
}

public function getDocType(): ?TypeReflector
{
$doc = $this->reflectionProperty->getDocComment();

if (! $doc) {
return null;
}

preg_match('/@var ([^\s]+)/', $doc, $match);

if (! isset($match[1])) {
return null;
}

return new TypeReflector($match[1]);
}

public function isUninitialized(object $object): bool
{
return ! $this->reflectionProperty->isInitialized($object);
Expand Down
34 changes: 32 additions & 2 deletions packages/reflection/src/TypeReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ public function accepts(mixed $input): bool
return true;
}

if ($this->cleanDefinition === 'mixed') {
return true;
}

if ($this->isBuiltIn()) {
return match ($this->cleanDefinition) {
'false' => $input === false,
'mixed' => true,
'never' => false,
'true' => $input === true,
'void' => false,
Expand All @@ -110,19 +113,41 @@ public function accepts(mixed $input): bool
return is_iterable($input);
}

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

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

// non-native array types, e.g. string[] or Type[]
if (\str_contains($this->definition, '[]')) {
if (! \is_iterable($input)) {
return false;
}

$typeName = str_replace('[]', '', $this->definition);
$itemType = new self($typeName);

foreach ($input as $item) {
if (! $itemType->accepts($item)) {
return false;
}
}

return true;
}

return false;
}

public function matches(string $className): bool
{
if ($this->isBuiltIn()) {
return $this->cleanDefinition === $className;
}

return is_a($this->cleanDefinition, $className, true);
}

Expand Down Expand Up @@ -168,6 +193,11 @@ public function isBackedEnum(): bool
return $this->matches(BackedEnum::class);
}

public function isUnion(): bool
{
return str_contains($this->definition, '|');
}

// TODO: should be refactored outside of the reflector component
public function isRelation(): bool
{
Expand Down
13 changes: 13 additions & 0 deletions packages/reflection/tests/Fixtures/ClassWithIterableProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Tempest\Reflection\Tests\Fixtures;

class ClassWithIterableProperty
{
/**
* @var string[]
*/
public array $items;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Tempest\Reflection\Tests\Fixtures;

class ClassWithUnionOfStringAndArray
{
/**
* @var string|string[]|null
*/
public string|array|null $items;
}
38 changes: 38 additions & 0 deletions packages/reflection/tests/PropertyReflectorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Tempest\Reflection\Tests;

use PHPUnit\Framework\TestCase;
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\Tests\Fixtures\ClassWithIterableProperty;
use Tempest\Reflection\Tests\Fixtures\ClassWithUnionOfStringAndArray;

final class PropertyReflectorTest extends TestCase
{
public function test_get_iterable_type(): void
{
$reflector = new ClassReflector(ClassWithIterableProperty::class)->getProperty('items');

$iterableType = $reflector->getIterableType();

$this->assertEquals('string', $iterableType->getName());
}

public function test_get_union_of_string_and_array(): void
{
$reflector = new ClassReflector(ClassWithUnionOfStringAndArray::class)->getProperty('items');

$unionType = $reflector->getDocType();

$this->assertTrue($unionType->isUnion());
$this->assertCount(3, $unionType->split());
$this->assertTrue($unionType->accepts('a'));
$this->assertTrue($unionType->accepts(['a', 'b', 'c']));
$this->assertTrue($unionType->accepts([]));
$this->assertTrue($unionType->accepts(null));

$this->assertFalse($unionType->accepts(123));
$this->assertFalse($unionType->accepts([123]));
$this->assertFalse($unionType->accepts([null]));
}
}
8 changes: 8 additions & 0 deletions packages/reflection/tests/TypeReflectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PHPUnit\Framework\TestCase;
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\Tests\Fixtures\TestClassA;
use Tempest\Reflection\TypeReflector;

final class TypeReflectorTest extends TestCase
{
Expand Down Expand Up @@ -90,4 +91,11 @@ public function test_is_enum(): void
->isUnitEnum(),
);
}

public function test_string_type_matches_string(): void
{
$this->assertTrue(
new TypeReflector('string')->matches('string'),
);
}
}
11 changes: 11 additions & 0 deletions tests/Integration/Mapper/Fixtures/ObjectWithMixedArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\Mapper\Fixtures;

class ObjectWithMixedArray
{
/** @var mixed[] */
public array $items;
}
11 changes: 11 additions & 0 deletions tests/Integration/Mapper/Fixtures/ObjectWithStringOrMixedArray.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\Mapper\Fixtures;

class ObjectWithStringOrMixedArray
{
/** @var string|mixed[] */
public string|array $items;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\Mapper\Fixtures;

class ObjectWithStringOrObjectUnion
{
public string|ObjectA $item;
}
Loading