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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/Adapter/Out/WordPress/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.
*/
Expand Down
14 changes: 8 additions & 6 deletions src/Async/ArgumentNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,21 @@ public function extend(ObjectReference $reference): void
}

/**
* Normalize hook arguments.
* Normalize hook arguments, or values captured at trigger time.
*
* @param array<int|string, mixed> $arguments
* @return array<int|string, mixed> JSON-representable arguments
* @param array<int|string, mixed> $values
* @param string $kind How a value is named in errors: "argument #2", "captured value 'status'"
* @return array<int|string, mixed> 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;
Expand Down
30 changes: 30 additions & 0 deletions src/Async/AsyncDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
);

Expand Down Expand Up @@ -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<int, mixed> $arguments
* @return array<string, mixed>
*/
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.
*
Expand Down
58 changes: 58 additions & 0 deletions src/Async/PendingAsync.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ final class PendingAsync

private bool $keepMissing = false;

/** @var (\Closure(mixed...): array<string, mixed>)|null */
private ?\Closure $capture = null;

/** @var (\Closure(mixed...): bool)|null */
private ?\Closure $condition = null;

/** @var array<string, QueuedHandler> */
private array $handlers = [];

Expand Down Expand Up @@ -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<string, mixed> $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
*/
Expand Down Expand Up @@ -143,6 +181,26 @@ public function keepsMissing(): bool
return $this->keepMissing;
}

/**
* @internal
*
* @return (\Closure(mixed...): array<string, mixed>)|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(
Expand Down
2 changes: 2 additions & 0 deletions src/Async/QueuedHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions tests/Fixtures/Async/CapturingHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Tests\Fixtures\Async;

use Pollora\Hook\Async\AsyncContext;

/**
* A handler class with a public capture() method.
*/
final class CapturingHandler
{
/** @var list<array<string, mixed>> */
public static array $contexts = [];

public function handle(int $postId, AsyncContext $context): void
{
self::$contexts[] = ['method' => $context->get('method'), 'postId' => $context->get('postId')];
}

/**
* @return array<string, mixed>
*/
public function capture(int $postId): array
{
return ['method' => 'capture()', 'postId' => $postId];
}
}
35 changes: 35 additions & 0 deletions tests/Fixtures/Async/RecordingDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Tests\Fixtures\Async;

use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\AsyncPayload;
use Pollora\Hook\Async\Contracts\AsyncDriver;

/**
* Keeps queued payloads in memory; runAll() executes them as a driver would.
*/
final class RecordingDriver implements AsyncDriver
{
/** @var list<array{payload: AsyncPayload, delay: int}> */
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());
}
}
}
24 changes: 2 additions & 22 deletions tests/Unit/Async/AsyncActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -37,28 +38,7 @@
$GLOBALS['incidents'][] = $message;
});

$this->driver = new class implements AsyncDriver
{
/** @var list<array{payload: AsyncPayload, delay: int}> */
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;
Expand Down
Loading
Loading