diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf19b3..d06132d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to `pollora/hook` are documented in this file. ## [Unreleased] +### Added + +- `capture()` on an asynchronous registration: a callback run at trigger time, in the original request, with the hook arguments; the array it returns is read back at execution through `AsyncContext::get()`. Captured values travel like arguments. A handler class's public `capture()` method is used when `capture()` is not called. +- `when()`: queue only when the callback, given the hook arguments, returns `true`. It runs before the capture, and an exception it throws goes through, as it would synchronously. + ## [1.2.0] - 2026-10-07 ### Added diff --git a/src/Adapter/Out/WordPress/Action.php b/src/Adapter/Out/WordPress/Action.php index cfa8ea6..c1ae049 100644 --- a/src/Adapter/Out/WordPress/Action.php +++ b/src/Adapter/Out/WordPress/Action.php @@ -91,6 +91,7 @@ public function async(string|array $except = []): PendingAsync registeredArgs: $registration['args'], acceptedArgs: HandlerArguments::hookArgumentCount($registration['callback'], $registration['args']), options: $pending, + defaultCapture: $this->defaultCapture($registration['callback']), ); $this->swapCallback($queued->hook, $queued->priority, $queued->handler, $queued, $queued->acceptedArgs, $queued->handler); @@ -117,6 +118,24 @@ protected function removeHookEvent(string $hook, callable|string|array $callback remove_action($hook, $callback, $priority); } + /** + * The public capture() method of a handler class, used when capture() is not called. + * + * @return array{0: object, 1: 'capture'}|null + */ + private function defaultCapture(callable|string|array $callback): ?array + { + if (! is_array($callback) || ! is_object($callback[0] ?? null) || ($callback[1] ?? null) === 'capture') { + return null; + } + + if (! method_exists($callback[0], 'capture') || ! (new \ReflectionMethod($callback[0], 'capture'))->isPublic()) { + return null; + } + + return [$callback[0], 'capture']; + } + /** * Put an asynchronous handler back to synchronous execution. */ diff --git a/src/Async/ArgumentNormalizer.php b/src/Async/ArgumentNormalizer.php index 4fe99e6..9885d52 100644 --- a/src/Async/ArgumentNormalizer.php +++ b/src/Async/ArgumentNormalizer.php @@ -45,19 +45,21 @@ public function extend(ObjectReference $reference): void } /** - * Normalize hook arguments. + * Normalize hook arguments, or values captured at trigger time. * - * @param array $arguments - * @return array JSON-representable arguments + * @param array $values + * @param string $kind How a value is named in errors: "argument #2", "captured value 'status'" + * @return array JSON-representable values * * @throws UnsupportedArgument When a value cannot be carried */ - public function normalize(array $arguments): array + public function normalize(array $values, string $kind = 'argument'): array { $normalized = []; - foreach ($arguments as $position => $value) { - $normalized[$position] = $this->normalizeValue($value, sprintf('argument #%d', (int) $position + 1)); + foreach ($values as $key => $value) { + $path = is_int($key) ? sprintf('%s #%d', $kind, $key + 1) : sprintf("%s '%s'", $kind, $key); + $normalized[$key] = $this->normalizeValue($value, $path); } return $normalized; diff --git a/src/Async/AsyncDispatcher.php b/src/Async/AsyncDispatcher.php index 230a3e7..1fe992d 100644 --- a/src/Async/AsyncDispatcher.php +++ b/src/Async/AsyncDispatcher.php @@ -31,6 +31,12 @@ public function dispatch(QueuedHandler $handler, array $arguments): void return; } + // Outside the fallback below: a condition that throws fails as it would synchronously + $condition = $handler->options->condition(); + if ($condition instanceof \Closure && $condition(...$arguments) !== true) { + return; + } + try { $payload = new AsyncPayload( id: AsyncPayload::newId(), @@ -39,6 +45,7 @@ public function dispatch(QueuedHandler $handler, array $arguments): void priority: $handler->priority, arguments: $this->normalizer->normalize($arguments), origin: self::origin(), + captured: $this->normalizer->normalize($this->capture($handler, $arguments), 'captured value'), keepMissing: $handler->options->keepsMissing(), ); @@ -76,6 +83,29 @@ public static function origin(): array ]; } + /** + * Run the capture callback of the registration, or the handler class's capture() method. + * + * @param array $arguments + * @return array + */ + private function capture(QueuedHandler $handler, array $arguments): array + { + $capture = $handler->options->captureCallback() ?? $handler->defaultCapture; + + if (! is_callable($capture)) { + return []; + } + + $captured = $capture(...$arguments); + + if (! is_array($captured)) { + throw new \UnexpectedValueException(sprintf('The capture of %s must return an array, %s returned.', $handler->hook, get_debug_type($captured))); + } + + return $captured; + } + /** * The driver requested by the registration, or the default one. * diff --git a/src/Async/PendingAsync.php b/src/Async/PendingAsync.php index d8e8e9e..4d8620d 100644 --- a/src/Async/PendingAsync.php +++ b/src/Async/PendingAsync.php @@ -21,6 +21,12 @@ final class PendingAsync private bool $keepMissing = false; + /** @var (\Closure(mixed...): array)|null */ + private ?\Closure $capture = null; + + /** @var (\Closure(mixed...): bool)|null */ + private ?\Closure $condition = null; + /** @var array */ private array $handlers = []; @@ -95,6 +101,38 @@ public function keepMissing(bool $keepMissing = true): self return $this; } + /** + * Record values at trigger time, read back at execution through AsyncContext::get(). + * + * The callback runs in the original request with the hook arguments and + * returns an array. Values must be able to travel like arguments: capture + * an ID rather than personal data or a secret, they wait in the database. + * + * ->capture(fn (int $postId, WP_Post $post) => ['status' => $post->post_status]) + * + * @param callable(mixed...): array $capture + */ + public function capture(callable $capture): self + { + $this->capture = $capture(...); + + return $this; + } + + /** + * Queue only when the callback, given the hook arguments, returns true. + * + * ->when(fn (int $postId) => ! wp_is_post_revision($postId)) + * + * @param callable(mixed...): bool $condition + */ + public function when(callable $condition): self + { + $this->condition = $condition(...); + + return $this; + } + /** * @internal */ @@ -143,6 +181,26 @@ public function keepsMissing(): bool return $this->keepMissing; } + /** + * @internal + * + * @return (\Closure(mixed...): array)|null + */ + public function captureCallback(): ?\Closure + { + return $this->capture; + } + + /** + * @internal + * + * @return (\Closure(mixed...): bool)|null + */ + public function condition(): ?\Closure + { + return $this->condition; + } + private function rejectUnknownHook(string $hook): void { $message = sprintf( diff --git a/src/Async/QueuedHandler.php b/src/Async/QueuedHandler.php index e6e3ede..5b3549e 100644 --- a/src/Async/QueuedHandler.php +++ b/src/Async/QueuedHandler.php @@ -19,6 +19,7 @@ * @param int $registeredArgs Argument count of the original registration * @param int $acceptedArgs Hook arguments the handler takes, AsyncContext left out * @param PendingAsync $options Options, read when the hook fires + * @param callable|null $defaultCapture The handler class's public capture() method, used when capture() is not called */ public function __construct( public string $hook, @@ -28,6 +29,7 @@ public function __construct( public int $registeredArgs, public int $acceptedArgs, public PendingAsync $options, + public mixed $defaultCapture = null, ) {} public function __invoke(mixed ...$arguments): void diff --git a/tests/Fixtures/Async/CapturingHandler.php b/tests/Fixtures/Async/CapturingHandler.php new file mode 100644 index 0000000..ec9c099 --- /dev/null +++ b/tests/Fixtures/Async/CapturingHandler.php @@ -0,0 +1,29 @@ +> */ + public static array $contexts = []; + + public function handle(int $postId, AsyncContext $context): void + { + self::$contexts[] = ['method' => $context->get('method'), 'postId' => $context->get('postId')]; + } + + /** + * @return array + */ + public function capture(int $postId): array + { + return ['method' => 'capture()', 'postId' => $postId]; + } +} diff --git a/tests/Fixtures/Async/RecordingDriver.php b/tests/Fixtures/Async/RecordingDriver.php new file mode 100644 index 0000000..c737e98 --- /dev/null +++ b/tests/Fixtures/Async/RecordingDriver.php @@ -0,0 +1,35 @@ + */ + public array $queued = []; + + public function available(): bool + { + return true; + } + + public function dispatch(AsyncPayload $payload, int $delay = 0): void + { + $this->queued[] = ['payload' => $payload, 'delay' => $delay]; + } + + public function runAll(): void + { + foreach ($this->queued as $entry) { + Async::receive($entry['payload']->toJson()); + } + } +} diff --git a/tests/Unit/Async/AsyncActionTest.php b/tests/Unit/Async/AsyncActionTest.php index e997b3f..cd591af 100644 --- a/tests/Unit/Async/AsyncActionTest.php +++ b/tests/Unit/Async/AsyncActionTest.php @@ -15,6 +15,7 @@ use Pollora\Hook\Async\PendingAsync; use Pollora\Hook\Async\QueuedHandler; use Pollora\Hook\Domain\Contract\CallbackResolverInterface; +use Pollora\Hook\Tests\Fixtures\Async\RecordingDriver; use Pollora\Hook\Tests\Fixtures\Async\RecordingHandler; beforeEach(function (): void { @@ -37,28 +38,7 @@ $GLOBALS['incidents'][] = $message; }); - $this->driver = new class implements AsyncDriver - { - /** @var list */ - public array $queued = []; - - public function available(): bool - { - return true; - } - - public function dispatch(AsyncPayload $payload, int $delay = 0): void - { - $this->queued[] = ['payload' => $payload, 'delay' => $delay]; - } - - public function runAll(): void - { - foreach ($this->queued as $entry) { - Async::receive($entry['payload']->toJson()); - } - } - }; + $this->driver = new RecordingDriver; Async::extend('recording', fn (): AsyncDriver => $this->driver); $this->action = new Action; diff --git a/tests/Unit/Async/CaptureTest.php b/tests/Unit/Async/CaptureTest.php new file mode 100644 index 0000000..9708ae8 --- /dev/null +++ b/tests/Unit/Async/CaptureTest.php @@ -0,0 +1,174 @@ + 5, 'blog' => 1, 'locale' => 'fr_FR', 'multisite' => false]; + $GLOBALS['incidents'] = []; + RecordingHandler::$calls = []; + CapturingHandler::$contexts = []; + + Async::flush(); + Async::setDebug(true); + Async::reportUsing(function (string $message): void { + $GLOBALS['incidents'][] = $message; + }); + $this->driver = new RecordingDriver; + Async::extend('recording', fn (): AsyncDriver => $this->driver); + Async::setDefaultDriver('recording'); + + $this->action = new Action; +}); + +afterEach(function (): void { + Async::flush(); +}); + +describe('capture()', function (): void { + it('records values at trigger time, read back at execution through the context', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async() + ->capture(fn (int $postId): array => ['source' => 'admin', 'double' => $postId * 2]); + + wp_stub_fire('save_post', 21); + $this->driver->runAll(); + + $context = RecordingHandler::$calls[0]['arguments'][1]; + expect($context)->toBeInstanceOf(AsyncContext::class) + ->and($context->get('source'))->toBe('admin') + ->and($context->get('double'))->toBe(42); + }); + + it('keeps the trigger-time state of an object that is reloaded at execution', function (): void { + $GLOBALS['wp_objects']['post'][12] = new WP_Post(12, 'publish'); + $this->action->add('transition', [RecordingHandler::class, 'contextFirst'])->async() + ->capture(fn (int $postId, string $status): array => [ + 'statusThen' => $GLOBALS['wp_objects']['post'][$postId]->post_status, + 'post' => $GLOBALS['wp_objects']['post'][$postId], + ]); + + wp_stub_fire('transition', 12, 'publish'); + $GLOBALS['wp_objects']['post'][12] = new WP_Post(12, 'trash'); + $this->driver->runAll(); + + $context = RecordingHandler::$calls[0]['arguments'][0]; + expect($context->get('statusThen'))->toBe('publish') + ->and($context->get('post')->post_status)->toBe('trash'); + }); + + it('runs in the original request, not at execution', function (): void { + $GLOBALS['wp_state']['user'] = 5; + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async() + ->capture(fn (): array => ['user' => get_current_user_id()]); + + wp_stub_fire('save_post', 1); + $GLOBALS['wp_state']['user'] = 0; + $this->driver->runAll(); + + expect(RecordingHandler::$calls[0]['arguments'][1]->get('user'))->toBe(5); + }); + + it('uses the public capture() method of the handler class', function (): void { + $this->action->add('save_post', [CapturingHandler::class, 'handle'])->async(); + + wp_stub_fire('save_post', 7); + $this->driver->runAll(); + + expect(CapturingHandler::$contexts)->toBe([['method' => 'capture()', 'postId' => 7]]); + }); + + it('prefers capture() on the registration to the class method', function (): void { + $this->action->add('save_post', [CapturingHandler::class, 'handle'])->async() + ->capture(fn (int $postId): array => ['method' => 'registration', 'postId' => $postId]); + + wp_stub_fire('save_post', 7); + $this->driver->runAll(); + + expect(CapturingHandler::$contexts)->toBe([['method' => 'registration', 'postId' => 7]]); + }); + + it('rejects a captured value that cannot travel, naming it', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async() + ->capture(fn (): array => ['request' => new stdClass]); + + expect(fn () => wp_stub_fire('save_post', 1)) + ->toThrow(UnsupportedArgument::class, "Captured value 'request' cannot be carried"); + }); + + it('requires an array', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async() + ->capture(fn (): string => 'publish'); + + expect(fn () => wp_stub_fire('save_post', 1)) + ->toThrow(UnexpectedValueException::class, 'The capture of save_post must return an array, string returned.'); + }); + + it('runs the handler in place, in production, when the capture fails', function (): void { + Async::setDebug(false); + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async() + ->capture(fn (): array => throw new RuntimeException('Session expired')); + + wp_stub_fire('save_post', 3); + + expect($this->driver->queued)->toBe([]) + ->and($GLOBALS['incidents'])->toBe(['Session expired']) + ->and(RecordingHandler::$calls[0]['arguments'][0])->toBe(3); + }); +}); + +describe('when()', function (): void { + it('queues only when the condition, given the hook arguments, holds', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async() + ->when(fn (int $postId): bool => $postId !== 13); + + wp_stub_fire('save_post', 13); + wp_stub_fire('save_post', 14); + + expect($this->driver->queued)->toHaveCount(1) + ->and($this->driver->queued[0]['payload']->arguments)->toBe([14]); + }); + + it('requires true, not a truthy value', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async() + ->when(fn (int $postId): int => $postId); + + wp_stub_fire('save_post', 1); + + expect($this->driver->queued)->toBe([]); + }); + + it('lets an exception through, as a synchronous handler would, even in production', function (): void { + Async::setDebug(false); + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async() + ->when(fn (): bool => throw new RuntimeException('Condition failed')); + + expect(fn () => wp_stub_fire('save_post', 1))->toThrow(RuntimeException::class, 'Condition failed'); + expect(RecordingHandler::$calls)->toBe([]); + }); + + it('is checked before the capture', function (): void { + $captures = 0; + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async() + ->when(fn (): bool => false) + ->capture(function () use (&$captures): array { + $captures++; + + return []; + }); + + wp_stub_fire('save_post', 1); + + expect($captures)->toBe(0); + }); +});