feat(actions): add ScheduledActionScanner — discovers #[Scheduled] classes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ace48d57c2
commit
9ffb756969
5 changed files with 314 additions and 0 deletions
186
src/Core/Actions/ScheduledActionScanner.php
Normal file
186
src/Core/Actions/ScheduledActionScanner.php
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Actions;
|
||||
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* Scans directories for Action classes with the #[Scheduled] attribute.
|
||||
*
|
||||
* Unlike ModuleScanner (which scans Boot.php files), this scanner finds
|
||||
* any PHP class with the #[Scheduled] attribute in the given directories.
|
||||
*
|
||||
* It uses PHP's native reflection to read attributes — no file parsing.
|
||||
*
|
||||
* @see Scheduled The attribute this scanner discovers
|
||||
* @see \Core\ModuleScanner Similar pattern for Boot.php discovery
|
||||
*/
|
||||
class ScheduledActionScanner
|
||||
{
|
||||
/**
|
||||
* Scan directories for classes with #[Scheduled] attribute.
|
||||
*
|
||||
* @param array<string> $paths Directories to scan recursively
|
||||
* @return array<class-string, Scheduled> Map of class name to attribute instance
|
||||
*/
|
||||
public function scan(array $paths): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (! is_dir($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS)
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$class = $this->classFromFile($file->getPathname());
|
||||
|
||||
if ($class === null || ! class_exists($class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attribute = $this->extractScheduled($class);
|
||||
|
||||
if ($attribute !== null) {
|
||||
$results[$class] = $attribute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the #[Scheduled] attribute from a class.
|
||||
*/
|
||||
private function extractScheduled(string $class): ?Scheduled
|
||||
{
|
||||
try {
|
||||
$ref = new ReflectionClass($class);
|
||||
$attrs = $ref->getAttributes(Scheduled::class);
|
||||
|
||||
if (empty($attrs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $attrs[0]->newInstance();
|
||||
} catch (\ReflectionException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive fully qualified class name from a PHP file.
|
||||
*
|
||||
* Reads the file's namespace declaration and class name.
|
||||
*/
|
||||
private function classFromFile(string $file): ?string
|
||||
{
|
||||
$contents = file_get_contents($file);
|
||||
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$namespace = null;
|
||||
$class = null;
|
||||
|
||||
foreach (token_get_all($contents) as $token) {
|
||||
if (! is_array($token)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($token[0] === T_NAMESPACE) {
|
||||
$namespace = $this->extractNamespace($contents);
|
||||
}
|
||||
|
||||
if ($token[0] === T_CLASS) {
|
||||
$class = $this->extractClassName($contents);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($class === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $namespace !== null ? "{$namespace}\\{$class}" : $class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the namespace string from file contents.
|
||||
*/
|
||||
private function extractNamespace(string $contents): ?string
|
||||
{
|
||||
$tokens = token_get_all($contents);
|
||||
$capture = false;
|
||||
$parts = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (is_array($token) && $token[0] === T_NAMESPACE) {
|
||||
$capture = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($capture) {
|
||||
if (is_array($token) && in_array($token[0], [T_NAME_QUALIFIED, T_STRING, T_NS_SEPARATOR], true)) {
|
||||
$parts[] = $token[1];
|
||||
} elseif ($token === ';' || $token === '{') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ! empty($parts) ? implode('', $parts) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract class name from tokens after T_CLASS.
|
||||
*/
|
||||
private function extractClassName(string $contents): ?string
|
||||
{
|
||||
$tokens = token_get_all($contents);
|
||||
$nextIsClass = false;
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (is_array($token) && $token[0] === T_CLASS) {
|
||||
$nextIsClass = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($nextIsClass && is_array($token)) {
|
||||
if ($token[0] === T_WHITESPACE) {
|
||||
continue;
|
||||
}
|
||||
if ($token[0] === T_STRING) {
|
||||
return $token[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
73
tests/Feature/ScheduledActionScannerTest.php
Normal file
73
tests/Feature/ScheduledActionScannerTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tests\Feature;
|
||||
|
||||
use Core\Actions\Scheduled;
|
||||
use Core\Actions\ScheduledActionScanner;
|
||||
use Core\Tests\Fixtures\Mod\Scheduled\Actions\DailyAction;
|
||||
use Core\Tests\Fixtures\Mod\Scheduled\Actions\EveryMinuteAction;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ScheduledActionScannerTest extends TestCase
|
||||
{
|
||||
private ScheduledActionScanner $scanner;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->scanner = new ScheduledActionScanner();
|
||||
}
|
||||
|
||||
public function test_scan_discovers_scheduled_actions(): void
|
||||
{
|
||||
$results = $this->scanner->scan([
|
||||
dirname(__DIR__).'/Fixtures/Mod/Scheduled',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey(EveryMinuteAction::class, $results);
|
||||
$this->assertArrayHasKey(DailyAction::class, $results);
|
||||
}
|
||||
|
||||
public function test_scan_ignores_non_scheduled_actions(): void
|
||||
{
|
||||
$results = $this->scanner->scan([
|
||||
dirname(__DIR__).'/Fixtures/Mod/Scheduled',
|
||||
]);
|
||||
|
||||
$classes = array_keys($results);
|
||||
foreach ($classes as $class) {
|
||||
$this->assertStringNotContainsString('NotScheduled', $class);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_scan_returns_attribute_instances(): void
|
||||
{
|
||||
$results = $this->scanner->scan([
|
||||
dirname(__DIR__).'/Fixtures/Mod/Scheduled',
|
||||
]);
|
||||
|
||||
$attr = $results[EveryMinuteAction::class];
|
||||
$this->assertInstanceOf(Scheduled::class, $attr);
|
||||
$this->assertSame('everyMinute', $attr->frequency);
|
||||
}
|
||||
|
||||
public function test_scan_preserves_attribute_parameters(): void
|
||||
{
|
||||
$results = $this->scanner->scan([
|
||||
dirname(__DIR__).'/Fixtures/Mod/Scheduled',
|
||||
]);
|
||||
|
||||
$attr = $results[DailyAction::class];
|
||||
$this->assertSame('dailyAt:09:00', $attr->frequency);
|
||||
$this->assertSame('Europe/London', $attr->timezone);
|
||||
$this->assertFalse($attr->withoutOverlapping);
|
||||
}
|
||||
|
||||
public function test_scan_handles_empty_directory(): void
|
||||
{
|
||||
$results = $this->scanner->scan(['/nonexistent/path']);
|
||||
$this->assertEmpty($results);
|
||||
}
|
||||
}
|
||||
19
tests/Fixtures/Mod/Scheduled/Actions/DailyAction.php
Normal file
19
tests/Fixtures/Mod/Scheduled/Actions/DailyAction.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tests\Fixtures\Mod\Scheduled\Actions;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Actions\Scheduled;
|
||||
|
||||
#[Scheduled(frequency: 'dailyAt:09:00', timezone: 'Europe/London', withoutOverlapping: false)]
|
||||
class DailyAction
|
||||
{
|
||||
use Action;
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
return 'daily';
|
||||
}
|
||||
}
|
||||
19
tests/Fixtures/Mod/Scheduled/Actions/EveryMinuteAction.php
Normal file
19
tests/Fixtures/Mod/Scheduled/Actions/EveryMinuteAction.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tests\Fixtures\Mod\Scheduled\Actions;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Actions\Scheduled;
|
||||
|
||||
#[Scheduled(frequency: 'everyMinute')]
|
||||
class EveryMinuteAction
|
||||
{
|
||||
use Action;
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
return 'ran';
|
||||
}
|
||||
}
|
||||
17
tests/Fixtures/Mod/Scheduled/Actions/NotScheduledAction.php
Normal file
17
tests/Fixtures/Mod/Scheduled/Actions/NotScheduledAction.php
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tests\Fixtures\Mod\Scheduled\Actions;
|
||||
|
||||
use Core\Actions\Action;
|
||||
|
||||
class NotScheduledAction
|
||||
{
|
||||
use Action;
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
return 'not scheduled';
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue