From 72012affe9e1264056a29ec029004b7118e6f975 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 19:13:27 +0200 Subject: [PATCH] feat: generate #[Async] actions with pollora:make:action --async --- CHANGELOG.md | 3 ++ src/Hook/UI/Console/ActionMakeCommand.php | 14 +++++- src/Hook/UI/Console/AttributeMakeCommand.php | 29 +++++++++++-- src/Hook/UI/Console/FilterMakeCommand.php | 2 +- .../Console/stubs/hook-attribute-update.stub | 2 +- src/Hook/UI/Console/stubs/hook-attribute.stub | 4 +- tests/Feature/Hook/ActionMakeCommandTest.php | 43 +++++++++++++++++++ 7 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 tests/Feature/Hook/ActionMakeCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4ddd1b..1cd9a16e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `config/hooks.php`, published with `php artisan vendor:publish --tag=pollora-hooks`: default driver (`HOOKS_ASYNC_DRIVER`, `sync` in a developer's `.env` runs every handler at once), attempts, backoff, `as_user`, queue connection and name - `#[Async]`, next to `#[Action]`, makes a method asynchronous with the options of `->async()` as named parameters: `delay`, `via`, `onQueue`, `unique` (`true` or the lock duration), `tries`, `backoff`, `asUser`, `capture` and `when` (public methods of the class), `keepMissing`, `except`. On a class it applies to every `#[Action]` method, and a method's own `#[Async]` replaces it. A declaration that cannot be honoured (a hook in `except` the method does not declare, a missing or non-public `capture`/`when` method, invalid attempts, backoff or lock) is logged and the action runs synchronously; `#[Async]` on a `#[Filter]` or without `#[Action]` is logged - `pollora:doctor` and Site Health check asynchronous actions (`async-actions`): an ignored `#[Async]` (now kept, not only logged), an unavailable default driver (every handler then runs in the request) or `via()` driver, a `HOOKS_ASYNC_CONNECTION` that is undefined or `sync`/`null`, WP-Cron events overdue for an hour while WP-Cron or Action Scheduler is in use (Pollora sets `DISABLE_WP_CRON`: a system cron must request `wp-cron.php`), jobs of a database queue waiting 15 minutes for a worker, and payloads or unique locks the daily recovery task left behind +- `pollora:make:action --async` generates the method with `#[Async]` and its import, in a new class or added to an existing one - Async handlers in the framework: debug mode follows `app.debug`, incidents go to the Laravel log, closures are signed with the application key, Eloquent models travel by class and key and are reloaded at execution, and parameters typed with a service are resolved from the container ### Fixed +- `pollora:make:action` and `pollora:make:filter` were registered as `pollora:make:action {name}` and `pollora:make:filter {name}`: the console reached them by prefix, but `Artisan::call('pollora:make:action')` did not find them +- The method generated for a hook with separators read `handleSavepost` for `save_post`: it is now `handleSavePost` - Errors met while registering hooks discovered by attribute went to no logger: `HookDiscovery` now receives the application's logger - Login screen: the language switcher's label ("Language" and its icon, shown when a second language is installed) sat at the left edge of the window, far from its dropdown. The card style given to the login form also reached the switcher's form and made it a full-width block; it is an inline block again, as in WordPress's own sheet - Login screen: the rules for the buttons were written `.pollora-login .wp-core-ui …`, but both classes are on ``, so they never matched. The language switcher's button kept wp-admin's blue instead of the theme's primary colour, and the submit button kept WordPress's `button-large` padding diff --git a/src/Hook/UI/Console/ActionMakeCommand.php b/src/Hook/UI/Console/ActionMakeCommand.php index 078731e4..aadecb88 100755 --- a/src/Hook/UI/Console/ActionMakeCommand.php +++ b/src/Hook/UI/Console/ActionMakeCommand.php @@ -6,6 +6,7 @@ use Illuminate\Console\Attributes\Aliases; use Illuminate\Console\Attributes\Description; +use Symfony\Component\Console\Input\InputOption; /** * Class ActionMakeCommand @@ -22,7 +23,7 @@ class ActionMakeCommand extends AttributeMakeCommand * * @var string */ - protected $name = 'pollora:make:action {name}'; + protected $name = 'pollora:make:action'; /** * The type of the attribute. @@ -30,4 +31,15 @@ class ActionMakeCommand extends AttributeMakeCommand * @var string */ protected $type = 'Action'; + + /** + * Get the console command options. + */ + protected function getOptions(): array + { + return [ + ...parent::getOptions(), + ['async', null, InputOption::VALUE_NONE, 'Run the method after the request, with #[Async]'], + ]; + } } diff --git a/src/Hook/UI/Console/AttributeMakeCommand.php b/src/Hook/UI/Console/AttributeMakeCommand.php index 2eeb984b..daf48130 100755 --- a/src/Hook/UI/Console/AttributeMakeCommand.php +++ b/src/Hook/UI/Console/AttributeMakeCommand.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use Pollora\Attributes\Action; +use Pollora\Attributes\Async; use Pollora\Attributes\Filter; use Pollora\Console\AbstractGeneratorCommand; use Symfony\Component\Console\Input\InputOption; @@ -144,7 +145,7 @@ protected function updateExistingFile(string $path): void $stub = File::get($stubPath); $hook = $this->getDefaultOption('hook'); - $hookMethodName = 'handle'.Str::studly(preg_replace('/[^a-zA-Z0-9]/', '', (string) $hook)); + $hookMethodName = $this->methodName((string) $hook); $existingContent = File::get($path); @@ -173,6 +174,7 @@ protected function ensureAttributeImports(string $content): string $requiredImports = [ Action::class, Filter::class, + ...($this->isAsync() ? [Async::class] : []), ]; foreach ($requiredImports as $import) { @@ -198,21 +200,40 @@ public function makeReplacements(string $stub): string { $hook = $this->getDefaultOption('hook'); $priority = $this->getDefaultOption('priority'); - $hookMethodName = 'handle'.Str::studly(preg_replace('/[^a-zA-Z0-9]/', '', (string) $hook)); + $hookMethodName = $this->methodName((string) $hook); $hookType = $this->type; $returnType = $hookType === 'Action' ? ': void' : ''; $arg = $hookType === 'Filter' ? '$arg' : ''; $return = $hookType === 'Filter' ? "\n".' return $arg;' : ''; + $async = $this->isAsync(); + return str_replace( ['{{ hookType }}', '{{ hook }}', '{{ priority }}', '{{ hookMethodName }}', - '{{ arg }}', '{{ returnType }}', '{{ return }}'], - [$hookType, $hook, ', priority:'.$priority, $hookMethodName, $arg, $returnType, $return], + '{{ arg }}', '{{ returnType }}', '{{ return }}', '{{ async }}', '{{ asyncImport }}'], + [$hookType, $hook, ', priority:'.$priority, $hookMethodName, $arg, $returnType, $return, + $async ? "\n #[Async]" : '', $async ? "\nuse ".Async::class.';' : ''], $stub ); } + /** + * The generated method's name: save_post gives handleSavePost. + */ + protected function methodName(string $hook): string + { + return 'handle'.Str::studly((string) preg_replace('/[^a-zA-Z0-9]+/', ' ', $hook)); + } + + /** + * Whether the generated method carries #[Async]: only an action can be asynchronous. + */ + protected function isAsync(): bool + { + return $this->type === 'Action' && $this->hasOption('async') && $this->option('async'); + } + /** * Get the update stub file path. */ diff --git a/src/Hook/UI/Console/FilterMakeCommand.php b/src/Hook/UI/Console/FilterMakeCommand.php index fdb7d98c..7949fed3 100755 --- a/src/Hook/UI/Console/FilterMakeCommand.php +++ b/src/Hook/UI/Console/FilterMakeCommand.php @@ -24,7 +24,7 @@ class FilterMakeCommand extends AttributeMakeCommand * * @var string */ - protected $name = 'pollora:make:filter {name}'; + protected $name = 'pollora:make:filter'; /** * The type of the attribute. diff --git a/src/Hook/UI/Console/stubs/hook-attribute-update.stub b/src/Hook/UI/Console/stubs/hook-attribute-update.stub index 574b97c5..fbefd4e6 100755 --- a/src/Hook/UI/Console/stubs/hook-attribute-update.stub +++ b/src/Hook/UI/Console/stubs/hook-attribute-update.stub @@ -1,4 +1,4 @@ - #[{{ hookType }}('{{ hook }}'{{ priority }})] + #[{{ hookType }}('{{ hook }}'{{ priority }})]{{ async }} public function {{ hookMethodName }}({{ arg }}){{ returnType }} { // Code is executed on the "{{ hook }}" WordPress {{ hookType }} only.{{ return }} diff --git a/src/Hook/UI/Console/stubs/hook-attribute.stub b/src/Hook/UI/Console/stubs/hook-attribute.stub index 7159bf7f..7ef25132 100755 --- a/src/Hook/UI/Console/stubs/hook-attribute.stub +++ b/src/Hook/UI/Console/stubs/hook-attribute.stub @@ -1,11 +1,11 @@ themesDir = sys_get_temp_dir().'/pollora-make-action-'.uniqid(); + mkdir($this->themesDir.'/test-theme', 0755, true); + config(['theme.path' => $this->themesDir]); + $this->app->make(Kernel::class)->registerCommand($this->app->make(ActionMakeCommand::class)); + $this->generated = fn (): string => (string) file_get_contents((string) collect(File::allFiles($this->themesDir))->first()?->getPathname()); +}); + +afterEach(function (): void { + File::deleteDirectory($this->themesDir); +}); + +it('generates an #[Async] method with --async', function (): void { + $this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'save_post', '--async' => true])->assertSuccessful(); + + expect(($this->generated)()) + ->toContain("use Pollora\\Attributes\\Action;\nuse Pollora\\Attributes\\Async;\n") + ->toContain(" #[Action('save_post', priority:10)]\n #[Async]\n public function handleSavePost(): void"); +}); + +it('generates a synchronous method without --async', function (): void { + $this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'save_post'])->assertSuccessful(); + + expect(($this->generated)())->not->toContain('Async'); +}); + +it('adds an #[Async] method, and its import, to an existing class', function (): void { + $this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'save_post'])->assertSuccessful(); + $this->artisan('pollora:make:action', ['name' => 'SyncToCrm', '--theme' => 'test-theme', '--hook' => 'user_register', '--async' => true])->assertSuccessful(); + + expect(($this->generated)()) + ->toContain('use Pollora\\Attributes\\Async;') + ->toContain(" #[Action('save_post', priority:10)]\n public function handleSavePost(): void") + ->toContain(" #[Action('user_register', priority:10)]\n #[Async]\n public function handleUserRegister(): void"); +});