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/2] 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/2] 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.