From 132b6bc5019dfdfd11246c77c8c07f761c4d80e5 Mon Sep 17 00:00:00 2001 From: Abdalrhman Emad Saad <80687771+kettasoft@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:17:31 +0200 Subject: [PATCH 1/4] refactor(attributes): migrate AttributeContext to Engine-based architecture (#52) * refactor(attributes): replace query+state deps with Engine. * refactor(AttributePipeline): remove unused parameter from constructor * refactor: remove unused AttributeHandler implementations * refactor(engines): replace query deps with Engine context * fix(Required): update error message to use payload field instead of context state key * refactor(Scope): update query retrieval method and remove scope applied flag * refactor(CastAttributeTest): mock Engine in AttributeContext for improved test isolation --- .../Attributes/Annotations/Required.php | 2 +- .../Attributes/Annotations/Scope.php | 8 +--- .../Attributes/AttributeContext.php | 48 +++---------------- .../Attributes/AttributePipeline.php | 1 - .../Contracts/AttributeHandlerInterface.php | 17 ------- .../Handlers/DefaultValueHandler.php | 28 ----------- .../Attributes/Handlers/RequiredHandler.php | 29 ----------- src/Engines/Invokable.php | 6 +-- .../Engines/Attributes/CastAttributeTest.php | 2 + 9 files changed, 13 insertions(+), 128 deletions(-) delete mode 100644 src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php delete mode 100644 src/Engines/Foundation/Attributes/Handlers/DefaultValueHandler.php delete mode 100644 src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php diff --git a/src/Engines/Foundation/Attributes/Annotations/Required.php b/src/Engines/Foundation/Attributes/Annotations/Required.php index cd62f626..23237102 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Required.php +++ b/src/Engines/Foundation/Attributes/Annotations/Required.php @@ -37,7 +37,7 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri $payload = $context->payload; if ($payload && ($payload->isEmpty() || $payload->isNull())) { - throw new StrictnessException(sprintf($this->message, $context->state['key'])); + throw new StrictnessException(sprintf($this->message, $payload->field)); } } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Scope.php b/src/Engines/Foundation/Attributes/Annotations/Scope.php index 3206f05c..04db929b 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Scope.php +++ b/src/Engines/Foundation/Attributes/Annotations/Scope.php @@ -32,8 +32,8 @@ public static function stage(): int */ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void { - /** @var \Illuminate\Contracts\Eloquent\Builder $query */ - $query = $context->query; + /** @var \Illuminate\Database\Eloquent\Builder $query */ + $query = $context->engine->getContext()->getBuilder(); /** @var \Kettasoft\Filterable\Support\Payload $payload */ $payload = $context->payload; @@ -47,9 +47,5 @@ public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\Attri } $query->{$scope}($payload->value); - - // Set a flag in context to indicate the scope was applied, - // allowing the engine to optionally skip the filter method execution. - $context->set('scope_applied', true); } } diff --git a/src/Engines/Foundation/Attributes/AttributeContext.php b/src/Engines/Foundation/Attributes/AttributeContext.php index d66ca3bb..5edbed15 100644 --- a/src/Engines/Foundation/Attributes/AttributeContext.php +++ b/src/Engines/Foundation/Attributes/AttributeContext.php @@ -2,6 +2,9 @@ namespace Kettasoft\Filterable\Engines\Foundation\Attributes; +use Kettasoft\Filterable\Engines\Foundation\Engine; +use Kettasoft\Filterable\Support\Payload; + /** * The context in which attributes are processed. * @@ -12,48 +15,11 @@ class AttributeContext /** * Create a new attribute context instance. * - * @param mixed $query - * @param mixed $payload - * @param array $state + * @param Engine $engine + * @param Payload $payload */ public function __construct( - public mixed $query = null, - public mixed $payload = null, - public array $state = [] + public Engine $engine, + public Payload $payload ) {} - - /** - * Set a value in the context state. - * - * @param string $key - * @param mixed $value - * @return void - */ - public function set(string $key, mixed $value): void - { - $this->state[$key] = $value; - } - - /** - * Get a value from the context state. - * - * @param string $key - * @param mixed $default - * @return mixed - */ - public function get(string $key, mixed $default = null): mixed - { - return $this->state[$key] ?? $default; - } - - /** - * Check if a key exists in the context state. - * - * @param string $key - * @return bool - */ - public function has(string $key): bool - { - return array_key_exists($key, $this->state); - } } diff --git a/src/Engines/Foundation/Attributes/AttributePipeline.php b/src/Engines/Foundation/Attributes/AttributePipeline.php index 3db1d0aa..a91847c6 100644 --- a/src/Engines/Foundation/Attributes/AttributePipeline.php +++ b/src/Engines/Foundation/Attributes/AttributePipeline.php @@ -17,7 +17,6 @@ class AttributePipeline /** * Create a new attribute pipeline instance. * - * @param AttributeRegistry $registry * @param AttributeContext $context */ public function __construct(protected AttributeContext $context) diff --git a/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php b/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php deleted file mode 100644 index b1a8bd71..00000000 --- a/src/Engines/Foundation/Attributes/Handlers/Contracts/AttributeHandlerInterface.php +++ /dev/null @@ -1,17 +0,0 @@ -payload; - - if ($payload && $payload->isEmpty()) { - $context->payload->setValue($attribute->value); - } - } -} diff --git a/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php b/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php deleted file mode 100644 index 3a43e52b..00000000 --- a/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php +++ /dev/null @@ -1,29 +0,0 @@ -payload; - - if ($payload && ($payload->isEmpty() || $payload->isNull())) { - throw new StrictnessException(sprintf($attribute->message, $context->state['key'])); - } - } -} diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index 380facb1..b8c8f970 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -79,11 +79,7 @@ protected function applyFilterMethod(string $key, string $method, Payload $paylo return; } - $attrContext = new AttributeContext( - $this->builder, - $payload, - state: ['method' => $method, 'key' => $key] - ); + $attrContext = new AttributeContext($this, $payload); $pipeline = new AttributePipeline($attrContext); $process = $pipeline->process($this->context, $method); diff --git a/tests/Feature/Engines/Attributes/CastAttributeTest.php b/tests/Feature/Engines/Attributes/CastAttributeTest.php index a67fcc95..319fa5a6 100644 --- a/tests/Feature/Engines/Attributes/CastAttributeTest.php +++ b/tests/Feature/Engines/Attributes/CastAttributeTest.php @@ -218,6 +218,7 @@ public function test_cast_attribute_handle_method_directly() { $payload = Payload::create('views', '=', '42', '42'); $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( + engine: $this->createMock(\Kettasoft\Filterable\Engines\Foundation\Engine::class), payload: $payload ); @@ -234,6 +235,7 @@ public function test_cast_attribute_handle_throws_for_invalid_type() $payload = Payload::create('status', '=', 'active', 'active'); $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( + engine: $this->createMock(\Kettasoft\Filterable\Engines\Foundation\Engine::class), payload: $payload ); From 6ed30edb0da133da590badb08edcc00766df1d7a Mon Sep 17 00:00:00 2001 From: kettasoft Date: Mon, 7 Sep 2026 07:19:43 +0300 Subject: [PATCH 2/4] docs: update AttributeContext API examples --- docs/engines/invokable/annotations/index.md | 19 +++++++++--------- docs/engines/invokable/custom-annotations.md | 21 ++++++++++---------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/docs/engines/invokable/annotations/index.md b/docs/engines/invokable/annotations/index.md index fd31f3c7..d42d2d9d 100644 --- a/docs/engines/invokable/annotations/index.md +++ b/docs/engines/invokable/annotations/index.md @@ -199,16 +199,17 @@ protected function search(Payload $payload) The `AttributeContext` object passed to each annotation's `handle()` method contains: -| Property | Type | Description | -| --------- | ------- | ------------------------------------------------- | -| `query` | `mixed` | The Eloquent query builder instance | -| `payload` | `mixed` | The `Payload` object with the filter value | -| `state` | `array` | Shared state array (`method`, `key`, custom data) | +| Property | Type | Description | +| --------- | --------- | ---------------------------------------------- | +| `engine` | `Engine` | The active engine and its `Filterable` context | +| `payload` | `Payload` | The current filter payload | -You can read and write to `state` for inter-attribute communication: +Use the engine to access the current filter or builder. Transform annotations +can update the payload for attributes that run later in the pipeline: ```php -$context->set('my_flag', true); -$context->get('my_flag'); // true -$context->has('my_flag'); // true +$filter = $context->engine->getContext(); +$builder = $filter->getBuilder(); + +$context->payload->setValue(trim($context->payload->value)); ``` diff --git a/docs/engines/invokable/custom-annotations.md b/docs/engines/invokable/custom-annotations.md index d89f5cd9..788cf609 100644 --- a/docs/engines/invokable/custom-annotations.md +++ b/docs/engines/invokable/custom-annotations.md @@ -57,18 +57,19 @@ public static function stage(): int `handle()` receives an `AttributeContext` with: -| Property | Type | Description | -| --------- | --------- | --------------------------------------------------------- | -| `query` | `mixed` | The Eloquent query builder | -| `payload` | `Payload` | The filter payload (field, operator, value, rawValue) | -| `state` | `array` | Shared state between annotations in the same pipeline run | +| Property | Type | Description | +| --------- | --------- | ----------------------------------------------------- | +| `engine` | `Engine` | The active engine and its `Filterable` context | +| `payload` | `Payload` | The filter payload (field, operator, value, raw value) | -You can read and write to `state` for inter-annotation communication: +Use the engine to reach the active filter or query builder, and mutate the +payload when a downstream annotation should receive a transformed value: ```php -$context->set('my_flag', true); -$context->get('my_flag'); // true -$context->has('my_flag'); // true +$filter = $context->engine->getContext(); +$builder = $filter->getBuilder(); + +$context->payload->setValue(trim($context->payload->value)); ``` --- @@ -230,4 +231,4 @@ protected function salary(Payload $payload) - Use `Stage::VALIDATE` for constraints that should run after the value is already cleaned. - Use `SkipExecution` for optional filters, `StrictnessException` for required ones. - Add `Attribute::IS_REPEATABLE` to `#[Attribute(...)]` if the annotation should be stackable on the same method. -- Store computed values in `$context->state` if a downstream annotation (in the same pipeline run) needs them. +- Write transformed values to `$context->payload` when a downstream annotation needs them. From 8c263afef7f0e08c7d814ba3ab57db00ea9b62cd Mon Sep 17 00:00:00 2001 From: Abdalrhman Emad Saad <80687771+kettasoft@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:19:40 +0200 Subject: [PATCH 3/4] feat: track skipped filters with Payload context --- src/Engines/Contracts/Skippable.php | 9 ++-- .../Exceptions/InvalidOperatorException.php | 1 + .../NotAllowedEmptyValueException.php | 5 +- .../Exceptions/NotAllowedFieldException.php | 1 + src/Engines/Exceptions/SkipExecution.php | 3 +- .../Attributes/Annotations/Authorize.php | 5 +- .../Attributes/Annotations/Between.php | 6 ++- .../Foundation/Attributes/Annotations/In.php | 3 +- .../Attributes/Annotations/MapValue.php | 3 +- .../Attributes/Annotations/Regex.php | 6 ++- .../Attributes/Annotations/SkipIf.php | 3 +- src/Engines/Foundation/Engine.php | 11 +++-- src/Exceptions/Handlers/DefaultHandler.php | 5 ++ src/Filterable.php | 48 +++++++++++++++++++ 14 files changed, 91 insertions(+), 18 deletions(-) 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..e1c7382b 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..29787cc8 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,48 @@ 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 + { + $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_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. * From 81eabd442bfaf2d6af36aeec4f89e705e63ca0c9 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Mon, 7 Sep 2026 07:27:58 +0300 Subject: [PATCH 4/4] test: cover skipped payload tracking --- docs/api/filterable.md | 30 ++++++ docs/engines/invokable/custom-annotations.md | 10 +- docs/engines/invokable/testing.md | 11 ++- src/Engines/Foundation/Engine.php | 2 +- src/Filterable.php | 9 +- tests/Feature/Engines/SkipTrackingTest.php | 98 ++++++++++++++++++++ 6 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 tests/Feature/Engines/SkipTrackingTest.php 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/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index e1c7382b..fb7d43f6 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -53,7 +53,7 @@ final protected function attempt(\Closure $callback): bool /** * 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 diff --git a/src/Filterable.php b/src/Filterable.php index 29787cc8..5c2d49f3 100644 --- a/src/Filterable.php +++ b/src/Filterable.php @@ -167,7 +167,7 @@ class Filterable implements FilterableContext, Authorizable, Validatable, Commit /** * Skipped payloads. - * @var array + * @var array */ protected array $skipped = []; @@ -295,6 +295,8 @@ public function commit(string $key, Payload $payload): bool */ public function skip(Payload $payload, ?string $reason = null): bool { + $payload = clone $payload; + $this->skipped[] = [ 'payload' => $payload, 'reason' => $reason, @@ -316,7 +318,10 @@ public function skipped(?string $field = null): array return $this->skipped; } - return array_filter($this->skipped, fn($item) => $item['field'] === $field); + return array_values(array_filter( + $this->skipped, + fn($item) => $item['field'] === $field + )); } /** 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); + } + } +}