From db1a087b2495f36d33201f895b1c8d68497bade7 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sun, 1 Mar 2026 02:06:23 +0200 Subject: [PATCH 01/18] refactor(RequiredValueAttributeTest): remove redundant test for custom exception message --- .../Attributes/RequiredValueAttributeTest.php | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php b/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php index 340602f..f2aaf8a 100644 --- a/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php +++ b/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php @@ -48,26 +48,4 @@ public function status(\Kettasoft\Filterable\Support\Payload $payload) $this->assertStringContainsString($sql, \Kettasoft\Filterable\Tests\Models\Post::filter($class)->toRawSql()); } - - public function test_required_value_attribute_throws_exception_when_value_missing_with_custom_message() - { - $this->expectException(StrictnessException::class); - $this->expectExceptionMessage("The 'status' parameter is mandatory."); - - request()->merge([ - 'status' => '', - ]); - - $class = new class extends \Kettasoft\Filterable\Filterable { - protected $filters = ['status']; - - #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required('The \'%s\' parameter is mandatory.')] - public function status(\Kettasoft\Filterable\Support\Payload $payload) - { - $this->builder->where('name', '=', $payload); - } - }; - - \Kettasoft\Filterable\Tests\Models\Post::filter($class); - } } From 661e7e4756ea5c7dddc1bf00e8c379c1a6318c36 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sun, 1 Mar 2026 02:17:56 +0200 Subject: [PATCH 02/18] feat(Payload): add cast and as methods for dynamic type casting --- src/Support/Payload.php | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/Support/Payload.php b/src/Support/Payload.php index 1cc61c0..e1dc851 100644 --- a/src/Support/Payload.php +++ b/src/Support/Payload.php @@ -531,6 +531,44 @@ public function split(string $delimiter = ','): array return $this->explode($delimiter); } + /** + * Cast the payload value to the given type using the corresponding as* method. + * + * Supported types: 'boolean', 'array', 'int', 'carbon', 'slug', 'like', 'json'. + * + * Example: $payload->cast('int'), $payload->cast('boolean') + * + * @param string $type + * @param mixed ...$args Additional arguments to pass to the cast method. + * @return mixed + * + * @throws \InvalidArgumentException if the cast type method does not exist. + */ + public function cast(string $type, mixed ...$args): mixed + { + $method = 'as' . ucfirst($type); + + if (!method_exists($this, $method)) { + throw new \InvalidArgumentException("Cast type [{$type}] is not supported. Method {$method} does not exist."); + } + + return $this->$method(...$args); + } + + /** + * Alias for cast method. + * + * @param string $type + * @param mixed ...$args Additional arguments to pass to the cast method. + * @return mixed + * + * @throws \InvalidArgumentException if the cast type method does not exist. + */ + public function as(string $type, mixed ...$args): mixed + { + return $this->cast($type, ...$args); + } + /** * Wrap the value with a given prefix and suffix. * From c2a527d502cd625c9a7ee42f87a559da0f387ac4 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sun, 1 Mar 2026 02:43:05 +0200 Subject: [PATCH 03/18] feat(Attributes): implement MethodAttribute interface and enhance attribute handling --- .../Attributes/Annotations/DefaultValue.php | 28 +++++++++- .../Attributes/Annotations/Required.php | 36 ++++++++++++- .../Attributes/AttributePipeline.php | 17 ++++-- .../Attributes/AttributeRegistry.php | 54 +++++++------------ .../Attributes/Contracts/MethodAttribute.php | 23 ++++++++ .../Foundation/Attributes/Enums/Stage.php | 29 ++++++++++ src/Engines/Invokable.php | 2 +- 7 files changed, 146 insertions(+), 43 deletions(-) create mode 100644 src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php create mode 100644 src/Engines/Foundation/Attributes/Enums/Stage.php diff --git a/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php b/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php index 6cd9fe3..d8f9c0b 100644 --- a/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php +++ b/src/Engines/Foundation/Attributes/Annotations/DefaultValue.php @@ -5,11 +5,37 @@ use Attribute; #[Attribute(Attribute::TARGET_METHOD)] -class DefaultValue +class DefaultValue implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { /** * Constructor for DefaultValue attribute. * @param mixed $value The default value to be used if none is provided. */ public function __construct(public mixed $value) {} + + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + if ($payload->isEmpty() || $payload->isNull()) { + $payload->setValue($this->value); + } + } } diff --git a/src/Engines/Foundation/Attributes/Annotations/Required.php b/src/Engines/Foundation/Attributes/Annotations/Required.php index 40cbf01..cd62f62 100644 --- a/src/Engines/Foundation/Attributes/Annotations/Required.php +++ b/src/Engines/Foundation/Attributes/Annotations/Required.php @@ -3,9 +3,41 @@ namespace Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations; use Attribute; +use Kettasoft\Filterable\Exceptions\StrictnessException; #[Attribute(Attribute::TARGET_METHOD)] -class Required +class Required implements \Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute { - public function __construct(public string $message = "The parameter '%s' is required.") {} + /** + * The error message template. %s will be replaced with the parameter name. + * @var string + */ + public string $message = "The parameter '%s' is required."; + + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + * @throws StrictnessException if the parameter is missing or empty. + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + if ($payload && ($payload->isEmpty() || $payload->isNull())) { + throw new StrictnessException(sprintf($this->message, $context->state['key'])); + } + } } diff --git a/src/Engines/Foundation/Attributes/AttributePipeline.php b/src/Engines/Foundation/Attributes/AttributePipeline.php index 27e6bf5..3db1d0a 100644 --- a/src/Engines/Foundation/Attributes/AttributePipeline.php +++ b/src/Engines/Foundation/Attributes/AttributePipeline.php @@ -8,13 +8,22 @@ class AttributePipeline { + /** + * The attribute registry instance. + * @var AttributeRegistry + */ + protected AttributeRegistry $registry; + /** * Create a new attribute pipeline instance. * * @param AttributeRegistry $registry * @param AttributeContext $context */ - public function __construct(protected AttributeRegistry $registry, protected AttributeContext $context) {} + public function __construct(protected AttributeContext $context) + { + $this->registry = new AttributeRegistry(); + } /** * Process the attributes for the given target and method. @@ -27,10 +36,10 @@ public function process(Filterable $target, string $method): Outcome $execution = new Execution(); try { - $handlers = $this->registry->getHandlersForMethod($target, $method); + $attributes = $this->registry->getHandlersForMethod($target, $method); - foreach ($handlers as [$handler, $attributeInstance]) { - (new $handler)->handle($this->context, $attributeInstance); + foreach ($attributes as $attribute) { + $attribute->handle($this->context); } } catch (\Exception $e) { $execution->fail($e); diff --git a/src/Engines/Foundation/Attributes/AttributeRegistry.php b/src/Engines/Foundation/Attributes/AttributeRegistry.php index c1aed3f..496fc65 100644 --- a/src/Engines/Foundation/Attributes/AttributeRegistry.php +++ b/src/Engines/Foundation/Attributes/AttributeRegistry.php @@ -2,56 +2,40 @@ namespace Kettasoft\Filterable\Engines\Foundation\Attributes; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\DefaultValue; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required; -use ReflectionMethod; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Contracts\MethodAttribute; use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers\Contracts\AttributeHandlerInterface; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers\DefaultValueHandler; -use Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers\RequiredHandler; +use ReflectionMethod; class AttributeRegistry { - /** - * @var array - */ - protected array $handlers = [ - DefaultValue::class => DefaultValueHandler::class, - Required::class => RequiredHandler::class, - ]; - - /** - * Register an attribute handler. - * @param string $attributeClass - * @param AttributeHandlerInterface $handler - * @return void - */ - public function register(string $attributeClass, AttributeHandlerInterface $handler): void - { - $this->handlers[$attributeClass] = $handler; - } - /** * Get handlers for the given method of a filterable class. * * @param Filterable $filterable * @param string $method - * @return array + * @return array */ public function getHandlersForMethod(Filterable $filterable, string $method): array { $reflection = new ReflectionMethod($filterable, $method); - $attributes = $reflection->getAttributes(); - $matchedHandlers = []; - - foreach ($attributes as $attribute) { - $attrName = $attribute->getName(); - if (isset($this->handlers[$attrName])) { - $handler = $this->handlers[$attrName]; - $matchedHandlers[] = [$handler, $attribute->newInstance()]; + + $resolved = []; + + foreach ($reflection->getAttributes() as $attribute) { + $instance = $attribute->newInstance(); + + if (! $instance instanceof MethodAttribute) { + continue; } + + $resolved[] = $instance; } - return $matchedHandlers; + usort( + $resolved, + fn($a, $b) => $a::stage() <=> $b::stage() + ); + + return $resolved; } } diff --git a/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php b/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php new file mode 100644 index 0000000..8862244 --- /dev/null +++ b/src/Engines/Foundation/Attributes/Contracts/MethodAttribute.php @@ -0,0 +1,23 @@ + $method, 'key' => $key] ); - $pipeline = new AttributePipeline(new AttributeRegistry(), $attrContext); + $pipeline = new AttributePipeline($attrContext); $process = $pipeline->process($this->context, $method); $process->then(function () use ($method, $payload) { From 37d97b79d135de675156f3e27b55f8eff9ef6a58 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sun, 1 Mar 2026 02:43:43 +0200 Subject: [PATCH 04/18] feat(Cast): implement Cast attribute for type casting and add tests --- .../Attributes/Annotations/Cast.php | 45 ++++ .../Engines/Attributes/CastAttributeTest.php | 245 ++++++++++++++++++ 2 files changed, 290 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/Cast.php create mode 100644 tests/Feature/Engines/Attributes/CastAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/Cast.php b/src/Engines/Foundation/Attributes/Annotations/Cast.php new file mode 100644 index 0000000..5e8c475 --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/Cast.php @@ -0,0 +1,45 @@ +value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + * @throws StrictnessException if the parameter is missing or empty. + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + try { + $payload->cast($this->type); + } catch (\Exception $e) { + throw new StrictnessException($e->getMessage()); + } + } +} diff --git a/tests/Feature/Engines/Attributes/CastAttributeTest.php b/tests/Feature/Engines/Attributes/CastAttributeTest.php new file mode 100644 index 0000000..7b17507 --- /dev/null +++ b/tests/Feature/Engines/Attributes/CastAttributeTest.php @@ -0,0 +1,245 @@ +merge([ + 'views' => '42', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Cast('int')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->cast('int')); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('where "views" = 42', $sql); + } + + public function test_cast_attribute_casts_value_to_boolean_true() + { + request()->merge([ + 'is_featured' => 'true', + ]); + + $class = new class extends Filterable { + protected $filters = ['is_featured']; + + #[Cast('boolean')] + public function isFeatured(Payload $payload) + { + $this->builder->where('is_featured', '=', $payload->cast('boolean')); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"is_featured" = 1', $sql); + } + + public function test_cast_attribute_casts_value_to_boolean_false() + { + request()->merge([ + 'is_featured' => 'false', + ]); + + $class = new class extends Filterable { + protected $filters = ['is_featured']; + + #[Cast('boolean')] + public function isFeatured(Payload $payload) + { + $this->builder->where('is_featured', '=', $payload->cast('boolean')); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"is_featured"', $sql); + } + + public function test_cast_attribute_casts_value_to_array_from_json() + { + request()->merge([ + 'tags' => '["php","laravel"]', + ]); + + $class = new class extends Filterable { + protected $filters = ['tags']; + + #[Cast('array')] + public function tags(Payload $payload) + { + $casted = $payload->cast('array'); + $this->builder->whereIn('tags', $casted); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"tags" in', $sql); + $this->assertStringContainsString('php', $sql); + $this->assertStringContainsString('laravel', $sql); + } + + public function test_cast_attribute_throws_strictness_exception_for_unsupported_type() + { + $this->expectException(StrictnessException::class); + $this->expectExceptionMessage('Cast type [unsupported] is not supported.'); + + request()->merge([ + 'status' => 'active', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[Cast('unsupported')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload); + } + }; + + Post::filter($class)->toRawSql(); + } + + public function test_cast_attribute_does_not_throw_for_valid_cast_type() + { + request()->merge([ + 'views' => '100', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Cast('int')] + public function views(Payload $payload) + { + $this->builder->where('views', '>', $payload->cast('int')); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('where "views" > 100', $sql); + } + + public function test_cast_attribute_with_slug_type() + { + request()->merge([ + 'title' => 'Hello World Post', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Cast('slug')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->cast('slug')); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('hello-world-post', $sql); + } + + public function test_cast_attribute_with_like_type() + { + request()->merge([ + 'title' => 'Laravel', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Cast('like')] + public function title(Payload $payload) + { + $this->builder->where('title', 'LIKE', $payload->cast('like')); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('%Laravel%', $sql); + } + + public function test_cast_attribute_with_empty_value_for_int_returns_null() + { + request()->merge([ + 'views' => '', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Cast('int')] + public function views(Payload $payload) + { + $casted = $payload->cast('int'); + if (!is_null($casted)) { + $this->builder->where('views', '=', $casted); + } + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Empty non-numeric value should produce null from asInt(), so no where clause added + $this->assertStringNotContainsString('where "views"', $sql); + } + + public function test_cast_attribute_stage_is_transform() + { + $this->assertEquals( + \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value, + Cast::stage() + ); + } + + public function test_cast_attribute_handle_method_directly() + { + $payload = Payload::create('views', '=', '42', '42'); + $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( + payload: $payload + ); + + $cast = new Cast('int'); + $cast->handle($context); + + // handle doesn't throw, the cast is valid + $this->assertTrue(true); + } + + public function test_cast_attribute_handle_throws_for_invalid_type() + { + $this->expectException(StrictnessException::class); + + $payload = Payload::create('status', '=', 'active', 'active'); + $context = new \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext( + payload: $payload + ); + + $cast = new Cast('nonExistent'); + $cast->handle($context); + } +} From 102e5c4203cee4dab3cccb424d32086fe2aacd01 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sun, 1 Mar 2026 03:03:45 +0200 Subject: [PATCH 05/18] feat(In): add In attribute for value validation and implement tests --- .../Foundation/Attributes/Annotations/In.php | 53 ++++++++++++++++++ .../Engines/Attributes/InAttributeTest.php | 55 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/In.php create mode 100644 tests/Feature/Engines/Attributes/InAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/In.php b/src/Engines/Foundation/Attributes/Annotations/In.php new file mode 100644 index 0000000..baa4071 --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/In.php @@ -0,0 +1,53 @@ +values = $values; + } + + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::VALIDATE->value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + 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) + ); + } + } +} diff --git a/tests/Feature/Engines/Attributes/InAttributeTest.php b/tests/Feature/Engines/Attributes/InAttributeTest.php new file mode 100644 index 0000000..d94606c --- /dev/null +++ b/tests/Feature/Engines/Attributes/InAttributeTest.php @@ -0,0 +1,55 @@ +merge([ + 'status' => 'allowedValue', + ]); + $class = new class extends Filterable { + protected $filters = ['status']; + + #[In('allowedValue', 'anotherAllowedValue')] + public function status(Payload $payload) + { + $this->builder->where('name', '=', $payload); + } + }; + + $sql = 'select * from "posts" where "name" = \'allowedValue\''; + + $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); + } + + public function test_in_attribute_throws_exception_for_value_not_in_allowed_set() + { + request()->merge([ + 'status' => 'stopped', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[In('pending', 'approved', 'rejected')] + public function status(Payload $payload) + { + $this->builder->where('name', '=', $payload); + } + }; + + $sql = 'select * from "posts"'; + + $this->assertStringContainsString($sql, Post::filter($class)->toRawSql()); + } +} From 71ebd1381a542de48af9e631168d333709a16072 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sun, 1 Mar 2026 03:17:31 +0200 Subject: [PATCH 06/18] feat(Payload): enhance explode and split methods to support value overwriting --- src/Support/Payload.php | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/Support/Payload.php b/src/Support/Payload.php index e1dc851..5220628 100644 --- a/src/Support/Payload.php +++ b/src/Support/Payload.php @@ -508,27 +508,40 @@ public function asInt(): ?int * If the value is a string, it will be split by the delimiter. * If the value is already an array, it will be returned as is. * - * @param string $delimiter + * @param string $delimiter The delimiter to split the string by. + * @param bool $overwrite Whether to replace the original payload value. Defaults to false. * @return array */ - public function explode(string $delimiter = ','): array + public function explode(string $delimiter = ',', bool $overwrite = false): array { + if ($this->isArray()) { + return (array) $this->value; + } + if ($this->isString()) { - return explode($delimiter, $this->value); + $exploded = explode($delimiter, $this->value); + + if ($overwrite) { + $this->value = $exploded; + } + + return $exploded; } + // If value is neither string nor array, just return it as-is return (array) $this->value; } /** * Alias for explode method. * - * @param string $delimiter + * @param string $delimiter The delimiter to split the string by. + * @param bool $overwrite Whether to replace the original payload value. Defaults to false. * @return array */ - public function split(string $delimiter = ','): array + public function split(string $delimiter = ',', bool $overwrite = false): array { - return $this->explode($delimiter); + return $this->explode($delimiter, $overwrite); } /** From 2687ccc908aab597c0680d3c0287e3eef46ae01d Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sun, 1 Mar 2026 03:17:52 +0200 Subject: [PATCH 07/18] feat(Explode): add Explode attribute for string splitting functionality --- .../Attributes/Annotations/Explode.php | 39 +++++++++++++++++++ .../Attributes/ExplodeAttributeTest.php | 38 ++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/Explode.php create mode 100644 tests/Feature/Engines/Attributes/ExplodeAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/Explode.php b/src/Engines/Foundation/Attributes/Annotations/Explode.php new file mode 100644 index 0000000..7f0c590 --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/Explode.php @@ -0,0 +1,39 @@ +value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + $payload->explode($this->delimiter, true); + } +} diff --git a/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php b/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php new file mode 100644 index 0000000..05dba84 --- /dev/null +++ b/tests/Feature/Engines/Attributes/ExplodeAttributeTest.php @@ -0,0 +1,38 @@ +merge([ + 'tags' => 'php,laravel,testing', + ]); + + $class = new class extends Filterable { + protected $filters = ['tags']; + + #[Explode(',')] + public function tags(Payload $payload) + { + $this->builder->whereIn('tags', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('where "tags" in', $sql); + $this->assertStringContainsString('php', $sql); + $this->assertStringContainsString('laravel', $sql); + $this->assertStringContainsString('testing', $sql); + } +} From 395ffaa9ce0c252be4b36e0d59284bb11da8f7fb Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 09:57:21 +0200 Subject: [PATCH 08/18] feat(Between): implement Between attribute for value range validation and add corresponding tests --- .../Attributes/Annotations/Between.php | 57 ++++++ .../Attributes/BetweenAttributeTest.php | 164 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/Between.php create mode 100644 tests/Feature/Engines/Attributes/BetweenAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/Between.php b/src/Engines/Foundation/Attributes/Annotations/Between.php new file mode 100644 index 0000000..ea1b503 --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/Between.php @@ -0,0 +1,57 @@ +value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + if (! is_numeric($payload->value)) { + throw new SkipExecution( + "The value '{$payload->value}' is not numeric. Expected a value between {$this->min} and {$this->max}." + ); + } + + $value = (float) $payload->value; + + if ($value < $this->min || $value > $this->max) { + throw new SkipExecution( + "The value '{$value}' is not between {$this->min} and {$this->max}." + ); + } + } +} diff --git a/tests/Feature/Engines/Attributes/BetweenAttributeTest.php b/tests/Feature/Engines/Attributes/BetweenAttributeTest.php new file mode 100644 index 0000000..1df33e6 --- /dev/null +++ b/tests/Feature/Engines/Attributes/BetweenAttributeTest.php @@ -0,0 +1,164 @@ +merge([ + 'views' => '50', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + $this->assertStringContainsString('50', $sql); + } + + public function test_between_attribute_allows_value_at_minimum_boundary() + { + request()->merge([ + 'views' => '1', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + } + + public function test_between_attribute_allows_value_at_maximum_boundary() + { + request()->merge([ + 'views' => '100', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + } + + public function test_between_attribute_skips_filter_when_value_below_range() + { + request()->merge([ + 'views' => '0', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped, no where clause for views + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_between_attribute_skips_filter_when_value_above_range() + { + request()->merge([ + 'views' => '200', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_between_attribute_skips_filter_for_non_numeric_value() + { + request()->merge([ + 'views' => 'abc', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1, max: 100)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_between_attribute_works_with_float_values() + { + request()->merge([ + 'views' => '3.5', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Between(min: 1.0, max: 5.0)] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + $this->assertStringContainsString('3.5', $sql); + } +} From 75e90d1b065817ced115223d748da718612c31fe Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 09:59:01 +0200 Subject: [PATCH 09/18] feat(Regex): implement Regex attribute for regex pattern validation and add corresponding tests --- .../Attributes/Annotations/Regex.php | 55 ++++++ .../Engines/Attributes/RegexAttributeTest.php | 161 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/Regex.php create mode 100644 tests/Feature/Engines/Attributes/RegexAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/Regex.php b/src/Engines/Foundation/Attributes/Annotations/Regex.php new file mode 100644 index 0000000..e478980 --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/Regex.php @@ -0,0 +1,55 @@ +value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + if (! is_string($payload->value)) { + throw new SkipExecution( + $this->message ?: "The value is not a string and cannot be matched against pattern '{$this->pattern}'." + ); + } + + if (! preg_match($this->pattern, $payload->value)) { + throw new SkipExecution( + $this->message ?: "The value '{$payload->value}' does not match the pattern '{$this->pattern}'." + ); + } + } +} diff --git a/tests/Feature/Engines/Attributes/RegexAttributeTest.php b/tests/Feature/Engines/Attributes/RegexAttributeTest.php new file mode 100644 index 0000000..b79aff9 --- /dev/null +++ b/tests/Feature/Engines/Attributes/RegexAttributeTest.php @@ -0,0 +1,161 @@ +merge([ + 'status' => 'active', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[Regex('/^[a-z]+$/')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'active'", $sql); + } + + public function test_regex_attribute_skips_filter_when_value_does_not_match() + { + request()->merge([ + 'status' => 'ACTIVE123', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[Regex('/^[a-z]+$/')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_regex_attribute_validates_email_pattern() + { + request()->merge([ + 'title' => 'test@example.com', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'test@example.com'", $sql); + } + + public function test_regex_attribute_skips_filter_for_invalid_email() + { + request()->merge([ + 'title' => 'not-an-email', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"title" =', $sql); + } + + public function test_regex_attribute_validates_numeric_pattern() + { + request()->merge([ + 'views' => '12345', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Regex('/^\d+$/')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + $this->assertStringContainsString('12345', $sql); + } + + public function test_regex_attribute_skips_filter_for_non_numeric_value_with_numeric_pattern() + { + request()->merge([ + 'views' => 'abc', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Regex('/^\d+$/')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_regex_attribute_validates_slug_pattern() + { + request()->merge([ + 'title' => 'hello-world-post', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello-world-post'", $sql); + } +} From c8f1b844352bbbf4580d4a987c1f481181d51e4f Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 10:00:45 +0200 Subject: [PATCH 10/18] feat(Trim): implement Trim attribute for string trimming functionality and add corresponding tests --- .../Attributes/Annotations/Trim.php | 51 ++++++++ .../Engines/Attributes/TrimAttributeTest.php | 117 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/Trim.php create mode 100644 tests/Feature/Engines/Attributes/TrimAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/Trim.php b/src/Engines/Foundation/Attributes/Annotations/Trim.php new file mode 100644 index 0000000..901e92e --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/Trim.php @@ -0,0 +1,51 @@ +value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + if (! is_string($payload->value)) { + return; + } + + $payload->setValue(match ($this->side) { + 'left' => ltrim($payload->value, $this->characters), + 'right' => rtrim($payload->value, $this->characters), + default => trim($payload->value, $this->characters), + }); + } +} diff --git a/tests/Feature/Engines/Attributes/TrimAttributeTest.php b/tests/Feature/Engines/Attributes/TrimAttributeTest.php new file mode 100644 index 0000000..aaad96a --- /dev/null +++ b/tests/Feature/Engines/Attributes/TrimAttributeTest.php @@ -0,0 +1,117 @@ +merge([ + 'title' => ' hello world ', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Trim] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello world'", $sql); + } + + public function test_trim_attribute_trims_left_only() + { + request()->merge([ + 'title' => ' hello world ', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Trim(side: 'left')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello world '", $sql); + } + + public function test_trim_attribute_trims_right_only() + { + request()->merge([ + 'title' => ' hello world ', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Trim(side: 'right')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = ' hello world'", $sql); + } + + public function test_trim_attribute_trims_custom_characters() + { + request()->merge([ + 'title' => '---hello world---', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Trim(characters: '-')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello world'", $sql); + } + + public function test_trim_attribute_does_not_affect_non_string_values() + { + request()->merge([ + 'views' => '42', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Trim] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + } +} From b6e013310008d5d61dc60a38ffc232ec1c0ce848 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 10:02:22 +0200 Subject: [PATCH 11/18] feat(Sanitize): implement Sanitize attribute for input sanitization with multiple rules and add corresponding tests --- .../Attributes/Annotations/Sanitize.php | 72 ++++++++ .../Attributes/SanitizeAttributeTest.php | 159 ++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/Sanitize.php create mode 100644 tests/Feature/Engines/Attributes/SanitizeAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/Sanitize.php b/src/Engines/Foundation/Attributes/Annotations/Sanitize.php new file mode 100644 index 0000000..d0d019e --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/Sanitize.php @@ -0,0 +1,72 @@ + + */ + protected array $rules; + + /** + * Constructor for Sanitize attribute. + * + * Supported rules: 'lowercase', 'uppercase', 'ucfirst', 'strip_tags', 'nl2br', 'slug', 'trim'. + * + * @param string ...$rules The sanitization rules to apply in order. + */ + public function __construct(string ...$rules) + { + $this->rules = $rules; + } + + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + * @throws \InvalidArgumentException if a rule is not supported. + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + if (! is_string($payload->value)) { + return; + } + + $value = $payload->value; + + foreach ($this->rules as $rule) { + $value = match ($rule) { + 'lowercase' => mb_strtolower($value), + 'uppercase' => mb_strtoupper($value), + 'ucfirst' => ucfirst($value), + 'strip_tags' => strip_tags($value), + 'nl2br' => nl2br($value), + 'slug' => \Illuminate\Support\Str::slug($value), + 'trim' => trim($value), + default => throw new \InvalidArgumentException("Sanitization rule [{$rule}] is not supported."), + }; + } + + $payload->setValue($value); + } +} diff --git a/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php b/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php new file mode 100644 index 0000000..b5ac581 --- /dev/null +++ b/tests/Feature/Engines/Attributes/SanitizeAttributeTest.php @@ -0,0 +1,159 @@ +merge([ + 'status' => 'ACTIVE', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[Sanitize('lowercase')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'active'", $sql); + } + + public function test_sanitize_attribute_converts_to_uppercase() + { + request()->merge([ + 'status' => 'active', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[Sanitize('uppercase')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'ACTIVE'", $sql); + } + + public function test_sanitize_attribute_applies_ucfirst() + { + request()->merge([ + 'title' => 'hello world', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Sanitize('ucfirst')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'Hello world'", $sql); + } + + public function test_sanitize_attribute_strips_html_tags() + { + request()->merge([ + 'title' => 'hello world', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Sanitize('strip_tags')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello world'", $sql); + } + + public function test_sanitize_attribute_applies_multiple_rules_in_order() + { + request()->merge([ + 'status' => ' ACTIVE ', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[Sanitize('trim', 'strip_tags', 'lowercase')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'active'", $sql); + } + + public function test_sanitize_attribute_converts_to_slug() + { + request()->merge([ + 'title' => 'Hello World Post', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[Sanitize('slug')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"title\" = 'hello-world-post'", $sql); + } + + public function test_sanitize_attribute_does_not_affect_non_string_values() + { + request()->merge([ + 'views' => '42', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Sanitize('lowercase')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + } +} From 3adcb16b305cabaf57667f16e64f077fd9932c6a Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 10:08:59 +0200 Subject: [PATCH 12/18] feat(Scope): add Scope attribute for Eloquent scope application and implement corresponding tests --- .../Attributes/Annotations/Scope.php | 55 ++++++++++ .../Engines/Attributes/ScopeAttributeTest.php | 103 ++++++++++++++++++ tests/Models/Post.php | 11 ++ 3 files changed, 169 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/Scope.php create mode 100644 tests/Feature/Engines/Attributes/ScopeAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/Scope.php b/src/Engines/Foundation/Attributes/Annotations/Scope.php new file mode 100644 index 0000000..3206f05 --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/Scope.php @@ -0,0 +1,55 @@ +value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Illuminate\Contracts\Eloquent\Builder $query */ + $query = $context->query; + + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + $scope = $this->scope; + + if (! method_exists($query->getModel(), 'scope' . ucfirst($scope))) { + throw new \InvalidArgumentException( + "The scope '{$scope}' does not exist on the model '" . get_class($query->getModel()) . "'." + ); + } + + $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/tests/Feature/Engines/Attributes/ScopeAttributeTest.php b/tests/Feature/Engines/Attributes/ScopeAttributeTest.php new file mode 100644 index 0000000..35e58ce --- /dev/null +++ b/tests/Feature/Engines/Attributes/ScopeAttributeTest.php @@ -0,0 +1,103 @@ +merge([ + 'status' => 'active', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[Scope('active')] + public function status(Payload $payload) + { + // The scope is already applied by the attribute. + // This method can add additional logic if needed. + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"status"', $sql); + $this->assertStringContainsString('active', $sql); + } + + public function test_scope_attribute_applies_popular_scope_with_value() + { + request()->merge([ + 'views' => '500', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[Scope('popular')] + public function views(Payload $payload) + { + // Scope is applied by the attribute with the payload value. + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views" >=', $sql); + $this->assertStringContainsString('500', $sql); + } + + public function test_scope_attribute_skips_filter_for_non_existent_scope() + { + request()->merge([ + 'status' => 'active', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[Scope('nonExistentScope')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // The scope does not exist, so the filter should be skipped entirely + // because the InvalidArgumentException is caught by the engine's attempt handler. + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_scope_attribute_works_with_other_attributes() + { + request()->merge([ + 'status' => ' active ', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[\Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim] + #[Scope('active')] + public function status(Payload $payload) + { + // Trim runs first (TRANSFORM stage), then Scope (BEHAVIOR stage). + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"status"', $sql); + $this->assertStringContainsString('active', $sql); + } +} diff --git a/tests/Models/Post.php b/tests/Models/Post.php index 87ce475..9578a79 100644 --- a/tests/Models/Post.php +++ b/tests/Models/Post.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Kettasoft\Filterable\Tests\Models\Tag; use Kettasoft\Filterable\Traits\HasFilterable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Factories\HasFactory; use Kettasoft\Filterable\Tests\Database\Factories\PostFactory; @@ -21,6 +22,16 @@ class Post extends Model 'tags' => 'array', ]; + public function scopeActive(Builder $query, $value = null): Builder + { + return $query->where('status', $value ?? 'active'); + } + + public function scopePopular(Builder $query, $minViews = 100): Builder + { + return $query->where('views', '>=', $minViews); + } + public function tags(): HasMany { return $this->hasMany(Tag::class); From 11d0ef225c67a17ab744f4989b8255c6f50043c9 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 10:10:58 +0200 Subject: [PATCH 13/18] feat(SkipIf): implement SkipIf attribute for conditional filtering and add corresponding tests --- .../Attributes/Annotations/SkipIf.php | 70 +++++++ .../Attributes/SkipIfAttributeTest.php | 183 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/SkipIf.php create mode 100644 tests/Feature/Engines/Attributes/SkipIfAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/SkipIf.php b/src/Engines/Foundation/Attributes/Annotations/SkipIf.php new file mode 100644 index 0000000..583fa0d --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/SkipIf.php @@ -0,0 +1,70 @@ +value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + $check = $this->check; + $negate = false; + + if (str_starts_with($check, '!')) { + $negate = true; + $check = substr($check, 1); + } + + $method = 'is' . ucfirst($check); + + if (! method_exists($payload, $method)) { + throw new \InvalidArgumentException("Check method [{$method}] does not exist on Payload."); + } + + $result = $payload->$method(); + + if ($negate) { + $result = ! $result; + } + + if ($result) { + throw new SkipExecution( + $this->message ?: "Filter skipped because payload {$this->check} check was true." + ); + } + } +} diff --git a/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php b/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php new file mode 100644 index 0000000..b26be89 --- /dev/null +++ b/tests/Feature/Engines/Attributes/SkipIfAttributeTest.php @@ -0,0 +1,183 @@ +merge([ + 'status' => '', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[SkipIf('empty')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_skip_if_attribute_does_not_skip_when_value_is_not_empty() + { + request()->merge([ + 'status' => 'active', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[SkipIf('empty')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'active'", $sql); + } + + public function test_skip_if_attribute_skips_when_value_is_null() + { + request()->merge([ + 'status' => null, + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[SkipIf('null')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_skip_if_attribute_with_negation_skips_when_value_is_not_numeric() + { + request()->merge([ + 'views' => 'abc', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[SkipIf('!numeric')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Should skip because !numeric is true (value is not numeric) + $this->assertStringNotContainsString('"views" =', $sql); + } + + public function test_skip_if_attribute_with_negation_does_not_skip_when_value_is_numeric() + { + request()->merge([ + 'views' => '42', + ]); + + $class = new class extends Filterable { + protected $filters = ['views']; + + #[SkipIf('!numeric')] + public function views(Payload $payload) + { + $this->builder->where('views', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"views"', $sql); + $this->assertStringContainsString('42', $sql); + } + + public function test_skip_if_attribute_skips_when_value_is_empty_string() + { + request()->merge([ + 'title' => ' ', + ]); + + $class = new class extends Filterable { + protected $filters = ['title']; + + #[SkipIf('emptyString')] + public function title(Payload $payload) + { + $this->builder->where('title', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"title" =', $sql); + } + + public function test_skip_if_attribute_multiple_instances_on_same_method() + { + request()->merge([ + 'status' => '', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[SkipIf('empty')] + #[SkipIf('emptyString')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_skip_if_attribute_skips_when_value_is_boolean() + { + request()->merge([ + 'status' => 'true', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[SkipIf('boolean')] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringNotContainsString('"status" =', $sql); + } +} From a3f438e4da5752b6436575550a6af098ff51e8b8 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 14:38:59 +0200 Subject: [PATCH 14/18] feat(MapValue): implement MapValue attribute for value mapping with strict mode and add corresponding tests --- .../Attributes/Annotations/MapValue.php | 70 +++++++++++ .../Attributes/MapValueAttributeTest.php | 118 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/MapValue.php create mode 100644 tests/Feature/Engines/Attributes/MapValueAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/MapValue.php b/src/Engines/Foundation/Attributes/Annotations/MapValue.php new file mode 100644 index 0000000..6e785f7 --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/MapValue.php @@ -0,0 +1,70 @@ + + */ + protected array $map; + + /** + * Whether to skip the filter if the value is not in the map. + * + * @var bool + */ + protected bool $strict; + + /** + * Constructor for MapValue attribute. + * + * @param array $map The value mapping (e.g., ['active' => 1, 'inactive' => 0]). + * @param bool $strict If true, skip execution when value is not found in map. Defaults to false. + */ + public function __construct(array $map, bool $strict = false) + { + $this->map = $map; + $this->strict = $strict; + } + + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::TRANSFORM->value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + /** @var \Kettasoft\Filterable\Support\Payload $payload */ + $payload = $context->payload; + + $key = (string) $payload->value; + + if (array_key_exists($key, $this->map)) { + $payload->setValue($this->map[$key]); + return; + } + + if ($this->strict) { + throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution( + "The value '{$key}' is not in the value map: " . implode(', ', array_keys($this->map)) + ); + } + } +} diff --git a/tests/Feature/Engines/Attributes/MapValueAttributeTest.php b/tests/Feature/Engines/Attributes/MapValueAttributeTest.php new file mode 100644 index 0000000..3547cb7 --- /dev/null +++ b/tests/Feature/Engines/Attributes/MapValueAttributeTest.php @@ -0,0 +1,118 @@ +merge([ + 'status' => 'active', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[MapValue(['active' => 1, 'inactive' => 0])] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"status" = 1', $sql); + } + + public function test_map_value_attribute_maps_inactive_to_zero() + { + request()->merge([ + 'status' => 'inactive', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[MapValue(['active' => 1, 'inactive' => 0])] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('"status" = 0', $sql); + } + + public function test_map_value_attribute_keeps_original_value_when_not_in_map_non_strict() + { + request()->merge([ + 'status' => 'pending', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[MapValue(['active' => 1, 'inactive' => 0])] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'pending'", $sql); + } + + public function test_map_value_attribute_skips_filter_in_strict_mode_when_not_in_map() + { + request()->merge([ + 'status' => 'unknown', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[MapValue(['active' => 1, 'inactive' => 0], strict: true)] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + // Filter should be skipped entirely, so no where clause + $this->assertStringNotContainsString('"status" =', $sql); + } + + public function test_map_value_attribute_maps_string_to_string() + { + request()->merge([ + 'status' => 'published', + ]); + + $class = new class extends Filterable { + protected $filters = ['status']; + + #[MapValue(['published' => 'live', 'draft' => 'hidden'])] + public function status(Payload $payload) + { + $this->builder->where('status', '=', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString("\"status\" = 'live'", $sql); + } +} From 2dcb48ad790c4712d52fc71df6ee20bd03ff27b7 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 15:10:29 +0200 Subject: [PATCH 15/18] fix(Payload): update method to set value before returning it --- src/Support/Payload.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Support/Payload.php b/src/Support/Payload.php index 5220628..e1e5c4e 100644 --- a/src/Support/Payload.php +++ b/src/Support/Payload.php @@ -565,7 +565,9 @@ public function cast(string $type, mixed ...$args): mixed throw new \InvalidArgumentException("Cast type [{$type}] is not supported. Method {$method} does not exist."); } - return $this->$method(...$args); + $this->value = $this->$method(...$args); + + return $this->value; } /** From 54672aa54279c2ec3b3d54b7cb8a9562cc2ed46f Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 15:10:45 +0200 Subject: [PATCH 16/18] refactor(CastAttributeTest): simplify value retrieval in cast attribute tests --- .../Engines/Attributes/CastAttributeTest.php | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/Feature/Engines/Attributes/CastAttributeTest.php b/tests/Feature/Engines/Attributes/CastAttributeTest.php index 7b17507..a67fcc9 100644 --- a/tests/Feature/Engines/Attributes/CastAttributeTest.php +++ b/tests/Feature/Engines/Attributes/CastAttributeTest.php @@ -23,7 +23,7 @@ public function test_cast_attribute_casts_value_to_int() #[Cast('int')] public function views(Payload $payload) { - $this->builder->where('views', '=', $payload->cast('int')); + $this->builder->where('views', '=', $payload->value); } }; @@ -44,7 +44,7 @@ public function test_cast_attribute_casts_value_to_boolean_true() #[Cast('boolean')] public function isFeatured(Payload $payload) { - $this->builder->where('is_featured', '=', $payload->cast('boolean')); + $this->builder->where('is_featured', '=', $payload->value); } }; @@ -65,7 +65,7 @@ public function test_cast_attribute_casts_value_to_boolean_false() #[Cast('boolean')] public function isFeatured(Payload $payload) { - $this->builder->where('is_featured', '=', $payload->cast('boolean')); + $this->builder->where('is_featured', '=', $payload->value); } }; @@ -86,8 +86,7 @@ public function test_cast_attribute_casts_value_to_array_from_json() #[Cast('array')] public function tags(Payload $payload) { - $casted = $payload->cast('array'); - $this->builder->whereIn('tags', $casted); + $this->builder->whereIn('tags', $payload->value); } }; @@ -113,7 +112,7 @@ public function test_cast_attribute_throws_strictness_exception_for_unsupported_ #[Cast('unsupported')] public function status(Payload $payload) { - $this->builder->where('status', '=', $payload); + $this->builder->where('status', '=', $payload->value); } }; @@ -132,7 +131,7 @@ public function test_cast_attribute_does_not_throw_for_valid_cast_type() #[Cast('int')] public function views(Payload $payload) { - $this->builder->where('views', '>', $payload->cast('int')); + $this->builder->where('views', '>', $payload->value); } }; @@ -153,7 +152,7 @@ public function test_cast_attribute_with_slug_type() #[Cast('slug')] public function title(Payload $payload) { - $this->builder->where('title', '=', $payload->cast('slug')); + $this->builder->where('title', '=', $payload->value); } }; @@ -174,7 +173,7 @@ public function test_cast_attribute_with_like_type() #[Cast('like')] public function title(Payload $payload) { - $this->builder->where('title', 'LIKE', $payload->cast('like')); + $this->builder->where('title', 'LIKE', $payload->value); } }; @@ -195,9 +194,8 @@ public function test_cast_attribute_with_empty_value_for_int_returns_null() #[Cast('int')] public function views(Payload $payload) { - $casted = $payload->cast('int'); - if (!is_null($casted)) { - $this->builder->where('views', '=', $casted); + if (!is_null($payload->value)) { + $this->builder->where('views', '=', $payload->value); } } }; From d08128153a482aa444f3ab09661bcbac9f0a2c88 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 15:12:58 +0200 Subject: [PATCH 17/18] feat(Authorize): implement Authorize attribute for method-level authorization and add corresponding tests --- .../Attributes/Annotations/Authorize.php | 44 +++++++++++++++++++ .../Authorizations/CanMakeFilter.php | 14 ++++++ .../Attributes/AuthorizeAttributeTest.php | 34 ++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 src/Engines/Foundation/Attributes/Annotations/Authorize.php create mode 100644 tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php create mode 100644 tests/Feature/Engines/Attributes/AuthorizeAttributeTest.php diff --git a/src/Engines/Foundation/Attributes/Annotations/Authorize.php b/src/Engines/Foundation/Attributes/Annotations/Authorize.php new file mode 100644 index 0000000..9c9a810 --- /dev/null +++ b/src/Engines/Foundation/Attributes/Annotations/Authorize.php @@ -0,0 +1,44 @@ + $authorize The class name of the authorization logic. + */ + public function __construct(public string $authorize) {} + + /** + * Get the stage at which this attribute should be applied. + * + * @return int + */ + public static function stage(): int + { + return \Kettasoft\Filterable\Engines\Foundation\Attributes\Enums\Stage::CONTROL->value; + } + + /** + * Handle the attribute logic. + * + * @param \Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context + * @return void + */ + public function handle(\Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext $context): void + { + if (!is_a($this->authorize, \Kettasoft\Filterable\Contracts\Authorizable::class, true)) { + throw new \InvalidArgumentException("The class '{$this->authorize}' must implement the Authorizable contract."); + } + + $authorize = new $this->authorize; + + if (! $authorize->authorize()) { + throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution("Authorization failed for class '{$this->authorize}'."); + } + } +} diff --git a/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php b/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php new file mode 100644 index 0000000..7f3d6d6 --- /dev/null +++ b/tests/Feature/Engines/Attributes/Authorizations/CanMakeFilter.php @@ -0,0 +1,14 @@ +merge([ + 'tags' => 'testing', + ]); + + $class = new class extends Filterable { + protected $filters = ['tags']; + + #[Authorize(CanMakeFilter::class)] + public function tags(Payload $payload) + { + $this->builder->where('tags', $payload->value); + } + }; + + $sql = Post::filter($class)->toRawSql(); + + $this->assertStringContainsString('where "tags"', $sql); + } +} From 20f950d50368d22edc7fa7acb7bbbb0e7e314559 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 4 Mar 2026 15:14:36 +0200 Subject: [PATCH 18/18] Add documentation for Invokable Engine annotations - Created detailed markdown files for various annotations including #[Cast], #[DefaultValue], #[Explode], #[In], #[MapValue], #[Regex], #[Required], #[Sanitize], #[Scope], #[SkipIf], and #[Trim]. - Each annotation includes sections for parameters, usage examples, behavior, and combining with other attributes. - Updated index.md to include an overview of the Invokable Engine and its features, along with a detailed explanation of the attribute pipeline and lifecycle. --- docs/.vuepress/config.js | 70 ++++- .../invokable/annotations/authorize.md | 84 ++++++ docs/engines/invokable/annotations/between.md | 86 ++++++ docs/engines/invokable/annotations/cast.md | 80 ++++++ .../invokable/annotations/default-value.md | 66 +++++ docs/engines/invokable/annotations/explode.md | 68 +++++ docs/engines/invokable/annotations/in.md | 79 ++++++ docs/engines/invokable/annotations/index.md | 211 ++++++++++++++ .../invokable/annotations/map-value.md | 68 +++++ docs/engines/invokable/annotations/regex.md | 99 +++++++ .../engines/invokable/annotations/required.md | 69 +++++ .../engines/invokable/annotations/sanitize.md | 96 +++++++ docs/engines/invokable/annotations/scope.md | 103 +++++++ docs/engines/invokable/annotations/skip-if.md | 95 +++++++ docs/engines/invokable/annotations/trim.md | 77 ++++++ docs/engines/invokable/index.md | 259 ++++++++++++++++++ 16 files changed, 1609 insertions(+), 1 deletion(-) create mode 100644 docs/engines/invokable/annotations/authorize.md create mode 100644 docs/engines/invokable/annotations/between.md create mode 100644 docs/engines/invokable/annotations/cast.md create mode 100644 docs/engines/invokable/annotations/default-value.md create mode 100644 docs/engines/invokable/annotations/explode.md create mode 100644 docs/engines/invokable/annotations/in.md create mode 100644 docs/engines/invokable/annotations/index.md create mode 100644 docs/engines/invokable/annotations/map-value.md create mode 100644 docs/engines/invokable/annotations/regex.md create mode 100644 docs/engines/invokable/annotations/required.md create mode 100644 docs/engines/invokable/annotations/sanitize.md create mode 100644 docs/engines/invokable/annotations/scope.md create mode 100644 docs/engines/invokable/annotations/skip-if.md create mode 100644 docs/engines/invokable/annotations/trim.md create mode 100644 docs/engines/invokable/index.md diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 12e7dad..7951d5e 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -75,7 +75,75 @@ export default defineUserConfig({ children: [ { text: "Invokable", - link: "engines/invokable", + collapsible: true, + children: [ + { + text: "Overview", + link: "engines/invokable/", + }, + { + text: "Annotations", + collapsible: true, + children: [ + { + text: "Overview", + link: "engines/invokable/annotations/", + }, + { + text: "Authorize", + link: "engines/invokable/annotations/authorize", + }, + { + text: "SkipIf", + link: "engines/invokable/annotations/skip-if", + }, + { + text: "Trim", + link: "engines/invokable/annotations/trim", + }, + { + text: "Sanitize", + link: "engines/invokable/annotations/sanitize", + }, + { + text: "Cast", + link: "engines/invokable/annotations/cast", + }, + { + text: "DefaultValue", + link: "engines/invokable/annotations/default-value", + }, + { + text: "MapValue", + link: "engines/invokable/annotations/map-value", + }, + { + text: "Explode", + link: "engines/invokable/annotations/explode", + }, + { + text: "Required", + link: "engines/invokable/annotations/required", + }, + { + text: "In", + link: "engines/invokable/annotations/in", + }, + { + text: "Between", + link: "engines/invokable/annotations/between", + }, + { + text: "Regex", + link: "engines/invokable/annotations/regex", + }, + { + text: "Scope", + link: "engines/invokable/annotations/scope", + }, + ], + }, + ], }, { text: "Tree", diff --git a/docs/engines/invokable/annotations/authorize.md b/docs/engines/invokable/annotations/authorize.md new file mode 100644 index 0000000..8fb6746 --- /dev/null +++ b/docs/engines/invokable/annotations/authorize.md @@ -0,0 +1,84 @@ +--- +sidebarDepth: 1 +--- + +# #[Authorize] + +**Stage:** `CONTROL` (1) + +Requires authorization before the filter method executes. If authorization fails, the filter is skipped entirely. + +--- + +## Parameters + +| Parameter | Type | Required | Description | +| ------------ | -------- | -------- | ------------------------------------------------------------------- | +| `$authorize` | `string` | ✅ | Fully qualified class name implementing the `Authorizable` contract | + +--- + +## Usage + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Authorize; + +#[Authorize(AdminOnly::class)] +protected function secretField(Payload $payload) +{ + return $this->builder->where('secret_field', $payload->value); +} +``` + +--- + +## Authorizable Contract + +The class passed to `#[Authorize]` must implement `Kettasoft\Filterable\Contracts\Authorizable`: + +```php +user()?->is_admin ?? false; + } +} +``` + +--- + +## Behavior + +| Scenario | Result | +| -------------------------------------- | ------------------------------------------------- | +| `authorize()` returns `true` | Filter method executes normally | +| `authorize()` returns `false` | Filter is **skipped** (SkipExecution is thrown) | +| Class doesn't implement `Authorizable` | `InvalidArgumentException` is thrown | + +--- + +## Example: Role-Based Filter Access + +```php +class RoleFilter implements Authorizable +{ + public function authorize(): bool + { + return auth()->user()?->hasRole('manager'); + } +} + +// In your filter class: +#[Authorize(RoleFilter::class)] +protected function salary(Payload $payload) +{ + return $this->builder->where('salary', '>=', $payload->value); +} +``` diff --git a/docs/engines/invokable/annotations/between.md b/docs/engines/invokable/annotations/between.md new file mode 100644 index 0000000..93bd32d --- /dev/null +++ b/docs/engines/invokable/annotations/between.md @@ -0,0 +1,86 @@ +--- +sidebarDepth: 1 +--- + +# #[Between] + +**Stage:** `VALIDATE` (3) + +Validates that the payload value falls within a specified numeric range. If the value is outside the range or not numeric, the filter is **skipped**. + +--- + +## Parameters + +| Parameter | Type | Required | Description | +| --------- | ------------- | -------- | --------------------- | +| `$min` | `float\|int` | ✅ | Minimum allowed value | +| `$max` | `float\|int` | ✅ | Maximum allowed value | + +--- + +## Usage + +### Integer Range + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Between; + +#[Between(min: 1, max: 100)] +protected function views(Payload $payload) +{ + return $this->builder->where('views', '>=', $payload->value); +} +``` + +### Float Range + +```php +#[Between(min: 0.0, max: 5.0)] +protected function rating(Payload $payload) +{ + return $this->builder->where('rating', '>=', $payload->value); +} +``` + +--- + +## Behavior + +| Scenario | Result | +| ----------------------------------- | ------------------------------------ | +| Value is numeric and within range | Filter executes normally | +| Value is at the minimum boundary | Filter executes normally (**inclusive**) | +| Value is at the maximum boundary | Filter executes normally (**inclusive**) | +| Value is below the range | Filter is **skipped** | +| Value is above the range | Filter is **skipped** | +| Value is not numeric | Filter is **skipped** | + +--- + +## Boundary Behavior + +The check is **inclusive** on both ends: + +```php +#[Between(min: 1, max: 100)] +// 1 → ✅ passes +// 50 → ✅ passes +// 100 → ✅ passes +// 0 → ❌ skipped +// 101 → ❌ skipped +``` + +--- + +## Combining with Other Attributes + +```php +#[SkipIf('empty')] +#[Trim] +#[Between(min: 1, max: 1000)] +protected function price(Payload $payload) +{ + return $this->builder->where('price', '>=', $payload->value); +} +``` diff --git a/docs/engines/invokable/annotations/cast.md b/docs/engines/invokable/annotations/cast.md new file mode 100644 index 0000000..ff4aa73 --- /dev/null +++ b/docs/engines/invokable/annotations/cast.md @@ -0,0 +1,80 @@ +--- +sidebarDepth: 1 +--- + +# #[Cast] + +**Stage:** `TRANSFORM` (2) + +Casts the payload value to a specific type using the Payload's `as*` methods. + +--- + +## Parameters + +| Parameter | Type | Required | Description | +| --------- | -------- | -------- | ---------------------------------------------------- | +| `$type` | `string` | ✅ | The target type name (maps to `Payload::as{Type}()`) | + +--- + +## Supported Types + +| Type | Maps To | Description | +| --------- | ----------------------- | ------------------------------------ | +| `int` | `$payload->asInt()` | Cast to integer | +| `boolean` | `$payload->asBoolean()` | Cast to boolean | +| `array` | `$payload->asArray()` | Decode JSON or return existing array | +| `carbon` | `$payload->asCarbon()` | Parse to Carbon date instance | +| `slug` | `$payload->asSlug()` | Convert to URL-friendly slug | +| `like` | `$payload->asLike()` | Wrap with `%` for LIKE queries | + +--- + +## Usage + +### Cast to Integer + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Cast; + +#[Cast('int')] +protected function views(Payload $payload) +{ + // "42" → 42 + return $this->builder->where('views', '>=', $payload->value); +} +``` + +### Cast to Boolean + +```php +#[Cast('boolean')] +protected function isFeatured(Payload $payload) +{ + // "true" → true, "false" → false + return $this->builder->where('is_featured', $payload->value); +} +``` + +### Cast to Array (from JSON) + +```php +#[Cast('array')] +protected function tags(Payload $payload) +{ + // '["php","laravel"]' → ['php', 'laravel'] + $tags = $payload->value; + return $this->builder->whereIn('tag', $tags); +} +``` + +--- + +## Behavior + +| Scenario | Result | +| -------------------------- | ------------------------------- | +| Cast type is supported | Value is cast and returned | +| Cast type is not supported | `StrictnessException` is thrown | +| Cast fails (invalid value) | `StrictnessException` is thrown | diff --git a/docs/engines/invokable/annotations/default-value.md b/docs/engines/invokable/annotations/default-value.md new file mode 100644 index 0000000..c4b96b6 --- /dev/null +++ b/docs/engines/invokable/annotations/default-value.md @@ -0,0 +1,66 @@ +--- +sidebarDepth: 1 +--- + +# #[DefaultValue] + +**Stage:** `TRANSFORM` (2) + +Sets a fallback value when the payload value is empty or null. The filter method still executes, but with the default value instead of the empty input. + +--- + +## Parameters + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------------------ | +| `$value` | `mixed` | ✅ | The default value to use as fallback | + +--- + +## Usage + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\DefaultValue; + +#[DefaultValue('active')] +protected function status(Payload $payload) +{ + // If status is empty → uses "active" + return $this->builder->where('status', $payload->value); +} +``` + +### With Numeric Default + +```php +#[DefaultValue(10)] +protected function perPage(Payload $payload) +{ + // If perPage is empty → uses 10 + return $this->builder->limit($payload->value); +} +``` + +--- + +## Behavior + +| Scenario | Result | +| ------------------------------ | ----------------------------------------- | +| Value is empty or null | Payload value is set to the default | +| Value is provided (non-empty) | Default is **not** applied, original kept | + +--- + +## Combining with Other Attributes + +```php +#[DefaultValue('pending')] +#[In('active', 'pending', 'archived')] +protected function status(Payload $payload) +{ + // Empty input → "pending" → passes In validation + return $this->builder->where('status', $payload->value); +} +``` diff --git a/docs/engines/invokable/annotations/explode.md b/docs/engines/invokable/annotations/explode.md new file mode 100644 index 0000000..8f32850 --- /dev/null +++ b/docs/engines/invokable/annotations/explode.md @@ -0,0 +1,68 @@ +--- +sidebarDepth: 1 +--- + +# #[Explode] + +**Stage:** `VALIDATE` (3) + +Splits a string value into an array using a specified delimiter. The payload value is **overwritten** with the resulting array, making it ready for `whereIn` and similar queries. + +--- + +## Parameters + +| Parameter | Type | Required | Default | Description | +| ------------ | -------- | -------- | ------- | ------------------------------ | +| `$delimiter` | `string` | ❌ | `','` | The delimiter to split by | + +--- + +## Usage + +### Default Delimiter (Comma) + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Explode; + +#[Explode] +protected function tags(Payload $payload) +{ + // "php,laravel,testing" → ["php", "laravel", "testing"] + return $this->builder->whereIn('tag', $payload->value); +} +``` + +### Custom Delimiter + +```php +#[Explode('|')] +protected function categories(Payload $payload) +{ + // "news|sports|tech" → ["news", "sports", "tech"] + return $this->builder->whereIn('category', $payload->value); +} +``` + +--- + +## Behavior + +| Scenario | Result | +| ------------------------- | ----------------------------------------------- | +| Value is a string | Split into array, payload value is overwritten | +| Value is already an array | Returned as-is | + +--- + +## Combining with Other Attributes + +```php +#[Trim] +#[Explode(',')] +protected function statuses(Payload $payload) +{ + // " active,pending,archived " → ["active", "pending", "archived"] + return $this->builder->whereIn('status', $payload->value); +} +``` diff --git a/docs/engines/invokable/annotations/in.md b/docs/engines/invokable/annotations/in.md new file mode 100644 index 0000000..1c27657 --- /dev/null +++ b/docs/engines/invokable/annotations/in.md @@ -0,0 +1,79 @@ +--- +sidebarDepth: 1 +--- + +# #[In] + +**Stage:** `VALIDATE` (3) + +Validates that the payload value is within a predefined set of allowed values. If the value is not in the set, the filter is **skipped** silently. + +--- + +## Parameters + +| Parameter | Type | Required | Description | +| ------------ | ------- | -------- | ------------------------------------ | +| `...$values` | `mixed` | ✅ | The allowed values (variadic) | + +--- + +## Usage + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\In; + +#[In('active', 'pending', 'archived')] +protected function status(Payload $payload) +{ + return $this->builder->where('status', $payload->value); +} +``` + +--- + +## Behavior + +| Scenario | Result | +| --------------------------- | ------------------------------------------- | +| Value is in the allowed set | Filter executes normally | +| Value is **not** in the set | Filter is **skipped** (SkipExecution thrown) | + +--- + +## Examples + +### Restrict to Specific Types + +```php +#[In('post', 'page', 'article')] +protected function type(Payload $payload) +{ + return $this->builder->where('type', $payload->value); +} +``` + +### Numeric Values + +```php +#[In(1, 2, 3, 4, 5)] +protected function rating(Payload $payload) +{ + return $this->builder->where('rating', $payload->value); +} +``` + +--- + +## Combining with Transform Attributes + +```php +#[Trim] +#[Sanitize('lowercase')] +#[In('active', 'pending', 'archived')] +protected function status(Payload $payload) +{ + // " ACTIVE " → "active" → passes In check + return $this->builder->where('status', $payload->value); +} +``` diff --git a/docs/engines/invokable/annotations/index.md b/docs/engines/invokable/annotations/index.md new file mode 100644 index 0000000..b2311d8 --- /dev/null +++ b/docs/engines/invokable/annotations/index.md @@ -0,0 +1,211 @@ +--- +sidebarDepth: 2 +--- + +# Annotations (PHP Attributes) + +The **Invokable Engine** supports PHP 8 Attributes as a powerful declarative way to control filter behavior. Instead of writing validation, transformation, and authorization logic inside your filter methods, you declare it with attributes directly on the method signature. + +--- + +## How Annotations Work + +When the Invokable Engine processes a filter method, it runs all declared attributes through an **Attribute Pipeline** before executing the method itself. If any attribute throws a `SkipExecution` exception, the filter method is skipped entirely. If an attribute throws a `StrictnessException`, the error propagates up. + +```php +#[Trim] +#[Sanitize('lowercase')] +#[Required] +#[In('active', 'pending', 'archived')] +protected function status(Payload $payload) +{ + return $this->builder->where('status', $payload->value); +} +``` + +--- + +## Execution Stages + +Attributes are **sorted by stage** before execution, regardless of the order you declare them. This ensures a predictable pipeline: + +| Order | Stage | Value | Purpose | Description | +| ----- | ------------- | ----- | -------------------------------- | ---------------------------------------- | +| 1 | **CONTROL** | `1` | Gate / Skip | Decide whether the filter should run | +| 2 | **TRANSFORM** | `2` | Modify Payload | Clean, convert, or map the input value | +| 3 | **VALIDATE** | `3` | Assert Correctness | Verify the value meets constraints | +| 4 | **BEHAVIOR** | `4` | Affect Query | Modify query behavior directly | + +### Pipeline Flow + +```text +Incoming Payload + │ + ▼ +┌─────────────────┐ +│ CONTROL (1) │ → #[Authorize], #[SkipIf] +│ Should we run? │ → Throws SkipExecution to abort +└────────┬────────┘ + │ ✓ Pass + ▼ +┌─────────────────┐ +│ TRANSFORM (2) │ → #[Trim], #[Sanitize], #[Cast], #[MapValue], #[DefaultValue], #[Explode] +│ Clean the data │ → Modifies payload.value in place +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ VALIDATE (3) │ → #[Required], #[In], #[Between], #[Regex] +│ Is data valid? │ → Throws SkipExecution or StrictnessException +└────────┬────────┘ + │ ✓ Pass + ▼ +┌─────────────────┐ +│ BEHAVIOR (4) │ → #[Scope] +│ Affect query │ → May apply scopes or modify builder +└────────┬────────┘ + │ + ▼ + Filter Method Executes +``` + +--- + +## Available Annotations + +### Control Stage + +| Attribute | Description | +| ---------------------------------- | ------------------------------------------------------- | +| [`#[Authorize]`](./authorize.md) | Require authorization before running the filter | +| [`#[SkipIf]`](./skip-if.md) | Skip the filter based on a Payload condition | + +### Transform Stage + +| Attribute | Description | +| -------------------------------------- | ---------------------------------------------------- | +| [`#[Trim]`](./trim.md) | Remove whitespace from string values | +| [`#[Sanitize]`](./sanitize.md) | Apply sanitization rules (lowercase, strip_tags, etc.) | +| [`#[Cast]`](./cast.md) | Cast the value to a specific type | +| [`#[MapValue]`](./map-value.md) | Map input values to different values | +| [`#[DefaultValue]`](./default-value.md)| Set a fallback value when input is empty | +| [`#[Explode]`](./explode.md) | Split a string value into an array | + +### Validate Stage + +| Attribute | Description | +| ---------------------------------- | ------------------------------------------------------- | +| [`#[Required]`](./required.md) | Ensure the value is present and not empty | +| [`#[In]`](./in.md) | Validate the value is in an allowed set | +| [`#[Between]`](./between.md) | Validate the value is within a numeric range | +| [`#[Regex]`](./regex.md) | Validate the value matches a regex pattern | + +### Behavior Stage + +| Attribute | Description | +| ------------------------------ | ----------------------------------------------------------- | +| [`#[Scope]`](./scope.md) | Auto-apply an Eloquent scope with the payload value | + +--- + +## Combining Attributes + +You can stack multiple attributes on a single method. They always execute in stage order: + +```php +#[SkipIf('empty')] // Stage 1: Skip if empty +#[Trim] // Stage 2: Remove whitespace +#[Sanitize('lowercase', 'strip_tags')] // Stage 2: Clean the value +#[Cast('int')] // Stage 2: Cast to integer +#[Required] // Stage 3: Must have a value +#[Between(min: 1, max: 1000)] // Stage 3: Range check +protected function price(Payload $payload) +{ + return $this->builder->where('price', $payload->value); +} +``` + +--- + +## Creating Custom Annotations + +All annotations implement the `MethodAttribute` interface: + +```php +value; + } + + public function handle(AttributeContext $context): void + { + $payload = $context->payload; + + if (is_string($payload->value) && mb_strlen($payload->value) < $this->length) { + throw new \Kettasoft\Filterable\Engines\Exceptions\SkipExecution( + "Value must be at least {$this->length} characters." + ); + } + } +} +``` + +Usage: + +```php +#[MinLength(3)] +protected function search(Payload $payload) +{ + return $this->builder->where('title', 'like', $payload->asLike()); +} +``` + +--- + +## AttributeContext + +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) | + +You can read and write to `state` for inter-attribute communication: + +```php +$context->set('my_flag', true); +$context->get('my_flag'); // true +$context->has('my_flag'); // true +``` diff --git a/docs/engines/invokable/annotations/map-value.md b/docs/engines/invokable/annotations/map-value.md new file mode 100644 index 0000000..7a6ceca --- /dev/null +++ b/docs/engines/invokable/annotations/map-value.md @@ -0,0 +1,68 @@ +--- +sidebarDepth: 1 +--- + +# #[MapValue] + +**Stage:** `TRANSFORM` (2) + +Maps incoming values to different output values using a key-value map. Useful for converting human-readable values (like `'active'`, `'inactive'`) to database values (like `1`, `0`). + +--- + +## Parameters + +| Parameter | Type | Required | Default | Description | +| --------- | ------- | -------- | ------- | -------------------------------------------------------- | +| `$map` | `array` | ✅ | — | Key-value mapping (e.g., `['active' => 1]`) | +| `$strict` | `bool` | ❌ | `false` | If `true`, skip the filter when value is not in the map | + +--- + +## Usage + +### Basic Mapping + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\MapValue; + +#[MapValue(['active' => 1, 'inactive' => 0])] +protected function status(Payload $payload) +{ + // "active" → 1, "inactive" → 0 + return $this->builder->where('status', $payload->value); +} +``` + +### String to String Mapping + +```php +#[MapValue(['published' => 'live', 'draft' => 'hidden'])] +protected function visibility(Payload $payload) +{ + // "published" → "live", "draft" → "hidden" + return $this->builder->where('visibility', $payload->value); +} +``` + +### Strict Mode + +When `strict: true`, if the incoming value is not found in the map, the filter is skipped entirely: + +```php +#[MapValue(['active' => 1, 'inactive' => 0], strict: true)] +protected function status(Payload $payload) +{ + // "unknown" → filter is SKIPPED + return $this->builder->where('status', $payload->value); +} +``` + +--- + +## Behavior + +| Scenario | Non-Strict (default) | Strict Mode | +| ---------------------------------- | ------------------------------------- | ----------------------------- | +| Value exists in map | Value is replaced with mapped value | Value is replaced | +| Value does **not** exist in map | Original value is kept | Filter is **skipped** | diff --git a/docs/engines/invokable/annotations/regex.md b/docs/engines/invokable/annotations/regex.md new file mode 100644 index 0000000..3b9d5a9 --- /dev/null +++ b/docs/engines/invokable/annotations/regex.md @@ -0,0 +1,99 @@ +--- +sidebarDepth: 1 +--- + +# #[Regex] + +**Stage:** `VALIDATE` (3) + +Validates that the payload value matches a given regular expression pattern. If it doesn't match, the filter is **skipped**. + +--- + +## Parameters + +| Parameter | Type | Required | Default | Description | +| ---------- | -------- | -------- | ------- | --------------------------------------------- | +| `$pattern` | `string` | ✅ | — | The regex pattern to match against | +| `$message` | `string` | ❌ | `''` | Custom error message when validation fails | + +--- + +## Usage + +### Alphabetic Only + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Regex; + +#[Regex('/^[a-zA-Z]+$/')] +protected function status(Payload $payload) +{ + return $this->builder->where('status', $payload->value); +} +``` + +### Email Validation + +```php +#[Regex('/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/')] +protected function email(Payload $payload) +{ + return $this->builder->where('email', $payload->value); +} +``` + +### Slug Pattern + +```php +#[Regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/')] +protected function slug(Payload $payload) +{ + return $this->builder->where('slug', $payload->value); +} +``` + +### Numeric Only + +```php +#[Regex('/^\d+$/')] +protected function zipCode(Payload $payload) +{ + return $this->builder->where('zip_code', $payload->value); +} +``` + +### Custom Error Message + +```php +#[Regex('/^[A-Z]{2}-\d{4}$/', message: 'Invalid product code format. Expected: XX-1234')] +protected function productCode(Payload $payload) +{ + return $this->builder->where('code', $payload->value); +} +``` + +--- + +## Behavior + +| Scenario | Result | +| ------------------------------- | ------------------------------------ | +| Value matches the pattern | Filter executes normally | +| Value does **not** match | Filter is **skipped** | +| Value is not a string | Filter is **skipped** | + +--- + +## Combining with Transform Attributes + +```php +#[Trim] +#[Sanitize('lowercase')] +#[Regex('/^[a-z0-9-]+$/')] +protected function slug(Payload $payload) +{ + // " My-Slug-123 " → "my-slug-123" → passes regex + return $this->builder->where('slug', $payload->value); +} +``` diff --git a/docs/engines/invokable/annotations/required.md b/docs/engines/invokable/annotations/required.md new file mode 100644 index 0000000..741d588 --- /dev/null +++ b/docs/engines/invokable/annotations/required.md @@ -0,0 +1,69 @@ +--- +sidebarDepth: 1 +--- + +# #[Required] + +**Stage:** `VALIDATE` (3) + +Ensures the payload value is present and not empty. If the value is missing or empty, a `StrictnessException` is thrown, which **propagates up** rather than silently skipping. + +--- + +## Parameters + +This attribute has no constructor parameters. The error message includes the parameter name automatically. + +--- + +## Usage + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required; + +#[Required] +protected function status(Payload $payload) +{ + return $this->builder->where('status', $payload->value); +} +``` + +--- + +## Error Message + +When the value is empty, the exception message follows this format: + +``` +The parameter 'status' is required. +``` + +The parameter name (`status`) is taken from the filter key in the request. + +--- + +## Behavior + +| Scenario | Result | +| -------------------------- | --------------------------------------------------- | +| Value is provided | Filter executes normally | +| Value is empty (`''`) | `StrictnessException` is thrown | +| Value is null | `StrictnessException` is thrown | + +::: warning +Unlike other validation attributes (like `#[In]` or `#[Between]`) which **skip** the filter silently, `#[Required]` throws a `StrictnessException` that propagates to the caller. +::: + +--- + +## Combining with Other Attributes + +```php +#[Trim] // First: trim whitespace +#[Required] // Then: ensure it's not empty after trimming +#[In('active', 'pending')] // Finally: validate allowed values +protected function status(Payload $payload) +{ + return $this->builder->where('status', $payload->value); +} +``` diff --git a/docs/engines/invokable/annotations/sanitize.md b/docs/engines/invokable/annotations/sanitize.md new file mode 100644 index 0000000..647030e --- /dev/null +++ b/docs/engines/invokable/annotations/sanitize.md @@ -0,0 +1,96 @@ +--- +sidebarDepth: 1 +--- + +# #[Sanitize] + +**Stage:** `TRANSFORM` (2) + +Applies one or more sanitization rules to the payload value in order. This is the most versatile transform attribute, supporting multiple chained operations. + +--- + +## Parameters + +| Parameter | Type | Required | Description | +| --------- | ---------- | -------- | --------------------------------------------- | +| `...$rules` | `string` | ✅ | One or more sanitization rule names to apply | + +--- + +## Supported Rules + +| Rule | Description | Example | +| ------------- | --------------------------------------- | ------------------------------------- | +| `lowercase` | Convert to lowercase | `"ACTIVE"` → `"active"` | +| `uppercase` | Convert to uppercase | `"active"` → `"ACTIVE"` | +| `ucfirst` | Capitalize first letter | `"hello world"` → `"Hello world"` | +| `strip_tags` | Remove HTML and PHP tags | `"hello"` → `"hello"` | +| `nl2br` | Convert newlines to `
` tags | `"a\nb"` → `"a
\nb"` | +| `slug` | Convert to URL-friendly slug | `"Hello World"` → `"hello-world"` | +| `trim` | Remove whitespace from both sides | `" hello "` → `"hello"` | + +--- + +## Usage + +### Single Rule + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Sanitize; + +#[Sanitize('lowercase')] +protected function status(Payload $payload) +{ + // "ACTIVE" → "active" + return $this->builder->where('status', $payload->value); +} +``` + +### Multiple Rules (Applied in Order) + +```php +#[Sanitize('trim', 'strip_tags', 'lowercase')] +protected function status(Payload $payload) +{ + // " ACTIVE " → "active" + return $this->builder->where('status', $payload->value); +} +``` + +### Generate Slug + +```php +#[Sanitize('slug')] +protected function category(Payload $payload) +{ + // "Hello World Post" → "hello-world-post" + return $this->builder->where('slug', $payload->value); +} +``` + +--- + +## Rule Order Matters + +Rules are applied **left to right**, so the order can affect the result: + +```php +// ✅ Correct: strip tags first, then lowercase +#[Sanitize('strip_tags', 'lowercase')] +// "HELLO" → "HELLO" → "hello" + +// ⚠️ Different result: lowercase first, then strip tags +#[Sanitize('lowercase', 'strip_tags')] +// "HELLO" → "hello" → "hello" +``` + +--- + +## Behavior + +| Scenario | Result | +| --------------------------- | ----------------------------------------------- | +| Value is a string | All rules are applied in order | +| Value is not a string | No modification (silently skipped) | +| Unknown rule name | `InvalidArgumentException` is thrown | diff --git a/docs/engines/invokable/annotations/scope.md b/docs/engines/invokable/annotations/scope.md new file mode 100644 index 0000000..19a65c9 --- /dev/null +++ b/docs/engines/invokable/annotations/scope.md @@ -0,0 +1,103 @@ +--- +sidebarDepth: 1 +--- + +# #[Scope] + +**Stage:** `BEHAVIOR` (4) + +Automatically applies an Eloquent local scope on the query builder, passing the payload value to the scope. This allows you to reuse your model's scope methods directly from filter attributes. + +--- + +## Parameters + +| Parameter | Type | Required | Description | +| --------- | -------- | -------- | -------------------------------------------------------------- | +| `$scope` | `string` | ✅ | The scope name (without the `scope` prefix on the model) | + +--- + +## Usage + +### Model with Scope + +```php +// App\Models\Post +class Post extends Model +{ + public function scopeActive(Builder $query, $value = null): Builder + { + return $query->where('status', $value ?? 'active'); + } + + public function scopePopular(Builder $query, $minViews = 100): Builder + { + return $query->where('views', '>=', $minViews); + } +} +``` + +### Filter Class + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Scope; + +#[Scope('active')] +protected function status(Payload $payload) +{ + // The scope is applied automatically before this method runs. + // The scope receives $payload->value as its argument. + // This method body also executes after the scope. +} +``` + +### Using Popular Scope + +```php +#[Scope('popular')] +protected function minViews(Payload $payload) +{ + // Calls: $query->popular($payload->value) + // e.g., $query->where('views', '>=', 500) +} +``` + +--- + +## How It Works + +1. The attribute checks that the scope method exists on the model (`scope{Name}`). +2. It calls `$query->{scopeName}($payload->value)` on the query builder. +3. It sets `scope_applied = true` in the attribute context state. +4. The filter method still executes after the scope is applied. + +--- + +## Behavior + +| Scenario | Result | +| --------------------------------- | ----------------------------------------------------- | +| Scope exists on the model | Scope is applied, then filter method executes | +| Scope does **not** exist | `InvalidArgumentException` (caught by engine pipeline)| + +--- + +## Combining with Other Attributes + +```php +#[SkipIf('empty')] +#[Trim] +#[Sanitize('lowercase')] +#[In('active', 'pending', 'archived')] +#[Scope('active')] +protected function status(Payload $payload) +{ + // 1. Skip if empty + // 2. Trim whitespace + // 3. Lowercase + // 4. Validate against allowed values + // 5. Apply the 'active' scope with the value + // 6. Filter method body runs +} +``` diff --git a/docs/engines/invokable/annotations/skip-if.md b/docs/engines/invokable/annotations/skip-if.md new file mode 100644 index 0000000..a661796 --- /dev/null +++ b/docs/engines/invokable/annotations/skip-if.md @@ -0,0 +1,95 @@ +--- +sidebarDepth: 1 +--- + +# #[SkipIf] + +**Stage:** `CONTROL` (1) — **Repeatable** + +Skips the filter execution if a specified condition on the `Payload` is met. Uses the Payload's `is*` methods for checks. + +--- + +## Parameters + +| Parameter | Type | Required | Default | Description | +| ---------- | -------- | -------- | ------- | ------------------------------------------------------------------------ | +| `$check` | `string` | ✅ | — | The Payload `is*` check name (e.g., `'empty'`, `'null'`, `'!numeric'`) | +| `$message` | `string` | ❌ | `''` | Custom message when the filter is skipped | + +--- + +## Usage + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\SkipIf; + +#[SkipIf('empty')] +protected function status(Payload $payload) +{ + return $this->builder->where('status', $payload->value); +} +``` + +--- + +## Negation with `!` + +Prefix the check name with `!` to negate it: + +```php +// Skip if value is NOT numeric +#[SkipIf('!numeric')] +protected function price(Payload $payload) +{ + return $this->builder->where('price', $payload->value); +} +``` + +--- + +## Available Checks + +Any `is*` method on the `Payload` class can be used: + +| Check | Maps To | Description | +| --------------- | ---------------------- | ------------------------------------ | +| `'empty'` | `$payload->isEmpty()` | Value is empty | +| `'null'` | `$payload->isNull()` | Value is null | +| `'emptyString'` | `$payload->isEmptyString()` | Value is a blank string | +| `'numeric'` | `$payload->isNumeric()`| Value is numeric | +| `'boolean'` | `$payload->isBoolean()`| Value is boolean-like | +| `'string'` | `$payload->isString()` | Value is a string | +| `'array'` | `$payload->isArray()` | Value is an array | +| `'date'` | `$payload->isDate()` | Value is a valid date | +| `'json'` | `$payload->isJson()` | Value is valid JSON | +| `'!numeric'` | `!$payload->isNumeric()` | Value is **not** numeric | +| `'!empty'` | `!$payload->isEmpty()` | Value is **not** empty | + +--- + +## Stacking Multiple Checks + +Since `#[SkipIf]` is repeatable, you can stack multiple conditions: + +```php +#[SkipIf('empty')] +#[SkipIf('emptyString')] +protected function title(Payload $payload) +{ + return $this->builder->where('title', 'like', $payload->asLike()); +} +``` + +Each `#[SkipIf]` is evaluated independently. If **any** of them triggers, the filter is skipped. + +--- + +## Behavior + +| Scenario | Result | +| ------------------------- | ----------------------------------- | +| Check returns `true` | Filter is **skipped** | +| Check returns `false` | Filter executes normally | +| Negated check (`!`) true | Filter is **skipped** | +| Method doesn't exist | `InvalidArgumentException` is thrown | diff --git a/docs/engines/invokable/annotations/trim.md b/docs/engines/invokable/annotations/trim.md new file mode 100644 index 0000000..e39dee0 --- /dev/null +++ b/docs/engines/invokable/annotations/trim.md @@ -0,0 +1,77 @@ +--- +sidebarDepth: 1 +--- + +# #[Trim] + +**Stage:** `TRANSFORM` (2) + +Removes whitespace (or custom characters) from the payload value before the filter method executes. + +--- + +## Parameters + +| Parameter | Type | Required | Default | Description | +| ------------- | -------- | -------- | ----------------------- | ---------------------------------------------- | +| `$characters` | `string` | ❌ | `" \t\n\r\0\x0B"` | Characters to trim | +| `$side` | `string` | ❌ | `'both'` | Side to trim: `'both'`, `'left'`, or `'right'` | + +--- + +## Usage + +### Basic — Trim Both Sides + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim; + +#[Trim] +protected function title(Payload $payload) +{ + // " hello world " → "hello world" + return $this->builder->where('title', $payload->value); +} +``` + +### Trim Left Only + +```php +#[Trim(side: 'left')] +protected function title(Payload $payload) +{ + // " hello world " → "hello world " + return $this->builder->where('title', $payload->value); +} +``` + +### Trim Right Only + +```php +#[Trim(side: 'right')] +protected function title(Payload $payload) +{ + // " hello world " → " hello world" + return $this->builder->where('title', $payload->value); +} +``` + +### Custom Characters + +```php +#[Trim(characters: '-')] +protected function slug(Payload $payload) +{ + // "---hello-world---" → "hello-world" + return $this->builder->where('slug', $payload->value); +} +``` + +--- + +## Behavior + +| Scenario | Result | +| ------------------------- | ----------------------------------- | +| Value is a string | Whitespace/characters are trimmed | +| Value is not a string | No modification (silently skipped) | diff --git a/docs/engines/invokable/index.md b/docs/engines/invokable/index.md new file mode 100644 index 0000000..f3eab77 --- /dev/null +++ b/docs/engines/invokable/index.md @@ -0,0 +1,259 @@ +--- +sidebarDepth: 2 +--- + +# Invokable Engine + +The **Invokable Engine** is the default and most commonly used engine in Filterable. It dynamically maps incoming request parameters to corresponding methods in your filter class, enabling clean, scalable filtering logic without large `switch` or `if-else` blocks. + +--- + +## Purpose + +Automatically execute specific methods in a filter class based on incoming request keys. Each key in the request is matched with a method of the same name (or mapped name) registered in the `$filters` property, and the method is invoked with a rich `Payload` object. + +--- + +## How It Works + +```text +[ Request ] + │ + ▼ +[ Extract Filter Keys ] ─── from $filters property + │ + ▼ +[ For Each Key ] + ├── Parse operator & value (Dissector) + ├── Create Payload (field, operator, value, rawValue) + ├── Run Attribute Pipeline (CONTROL → TRANSFORM → VALIDATE → BEHAVIOR) + ├── Call filter method with Payload + └── Commit clause to query + │ + ▼ +[ Modified Query Builder ] +``` + +### Step by Step + +1. The request is parsed and filter keys are extracted from the `$filters` property. +2. For each key, the engine parses the value through a **Dissector** to extract the operator and value. +3. A `Payload` object is created containing `field`, `operator`, `value`, and `rawValue`. +4. The **Attribute Pipeline** runs all PHP attributes (annotations) on the method, sorted by stage. +5. If the pipeline succeeds, the filter method is invoked with the `Payload`. +6. The resulting clause is committed to the query builder. + +--- + +## Basic Example + +### Incoming Request + +```http +GET /api/posts?status=pending&title=PHP +``` + +### Filter Class + +```php +builder->where('title', 'like', $payload->asLike()); + } + + protected function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } +} +``` + +### Usage + +```php +$posts = Post::filter(PostFilter::class)->paginate(); +``` + +--- + +## The Payload Object + +Every filter method receives a `Payload` instance, giving you full access to the parsed request data: + +| Property | Type | Description | +| ----------- | -------- | ---------------------------------------------- | +| `field` | `string` | The column/filter name | +| `operator` | `string` | The parsed operator (e.g., `eq`, `like`, `gt`) | +| `value` | `mixed` | The sanitized filter value | +| `rawValue` | `mixed` | The original raw input before sanitization | + +```php +protected function price(Payload $payload) +{ + return $this->builder->where('price', $payload->operator, $payload->value); +} +``` + +See the full [Payload API Reference](/api/payload) for all available methods. + +--- + +## Method Mapping with `$mentors` + +By default, the engine matches request keys directly to method names (converted to camelCase). You can customize this mapping with the `$mentors` property: + +```php +class PostFilter extends Filterable +{ + protected $filters = ['joined', 'status']; + + protected $mentors = [ + 'joined' => 'filterByJoinDate', + 'status' => 'filterByStatus', + ]; + + protected function filterByJoinDate(Payload $payload) + { + return $this->builder->whereDate('joined_at', '>', $payload->value); + } + + protected function filterByStatus(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } +} +``` + +### Automatic Fallback + +If `$mentors` is empty or not defined, the engine automatically matches request keys to method names: + +``` +'status' → calls status() +'created_at' → calls createdAt() +``` + +--- + +## Attribute Pipeline + +The Invokable Engine supports **PHP 8 Attributes** (annotations) on filter methods. These attributes are processed through an **Attribute Pipeline** before the filter method executes. + +Attributes are sorted and executed by **stage**: + +| Order | Stage | Purpose | Example Attributes | +| ----- | ------------- | ---------------------------------- | ----------------------------------- | +| 1 | **CONTROL** | Decide whether to run the filter | `#[Authorize]`, `#[SkipIf]` | +| 2 | **TRANSFORM** | Modify the payload value | `#[Trim]`, `#[Sanitize]`, `#[Cast]`, `#[MapValue]`, `#[DefaultValue]`, `#[Explode]` | +| 3 | **VALIDATE** | Assert correctness of the value | `#[Required]`, `#[In]`, `#[Between]`, `#[Regex]` | +| 4 | **BEHAVIOR** | Affect query behavior | `#[Scope]` | + +### Example with Attributes + +```php +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Trim; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Sanitize; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required; +use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\In; + +class PostFilter extends Filterable +{ + protected $filters = ['status', 'title']; + + #[Trim] + #[Sanitize('lowercase')] + #[Required] + #[In('active', 'pending', 'archived')] + protected function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + + #[Trim] + #[Sanitize('strip_tags')] + protected function title(Payload $payload) + { + return $this->builder->where('title', 'like', $payload->asLike()); + } +} +``` + +In this example, when a `status` filter is received: + +1. **Trim** removes whitespace from the value. +2. **Sanitize** converts it to lowercase. +3. **Required** ensures the value is not empty (throws exception if it is). +4. **In** validates the value is one of the allowed options (skips if not). +5. The filter method executes with the cleaned, validated payload. + +👉 See [Annotations Reference](./annotations/) for full documentation of all available attributes. + +--- + +## Default Operator + +The default operator can be configured per engine: + +```php +// config/filterable.php +'engines' => [ + 'invokable' => [ + 'default_operator' => 'eq', + ], +], +``` + +--- + +## Key Features + +| Feature | Description | +| ------------------------------ | -------------------------------------------------------------- | +| **Convention over Configuration** | Method names match request keys automatically | +| **Safe Execution** | Only registered filter keys in `$filters` are processed | +| **Attribute Pipeline** | PHP 8 attributes for validation, transformation, and control | +| **Custom Method Mapping** | `$mentors` property for flexible key-to-method mapping | +| **Rich Payload Object** | Full access to field, operator, value, and raw value | +| **Extensible** | Add or override filter methods easily | + +--- + +## Lifecycle + +```text +1. Controller receives request +2. Post::filter(PostFilter::class) is called +3. Engine extracts keys from $filters +4. For each key present in the request: + a. Dissector parses the operator and value + b. Payload is created + c. Attribute Pipeline runs (CONTROL → TRANSFORM → VALIDATE → BEHAVIOR) + d. If pipeline passes, filter method is called with Payload + e. Clause is committed to query +5. Modified Eloquent query is returned +``` + +--- + +## Best Practices + +- **Always register filters** in the `$filters` property — unregistered methods won't execute. +- **Use attributes** to keep your filter methods focused on query logic, not validation. +- **Combine multiple attributes** — they execute in stage order, so `#[Trim]` always runs before `#[Required]`. +- **Type-hint `Payload`** in your filter methods for full IDE support. +- **Use `$mentors`** to decouple public API parameter names from internal method names. +- **Validate input** using `#[Required]`, `#[In]`, `#[Between]`, or `#[Regex]` attributes.