diff --git a/packages/mapper/src/Mappers/ArrayToObjectMapper.php b/packages/mapper/src/Mappers/ArrayToObjectMapper.php index 4c4dc54b7..278a8f04f 100644 --- a/packages/mapper/src/Mappers/ArrayToObjectMapper.php +++ b/packages/mapper/src/Mappers/ArrayToObjectMapper.php @@ -49,6 +49,7 @@ public function map(mixed $from, mixed $to): object $isStrictClass = $targetClass->hasAttribute(Strict::class); $missingValues = []; + /** @var PropertyReflector[] $unsetProperties */ $unsetProperties = []; foreach ($targetClass->getPublicProperties() as $property) { @@ -58,7 +59,11 @@ public function map(mixed $from, mixed $to): object $propertyName = $this->resolvePropertyName($property, $from); - if (! array_key_exists($propertyName, $from)) { + $isMissing = ! array_key_exists($propertyName, $from); + + if ($isMissing && ($getHook = $property->getGetHook())) { + $from[$propertyName] = $getHook->invokeArgs($targetObject); + } elseif ($isMissing) { $this->handleMissingProperty( property: $property, propertyName: $propertyName, @@ -70,10 +75,14 @@ public function map(mixed $from, mixed $to): object continue; } - $property->setValue( - object: $targetObject, - value: $this->resolveValue($property, $from[$propertyName]), - ); + if (($setHook = $property->getSetHook()) && $setHook->getParameter(0)?->getType()->accepts($from[$propertyName])) { + $setHook->invokeArgs($targetObject, [$from[$propertyName]]); + } else { + $property->setValue( + object: $targetObject, + value: $this->resolveValue($property, $from[$propertyName]), + ); + } } if ($missingValues !== []) { @@ -91,6 +100,10 @@ public function map(mixed $from, mixed $to): object continue; } + if ($property->isHooked()) { + continue; + } + $property->unset($targetObject); } diff --git a/packages/reflection/src/PropertyReflector.php b/packages/reflection/src/PropertyReflector.php index eafb3dae4..f98a6330d 100644 --- a/packages/reflection/src/PropertyReflector.php +++ b/packages/reflection/src/PropertyReflector.php @@ -5,6 +5,8 @@ namespace Tempest\Reflection; use Error; +use PropertyHookType; +use ReflectionMethod; use ReflectionProperty as PHPReflectionProperty; use Stringable; @@ -120,6 +122,33 @@ public function isVirtual(): bool return $this->reflectionProperty->isVirtual(); } + public function isHooked(): bool + { + return $this->getGetHook() || $this->getSetHook(); + } + + public function getGetHook(): ?MethodReflector + { + $hook = $this->reflectionProperty->getHook(PropertyHookType::Get); + + if (! $hook instanceof ReflectionMethod) { + return null; + } + + return new MethodReflector($hook); + } + + public function getSetHook(): ?MethodReflector + { + $hook = $this->reflectionProperty->getHook(PropertyHookType::Set); + + if (! $hook instanceof ReflectionMethod) { + return null; + } + + return new MethodReflector($hook); + } + public function unset(object $object): void { unset($object->{$this->getName()}); diff --git a/tests/Integration/Database/HookedPropertyTest.php b/tests/Integration/Database/HookedPropertyTest.php new file mode 100644 index 000000000..e80b7028a --- /dev/null +++ b/tests/Integration/Database/HookedPropertyTest.php @@ -0,0 +1,155 @@ +database->migrate(CreateMigrationsTable::class, HookedModel::class); + + $model = HookedModel::create(); + + $this->assertSame( + 'default', + $model->hooked, + ); + + $this->database->assertTableHasRow( + 'hooked_model', + hooked: 'default', + ); + } + + #[Test] + public function test_hooked_property_can_be_overwritten(): void + { + $this->database->migrate(CreateMigrationsTable::class, HookedModel::class); + + $model = HookedModel::create( + hooked: 'other', + ); + + $this->assertSame( + 'other', + $model->hooked, + ); + + $this->database->assertTableHasRow( + 'hooked_model', + hooked: 'other', + ); + } + + #[Test] + public function test_hooked_property_with_dto(): void + { + $this->database->migrate(CreateMigrationsTable::class, HookedModelWithKey::class); + + $model = HookedModelWithKey::create(); + + $this->assertSame('default', $model->key->value); + } + + #[Test] + public function test_hooked_property_with_dto_and_provided_value(): void + { + $this->database->migrate(CreateMigrationsTable::class, HookedModelWithKey::class); + + $model = HookedModelWithKey::create( + key: new Key('other'), + ); + + $this->assertSame('other', $model->key->value); + } + + #[Test] + public function test_hooked_property_with_set_hook(): void + { + $this->database->migrate(CreateMigrationsTable::class, HookedModelWithKey::class); + + $model = HookedModelWithKey::create( + key: 'other', + ); + + $this->assertSame('other', $model->key->value); + } +} + +#[Table('hooked_model')] +class HookedModel implements MigratesUp +{ + use IsDatabaseModel; + + public string $hooked { + get { + $this->hooked ??= 'default'; + + return $this->hooked; + } + set(string $hooked) { + $this->hooked = $hooked; + } + } + + #[Virtual] + public string $name = 'hooked_model'; + + public function up(): QueryStatement + { + return new CreateTableStatement('hooked_model') + ->primary() + ->string('hooked'); + } +} + +#[SerializeAs('key')] +class Key +{ + public function __construct( + public string $value, + ) {} +} + +#[Table('hooked_model_with_key')] +class HookedModelWithKey implements MigratesUp +{ + use IsDatabaseModel; + + public Key $key { + get { + $this->key ??= new Key('default'); + + return $this->key; + } + set(string|Key $value) { + if (! $value instanceof Key) { + $value = new Key($value); + } + + $this->key = $value; + } + } + + #[Virtual] + public string $name = 'hooked_model_with_key'; + + public function up(): QueryStatement + { + return new CreateTableStatement('hooked_model_with_key') + ->primary() + ->dto('key'); + } +}