Skip to content

Commit

Permalink
Add recursive attribute handling and object casting
Browse files Browse the repository at this point in the history
  • Loading branch information
brendt committed Jan 16, 2025
1 parent f880072 commit 04a843b
Show file tree
Hide file tree
Showing 19 changed files with 247 additions and 11 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"illuminate/view": "~11.7.0",
"jenssegers/blade": "^2.0",
"mikey179/vfsstream": "^2.0@dev",
"nesbot/carbon": "^3.8",
"nyholm/psr7": "^1.8",
"phpat/phpat": "^0.11.0",
"phpbench/phpbench": "84.x-dev",
Expand Down
2 changes: 1 addition & 1 deletion src/Tempest/Core/src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,6 @@ function env(string $key, mixed $default = null): mixed
*/
function defer(Closure $closure): void
{
get(DeferredTasks::class)->add($closure);
get(DeferredTasks::class)->add($closure);
}
}
6 changes: 6 additions & 0 deletions src/Tempest/Database/src/Builder/FieldName.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Tempest\Database\Builder;

use Stringable;
use Tempest\Database\DatabaseModel;
use Tempest\Mapper\Casters\CasterFactory;
use Tempest\Reflection\ClassReflector;

Expand All @@ -25,6 +26,11 @@ public static function make(ClassReflector $class, ?TableName $tableName = null)
$tableName ??= $class->callStatic('table');

foreach ($class->getPublicProperties() as $property) {
// Don't include the field if it's a relation
if ($property->getType()->matches(DatabaseModel::class)) {
continue;
}

$caster = $casterFactory->forProperty($property);

if ($caster !== null) {
Expand Down
15 changes: 15 additions & 0 deletions src/Tempest/Database/src/Casters/RelationCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Tempest\Database\Casters;

use Tempest\Mapper\Caster;

final class RelationCaster implements Caster
{
public function cast(mixed $input): mixed
{
return $input;
}
}
3 changes: 3 additions & 0 deletions src/Tempest/Database/src/DatabaseModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

use Tempest\Database\Builder\ModelQueryBuilder;
use Tempest\Database\Builder\TableName;
use Tempest\Database\Casters\RelationCaster;
use Tempest\Mapper\CastWith;

#[CastWith(RelationCaster::class)]
interface DatabaseModel
{
public static function table(): TableName;
Expand Down
5 changes: 4 additions & 1 deletion src/Tempest/Database/src/Mappers/QueryToModelMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Tempest\Database\Mappers;

use Tempest\Database\Casters\RelationCaster;
use Tempest\Database\DatabaseModel;
use Tempest\Database\Query;
use Tempest\Mapper\Casters\CasterFactory;
Expand Down Expand Up @@ -122,7 +123,9 @@ private function parse(ClassReflector $class, DatabaseModel $model, array $row):

private function parseProperty(PropertyReflector $property, DatabaseModel $model, mixed $value): DatabaseModel
{
if ($value && ($caster = $this->casterFactory->forProperty($property)) !== null) {
$caster = $this->casterFactory->forProperty($property);

if ($value && $caster !== null && ! $caster instanceof RelationCaster) {
$value = $caster->cast($value);
}

Expand Down
28 changes: 21 additions & 7 deletions src/Tempest/Mapper/src/Casters/CasterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,51 @@
{
public function forProperty(PropertyReflector $property): ?Caster
{
$type = $property->getType();

// Get CastWith from the property
$castWith = $property->getAttribute(CastWith::class);

$type = $property->getType();

// Get CastWith from the property's type
// Get CastWith from the property's type if there's no property-defined CastWith
if ($castWith === null) {
try {
$castWith = $type->asClass()->getAttribute(CastWith::class);
$castWith = $type->asClass()->getAttribute(CastWith::class, recursive: true);
} catch (ReflectionException) {
// Could not resolve CastWith from the type
}
}

// Return the caster if defined with CastWith
if ($castWith !== null) {
// Resolve the caster from the container
return get($castWith->className);
}

$typeName = $type->getName();

// Check if backed enum
if ($type->matches(BackedEnum::class)) {
return new EnumCaster($type->getName());
return new EnumCaster($typeName);
}

// Get Caster from built-in casters
return match ($type->getName()) {
// Try a built-in caster
$builtInCaster = match ($type->getName()) {
'int' => new IntegerCaster(),
'float' => new FloatCaster(),
'bool' => new BooleanCaster(),
DateTimeImmutable::class, DateTimeInterface::class, DateTime::class => DateTimeCaster::fromProperty($property),
default => null,
};

if ($builtInCaster !== null) {
return $builtInCaster;
}

// If the type's a class, we'll cast it with the generic object caster
if ($type->isClass()) {
return new ObjectCaster($type);
}

return null;
}
}
26 changes: 26 additions & 0 deletions src/Tempest/Mapper/src/Casters/ObjectCaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Tempest\Mapper\Casters;

use Tempest\Mapper\Caster;
use Tempest\Reflection\TypeReflector;
use Throwable;

final readonly class ObjectCaster implements Caster
{
public function __construct(
private TypeReflector $type,
) {
}

public function cast(mixed $input): mixed
{
try {
return $this->type->asClass()->newInstanceArgs([$input]);
} catch (Throwable) {
return $input;
}
}
}
17 changes: 17 additions & 0 deletions src/Tempest/Reflection/src/ClassReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ public function getReflection(): PHPReflectionClass
return $this->reflectionClass;
}

public function getParent(): ?ClassReflector
{
if ($parentClass = $this->reflectionClass->getParentClass()) {
return new ClassReflector($parentClass);
}

return null;
}

/** @return Generator<\Tempest\Reflection\TypeReflector> */
public function getInterfaces(): Generator
{
foreach ($this->reflectionClass->getInterfaces() as $interface) {
yield new TypeReflector($interface);
}
}

/** @return Generator<PropertyReflector> */
public function getPublicProperties(): Generator
{
Expand Down
24 changes: 22 additions & 2 deletions src/Tempest/Reflection/src/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,31 @@ public function hasAttribute(string $name): bool
* @param class-string<TAttributeClass> $attributeClass
* @return TAttributeClass|null
*/
public function getAttribute(string $attributeClass): object|null
public function getAttribute(string $attributeClass, bool $recursive = false): object|null
{
$attribute = $this->getReflection()->getAttributes($attributeClass, PHPReflectionAttribute::IS_INSTANCEOF)[0] ?? null;

return $attribute?->newInstance();
$attributeInstance = $attribute?->newInstance();

if (! $recursive) {
return $attributeInstance;
}

if ($this instanceof ClassReflector) {
foreach ($this->getInterfaces() as $interface) {
$attributeInstance = $interface->asClass()->getAttribute($attributeClass);

if ($attributeInstance !== null) {
break;
}
}

if ($attributeInstance === null && $parent = $this->getParent()) {
$attributeInstance = $parent->getAttribute($attributeClass, true);
}
}

return $attributeInstance;
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/Tempest/Reflection/tests/ClassReflectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\Tests\Fixtures\ChildWithRecursiveAttribute;
use Tempest\Reflection\Tests\Fixtures\ClassWithInterfaceWithRecursiveAttribute;
use Tempest\Reflection\Tests\Fixtures\RecursiveAttribute;
use Tempest\Reflection\Tests\Fixtures\TestClassA;
use Tempest\Reflection\Tests\Fixtures\TestClassB;

Expand Down Expand Up @@ -43,4 +46,18 @@ public function test_nullable_property_type(): void
$reflector = new ClassReflector(TestClassB::class);
$this->assertTrue($reflector->getProperty('name')->isNullable());
}

public function test_recursive_attribute_from_interface(): void
{
$reflector = new ClassReflector(ClassWithInterfaceWithRecursiveAttribute::class);
$this->assertNull($reflector->getAttribute(RecursiveAttribute::class));
$this->assertNotNull($reflector->getAttribute(RecursiveAttribute::class, recursive: true));
}

public function test_recursive_attribute_from_parent(): void
{
$reflector = new ClassReflector(ChildWithRecursiveAttribute::class);
$this->assertNull($reflector->getAttribute(RecursiveAttribute::class));
$this->assertNotNull($reflector->getAttribute(RecursiveAttribute::class, recursive: true));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Tempest\Reflection\Tests\Fixtures;

class ChildWithRecursiveAttribute extends ParentWithRecursiveAttribute

Check failure on line 7 in src/Tempest/Reflection/tests/Fixtures/ChildWithRecursiveAttribute.php

View workflow job for this annotation

GitHub Actions / Run static analysis: PHPStan

Tempest\Reflection\Tests\Fixtures\ChildWithRecursiveAttribute should be final
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Tempest\Reflection\Tests\Fixtures;

class ClassWithInterfaceWithRecursiveAttribute implements InterfaceWithRecursiveAttribute

Check failure on line 7 in src/Tempest/Reflection/tests/Fixtures/ClassWithInterfaceWithRecursiveAttribute.php

View workflow job for this annotation

GitHub Actions / Run static analysis: PHPStan

Tempest\Reflection\Tests\Fixtures\ClassWithInterfaceWithRecursiveAttribute should be final
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Tempest\Reflection\Tests\Fixtures;

#[RecursiveAttribute]
interface InterfaceWithRecursiveAttribute
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Tempest\Reflection\Tests\Fixtures;

#[RecursiveAttribute]
abstract class ParentWithRecursiveAttribute
{
}
12 changes: 12 additions & 0 deletions src/Tempest/Reflection/tests/Fixtures/RecursiveAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Tempest\Reflection\Tests\Fixtures;

use Attribute;

#[Attribute]
final class RecursiveAttribute
{
}
17 changes: 17 additions & 0 deletions tests/Integration/ORM/IsDatabaseModelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Tests\Tempest\Integration\ORM;

use Carbon\Carbon;
use Tempest\Database\Exceptions\MissingRelation;
use Tempest\Database\Exceptions\MissingValue;
use Tempest\Database\Id;
Expand All @@ -22,10 +23,12 @@
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
use Tests\Tempest\Integration\ORM\Migrations\CreateATable;
use Tests\Tempest\Integration\ORM\Migrations\CreateBTable;
use Tests\Tempest\Integration\ORM\Migrations\CreateCarbonModelTable;
use Tests\Tempest\Integration\ORM\Migrations\CreateCTable;
use Tests\Tempest\Integration\ORM\Migrations\CreateHasManyChildTable;
use Tests\Tempest\Integration\ORM\Migrations\CreateHasManyParentTable;
use Tests\Tempest\Integration\ORM\Migrations\CreateHasManyThroughTable;
use Tests\Tempest\Integration\ORM\Models\CarbonModel;
use Tests\Tempest\Integration\ORM\Models\ChildModel;
use Tests\Tempest\Integration\ORM\Models\ParentModel;
use Tests\Tempest\Integration\ORM\Models\ThroughModel;
Expand Down Expand Up @@ -437,4 +440,18 @@ public function test_delete(): void
$this->assertNull(Foo::find($foo->getId()));
$this->assertNotNull(Foo::find($bar->getId()));
}

public function test_property_with_carbon_type(): void
{
$this->migrate(
CreateMigrationsTable::class,
CreateCarbonModelTable::class,
);

new CarbonModel(createdAt: new Carbon('2024-01-01'))->save();

$model = CarbonModel::query()->first();

$this->assertTrue($model->createdAt->equalTo(new Carbon('2024-01-01')));
}
}
28 changes: 28 additions & 0 deletions tests/Integration/ORM/Migrations/CreateCarbonModelTable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\ORM\Migrations;

use Tempest\Database\DatabaseMigration;
use Tempest\Database\QueryStatement;
use Tempest\Database\QueryStatements\CreateTableStatement;
use Tempest\Database\QueryStatements\DropTableStatement;
use Tests\Tempest\Integration\ORM\Models\CarbonModel;

final class CreateCarbonModelTable implements DatabaseMigration
{
public string $name = '2024-12-17_create_users_table';

public function up(): QueryStatement
{
return CreateTableStatement::forModel(CarbonModel::class)
->primary()
->raw('`createdAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
}

public function down(): QueryStatement
{
return DropTableStatement::forModel(CarbonModel::class);
}
}
19 changes: 19 additions & 0 deletions tests/Integration/ORM/Models/CarbonModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Tests\Tempest\Integration\ORM\Models;

use Carbon\Carbon;
use Tempest\Database\DatabaseModel;
use Tempest\Database\IsDatabaseModel;

final class CarbonModel implements DatabaseModel
{
use IsDatabaseModel;

public function __construct(
public Carbon $createdAt,
) {
}
}

0 comments on commit 04a843b

Please sign in to comment.