diff --git a/composer.json b/composer.json index cda1f0ba6..366398c72 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/Tempest/Core/src/functions.php b/src/Tempest/Core/src/functions.php index db80560d7..22d8563c2 100644 --- a/src/Tempest/Core/src/functions.php +++ b/src/Tempest/Core/src/functions.php @@ -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); } } diff --git a/src/Tempest/Database/src/Builder/FieldName.php b/src/Tempest/Database/src/Builder/FieldName.php index a7a853ef1..04337ece2 100644 --- a/src/Tempest/Database/src/Builder/FieldName.php +++ b/src/Tempest/Database/src/Builder/FieldName.php @@ -5,6 +5,7 @@ namespace Tempest\Database\Builder; use Stringable; +use Tempest\Database\DatabaseModel; use Tempest\Mapper\Casters\CasterFactory; use Tempest\Reflection\ClassReflector; @@ -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) { diff --git a/src/Tempest/Database/src/Casters/RelationCaster.php b/src/Tempest/Database/src/Casters/RelationCaster.php new file mode 100644 index 000000000..fdb171b85 --- /dev/null +++ b/src/Tempest/Database/src/Casters/RelationCaster.php @@ -0,0 +1,15 @@ +casterFactory->forProperty($property)) !== null) { + $caster = $this->casterFactory->forProperty($property); + + if ($value && $caster !== null && ! $caster instanceof RelationCaster) { $value = $caster->cast($value); } diff --git a/src/Tempest/Mapper/src/Casters/CasterFactory.php b/src/Tempest/Mapper/src/Casters/CasterFactory.php index f8e93c65a..b5b7ba903 100644 --- a/src/Tempest/Mapper/src/Casters/CasterFactory.php +++ b/src/Tempest/Mapper/src/Casters/CasterFactory.php @@ -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; } } diff --git a/src/Tempest/Mapper/src/Casters/ObjectCaster.php b/src/Tempest/Mapper/src/Casters/ObjectCaster.php new file mode 100644 index 000000000..3e5d34afb --- /dev/null +++ b/src/Tempest/Mapper/src/Casters/ObjectCaster.php @@ -0,0 +1,26 @@ +type->asClass()->newInstanceArgs([$input]); + } catch (Throwable) { + return $input; + } + } +} diff --git a/src/Tempest/Reflection/src/ClassReflector.php b/src/Tempest/Reflection/src/ClassReflector.php index f3a21b38e..ec6db64af 100644 --- a/src/Tempest/Reflection/src/ClassReflector.php +++ b/src/Tempest/Reflection/src/ClassReflector.php @@ -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 */ public function getPublicProperties(): Generator { diff --git a/src/Tempest/Reflection/src/HasAttributes.php b/src/Tempest/Reflection/src/HasAttributes.php index d083a44be..6f9c5d8e3 100644 --- a/src/Tempest/Reflection/src/HasAttributes.php +++ b/src/Tempest/Reflection/src/HasAttributes.php @@ -24,11 +24,31 @@ public function hasAttribute(string $name): bool * @param class-string $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; } /** diff --git a/src/Tempest/Reflection/tests/ClassReflectorTest.php b/src/Tempest/Reflection/tests/ClassReflectorTest.php index c4d3905ea..df37a3a0e 100644 --- a/src/Tempest/Reflection/tests/ClassReflectorTest.php +++ b/src/Tempest/Reflection/tests/ClassReflectorTest.php @@ -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; @@ -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)); + } } diff --git a/src/Tempest/Reflection/tests/Fixtures/ChildWithRecursiveAttribute.php b/src/Tempest/Reflection/tests/Fixtures/ChildWithRecursiveAttribute.php new file mode 100644 index 000000000..5c9afaaec --- /dev/null +++ b/src/Tempest/Reflection/tests/Fixtures/ChildWithRecursiveAttribute.php @@ -0,0 +1,9 @@ +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'))); + } } diff --git a/tests/Integration/ORM/Migrations/CreateCarbonModelTable.php b/tests/Integration/ORM/Migrations/CreateCarbonModelTable.php new file mode 100644 index 000000000..5628b20d8 --- /dev/null +++ b/tests/Integration/ORM/Migrations/CreateCarbonModelTable.php @@ -0,0 +1,28 @@ +primary() + ->raw('`createdAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP'); + } + + public function down(): QueryStatement + { + return DropTableStatement::forModel(CarbonModel::class); + } +} diff --git a/tests/Integration/ORM/Models/CarbonModel.php b/tests/Integration/ORM/Models/CarbonModel.php new file mode 100644 index 000000000..56f17356d --- /dev/null +++ b/tests/Integration/ORM/Models/CarbonModel.php @@ -0,0 +1,19 @@ +