Skip to content

Commit caebb42

Browse files
authored
feat: Observable Model Properties (#[Watches] attribute system) (#241)
2 parents 7194807 + 46f0215 commit caebb42

16 files changed

+1714
-0
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
namespace Phaseolies\Console\Commands;
4+
5+
use Phaseolies\Console\Schedule\Command;
6+
7+
class MakeWatcherCommand extends Command
8+
{
9+
/**
10+
* The name and signature of the console command.
11+
*
12+
* @var string
13+
*/
14+
protected $name = 'make:watcher {name}';
15+
16+
/**
17+
* The description of the console command.
18+
*
19+
* @var string
20+
*/
21+
protected $description = 'Create a new model property watcher listener class';
22+
23+
/**
24+
* Execute the console command.
25+
*
26+
* @return int
27+
*/
28+
public function handle(): int
29+
{
30+
return $this->executeWithTiming(function () {
31+
$name = $this->argument('name');
32+
$parts = explode('/', $name);
33+
$className = array_pop($parts);
34+
$namespace = 'App\\Watchers' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : '');
35+
$filePath = base_path('app/Watchers/' . str_replace('/', DIRECTORY_SEPARATOR, $name) . '.php');
36+
37+
if (file_exists($filePath)) {
38+
$this->displayError('Watcher already exists at:');
39+
$this->line('<fg=white>' . str_replace(base_path(), '', $filePath) . '</>');
40+
return Command::FAILURE;
41+
}
42+
43+
$directoryPath = dirname($filePath);
44+
if (!is_dir($directoryPath)) {
45+
mkdir($directoryPath, 0755, true);
46+
}
47+
48+
file_put_contents($filePath, $this->generateWatcherContent($namespace, $className));
49+
50+
$this->displaySuccess('Watcher created successfully');
51+
$this->line('<fg=yellow>👁️ File:</> <fg=white>' . str_replace(base_path('/'), '', $filePath) . '</>');
52+
$this->newLine();
53+
$this->line('<fg=yellow>📌 Class:</> <fg=white>' . $className . '</>');
54+
55+
return Command::SUCCESS;
56+
});
57+
}
58+
59+
/**
60+
* Generate watcher listener class content.
61+
*/
62+
protected function generateWatcherContent(string $namespace, string $className): string
63+
{
64+
return <<<EOT
65+
<?php
66+
67+
namespace {$namespace};
68+
69+
use Phaseolies\Database\Entity\Model;
70+
71+
class {$className}
72+
{
73+
/**
74+
* Handle the watched property change.
75+
*
76+
* @param mixed \$old
77+
* @param mixed \$new
78+
* @param Model \$model
79+
* @return void
80+
*/
81+
public function handle(mixed \$old, mixed \$new, Model \$model): void
82+
{
83+
//
84+
}
85+
}
86+
87+
EOT;
88+
}
89+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Phaseolies\Database\Entity\Attributes;
4+
5+
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
6+
class Watches
7+
{
8+
/**
9+
* @param string $watcher
10+
* @param string|null $when
11+
*/
12+
public function __construct(
13+
public readonly string $watcher,
14+
public readonly ?string $when = null,
15+
) {}
16+
}

src/Phaseolies/Database/Entity/Model.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Phaseolies\Database\Entity\Hooks\HookHandler;
99
use Phaseolies\Database\Entity\Casts\InteractsWithCasting;
1010
use Phaseolies\Database\Temporal\InteractsWithTemporal;
11+
use Phaseolies\Database\Entity\Watches\InteractsWithWatches;
1112
use Phaseolies\Database\Database;
1213
use Phaseolies\Database\Contracts\Support\Jsonable;
1314
use PDO;
@@ -20,6 +21,7 @@ abstract class Model implements ArrayAccess, JsonSerializable, Stringable, Jsona
2021
use InteractsWithModelQueryProcessing;
2122
use InteractsWithTemporal;
2223
use InteractsWithCasting;
24+
use InteractsWithWatches;
2325

2426
/**
2527
* The name of the database table associated with the model.
@@ -191,6 +193,7 @@ protected function registerHooks(): void
191193

192194
$this->registerAttributeHooks();
193195
$this->registerTemporalHooks();
196+
$this->registerWatchesAttributes();
194197
}
195198

196199
/**

src/Phaseolies/Database/Entity/Query/InteractsWithModelQueryProcessing.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ public function save(): bool
195195

196196
if (self::$isHookShouldBeCalled && $response) {
197197
$this->fireAfterHooks('updated');
198+
$this->firePropertyWatches($dirtyAttributes);
198199
$this->originalAttributes = $this->attributes;
199200
}
200201

@@ -502,6 +503,7 @@ public function update(array $attributes): bool
502503
if ($result) {
503504
if (self::$isHookShouldBeCalled) {
504505
$this->fireAfterHooks('updated');
506+
$this->firePropertyWatches($dirty);
505507
}
506508
$this->originalAttributes = $this->attributes;
507509
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
namespace Phaseolies\Database\Entity\Watches;
4+
5+
use Phaseolies\Database\Entity\Attributes\Watches;
6+
7+
trait InteractsWithWatches
8+
{
9+
/**
10+
* Per-class cache of scanned #[Watches] metadata so reflection only runs once.
11+
*
12+
* @var array<string, list<array{property: string, watcher: string, when: string|null}>>
13+
*/
14+
private static array $watchesAttributeCache = [];
15+
16+
/**
17+
* Scan #[Watches] attributes on the model's properties and register them with WatchesHandler.
18+
*
19+
* @return void
20+
*/
21+
protected function registerWatchesAttributes(): void
22+
{
23+
$class = static::class;
24+
25+
if (!array_key_exists($class, self::$watchesAttributeCache)) {
26+
self::$watchesAttributeCache[$class] = self::scanWatchesAttributes($class);
27+
}
28+
29+
foreach (self::$watchesAttributeCache[$class] as $entry) {
30+
WatchesHandler::register(
31+
$class,
32+
$entry['property'],
33+
$entry['watcher'],
34+
$entry['when']
35+
);
36+
}
37+
}
38+
39+
/**
40+
* Fire all registered property watches for the given set of dirty attributes.
41+
*
42+
* @param array $dirty
43+
* @return void
44+
*/
45+
public function firePropertyWatches(array $dirty): void
46+
{
47+
if (empty($dirty)) {
48+
return;
49+
}
50+
51+
WatchesHandler::fireForDirty($this, $dirty);
52+
}
53+
54+
/**
55+
* Reset the #[Watches] reflection cache for one or all model classes
56+
*
57+
* @param string|null $class
58+
* @return void
59+
*/
60+
public static function resetWatchesCache(?string $class = null): void
61+
{
62+
if ($class !== null) {
63+
unset(self::$watchesAttributeCache[$class]);
64+
} else {
65+
self::$watchesAttributeCache = [];
66+
}
67+
}
68+
69+
/**
70+
* Use reflection to collect all #[Watches] metadata from the model's properties
71+
*
72+
* @param string $class
73+
* @return list<array{property: string, watcher: string, when: string|null}>
74+
*/
75+
private static function scanWatchesAttributes(string $class): array
76+
{
77+
$found = [];
78+
$reflection = new \ReflectionClass($class);
79+
80+
foreach ($reflection->getProperties() as $property) {
81+
$watchAttrs = $property->getAttributes(Watches::class);
82+
83+
if (empty($watchAttrs)) {
84+
continue;
85+
}
86+
87+
foreach ($watchAttrs as $watchAttr) {
88+
/** @var Watches $watches */
89+
$watches = $watchAttr->newInstance();
90+
91+
$found[] = [
92+
'property' => $property->getName(),
93+
'watcher' => $watches->watcher,
94+
'when' => $watches->when,
95+
];
96+
}
97+
}
98+
99+
return $found;
100+
}
101+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace Phaseolies\Database\Entity\Watches;
4+
5+
use Phaseolies\Database\Entity\Model;
6+
7+
interface WatchConditionInterface
8+
{
9+
/**
10+
* Evaluate whether the watch watcher should fire.
11+
*
12+
* @param mixed $old
13+
* @param mixed $new
14+
* @param Model $model
15+
* @return bool
16+
*/
17+
public function evaluate(mixed $old, mixed $new, Model $model): bool;
18+
}

0 commit comments

Comments
 (0)