Skip to content
Merged
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
45 changes: 45 additions & 0 deletions src/Phaseolies/Database/Entity/Attributes/Hook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace Phaseolies\Database\Entity\Attributes;

/**
* Declares a model method as a lifecycle hook handler.
*
* Usage — basic:
*
* #[Hook('before_created')]
* public function setDefaults(): void { ... }
*
* Usage — with a string condition (method name on the model):
*
* #[Hook('before_updated', when: 'isPublished')]
* public function generateSlug(): void { ... }
*
* Usage — with multiple events on the same method:
*
* #[Hook('before_created')]
* #[Hook('before_updated')]
* public function generateSlug(): void { ... }
*
* Supported events:
* booting, booted,
* before_created, after_created,
* before_updated, after_updated,
* before_deleted, after_deleted
*
* The 'when' parameter (optional):
* - A string → name of a public/protected method on the model that returns bool
* - Omitted → hook always fires
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Hook
{
/**
* @param string $event Lifecycle event name e.g. 'before_updated'
* @param string|null $when Optional method name on the model returning bool
*/
public function __construct(
public readonly string $event,
public readonly ?string $when = null,
) {}
}
115 changes: 115 additions & 0 deletions src/Phaseolies/Database/Entity/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use PDO;
use JsonSerializable;
use ArrayAccess;
use Phaseolies\Database\Entity\Attributes\Hook;

abstract class Model implements ArrayAccess, JsonSerializable, Stringable, Jsonable
{
Expand Down Expand Up @@ -135,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<string, list<array{event: string, method: string, when: string|null}>>
*/
private static array $hookAttributeCache = [];

/**
* Model constructor.
*
Expand Down Expand Up @@ -176,6 +184,113 @@ protected function registerHooks(): void
if (!empty($this->hooks)) {
HookHandler::register(static::class, $this->hooks);
}

$this->registerAttributeHooks();
}

/**
* Register hooks defined in the model
*
* @return void
*/
private function registerAttributeHooks(): void
{
$class = static::class;

if (!array_key_exists($class, self::$hookAttributeCache)) {
self::$hookAttributeCache[$class] = self::scanHookAttributes($class);
}

if (empty(self::$hookAttributeCache[$class])) {
return;
}

foreach (self::$hookAttributeCache[$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<array{event: string, method: string, when: string|null}>
*/
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;
}

/**
* 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 = [];
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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);
Expand All @@ -229,17 +229,17 @@ public function save(): bool
return $response;
}

if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('created') === false) {
return false;
}

$attributes = $this->getCreatableAttributes();

if ($this->timeStamps) {
$attributes['created_at'] = $dateTime;
$attributes['updated_at'] = $dateTime;
}

if (self::$isHookShouldBeCalled && $this->fireBeforeHooks('created') === false) {
return false;
}

$id = $this->query()->insert($attributes);

if ($id && self::$isHookShouldBeCalled) {
Expand Down
Loading
Loading