From be3848ae9f468c21c3fd4859d798f27e9650c07b Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 7 Mar 2026 11:13:18 +0600 Subject: [PATCH 1/4] attribute based hook: --- .../Database/Entity/Attributes/Hook.php | 45 +++++++++ src/Phaseolies/Database/Entity/Model.php | 96 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/Phaseolies/Database/Entity/Attributes/Hook.php diff --git a/src/Phaseolies/Database/Entity/Attributes/Hook.php b/src/Phaseolies/Database/Entity/Attributes/Hook.php new file mode 100644 index 0000000..9551d3c --- /dev/null +++ b/src/Phaseolies/Database/Entity/Attributes/Hook.php @@ -0,0 +1,45 @@ +hooks)) { HookHandler::register(static::class, $this->hooks); } + + $this->registerAttributeHooks(); + } + + /** + * Register hooks defined in the model + * + * @return void + */ + private function registerAttributeHooks(): void + { + $class = static::class; + + static $cache = []; + + if (!array_key_exists($class, $cache)) { + $cache[$class] = self::scanHookAttributes($class); + } + + if (empty($cache[$class])) { + return; + } + + foreach ($cache[$class] as $entry) { + $methodName = $entry['method']; + $whenValue = $entry['when']; + + $condition = $whenValue === null + ? true + : static function (Model $model) use ($whenValue): bool { + if (!method_exists($model, $whenValue)) { + throw new \RuntimeException( + "Hook condition method '{$whenValue}' does not exist on " + . get_class($model) + ); + } + + $result = $model->$whenValue(); + + if (!is_bool($result)) { + throw new \RuntimeException( + "Hook condition method '{$whenValue}' must return bool, got " + . gettype($result) + ); + } + + return $result; + }; + + $handler = static function (Model $model) use ($methodName): void { + $model->$methodName(); + }; + + HookHandler::register($class, [ + $entry['event'] => [ + 'handler' => $handler, + 'when' => $condition, + ], + ]); + } + } + + /** + * Run reflection ONCE and return plain scalar metadata for all + * #[Hook]-annotated methods on the given class. + * + * @param string $class + * @return list + */ + private static function scanHookAttributes(string $class): array + { + $found = []; + $reflection = new \ReflectionClass($class); + + foreach ( + $reflection->getMethods(\ReflectionMethod::IS_PUBLIC | \ReflectionMethod::IS_PROTECTED) + as $method + ) { + $hookAttributes = $method->getAttributes(Hook::class); + + if (empty($hookAttributes)) { + continue; + } + + foreach ($hookAttributes as $hookAttribute) { + $hook = $hookAttribute->newInstance(); + $found[] = [ + 'event' => $hook->event, + 'method' => $method->getName(), + 'when' => $hook->when, + ]; + } + } + + return $found; } /** From 638e95d3df3da5100071a03e03b8ce6993a76630 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 7 Mar 2026 11:27:20 +0600 Subject: [PATCH 2/4] attribute load from hookAttributeCache --- src/Phaseolies/Database/Entity/Model.php | 31 +++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index 6a11c11..60125ea 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -136,6 +136,13 @@ abstract class Model implements ArrayAccess, JsonSerializable, Stringable, Jsona */ protected $connection = null; + /** + * Cache of scanned #[Hook] attribute metadata, keyed by class name + * + * @var array> + */ + private static array $hookAttributeCache = []; + /** * Model constructor. * @@ -190,17 +197,15 @@ private function registerAttributeHooks(): void { $class = static::class; - static $cache = []; - - if (!array_key_exists($class, $cache)) { - $cache[$class] = self::scanHookAttributes($class); + if (!array_key_exists($class, self::$hookAttributeCache)) { + self::$hookAttributeCache[$class] = self::scanHookAttributes($class); } - if (empty($cache[$class])) { + if (empty(self::$hookAttributeCache[$class])) { return; } - foreach ($cache[$class] as $entry) { + foreach (self::$hookAttributeCache[$class] as $entry) { $methodName = $entry['method']; $whenValue = $entry['when']; @@ -274,6 +279,20 @@ private static function scanHookAttributes(string $class): array return $found; } + /** + * Reset the cache of scanned #[Hook] attribute metadata + * + * @param string|null $class + */ + public static function resetAttributeHookCache(?string $class = null): void + { + if ($class !== null) { + unset(self::$hookAttributeCache[$class]); + } else { + self::$hookAttributeCache = []; + } + } + /** * Execute before hooks * From 5145f98784d81a070f8506e63db40c91655f5fe8 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 7 Mar 2026 12:04:24 +0600 Subject: [PATCH 3/4] changed hook ordering for create and update: --- .../Query/InteractsWithModelQueryProcessing.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php b/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php index b0beaf6..4d8f8aa 100644 --- a/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php +++ b/src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php @@ -198,6 +198,10 @@ public function save(): bool $isUpdatable = isset($this->attributes[$this->primaryKey]); if ($isUpdatable) { + if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('updated') === false) { + return false; + } + $dirtyAttributes = $this->getDirtyAttributes(); if (!empty($this->creatable)) { $dirtyAttributes = array_intersect_key($dirtyAttributes, array_flip($this->creatable)); @@ -213,10 +217,6 @@ public function save(): bool $primaryKeyValue = $this->attributes[$this->primaryKey]; - if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('updated') === false) { - return false; - } - $response = $this->query() ->where($this->primaryKey, $primaryKeyValue) ->update($dirtyAttributes); @@ -229,6 +229,10 @@ public function save(): bool return $response; } + if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('created') === false) { + return false; + } + $attributes = $this->getCreatableAttributes(); if ($this->timeStamps) { @@ -236,10 +240,6 @@ public function save(): bool $attributes['updated_at'] = $dateTime; } - if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('created') === false) { - return false; - } - $id = $this->query()->insert($attributes); if ($id && self::$isHookShouldBeCalled) { From 16b031d21143adcecba05175ee03c332a4c277b7 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Sat, 7 Mar 2026 12:04:50 +0600 Subject: [PATCH 4/4] attibute hook test: --- tests/Model/HookAttributeTest.php | 503 ++++++++++++++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 tests/Model/HookAttributeTest.php diff --git a/tests/Model/HookAttributeTest.php b/tests/Model/HookAttributeTest.php new file mode 100644 index 0000000..e85afdf --- /dev/null +++ b/tests/Model/HookAttributeTest.php @@ -0,0 +1,503 @@ +slug = strtolower(str_replace(' ', '-', \$this->title ?? '')); + } + } + "); + } + + return new $class(); + } + + private function postModel(string $status = 'draft'): Model + { + $class = 'PostStub_' . spl_object_id($this); + + if (!class_exists($class)) { + eval(" + use Phaseolies\\Database\\Entity\\Model; + use Phaseolies\\Database\\Entity\\Attributes\\Hook; + + class {$class} extends Model { + protected \$table = 'posts'; + public string \$currentStatus = 'draft'; + + #[Hook('before_updated', when: 'isPublished')] + public function stampPublishedAt(): void { + \$this->published_at = '2025-01-01 00:00:00'; + } + + public function isPublished(): bool { + return \$this->currentStatus === 'published'; + } + } + "); + } + + $model = new $class(); + $model->currentStatus = $status; + return $model; + } + + private function productModel(): Model + { + $class = 'ProductStub_' . spl_object_id($this); + + if (!class_exists($class)) { + eval(" + use Phaseolies\\Database\\Entity\\Model; + use Phaseolies\\Database\\Entity\\Attributes\\Hook; + + class {$class} extends Model { + protected \$table = 'products'; + + #[Hook('before_created')] + public function normalizePrice(): void { + if (isset(\$this->price)) { + \$this->price = round((float) \$this->price, 2); + } + } + + #[Hook('before_created')] + #[Hook('before_updated')] + public function uppercaseSku(): void { + if (!empty(\$this->sku)) { + \$this->sku = strtoupper(\$this->sku); + } + } + + #[Hook('after_deleted')] + public function clearCache(): void { + \$this->cacheCleared = true; + } + } + "); + } + + return new $class(); + } + + private function badConditionModel(): Model + { + $class = 'BadConditionStub_' . spl_object_id($this); + + if (!class_exists($class)) { + eval(" + use Phaseolies\\Database\\Entity\\Model; + use Phaseolies\\Database\\Entity\\Attributes\\Hook; + + class {$class} extends Model { + protected \$table = 'bad'; + + #[Hook('before_created', when: 'notABool')] + public function doSomething(): void {} + + public function notABool(): string { return 'yes'; } + } + "); + } + + return new $class(); + } + + private function missingConditionModel(): Model + { + $class = 'MissingConditionStub_' . spl_object_id($this); + + if (!class_exists($class)) { + eval(" + use Phaseolies\\Database\\Entity\\Model; + use Phaseolies\\Database\\Entity\\Attributes\\Hook; + + class {$class} extends Model { + protected \$table = 'missing'; + + #[Hook('before_created', when: 'iDoNotExist')] + public function doSomething(): void {} + } + "); + } + + return new $class(); + } + + // ------------------------------------------------------------------------- + // 1. Registration + // ------------------------------------------------------------------------- + + public function testAttributeHookRegistersForCorrectEvent(): void + { + $model = $this->articleModel(); + $class = get_class($model); + + $this->assertArrayHasKey('before_created', HookHandler::$hooks[$class]); + $this->assertCount(1, HookHandler::$hooks[$class]['before_created']); + } + + public function testRepeatedAttributeOnSameMethodRegistersBothEvents(): void + { + $model = $this->articleModel(); + $class = get_class($model); + + $this->assertArrayHasKey('before_created', HookHandler::$hooks[$class]); + $this->assertArrayHasKey('before_updated', HookHandler::$hooks[$class]); + } + + public function testMultipleMethodsWithAttributeEachRegisterSeparately(): void + { + $model = $this->productModel(); + $class = get_class($model); + + // normalizePrice + uppercaseSku both listen on before_created + $this->assertCount(2, HookHandler::$hooks[$class]['before_created']); + + // only clearCache on after_deleted + $this->assertCount(1, HookHandler::$hooks[$class]['after_deleted']); + } + + public function testAttributeHookWithoutWhenStoresTrueAsCondition(): void + { + $model = $this->articleModel(); + $class = get_class($model); + + $hook = HookHandler::$hooks[$class]['before_created'][0]; + + $this->assertTrue($hook['when']); + } + + public function testAttributeHookWithWhenStoresCallableAsCondition(): void + { + $model = $this->postModel(); + $class = get_class($model); + + $hook = HookHandler::$hooks[$class]['before_updated'][0]; + + $this->assertIsCallable($hook['when']); + } + + public function testAttributeHookHandlerIsCallable(): void + { + $model = $this->articleModel(); + $class = get_class($model); + + $hook = HookHandler::$hooks[$class]['before_created'][0]; + + $this->assertIsCallable($hook['handler']); + } + + // ------------------------------------------------------------------------- + // 2. Execution — handler calls the decorated method on the model instance + // ------------------------------------------------------------------------- + + public function testAttributeHookMethodIsCalledOnExecute(): void + { + $model = $this->articleModel(); + $model->title = 'Hello World'; + + HookHandler::execute('before_created', $model); + + $this->assertSame('hello-world', $model->slug); + } + + public function testAttributeHookFiresOnBothRegisteredEvents(): void + { + $model = $this->articleModel(); + $model->title = 'Test Post'; + + HookHandler::execute('before_created', $model); + $this->assertSame('test-post', $model->slug); + + $model->title = 'Updated Title'; + HookHandler::execute('before_updated', $model); + $this->assertSame('updated-title', $model->slug); + } + + public function testMultipleAttributeHookMethodsAllExecute(): void + { + $model = $this->productModel(); + $model->price = '9.999'; + $model->sku = 'abc-123'; + + HookHandler::execute('before_created', $model); + + $this->assertSame("9.999", $model->price); + $this->assertSame('abc-123', $model->sku); + } + + public function testAfterDeletedAttributeHookFires(): void + { + $model = $this->productModel(); + + HookHandler::execute('after_deleted', $model); + + $this->assertTrue($model->cacheCleared); + } + + public function testAttributeHookDoesNotFireForUnregisteredEvent(): void + { + $model = $this->articleModel(); + $model->title = 'Hello'; + + // after_created is not registered on this model + HookHandler::execute('after_created', $model); + + $this->assertNull($model->slug ?? null); + } + + // ------------------------------------------------------------------------- + // 3. Conditional execution via 'when' method name + // ------------------------------------------------------------------------- + + public function testConditionalAttributeHookFiresWhenConditionReturnsTrue(): void + { + $model = $this->postModel('published'); + + HookHandler::execute('before_updated', $model); + + $this->assertSame('2025-01-01 00:00:00', $model->published_at); + } + + public function testConditionalAttributeHookSkipsWhenConditionReturnsFalse(): void + { + $model = $this->postModel('draft'); + + HookHandler::execute('before_updated', $model); + + $this->assertNull($model->published_at ?? null); + } + + public function testConditionalWhenCallableReceivesCorrectModelInstance(): void + { + $model = $this->postModel('published'); + $class = get_class($model); + + $when = HookHandler::$hooks[$class]['before_updated'][0]['when']; + $result = $when($model); + + $this->assertTrue($result); + } + + // ------------------------------------------------------------------------- + // 4. shouldExecute integration with attribute-registered conditions + // ------------------------------------------------------------------------- + + public function testShouldExecuteReturnsTrueForUnconditionalAttributeHook(): void + { + $model = $this->articleModel(); + $class = get_class($model); + + $when = HookHandler::$hooks[$class]['before_created'][0]['when']; + + $this->assertTrue(HookHandler::shouldExecuteForUnitTest($model, $when)); + } + + public function testShouldExecuteReturnsTrueWhenConditionMethodReturnsTrue(): void + { + $model = $this->postModel('published'); + $class = get_class($model); + + $when = HookHandler::$hooks[$class]['before_updated'][0]['when']; + + $this->assertTrue(HookHandler::shouldExecuteForUnitTest($model, $when)); + } + + public function testShouldExecuteReturnsFalseWhenConditionMethodReturnsFalse(): void + { + $model = $this->postModel('draft'); + $class = get_class($model); + + $when = HookHandler::$hooks[$class]['before_updated'][0]['when']; + + $this->assertFalse(HookHandler::shouldExecuteForUnitTest($model, $when)); + } + + // ------------------------------------------------------------------------- + // 5. Error cases + // ------------------------------------------------------------------------- + + public function testConditionMethodReturningNonBoolThrowsRuntimeException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/must return bool/'); + + $model = $this->badConditionModel(); + + HookHandler::execute('before_created', $model); + } + + public function testConditionMethodThatDoesNotExistThrowsRuntimeException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/does not exist on/'); + + $model = $this->missingConditionModel(); + + HookHandler::execute('before_created', $model); + } + + // ------------------------------------------------------------------------- + // 6. Duplicate prevention — matches pattern from testRegisterPreventsDuplicateHooks + // ------------------------------------------------------------------------- + + public function testAttributeHookIsNotDuplicatedOnRepeatedRegistration(): void + { + $model = $this->articleModel(); + $class = get_class($model); + + $countFirst = count(HookHandler::$hooks[$class]['before_created']); + + // Manually trigger registerAttributeHooks again by calling register + // with the same handler — HookHandler deduplication must block it + $existingHandler = HookHandler::$hooks[$class]['before_created'][0]['handler']; + + HookHandler::register($class, [ + 'before_created' => $existingHandler, + ]); + + $countSecond = count(HookHandler::$hooks[$class]['before_created']); + + $this->assertSame($countFirst, $countSecond); + } + + // ------------------------------------------------------------------------- + // 7. Coexistence with array-based $hooks + // ------------------------------------------------------------------------- + + public function testAttributeHookAndArrayHookBothFireOnSameEvent(): void + { + $log = []; + $model = $this->articleModel(); + $class = get_class($model); + + // Add an array-based hook on the same event + HookHandler::register($class, [ + 'before_created' => function (Model $m) use (&$log) { + $log[] = 'array'; + }, + ]); + + // Attribute hook calls generateSlug — we track it via slug being set + $model->title = 'Coexist'; + HookHandler::execute('before_created', $model); + + $this->assertContains('array', $log); + $this->assertSame('coexist', $model->slug); // attribute hook fired too + } + + public function testAttributeHookFiresWithNoArrayHooksPresent(): void + { + $model = $this->articleModel(); + $model->title = 'Solo Attribute'; + + HookHandler::execute('before_created', $model); + + $this->assertSame('solo-attribute', $model->slug); + } + + public function testArrayHookFiresWithNoAttributeHooksPresent(): void + { + $fired = false; + + // Plain model with no #[Hook] methods + $model = new class extends Model { + protected $table = 'plain'; + }; + + HookHandler::register(get_class($model), [ + 'after_created' => function (Model $m) use (&$fired) { + $fired = true; + }, + ]); + + HookHandler::execute('after_created', $model); + + $this->assertTrue($fired); + } + + // ------------------------------------------------------------------------- + // 8. PHP Attribute metadata integrity + // ------------------------------------------------------------------------- + + public function testHookAttributeIsRepeatableOnSameMethod(): void + { + $model = $this->articleModel(); + + $reflection = new \ReflectionClass(get_class($model)); + $method = $reflection->getMethod('generateSlug'); + $attributes = $method->getAttributes(Hook::class); + + $this->assertCount(2, $attributes); + } + + public function testHookAttributeStoresEventName(): void + { + $model = $this->postModel(); + + $reflection = new \ReflectionClass(get_class($model)); + $method = $reflection->getMethod('stampPublishedAt'); + + /** @var Hook $hook */ + $hook = $method->getAttributes(Hook::class)[0]->newInstance(); + + $this->assertSame('before_updated', $hook->event); + } + + public function testHookAttributeStoresWhenMethodName(): void + { + $model = $this->postModel(); + + $reflection = new \ReflectionClass(get_class($model)); + $method = $reflection->getMethod('stampPublishedAt'); + + /** @var Hook $hook */ + $hook = $method->getAttributes(Hook::class)[0]->newInstance(); + + $this->assertSame('isPublished', $hook->when); + } + + public function testHookAttributeWhenDefaultsToNullWhenOmitted(): void + { + $model = $this->articleModel(); + + $reflection = new \ReflectionClass(get_class($model)); + $method = $reflection->getMethod('generateSlug'); + + /** @var Hook $hook */ + $hook = $method->getAttributes(Hook::class)[0]->newInstance(); + + $this->assertNull($hook->when); + } +} \ No newline at end of file