diff --git a/docs/api/filterable.md b/docs/api/filterable.md index 29e1de32..17ed86de 100644 --- a/docs/api/filterable.md +++ b/docs/api/filterable.md @@ -291,6 +291,36 @@ $payload->rawValue; // original request value --- +### Skipped Payloads + +#### `skipped(?string $field = null): array` + +Get every skipped payload, or only entries for a specific field. Each entry +contains a payload snapshot, the skip reason, field, value, and +timestamp. + +#### `hasSkipped(string $field): bool` + +Determine whether a payload for the given field was skipped. + +```php +$filter = PostFilter::create(); +$filter->apply(Post::query()); + +if ($filter->hasSkipped('status')) { + $entry = $filter->skipped('status')[0]; + + $entry['payload']; // Payload snapshot + $entry['reason']; // Why the filter was skipped + $entry['timestamp']; // Carbon timestamp +} +``` + +Skipped payloads are recorded in both permissive and strict modes. Strict mode +still rethrows the associated `SkipExecution` exception after recording it. + +--- + ### Flow Control #### `when(bool $condition, callable $callback): static` diff --git a/docs/engines/invokable/custom-annotations.md b/docs/engines/invokable/custom-annotations.md index 788cf609..9729c5f9 100644 --- a/docs/engines/invokable/custom-annotations.md +++ b/docs/engines/invokable/custom-annotations.md @@ -88,7 +88,7 @@ use Kettasoft\Filterable\Engines\Exceptions\SkipExecution; use Kettasoft\Filterable\Engines\Exceptions\StrictnessException; // Silent skip -throw new SkipExecution('Value too short.'); +throw new SkipExecution('Value too short.', $context->payload); // Hard fail throw new StrictnessException('This field is required.'); @@ -127,7 +127,8 @@ class MinLength implements MethodAttribute if (is_string($value) && mb_strlen($value) < $this->length) { throw new SkipExecution( - "Value must be at least {$this->length} characters." + "Value must be at least {$this->length} characters.", + $context->payload ); } } @@ -207,7 +208,10 @@ class OnlyWhen implements MethodAttribute public function handle(AttributeContext $context): void { if (! auth()->user()?->hasRole($this->role)) { - throw new SkipExecution("User does not have role: {$this->role}"); + throw new SkipExecution( + "User does not have role: {$this->role}", + $context->payload + ); } } } diff --git a/docs/engines/invokable/testing.md b/docs/engines/invokable/testing.md index 76bdd34f..a85f77da 100644 --- a/docs/engines/invokable/testing.md +++ b/docs/engines/invokable/testing.md @@ -72,16 +72,23 @@ public function test_title_filter_uses_like_operator(): void ## Testing Skipped Filters When a filter is skipped (e.g. via `#[SkipIf]`, `#[In]`, or `#[Authorize]`), -the clause should not appear in the query: +the condition should not appear in the query and its payload should be recorded: ```php public function test_status_filter_is_skipped_when_value_is_invalid(): void { $request = $this->makeRequest(['status' => 'invalid_status']); - $query = Post::filter(PostFilter::class, $request); + $filter = new PostFilter($request); + $query = Post::filter($filter); $this->assertStringNotContainsString('status', $query->toSql()); + $this->assertTrue($filter->hasSkipped('status')); + + $skipped = $filter->skipped('status')[0]; + + $this->assertSame('status', $skipped['payload']->field); + $this->assertNotEmpty($skipped['reason']); } ``` diff --git a/src/Engines/Contracts/Skippable.php b/src/Engines/Contracts/Skippable.php index 0f1d1af9..5d95c5c4 100644 --- a/src/Engines/Contracts/Skippable.php +++ b/src/Engines/Contracts/Skippable.php @@ -8,11 +8,12 @@ interface Skippable { /** - * Skip the current execution with a message and optional payload. - * @param string $message - * @param Payload|null $payload + * Skip the current payload execution. + * + * @param Payload $payload The payload being skipped + * @param string|null $message The reason for skipping * @throws SkipExecution * @return never */ - public function skip(string $message, ?Payload $payload = null): never; + public function skip(Payload $payload, ?string $message = null): never; } diff --git a/src/Engines/Exceptions/InvalidOperatorException.php b/src/Engines/Exceptions/InvalidOperatorException.php index 8c544908..c730fc90 100644 --- a/src/Engines/Exceptions/InvalidOperatorException.php +++ b/src/Engines/Exceptions/InvalidOperatorException.php @@ -9,6 +9,7 @@ class InvalidOperatorException extends SkipExecution /** * InvalidOperatorException constructor. * @param string $operator + * @param Payload|null $payload */ public function __construct(string $operator, ?Payload $payload = null) { diff --git a/src/Engines/Exceptions/NotAllowedEmptyValueException.php b/src/Engines/Exceptions/NotAllowedEmptyValueException.php index aea17530..db45c181 100644 --- a/src/Engines/Exceptions/NotAllowedEmptyValueException.php +++ b/src/Engines/Exceptions/NotAllowedEmptyValueException.php @@ -8,9 +8,10 @@ class NotAllowedEmptyValueException extends SkipExecution { /** * NotAllowedEmptyValueException constructor. - * @param mixed $message + * @param string $message + * @param Payload|null $payload */ - public function __construct($message = "", ?Payload $payload = null) + public function __construct(string $message = "", ?Payload $payload = null) { parent::__construct($message, $payload); } diff --git a/src/Engines/Exceptions/NotAllowedFieldException.php b/src/Engines/Exceptions/NotAllowedFieldException.php index 4048df67..5571a8e8 100644 --- a/src/Engines/Exceptions/NotAllowedFieldException.php +++ b/src/Engines/Exceptions/NotAllowedFieldException.php @@ -9,6 +9,7 @@ class NotAllowedFieldException extends SkipExecution /** * NotAllowedFieldException constructor. * @param string $field + * @param Payload|null $payload */ public function __construct(string $field, ?Payload $payload = null) { diff --git a/src/Engines/Exceptions/SkipExecution.php b/src/Engines/Exceptions/SkipExecution.php index 9520d5f0..cfb6f122 100644 --- a/src/Engines/Exceptions/SkipExecution.php +++ b/src/Engines/Exceptions/SkipExecution.php @@ -18,7 +18,8 @@ public function __construct(string $message, protected ?Payload $payload = null) } /** - * Get the associated payload. + * Get the associated payload that was skipped. + * @return Payload|null */ public function getPayload(): ?Payload { diff --git a/src/Engines/Foundation/Attributes/Annotations/Authorize.php b/src/Engines/Foundation/Attributes/Annotations/Authorize.php index 9c9a8103..277c8dc6 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Authorize.php +++ b/src/Engines/Foundation/Attributes/Annotations/Authorize.php @@ -38,7 +38,10 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri $authorize = new $this->authorize; if (! $authorize->authorize()) { - throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution("Authorization failed for class '{$this->authorize}'."); + throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution( + "Authorization failed for class '{$this->authorize}'.", + $context->payload + ); } } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Between.php b/src/Engines/Foundation/Attributes/Annotations/Between.php index ea1b5032..2bd9d00f 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Between.php +++ b/src/Engines/Foundation/Attributes/Annotations/Between.php @@ -42,7 +42,8 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri if (! is_numeric($payload->value)) { throw new SkipExecution( - "The value '{$payload->value}' is not numeric. Expected a value between {$this->min} and {$this->max}." + "The value '{$payload->value}' is not numeric. Expected a value between {$this->min} and {$this->max}.", + $payload ); } @@ -50,7 +51,8 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri if ($value < $this->min || $value > $this->max) { throw new SkipExecution( - "The value '{$value}' is not between {$this->min} and {$this->max}." + "The value '{$value}' is not between {$this->min} and {$this->max}.", + $payload ); } } diff --git a/src/Engines/Foundation/Attributes/Annotations/In.php b/src/Engines/Foundation/Attributes/Annotations/In.php index baa40716..346bfc6b 100644 --- a/src/Engines/Foundation/Attributes/Annotations/In.php +++ b/src/Engines/Foundation/Attributes/Annotations/In.php @@ -46,7 +46,8 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri if ($payload->notIn($this->values)) { throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution( - "The value '{$payload->value}' is not in the allowed set: " . implode(', ', $this->values) + "The value '{$payload->value}' is not in the allowed set: " . implode(', ', $this->values), + $payload ); } } diff --git a/src/Engines/Foundation/Attributes/Annotations/MapValue.php b/src/Engines/Foundation/Attributes/Annotations/MapValue.php index 6e785f70..1c65a447 100644 --- a/src/Engines/Foundation/Attributes/Annotations/MapValue.php +++ b/src/Engines/Foundation/Attributes/Annotations/MapValue.php @@ -63,7 +63,8 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri if ($this->strict) { throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution( - "The value '{$key}' is not in the value map: " . implode(', ', array_keys($this->map)) + "The value '{$key}' is not in the value map: " . implode(', ', array_keys($this->map)), + $payload ); } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Regex.php b/src/Engines/Foundation/Attributes/Annotations/Regex.php index e4789800..46846263 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Regex.php +++ b/src/Engines/Foundation/Attributes/Annotations/Regex.php @@ -42,13 +42,15 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri if (! is_string($payload->value)) { throw new SkipExecution( - $this->message ?: "The value is not a string and cannot be matched against pattern '{$this->pattern}'." + $this->message ?: "The value is not a string and cannot be matched against pattern '{$this->pattern}'.", + $payload ); } if (! preg_match($this->pattern, $payload->value)) { throw new SkipExecution( - $this->message ?: "The value '{$payload->value}' does not match the pattern '{$this->pattern}'." + $this->message ?: "The value '{$payload->value}' does not match the pattern '{$this->pattern}'.", + $payload ); } } diff --git a/src/Engines/Foundation/Attributes/Annotations/SkipIf.php b/src/Engines/Foundation/Attributes/Annotations/SkipIf.php index 583fa0d2..31e8b7e4 100644 --- a/src/Engines/Foundation/Attributes/Annotations/SkipIf.php +++ b/src/Engines/Foundation/Attributes/Annotations/SkipIf.php @@ -63,7 +63,8 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri if ($result) { throw new SkipExecution( - $this->message ?: "Filter skipped because payload {$this->check} check was true." + $this->message ?: "Filter skipped because payload {$this->check} check was true.", + $payload ); } } diff --git a/src/Engines/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index ab2ddb55..fb7d43f6 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -52,11 +52,16 @@ final protected function attempt(\Closure $callback): bool } /** - * @inheritDoc + * Skip the current filter execution with a message and payload. + * + * @param \Kettasoft\Filterable\Support\Payload $payload The payload being skipped + * @param string|null $message The reason for skipping + * @return never + * @throws SkipExecution */ - public function skip(string $message, ?Payload $payload = null): never + public function skip(Payload $payload, ?string $message = null): never { - throw new SkipExecution($message, $payload); + throw new SkipExecution($message ?? 'Filter execution skipped.', $payload); } /** diff --git a/src/Exceptions/Handlers/DefaultHandler.php b/src/Exceptions/Handlers/DefaultHandler.php index 4a91d354..e666eb7a 100644 --- a/src/Exceptions/Handlers/DefaultHandler.php +++ b/src/Exceptions/Handlers/DefaultHandler.php @@ -20,6 +20,11 @@ public function handle(\Throwable|SkipExecution $exception, Engine $engine): boo if ($this->hasSkipping($exception)) { /** @var SkipExecution $exception */ + // Register the skipped payload in the context + if ($payload = $exception->getPayload()) { + $engine->getContext()->skip($payload, $exception->getMessage()); + } + if ($engine->isStrict() || $this->isStrictThrowing()) { throw $exception; } diff --git a/src/Filterable.php b/src/Filterable.php index 14b866f0..5c2d49f3 100644 --- a/src/Filterable.php +++ b/src/Filterable.php @@ -165,6 +165,12 @@ class Filterable implements FilterableContext, Authorizable, Validatable, Commit */ protected $applied = []; + /** + * Skipped payloads. + * @var array + */ + protected array $skipped = []; + /** * Create a new Filterable instance. * @param Request|null $request @@ -281,6 +287,53 @@ public function commit(string $key, Payload $payload): bool return true; } + /** + * Register a skipped payload. + * @param Payload $payload + * @param string|null $reason Optional reason for skipping + * @return bool + */ + public function skip(Payload $payload, ?string $reason = null): bool + { + $payload = clone $payload; + + $this->skipped[] = [ + 'payload' => $payload, + 'reason' => $reason, + 'field' => $payload->field, + 'value' => $payload->value, + 'timestamp' => now(), + ]; + + return true; + } + + /** + * Get all skipped payloads. + * @return array + */ + public function skipped(?string $field = null): array + { + if ($field === null) { + return $this->skipped; + } + + return array_values(array_filter( + $this->skipped, + fn($item) => $item['field'] === $field + )); + } + + /** + * Check if a specific field was skipped. + * @param string $field + * @return bool + */ + public function hasSkipped(string $field): bool + { + return !empty($this->skipped($field)); + } + /** * Get applied payloads. * diff --git a/tests/Feature/Engines/SkipTrackingTest.php b/tests/Feature/Engines/SkipTrackingTest.php new file mode 100644 index 00000000..65af91bb --- /dev/null +++ b/tests/Feature/Engines/SkipTrackingTest.php @@ -0,0 +1,98 @@ +merge(['status' => 'invalid']); + + $filter = new class extends Filterable { + protected $filters = ['status']; + + #[In('active', 'pending')] + public function status(Payload $payload) + { + $this->builder->where('status', $payload->value); + } + }; + + Post::filter($filter)->get(); + + $this->assertTrue($filter->hasSkipped('status')); + $this->assertNull($filter->applied('status')); + + $skipped = $filter->skipped('status'); + + $this->assertCount(1, $skipped); + $this->assertInstanceOf(Payload::class, $skipped[0]['payload']); + $this->assertSame('status', $skipped[0]['field']); + $this->assertSame('invalid', $skipped[0]['value']); + $this->assertStringContainsString('not in the allowed set', $skipped[0]['reason']); + $this->assertInstanceOf(Carbon::class, $skipped[0]['timestamp']); + } + + public function test_skipped_payloads_are_stored_as_snapshots() + { + $filter = new Filterable; + $filter->skip( + Payload::create('title', '=', 'invalid', 'invalid'), + 'Invalid title' + ); + + $payload = Payload::create('status', '=', 'invalid', 'invalid'); + + $filter->skip($payload, 'Invalid status'); + $payload->setValue('changed'); + + $skipped = $filter->skipped('status'); + + $this->assertArrayHasKey(0, $skipped); + $this->assertCount(1, $skipped); + $this->assertSame('invalid', $skipped[0]['payload']->value); + $this->assertSame('invalid', $skipped[0]['value']); + } + + public function test_engine_skip_uses_a_default_reason() + { + $filter = new Filterable; + $payload = Payload::create('status', '=', 'invalid', 'invalid'); + + try { + $filter->getEngine()->skip($payload); + $this->fail('Expected SkipExecution to be thrown.'); + } catch (SkipExecution $exception) { + $this->assertSame('Filter execution skipped.', $exception->getMessage()); + $this->assertSame($payload, $exception->getPayload()); + } + } + + public function test_strict_mode_still_records_the_skipped_payload() + { + request()->merge(['status' => 'invalid']); + + $filter = new class extends Filterable { + protected $filters = ['status']; + + #[In('active', 'pending')] + public function status(Payload $payload) {} + }; + + try { + Post::filter($filter->strict())->get(); + $this->fail('Expected SkipExecution to be thrown.'); + } catch (SkipExecution $exception) { + $this->assertTrue($filter->hasSkipped('status')); + $this->assertSame('status', $exception->getPayload()?->field); + } + } +}