From de2f0ccf0c63df74d76bd1e07ba72d40c0d12a8f Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 01:58:33 +0200 Subject: [PATCH 01/31] refactor: update exception handling by moving exceptions to Engines namespace --- src/{ => Engines}/Exceptions/InvalidDataFormatException.php | 2 +- src/{ => Engines}/Exceptions/InvalidOperatorException.php | 2 +- src/{ => Engines}/Exceptions/NotAllowedFieldException.php | 2 +- src/Engines/Foundation/ClauseFactory.php | 4 ++-- src/Engines/Foundation/Handlers/AllowedFieldValidator.php | 2 +- src/Engines/Foundation/OperatorDefinition.php | 2 +- src/Support/AllowedFieldChecker.php | 2 +- src/Support/TreeBasedRelationsResolver.php | 2 +- src/Support/TreeBasedSignelConditionResolver.php | 2 +- src/Support/TreeNode.php | 2 +- tests/Unit/Engines/ExpressionEngineTest.php | 4 ++-- tests/Unit/Engines/RulesetEngineTest.php | 4 ++-- tests/Unit/Engines/TreeEngineTest.php | 6 +++--- 13 files changed, 18 insertions(+), 18 deletions(-) rename src/{ => Engines}/Exceptions/InvalidDataFormatException.php (80%) rename src/{ => Engines}/Exceptions/InvalidOperatorException.php (84%) rename src/{ => Engines}/Exceptions/NotAllowedFieldException.php (84%) diff --git a/src/Exceptions/InvalidDataFormatException.php b/src/Engines/Exceptions/InvalidDataFormatException.php similarity index 80% rename from src/Exceptions/InvalidDataFormatException.php rename to src/Engines/Exceptions/InvalidDataFormatException.php index 55f55ac..f4e7086 100644 --- a/src/Exceptions/InvalidDataFormatException.php +++ b/src/Engines/Exceptions/InvalidDataFormatException.php @@ -1,6 +1,6 @@ Date: Sat, 15 Nov 2025 03:46:11 +0200 Subject: [PATCH 02/31] refactor: replace built-in exceptions with SkipExecution for custom exception handling --- .../Exceptions/InvalidDataFormatException.php | 2 +- .../Exceptions/InvalidOperatorException.php | 2 +- .../Exceptions/NotAllowedFieldException.php | 2 +- src/Engines/Exceptions/SkipExecution.php | 37 +++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 src/Engines/Exceptions/SkipExecution.php diff --git a/src/Engines/Exceptions/InvalidDataFormatException.php b/src/Engines/Exceptions/InvalidDataFormatException.php index f4e7086..367ad2f 100644 --- a/src/Engines/Exceptions/InvalidDataFormatException.php +++ b/src/Engines/Exceptions/InvalidDataFormatException.php @@ -2,7 +2,7 @@ namespace Kettasoft\Filterable\Engines\Exceptions; -class InvalidDataFormatException extends \ErrorException +class InvalidDataFormatException extends SkipExecution { public function __construct() { diff --git a/src/Engines/Exceptions/InvalidOperatorException.php b/src/Engines/Exceptions/InvalidOperatorException.php index 24bf915..9b5a36b 100644 --- a/src/Engines/Exceptions/InvalidOperatorException.php +++ b/src/Engines/Exceptions/InvalidOperatorException.php @@ -2,7 +2,7 @@ namespace Kettasoft\Filterable\Engines\Exceptions; -class InvalidOperatorException extends \InvalidArgumentException +class InvalidOperatorException extends SkipExecution { /** * InvalidOperatorException constructor. diff --git a/src/Engines/Exceptions/NotAllowedFieldException.php b/src/Engines/Exceptions/NotAllowedFieldException.php index bc2009b..4325af0 100644 --- a/src/Engines/Exceptions/NotAllowedFieldException.php +++ b/src/Engines/Exceptions/NotAllowedFieldException.php @@ -2,7 +2,7 @@ namespace Kettasoft\Filterable\Engines\Exceptions; -class NotAllowedFieldException extends \InvalidArgumentException +class NotAllowedFieldException extends SkipExecution { /** * NotAllowedFieldException constructor. diff --git a/src/Engines/Exceptions/SkipExecution.php b/src/Engines/Exceptions/SkipExecution.php new file mode 100644 index 0000000..bbf303b --- /dev/null +++ b/src/Engines/Exceptions/SkipExecution.php @@ -0,0 +1,37 @@ +clause; + } + + /** + * Determine if this exception should be reported. + * @return bool + */ + public function shouldReport(): bool + { + return false; + } +} From 0bbffc22977ccd7db410e3a7a3eee93ac612966b Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 03:48:23 +0200 Subject: [PATCH 03/31] refactor: remove unused FilterableContext import from Engine class --- src/Engines/Foundation/Engine.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Engines/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index 6c9f461..7bd58a0 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -4,7 +4,6 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; -use Kettasoft\Filterable\Contracts\FilterableContext; use Kettasoft\Filterable\Engines\Contracts\Executable; use Kettasoft\Filterable\Engines\Contracts\HasAllowedFieldChecker; use Kettasoft\Filterable\Engines\Contracts\HasFieldMap; From 08a987528b669f0a03c3ce98a96d21649e517be2 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 04:02:32 +0200 Subject: [PATCH 04/31] refactor: change SkipExecution from abstract to concrete class --- src/Engines/Exceptions/SkipExecution.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Engines/Exceptions/SkipExecution.php b/src/Engines/Exceptions/SkipExecution.php index bbf303b..525d9c4 100644 --- a/src/Engines/Exceptions/SkipExecution.php +++ b/src/Engines/Exceptions/SkipExecution.php @@ -5,7 +5,7 @@ use Exception; use Kettasoft\Filterable\Engines\Foundation\Clause; -abstract class SkipExecution extends Exception +class SkipExecution extends Exception { /** * SkipExecution constructor. From 35095bfd09afda28000ed5bd92c3cd6aa1f63e11 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 04:03:19 +0200 Subject: [PATCH 05/31] feat: add Skippable interface for execution skipping functionality --- src/Engines/Contracts/Skippable.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/Engines/Contracts/Skippable.php diff --git a/src/Engines/Contracts/Skippable.php b/src/Engines/Contracts/Skippable.php new file mode 100644 index 0000000..688af36 --- /dev/null +++ b/src/Engines/Contracts/Skippable.php @@ -0,0 +1,17 @@ + Date: Sat, 15 Nov 2025 04:03:54 +0200 Subject: [PATCH 06/31] refactor: rename execute method to handle in engine classes for consistency --- src/Engines/Expression.php | 8 ++--- src/Engines/Foundation/Engine.php | 40 +++++++++++++++++++----- src/Engines/Invokable.php | 2 +- src/Engines/Ruleset.php | 2 +- src/Engines/Tree.php | 2 +- tests/Unit/Engines/EngineManagerTest.php | 2 +- 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/Engines/Expression.php b/src/Engines/Expression.php index e1222f6..e89ad65 100644 --- a/src/Engines/Expression.php +++ b/src/Engines/Expression.php @@ -2,8 +2,8 @@ namespace Kettasoft\Filterable\Engines; +use Illuminate\Database\Eloquent\Builder; use Kettasoft\Filterable\Support\Payload; -use Illuminate\Contracts\Database\Eloquent\Builder; use Kettasoft\Filterable\Engines\Foundation\Engine; use Kettasoft\Filterable\Support\ConditionNormalizer; use Kettasoft\Filterable\Support\ValidateTableColumns; @@ -22,10 +22,10 @@ class Expression extends Engine /** * Apply filters to the query. - * @param \Illuminate\Contracts\Database\Eloquent\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - public function execute(Builder $builder) + public function handle(Builder $builder): Builder { $filters = $this->context->getData(); @@ -46,7 +46,7 @@ public function execute(Builder $builder) $this->commit($field, $clause); - return Applier::apply(new ClauseApplier($clause), $builder); + Applier::apply(new ClauseApplier($clause), $builder); } return $builder; diff --git a/src/Engines/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index 7bd58a0..9ee23a2 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -2,17 +2,19 @@ namespace Kettasoft\Filterable\Engines\Foundation; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; +use Kettasoft\Filterable\Filterable; +use Illuminate\Database\Eloquent\Builder; +use Kettasoft\Filterable\Foundation\Resources; +use Kettasoft\Filterable\Engines\Contracts\Skippable; use Kettasoft\Filterable\Engines\Contracts\Executable; -use Kettasoft\Filterable\Engines\Contracts\HasAllowedFieldChecker; +use Kettasoft\Filterable\Engines\Contracts\Strictable; use Kettasoft\Filterable\Engines\Contracts\HasFieldMap; +use Kettasoft\Filterable\Engines\Exceptions\SkipExecution; +use Kettasoft\Filterable\Engines\Contracts\HasAllowedFieldChecker; use Kettasoft\Filterable\Engines\Contracts\HasInteractsWithOperators; -use Kettasoft\Filterable\Engines\Contracts\Strictable; -use Kettasoft\Filterable\Filterable; -use Kettasoft\Filterable\Foundation\Resources; -abstract class Engine implements HasInteractsWithOperators, HasFieldMap, Strictable, Executable, HasAllowedFieldChecker +abstract class Engine implements HasInteractsWithOperators, HasFieldMap, Strictable, Executable, HasAllowedFieldChecker, Skippable { /** * Create Engine instance. @@ -31,7 +33,31 @@ abstract public function getEngineName(): string; * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - abstract public function execute(Builder $builder); + abstract public function handle(Builder $builder): Builder; + + /** + * Apply filters to the query. + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return Builder + */ + final public function execute(Builder $builder) + { + try { + $this->handle($builder); + + return $builder; + } catch (\Throwable $e) { + throw $e; + } + } + + /** + * @inheritDoc + */ + public function skip(string $message, mixed $clause = null): never + { + throw new SkipExecution($message, $clause); + } /** * Get allowed fields to filtering. diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index eea76fc..04b4ee4 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -35,7 +35,7 @@ class Invokable extends Engine * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - public function execute(Builder $builder): Builder + public function handle(Builder $builder): Builder { $this->builder = $builder; diff --git a/src/Engines/Ruleset.php b/src/Engines/Ruleset.php index bdf9521..c909235 100644 --- a/src/Engines/Ruleset.php +++ b/src/Engines/Ruleset.php @@ -26,7 +26,7 @@ class Ruleset extends Engine * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - public function execute(Builder $builder): Builder + public function handle(Builder $builder): Builder { $data = $this->context->getData(); diff --git a/src/Engines/Tree.php b/src/Engines/Tree.php index cb797cc..10bb1ba 100644 --- a/src/Engines/Tree.php +++ b/src/Engines/Tree.php @@ -26,7 +26,7 @@ class Tree extends Engine * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - public function execute(Builder $builder) + public function handle(Builder $builder): Builder { $data = $this->context->getData(); diff --git a/tests/Unit/Engines/EngineManagerTest.php b/tests/Unit/Engines/EngineManagerTest.php index 9741b70..34da76f 100644 --- a/tests/Unit/Engines/EngineManagerTest.php +++ b/tests/Unit/Engines/EngineManagerTest.php @@ -59,7 +59,7 @@ public function test_it_can_create_custom_engine_from_engine_manager() { $engine = new class(new Filterable()) extends Engine { - public function execute(Builder $builder): Builder + public function handle(Builder $builder): Builder { return $builder; } From f1bff7b9d91a72965e311adf366c36b3cc92c009 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 06:06:28 +0200 Subject: [PATCH 07/31] feat: add StrictnessException class for custom runtime exception handling --- src/Exceptions/StrictnessException.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/Exceptions/StrictnessException.php diff --git a/src/Exceptions/StrictnessException.php b/src/Exceptions/StrictnessException.php new file mode 100644 index 0000000..a0b1085 --- /dev/null +++ b/src/Exceptions/StrictnessException.php @@ -0,0 +1,16 @@ + Date: Sat, 15 Nov 2025 06:07:23 +0200 Subject: [PATCH 08/31] refactor: replace InvalidArgumentException with StrictnessException in RequiredHandler and tests --- .../Foundation/Attributes/Handlers/RequiredHandler.php | 3 ++- .../Engines/Attributes/RequiredValueAttributeTest.php | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php b/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php index 58319ff..3a43e52 100644 --- a/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php +++ b/src/Engines/Foundation/Attributes/Handlers/RequiredHandler.php @@ -6,6 +6,7 @@ use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext; use Kettasoft\Filterable\Engines\Foundation\Attributes\Annotations\Required; use Kettasoft\Filterable\Engines\Foundation\Attributes\Handlers\Contracts\AttributeHandlerInterface; +use Kettasoft\Filterable\Exceptions\StrictnessException; class RequiredHandler implements AttributeHandlerInterface { @@ -22,7 +23,7 @@ public function handle(AttributeContext $context, object $attribute): void $payload = $context->payload; if ($payload && ($payload->isEmpty() || $payload->isNull())) { - throw new \InvalidArgumentException(sprintf($attribute->message, $context->state['key'])); + throw new StrictnessException(sprintf($attribute->message, $context->state['key'])); } } } diff --git a/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php b/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php index 5ccd073..340602f 100644 --- a/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php +++ b/tests/Feature/Engines/Attributes/RequiredValueAttributeTest.php @@ -3,12 +3,13 @@ namespace Kettasoft\Filterable\Tests\Feature\Engines\Attributes; use Kettasoft\Filterable\Tests\TestCase; +use Kettasoft\Filterable\Exceptions\StrictnessException; class RequiredValueAttributeTest extends TestCase { public function test_required_value_attribute_throws_exception_when_value_missing() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(StrictnessException::class); $this->expectExceptionMessage("The parameter 'status' is required."); request()->merge([ @@ -50,7 +51,7 @@ public function status(\Kettasoft\Filterable\Support\Payload $payload) public function test_required_value_attribute_throws_exception_when_value_missing_with_custom_message() { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(StrictnessException::class); $this->expectExceptionMessage("The 'status' parameter is mandatory."); request()->merge([ From 95928632ced5cef85e1f0f8ba2246270a36baae7 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 06:35:51 +0200 Subject: [PATCH 09/31] refactor: change InvalidDataFormatException to extend StrictnessException for improved exception handling --- src/Engines/Exceptions/InvalidDataFormatException.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Engines/Exceptions/InvalidDataFormatException.php b/src/Engines/Exceptions/InvalidDataFormatException.php index 367ad2f..b6a3a37 100644 --- a/src/Engines/Exceptions/InvalidDataFormatException.php +++ b/src/Engines/Exceptions/InvalidDataFormatException.php @@ -2,7 +2,9 @@ namespace Kettasoft\Filterable\Engines\Exceptions; -class InvalidDataFormatException extends SkipExecution +use Kettasoft\Filterable\Exceptions\StrictnessException; + +class InvalidDataFormatException extends StrictnessException { public function __construct() { From e9f440ab8958a24d540e604a2ba4f8adf980717b Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 07:10:45 +0200 Subject: [PATCH 10/31] fix: add stopOnFailure attribute to phpunit configuration for improved test execution --- phpunit.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml b/phpunit.xml index 4970d6f..c790b67 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + ./tests From fec8328ea3d7bd6c7eed5425d106c0516f4e4b9e Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 07:38:52 +0200 Subject: [PATCH 11/31] refactor: simplify exception usage in Operators enum by importing InvalidOperatorException --- src/Engines/Foundation/Enums/Operators.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Engines/Foundation/Enums/Operators.php b/src/Engines/Foundation/Enums/Operators.php index fca90bc..36cb46b 100644 --- a/src/Engines/Foundation/Enums/Operators.php +++ b/src/Engines/Foundation/Enums/Operators.php @@ -2,6 +2,8 @@ namespace Kettasoft\Filterable\Engines\Foundation\Enums; +use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; + enum Operators: string { case EQUALS = '='; @@ -37,7 +39,7 @@ public static function fromString(string $operator): string 'not_in' => self::NOT_IN->value, 'is_null' => self::IS_NULL->value, 'is_not_null' => self::IS_NOT_NULL->value, - default => throw new \Kettasoft\Filterable\Exceptions\InvalidOperatorException($operator), + default => throw new InvalidOperatorException($operator), }; } } From 098c09f55f7cf5ae60a8073cb28c30dc59f30a29 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 07:55:09 +0200 Subject: [PATCH 12/31] refactor: remove validated property and setStatus method from Clause class --- src/Engines/Foundation/Clause.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/Engines/Foundation/Clause.php b/src/Engines/Foundation/Clause.php index 7c44f6a..de551c1 100644 --- a/src/Engines/Foundation/Clause.php +++ b/src/Engines/Foundation/Clause.php @@ -31,12 +31,6 @@ class Clause implements Arrayable, Jsonable */ public readonly string|null $value; - /** - * Check if the clause is validated. - * @var bool - */ - public bool $validated = false; - /** * Clause constructor. * @@ -68,12 +62,6 @@ public function isRelational(): bool return is_string($this->field) && str_contains($this->field, '.'); } - public function setStatus(bool $status) - { - $this->validated = $status; - return $this; - } - /** * @inheritDoc */ From 1521a438317f9a0bc358284f6dbb607d6393f533 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 07:55:54 +0200 Subject: [PATCH 13/31] refactor: streamline validation methods in ClauseFactory and improve exception handling --- src/Engines/Foundation/ClauseFactory.php | 47 +++++++++--------------- 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/src/Engines/Foundation/ClauseFactory.php b/src/Engines/Foundation/ClauseFactory.php index 72afb23..98c4134 100644 --- a/src/Engines/Foundation/ClauseFactory.php +++ b/src/Engines/Foundation/ClauseFactory.php @@ -2,10 +2,11 @@ namespace Kettasoft\Filterable\Engines\Foundation; -use Kettasoft\Filterable\Engines\Foundation\Enums\Operators; use Kettasoft\Filterable\Support\Payload; +use Kettasoft\Filterable\Engines\Foundation\Enums\Operators; use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedEmptyValueException; /** * Class ClauseFactory @@ -35,44 +36,36 @@ public function __construct(protected Engine $engine) {} */ public function make(Payload $payload): Clause { - $valid = true; - - $valid = $this->validateField($payload) && $valid; - $valid = $this->validateOperator($payload) && $valid; - $valid = $this->validateValue($payload) && $valid; + $this->validateField($payload); + $this->validateOperator($payload); + $this->validateValue($payload); $resolvedField = $this->resolveField($payload); $resolvedOperator = $this->resolveOperator($payload); $payload->setField($resolvedField)->setOperator($resolvedOperator); - return (new Clause($payload)) - ->setStatus($valid); + return (new Clause($payload)); } /** * Validate the payload field against allowed fields and relations. * * @param Payload $payload - * @return bool + * @return void * * @throws NotAllowedFieldException */ - protected function validateField(Payload $payload): bool + protected function validateField(Payload $payload): void { $field = $payload->field; - - if (in_array($field, $this->engine->getAllowedFields(), true) || $this->isRelational($field)) { - return true; - } - // allow wildcard * as "all fields allowed" $isWildcardAllowed = ($this->engine->getAllowedFields()[0] ?? false) === '*'; - if ($this->engine->isStrict() && !$isWildcardAllowed) { + if (!(in_array($field, $this->engine->getAllowedFields(), true) || $this->isRelational($field) || $isWildcardAllowed)) { throw new NotAllowedFieldException($field); } - return $isWildcardAllowed; + return; } /** @@ -86,12 +79,8 @@ protected function validateField(Payload $payload): bool protected function validateOperator(Payload $payload): bool { $operator = $payload->operator; + if (! array_key_exists($operator, $this->engine->allowedOperators()) && $this->engine->isStrict()) { - if (array_key_exists($operator, $this->engine->allowedOperators())) { - return true; - } - - if ($this->engine->isStrict()) { throw new InvalidOperatorException($operator); } @@ -102,19 +91,17 @@ protected function validateOperator(Payload $payload): bool * Validate that the payload value is not empty when ignoredEmptyValues is enabled. * * @param Payload $payload - * @return bool + * @return void * - * @throws \InvalidArgumentException + * @throws NotAllowedEmptyValueException */ - protected function validateValue(Payload $payload): bool + protected function validateValue(Payload $payload): void { if ($this->engine->isIgnoredEmptyValues() && $payload->isEmpty()) { - if ($this->engine->isStrict()) { - throw new \InvalidArgumentException("Empty values are not allowed."); - } - return false; + throw new NotAllowedEmptyValueException("Empty values are not allowed."); } - return true; + + return; } /* ----------------------------------------------------------------- From c0593f589663d2155db5ee4c97a255d7bd7ec5f9 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Sat, 15 Nov 2025 08:02:33 +0200 Subject: [PATCH 14/31] test: add User model, factory, and migration for user management --- tests/Database/Factories/UserFactory.php | 31 +++++++++++++++ .../Database/Migrations/CreateUsersTable.php | 38 +++++++++++++++++++ tests/Models/User.php | 26 +++++++++++++ tests/TestCase.php | 4 +- 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 tests/Database/Factories/UserFactory.php create mode 100644 tests/Database/Migrations/CreateUsersTable.php create mode 100644 tests/Models/User.php diff --git a/tests/Database/Factories/UserFactory.php b/tests/Database/Factories/UserFactory.php new file mode 100644 index 0000000..9b5964d --- /dev/null +++ b/tests/Database/Factories/UserFactory.php @@ -0,0 +1,31 @@ + $this->faker->name, + 'email' => $this->faker->unique()->safeEmail, + 'is_blocked' => $this->faker->boolean, + 'platform' => $this->faker->randomElement(['web', 'ios', 'android']), + 'password' => bcrypt('password'), // or use Hash::make('password') + ]; + } +} diff --git a/tests/Database/Migrations/CreateUsersTable.php b/tests/Database/Migrations/CreateUsersTable.php new file mode 100644 index 0000000..7f1160e --- /dev/null +++ b/tests/Database/Migrations/CreateUsersTable.php @@ -0,0 +1,38 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->boolean('is_blocked'); + $table->enum('platform', ['web', 'ios', 'android']); + $table->string('password'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('users'); + } +} diff --git a/tests/Models/User.php b/tests/Models/User.php new file mode 100644 index 0000000..b41fcd3 --- /dev/null +++ b/tests/Models/User.php @@ -0,0 +1,26 @@ +hasMany(Post::class); + } + + protected static function newFactory(): UserFactory + { + return UserFactory::new(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 512d38f..3f750b1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,7 @@ use Orchestra\Testbench\TestCase as BaseTestCase; use Kettasoft\Filterable\Tests\Database\Migrations\CreateTagsTable; use Kettasoft\Filterable\Tests\Database\Migrations\CreatePostsTable; +use Kettasoft\Filterable\Tests\Database\Migrations\CreateUsersTable; class TestCase extends BaseTestCase { @@ -35,7 +36,8 @@ public function migrate() { $migrations = [ CreatePostsTable::class, - CreateTagsTable::class + CreateTagsTable::class, + CreateUsersTable::class ]; foreach ($migrations as $migration) { From 68e6166e153af0e1f0167da48f0dcd89a5840441 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 18 Nov 2025 19:59:52 +0200 Subject: [PATCH 15/31] fix(EngineManagerTest): rename handle method to execute in custom engine class --- tests/Unit/Engines/EngineManagerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Engines/EngineManagerTest.php b/tests/Unit/Engines/EngineManagerTest.php index 34da76f..9741b70 100644 --- a/tests/Unit/Engines/EngineManagerTest.php +++ b/tests/Unit/Engines/EngineManagerTest.php @@ -59,7 +59,7 @@ public function test_it_can_create_custom_engine_from_engine_manager() { $engine = new class(new Filterable()) extends Engine { - public function handle(Builder $builder): Builder + public function execute(Builder $builder): Builder { return $builder; } From 8885f1298255a9f0a5d21d10ca4da69b3c07bfe6 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 18 Nov 2025 20:56:08 +0200 Subject: [PATCH 16/31] fix(Clause): change value type from string|null to mixed for better flexibility --- src/Engines/Foundation/Clause.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Engines/Foundation/Clause.php b/src/Engines/Foundation/Clause.php index de551c1..153aa93 100644 --- a/src/Engines/Foundation/Clause.php +++ b/src/Engines/Foundation/Clause.php @@ -29,7 +29,7 @@ class Clause implements Arrayable, Jsonable * Original value. * @var string */ - public readonly string|null $value; + public readonly mixed $value; /** * Clause constructor. From 88c6268b755de3bae30306bee6217f39895b84cd Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 18 Nov 2025 21:25:46 +0200 Subject: [PATCH 17/31] test(InvokableEngineTest): add comprehensive filtering tests for various operators and conditions --- tests/Unit/Engines/InvokableEngineTest.php | 864 +++++++++++++++++++++ 1 file changed, 864 insertions(+) diff --git a/tests/Unit/Engines/InvokableEngineTest.php b/tests/Unit/Engines/InvokableEngineTest.php index 9876bd3..25b2182 100644 --- a/tests/Unit/Engines/InvokableEngineTest.php +++ b/tests/Unit/Engines/InvokableEngineTest.php @@ -114,4 +114,868 @@ public function status(Payload $payload) $this->assertEquals(5, $posts); } + + /** + * @test + */ + public function it_can_filter_with_multiple_filters() + { + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Laravel Tutorial' + ]); + + Post::factory()->create([ + 'status' => 'pending', + 'title' => 'PHP Guide' + ]); + + request()->merge([ + 'status' => 'active', + 'title' => 'Laravel Tutorial' + ]); + + $filter = new class extends Filterable { + protected $filters = ['status', 'title']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('active', $posts->first()->status); + $this->assertEquals('Laravel Tutorial', $posts->first()->title); + } + + /** + * @test + */ + public function it_can_filter_with_multiple_filters_and_operators() + { + // Clear setUp data for this specific test + Post::truncate(); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Test Post', + 'views' => 100 + ]); + + Post::factory()->create([ + 'status' => 'pending', + 'title' => 'Another Post', + 'views' => 50 + ]); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Third Post', + 'views' => 150 + ]); + + request()->merge([ + 'status' => [ + 'operator' => 'eq', + 'value' => 'active' + ], + 'views' => [ + 'operator' => 'gt', + 'value' => 75 + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['status', 'views']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->operator, $payload->value); + } + + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->operator, $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn($post) => $post->status === 'active' && $post->views > 75)); + } + + /** + * @test + */ + public function it_can_filter_with_like_operator() + { + Post::truncate(); + + Post::factory()->create(['title' => 'Laravel Framework']); + Post::factory()->create(['title' => 'PHP Tutorial']); + Post::factory()->create(['title' => 'Laravel Tips']); + + request()->merge([ + 'title' => [ + 'operator' => 'like', + 'value' => '%Laravel%' + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['title']; + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->operator, $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn($post) => str_contains($post->title, 'Laravel'))); + } + + /** + * @test + */ + public function it_can_filter_with_in_operator() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + Post::factory()->create(['status' => 'stopped']); + + request()->merge([ + 'status' => [ + 'operator' => 'in', + 'value' => ['active', 'stopped'] + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['status']; + + public function status(Payload $payload) + { + return $this->builder->whereIn('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn($post) => in_array($post->status, ['active', 'stopped']))); + } + + /** + * @test + */ + public function it_can_filter_with_between_operator() + { + Post::truncate(); + + Post::factory()->create(['views' => 10]); + Post::factory()->create(['views' => 50]); + Post::factory()->create(['views' => 100]); + Post::factory()->create(['views' => 150]); + + request()->merge([ + 'views' => [ + 'operator' => 'between', + 'value' => [40, 110] + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['views']; + + public function views(Payload $payload) + { + return $this->builder->whereBetween('views', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn($post) => $post->views >= 40 && $post->views <= 110)); + } + + /** + * @test + */ + public function it_can_filter_with_null_operator() + { + Post::truncate(); + + Post::factory()->create(['description' => null]); + Post::factory()->create(['description' => 'Some description']); + Post::factory()->create(['description' => null]); + + request()->merge([ + 'description' => [ + 'operator' => 'null', + 'value' => true + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['description']; + + public function description(Payload $payload) + { + if ($payload->value) { + return $this->builder->whereNull('description'); + } + + return $this->builder->whereNotNull('description'); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn($post) => is_null($post->description))); + } + + /** + * @test + */ + public function it_can_handle_camel_case_filter_methods() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active', 'is_featured' => true]); + Post::factory()->create(['status' => 'active', 'is_featured' => false]); + + request()->merge([ + 'is_featured' => true + ]); + + $filter = new class extends Filterable { + protected $filters = ['is_featured']; + + public function isFeatured(Payload $payload) + { + return $this->builder->where('is_featured', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertTrue($posts->first()->is_featured); + } + + /** + * @test + */ + public function it_can_use_method_mentors_mapping() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + + request()->merge([ + 'post_status' => 'active' + ]); + + $filter = new class extends Filterable { + protected $filters = ['post_status']; + protected $mentors = [ + 'post_status' => 'filterByStatus' + ]; + + public function filterByStatus(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('active', $posts->first()->status); + } + + /** + * @test + */ + public function it_can_handle_complex_nested_requests() + { + Post::truncate(); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Laravel', + 'views' => 100 + ]); + + Post::factory()->create([ + 'status' => 'pending', + 'title' => 'PHP', + 'views' => 50 + ]); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Vue.js', + 'views' => 150 + ]); + + request()->merge([ + 'status' => [ + 'operator' => '=', + 'value' => 'active' + ], + 'views' => [ + 'operator' => '>=', + 'value' => 100 + ], + 'title' => [ + 'operator' => 'like', + 'value' => '%a%' + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['status', 'views', 'title']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->operator, $payload->value); + } + + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->operator, $payload->value); + } + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->operator, $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('Laravel', $posts->first()->title); + } + + /** + * @test + */ + public function it_can_apply_sanitization_to_filters() + { + Post::truncate(); + + Post::factory()->create(['title' => 'Laravel']); + + request()->merge([ + 'title' => ' Laravel ' + ]); + + $filter = new class extends Filterable { + protected $filters = ['title']; + protected $sanitizers = [ + 'title' => 'trim' + ]; + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('Laravel', $posts->first()->title); + } + + /** + * @test + */ + public function it_ignores_empty_values_when_configured() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + + request()->merge([ + 'status' => '' + ]); + + $filter = new class extends Filterable { + protected $filters = ['status', 'title']; + protected $ignoreEmptyValues = true; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + }; + + // Should not filter by status since it's empty + $posts = Post::filter($filter)->get(); + + // All posts should be returned since status is ignored + $this->assertGreaterThanOrEqual(2, $posts->count()); + } + + /** + * @test + */ + public function it_can_chain_multiple_where_conditions_in_single_filter() + { + Post::factory()->create(['status' => 'active', 'views' => 100]); + Post::factory()->create(['status' => 'active', 'views' => 50]); + Post::factory()->create(['status' => 'pending', 'views' => 100]); + + request()->merge([ + 'combined' => [ + 'status' => 'active', + 'views' => 100 + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['combined']; + + public function combined(Payload $payload) + { + $data = $payload->value; + return $this->builder + ->where('status', $data['status']) + ->where('views', $data['views']); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('active', $posts->first()->status); + $this->assertEquals(100, $posts->first()->views); + } + + /** + * @test + */ + public function it_can_use_or_where_conditions() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + Post::factory()->create(['status' => 'stopped']); + + request()->merge([ + 'status_or' => ['active', 'pending'] + ]); + + $filter = new class extends Filterable { + protected $filters = ['status_or']; + + public function statusOr(Payload $payload) + { + return $this->builder->where(function ($query) use ($payload) { + foreach ($payload->value as $status) { + $query->orWhere('status', $status); + } + }); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn($post) => in_array($post->status, ['active', 'pending']))); + } + + /** + * @test + */ + public function it_can_handle_boolean_filters() + { + Post::truncate(); + + Post::factory()->create(['is_featured' => true, 'status' => 'active']); + Post::factory()->create(['is_featured' => false, 'status' => 'active']); + Post::factory()->create(['is_featured' => true, 'status' => 'pending']); + + request()->merge([ + 'is_featured' => true, + 'status' => 'active' + ]); + + $filter = new class extends Filterable { + protected $filters = ['is_featured', 'status']; + + public function isFeatured(Payload $payload) + { + return $this->builder->where('is_featured', $payload->asBoolean()); + } + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertTrue($posts->first()->is_featured); + $this->assertEquals('active', $posts->first()->status); + } + + /** + * @test + */ + public function it_can_handle_date_range_filters() + { + Post::factory()->create(['created_at' => now()->subDays(5)]); + Post::factory()->create(['created_at' => now()->subDays(3)]); + Post::factory()->create(['created_at' => now()->subDay()]); + + request()->merge([ + 'created_at' => [ + 'from' => now()->subDays(4)->toDateString(), + 'to' => now()->subDays(2)->toDateString() + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['created_at']; + + public function createdAt(Payload $payload) + { + return $this->builder->whereBetween('created_at', [ + $payload->value['from'], + $payload->value['to'] + ]); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + } + + /** + * @test + */ + public function it_can_handle_json_payload_values() + { + Post::truncate(); + + Post::factory()->create(['tags' => json_encode(['php', 'laravel'])]); + Post::factory()->create(['tags' => json_encode(['vue', 'javascript'])]); + + request()->merge([ + 'tags' => json_encode(['php', 'laravel']) + ]); + + $filter = new class extends Filterable { + protected $filters = ['tags']; + + public function tags(Payload $payload) + { + if ($payload->isJson()) { + return $this->builder->where('tags', 'LIKE', '"' . str_replace('"', '\"', $payload->value) . '"'); + } + + return $this->builder; + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + } + + /** + * @test + */ + public function it_returns_all_records_when_no_filters_applied() + { + Post::truncate(); + Post::factory()->count(5)->create(); + + request()->merge([]); + + $filter = new class extends Filterable { + protected $filters = ['status']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(5, $posts); + } + + /** + * @test + */ + public function it_can_use_payload_helper_methods() + { + Post::factory()->create(['title' => 'Laravel Tutorial']); + Post::factory()->create(['title' => 'PHP Guide']); + + request()->merge([ + 'title' => 'Laravel' + ]); + + $filter = new class extends Filterable { + protected $filters = ['title']; + + public function title(Payload $payload) + { + // Using payload helper method + return $this->builder->where('title', 'like', $payload->asLike('both')); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertStringContainsString('Laravel', $posts->first()->title); + } + + /** + * @test + */ + public function it_can_handle_numeric_string_filters() + { + Post::truncate(); + Post::factory()->create(['views' => 100]); + Post::factory()->create(['views' => 200]); + Post::factory()->create(['views' => 50]); + + request()->merge([ + 'views' => '100' + ]); + + $filter = new class extends Filterable { + protected $filters = ['views']; + + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->asInt()); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals(100, $posts->first()->views); + } + + /** + * @test + */ + public function it_can_filter_with_array_values() + { + Post::truncate(); + + Post::factory()->create(['status' => 'active']); + Post::factory()->create(['status' => 'pending']); + Post::factory()->create(['status' => 'stopped']); + + request()->merge([ + 'statuses' => ['active', 'pending'] + ]); + + $filter = new class extends Filterable { + protected $filters = ['statuses']; + + public function statuses(Payload $payload) + { + if ($payload->isArray()) { + return $this->builder->whereIn('status', $payload->value); + } + + return $this->builder; + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(2, $posts); + } + + /** + * @test + */ + public function it_properly_handles_payload_operators() + { + Post::truncate(); + + Post::factory()->create(['views' => 50]); + Post::factory()->create(['views' => 100]); + Post::factory()->create(['views' => 150]); + + $operators = [ + ['operator' => 'gt', 'value' => 75, 'expected' => 2], + ['operator' => 'lt', 'value' => 125, 'expected' => 2], + ['operator' => 'gte', 'value' => 100, 'expected' => 2], + ['operator' => 'lte', 'value' => 100, 'expected' => 2], + ['operator' => 'eq', 'value' => 100, 'expected' => 1], + ['operator' => 'neq', 'value' => 100, 'expected' => 2], + ]; + + foreach ($operators as $test) { + request()->merge([ + 'views' => [ + 'operator' => $test['operator'], + 'value' => $test['value'] + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['views']; + + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->operator, $payload->value); + } + }; + + $count = Post::filter($filter)->count(); + + $this->assertEquals( + $test['expected'], + $count, + "Failed for operator {$test['operator']} with value {$test['value']}" + ); + } + } + + /** + * @test + */ + public function it_can_access_raw_payload_value() + { + Post::truncate(); + + Post::factory()->create(['title' => 'Test']); + + request()->merge([ + 'title' => ' Test ' + ]); + + $filter = new class extends Filterable { + protected $filters = ['title']; + protected $sanitizers = [ + 'title' => 'trim' + ]; + + public function title(Payload $payload) + { + // Value is sanitized + $this->assertEquals('Test', $payload->value); + // Raw value is not sanitized + $this->assertEquals(' Test ', $payload->raw()); + + return $this->builder->where('title', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + } + + /** + * @test + */ + public function it_can_combine_multiple_filter_patterns() + { + Post::truncate(); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Laravel Framework', + 'views' => 100, + 'is_featured' => true + ]); + + Post::factory()->create([ + 'status' => 'pending', + 'title' => 'PHP Tutorial', + 'views' => 50, + 'is_featured' => false + ]); + + Post::factory()->create([ + 'status' => 'active', + 'title' => 'Vue.js Guide', + 'views' => 150, + 'is_featured' => true + ]); + + request()->merge([ + 'status' => 'active', + 'is_featured' => true, + 'views' => [ + 'operator' => '>=', + 'value' => 100 + ], + 'title' => [ + 'operator' => 'like', + 'value' => '%Framework%' + ] + ]); + + $filter = new class extends Filterable { + protected $filters = ['status', 'is_featured', 'views', 'title']; + + public function status(Payload $payload) + { + return $this->builder->where('status', $payload->value); + } + + public function isFeatured(Payload $payload) + { + return $this->builder->where('is_featured', $payload->asBoolean()); + } + + public function views(Payload $payload) + { + return $this->builder->where('views', $payload->operator, $payload->value); + } + + public function title(Payload $payload) + { + return $this->builder->where('title', $payload->operator, $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('Laravel Framework', $posts->first()->title); + $this->assertTrue($posts->first()->is_featured); + $this->assertEquals('active', $posts->first()->status); + $this->assertGreaterThanOrEqual(100, $posts->first()->views); + } } From 94dbc66953551947b1c38c742c1c4caedb7cd2d8 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 18:52:28 +0200 Subject: [PATCH 18/31] fix(ClauseFactory): remove unnecessary blank line in validateField method --- src/Engines/Foundation/ClauseFactory.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Engines/Foundation/ClauseFactory.php b/src/Engines/Foundation/ClauseFactory.php index 98c4134..08e42ff 100644 --- a/src/Engines/Foundation/ClauseFactory.php +++ b/src/Engines/Foundation/ClauseFactory.php @@ -60,7 +60,6 @@ protected function validateField(Payload $payload): void $field = $payload->field; // allow wildcard * as "all fields allowed" $isWildcardAllowed = ($this->engine->getAllowedFields()[0] ?? false) === '*'; - if (!(in_array($field, $this->engine->getAllowedFields(), true) || $this->isRelational($field) || $isWildcardAllowed)) { throw new NotAllowedFieldException($field); } From fe2606182216f2e2dcfe4d7f938a033d534395df Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 19:33:31 +0200 Subject: [PATCH 19/31] feat(PostFactory, CreatePostsTable, Post): enhance post model with additional fields and database structure --- tests/Database/Factories/PostFactory.php | 4 ++++ tests/Database/Migrations/CreatePostsTable.php | 4 ++++ tests/Models/Post.php | 8 +++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/Database/Factories/PostFactory.php b/tests/Database/Factories/PostFactory.php index 3a0a940..f5a75d5 100644 --- a/tests/Database/Factories/PostFactory.php +++ b/tests/Database/Factories/PostFactory.php @@ -24,6 +24,10 @@ public function definition() 'title' => $this->faker->word, 'content' => $this->faker->text, 'status' => $this->faker->randomElement(['active', 'pending', 'stopped']), + 'views' => $this->faker->numberBetween(0, 1000), + 'is_featured' => $this->faker->boolean, + 'description' => $this->faker->optional()->text, + 'tags' => $this->faker->optional()->randomElements(['php', 'laravel', 'javascript', 'vue'], $this->faker->numberBetween(0, 3)), ]; } } diff --git a/tests/Database/Migrations/CreatePostsTable.php b/tests/Database/Migrations/CreatePostsTable.php index 2122fd6..4b29e66 100644 --- a/tests/Database/Migrations/CreatePostsTable.php +++ b/tests/Database/Migrations/CreatePostsTable.php @@ -20,6 +20,10 @@ public function up() $table->string('title'); $table->text('content')->nullable(); $table->enum('status', ['active', 'pending', 'stopped']); + $table->integer('views')->default(0); + $table->boolean('is_featured')->default(false); + $table->text('description')->nullable(); + $table->json('tags')->nullable(); $table->timestamps(); }); } diff --git a/tests/Models/Post.php b/tests/Models/Post.php index 9eeee9d..87ce475 100644 --- a/tests/Models/Post.php +++ b/tests/Models/Post.php @@ -13,7 +13,13 @@ class Post extends Model { use HasFactory, HasFilterable; - protected $fillable = ['title', 'status', 'content']; + protected $fillable = ['title', 'status', 'content', 'views', 'is_featured', 'description', 'tags']; + + protected $casts = [ + 'is_featured' => 'boolean', + 'views' => 'integer', + 'tags' => 'array', + ]; public function tags(): HasMany { From d6c6d075db49298d983da267df2b3e0219c5b640 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 19:34:27 +0200 Subject: [PATCH 20/31] feat(ExceptionHandlerInterface): add interface for handling exceptions during filtering --- .../Contracts/ExceptionHandlerInterface.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/Exceptions/Contracts/ExceptionHandlerInterface.php diff --git a/src/Exceptions/Contracts/ExceptionHandlerInterface.php b/src/Exceptions/Contracts/ExceptionHandlerInterface.php new file mode 100644 index 0000000..dacf695 --- /dev/null +++ b/src/Exceptions/Contracts/ExceptionHandlerInterface.php @@ -0,0 +1,18 @@ + Date: Wed, 19 Nov 2025 19:36:36 +0200 Subject: [PATCH 21/31] feat(filterable.php): add configuration for exception handling during filtering --- config/filterable.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/config/filterable.php b/config/filterable.php index bf94dc9..6194bd0 100644 --- a/config/filterable.php +++ b/config/filterable.php @@ -861,4 +861,32 @@ 'log_channel' => env('FILTERABLE_CACHE_LOG_CHANNEL', 'daily'), ], ], + + /* + |-------------------------------------------------------------------------- + | Exception Handling + |-------------------------------------------------------------------------- + | + | Define how Filterable handles exceptions thrown during filtering. + | You can choose from built-in handlers or implement your own. + | + | Supported options: + | - handler: The class responsible for handling exceptions. + | - strict: When true, exceptions will always be thrown instead of skipped. + | - log_exceptions: Whether to log unhandled or skipped exceptions. + | - report: A closure or class name to customize how exceptions are reported. + | + */ + 'exceptions' => [ + /* + |-------------------------------------------------------------------------- + | Strict Mode + |-------------------------------------------------------------------------- + | + | When enabled, exceptions will always be thrown instead of skipped. + | This overrides per-engine strict settings. + | + */ + 'strict' => env('FILTERABLE_EXCEPTION_STRICT', false), + ] ]; From 514a61af7fd43c93c12925dfde93fa06c9df04d6 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 19:37:13 +0200 Subject: [PATCH 22/31] feat(FilterableExceptionHandler): implement abstract class for handling filterable exceptions --- src/Exceptions/FilterableExceptionHandler.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/Exceptions/FilterableExceptionHandler.php diff --git a/src/Exceptions/FilterableExceptionHandler.php b/src/Exceptions/FilterableExceptionHandler.php new file mode 100644 index 0000000..fe14fcc --- /dev/null +++ b/src/Exceptions/FilterableExceptionHandler.php @@ -0,0 +1,48 @@ + Date: Wed, 19 Nov 2025 19:37:34 +0200 Subject: [PATCH 23/31] feat(DefaultHandler): add default exception handler for Filterable --- src/Exceptions/Handlers/DefaultHandler.php | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/Exceptions/Handlers/DefaultHandler.php diff --git a/src/Exceptions/Handlers/DefaultHandler.php b/src/Exceptions/Handlers/DefaultHandler.php new file mode 100644 index 0000000..4a91d35 --- /dev/null +++ b/src/Exceptions/Handlers/DefaultHandler.php @@ -0,0 +1,36 @@ +hasSkipping($exception)) { + /** @var SkipExecution $exception */ + + if ($engine->isStrict() || $this->isStrictThrowing()) { + throw $exception; + } + + return false; + } + + if ($this->isStrictness($exception) || $this->isStrictThrowing()) { + throw $exception; + } + + return false; + } +} From 9288a5d2d8a3fbe144a7944160e51ccec9303d4f Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 19:38:19 +0200 Subject: [PATCH 24/31] feat(filterable.php): add exception handler configuration for filtering --- config/filterable.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/config/filterable.php b/config/filterable.php index 6194bd0..8f216d3 100644 --- a/config/filterable.php +++ b/config/filterable.php @@ -878,6 +878,18 @@ | */ 'exceptions' => [ + /* + |-------------------------------------------------------------------------- + | Exception Handler + |-------------------------------------------------------------------------- + | + | The class responsible for handling exceptions during filtering. + | You can implement your own handler by adhering to the + | Filterable\Contracts\ExceptionHandler interface. + | + */ + 'handler' => Kettasoft\Filterable\Exceptions\Handlers\DefaultHandler::class, + /* |-------------------------------------------------------------------------- | Strict Mode From 399565a99a9a70439d287f82539e2e128e63a44f Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 19:38:34 +0200 Subject: [PATCH 25/31] feat(Filterable): add method to retrieve exception handler instance --- src/Filterable.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Filterable.php b/src/Filterable.php index 5480c11..a1654dc 100644 --- a/src/Filterable.php +++ b/src/Filterable.php @@ -29,6 +29,7 @@ use Kettasoft\Filterable\Foundation\Events\FilterableEventManager; use Kettasoft\Filterable\HttpIntegration\HeaderDrivenEngineSelector; use Kettasoft\Filterable\Foundation\Contracts\ShouldReturnQueryBuilder; +use Kettasoft\Filterable\Exceptions\Contracts\ExceptionHandlerInterface; use Kettasoft\Filterable\Exceptions\RequestSourceIsNotSupportedException; class Filterable implements FilterableContext, Authorizable, Validatable, Commitable @@ -957,6 +958,21 @@ public function get(string $key) return $this->request->{$source}($key); } + /** + * Get exception handler instance. + * + * @return ExceptionHandlerInterface + */ + public function getExceptionHandler(): ExceptionHandlerInterface + { + $config = config('filterable.exceptions'); + $engineOverrides = config("filterable.engines.{$this->engine->getEngineName()}.exceptions", []); + + $merged = array_merge($config, $engineOverrides); + + return app($merged['handler']); + } + /** * Dynamically retrieve attributes from the request. * @param mixed $property From c47c2a4af9b8f20dc4a647c90330b773e4c64f7c Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 19:39:08 +0200 Subject: [PATCH 26/31] feat(NotAllowedEmptyValueException): create exception for handling empty value scenarios --- .../Exceptions/NotAllowedEmptyValueException.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/Engines/Exceptions/NotAllowedEmptyValueException.php diff --git a/src/Engines/Exceptions/NotAllowedEmptyValueException.php b/src/Engines/Exceptions/NotAllowedEmptyValueException.php new file mode 100644 index 0000000..709e86d --- /dev/null +++ b/src/Engines/Exceptions/NotAllowedEmptyValueException.php @@ -0,0 +1,15 @@ + Date: Wed, 19 Nov 2025 19:44:43 +0200 Subject: [PATCH 27/31] refactor(Engine): rename handle method to execute for consistency across engines --- src/Engines/Expression.php | 2 +- src/Engines/Foundation/Engine.php | 18 +----------------- src/Engines/Invokable.php | 2 +- src/Engines/Ruleset.php | 2 +- src/Engines/Tree.php | 2 +- 5 files changed, 5 insertions(+), 21 deletions(-) diff --git a/src/Engines/Expression.php b/src/Engines/Expression.php index e89ad65..92b19e5 100644 --- a/src/Engines/Expression.php +++ b/src/Engines/Expression.php @@ -25,7 +25,7 @@ class Expression extends Engine * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - public function handle(Builder $builder): Builder + public function execute(Builder $builder): Builder { $filters = $this->context->getData(); diff --git a/src/Engines/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index 9ee23a2..b3ebce3 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -33,23 +33,7 @@ abstract public function getEngineName(): string; * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - abstract public function handle(Builder $builder): Builder; - - /** - * Apply filters to the query. - * @param \Illuminate\Database\Eloquent\Builder $builder - * @return Builder - */ - final public function execute(Builder $builder) - { - try { - $this->handle($builder); - - return $builder; - } catch (\Throwable $e) { - throw $e; - } - } + abstract public function execute(Builder $builder); /** * @inheritDoc diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index 04b4ee4..eea76fc 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -35,7 +35,7 @@ class Invokable extends Engine * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - public function handle(Builder $builder): Builder + public function execute(Builder $builder): Builder { $this->builder = $builder; diff --git a/src/Engines/Ruleset.php b/src/Engines/Ruleset.php index c909235..bdf9521 100644 --- a/src/Engines/Ruleset.php +++ b/src/Engines/Ruleset.php @@ -26,7 +26,7 @@ class Ruleset extends Engine * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - public function handle(Builder $builder): Builder + public function execute(Builder $builder): Builder { $data = $this->context->getData(); diff --git a/src/Engines/Tree.php b/src/Engines/Tree.php index 10bb1ba..9746536 100644 --- a/src/Engines/Tree.php +++ b/src/Engines/Tree.php @@ -26,7 +26,7 @@ class Tree extends Engine * @param \Illuminate\Database\Eloquent\Builder $builder * @return Builder */ - public function handle(Builder $builder): Builder + public function execute(Builder $builder): Builder { $data = $this->context->getData(); From 706d0fb397bc8cfe181f60706a11ec2ecf460a2d Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 19:46:45 +0200 Subject: [PATCH 28/31] refactor(Invokable): rename initializeFilters method to applyFilterMethod for clarity --- src/Engines/Invokable.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index eea76fc..bb3c046 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -61,7 +61,7 @@ public function execute(Builder $builder): Builder throw new \RuntimeException(sprintf("Filter method [%s] conflicts with core Filterable method.", [$method])); } - $this->initializeFilters($filter, $method, $payload); + $this->applyFilterMethod($filter, $method, $payload); $this->commit($method, $clause); } @@ -76,7 +76,7 @@ public function execute(Builder $builder): Builder * @param Payload $payload * @return void */ - protected function initializeFilters(string $key, string $method, Payload $payload): void + protected function applyFilterMethod(string $key, string $method, Payload $payload): void { if (! method_exists($this->context, $method)) { return; From 4822eedd92bc6ab8c26763d163130127241e2dfd Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 19:48:08 +0200 Subject: [PATCH 29/31] feat(Engine): enhance attempt method to handle exceptions with context's exception handler --- src/Engines/Foundation/Engine.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Engines/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index b3ebce3..30d3781 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -35,6 +35,21 @@ abstract public function getEngineName(): string; */ abstract public function execute(Builder $builder); + /** + * Attempt to execute the given callback, handling exceptions. + * + * @param \Closure $callback + * @return bool + */ + final protected function attempt(\Closure $callback): bool + { + try { + return $callback->call($this); + } catch (\Throwable $e) { + return $this->context->getExceptionHandler()->handle($e, $this); + } + } + /** * @inheritDoc */ From c7de75d2165582339a11affcf47281a3f770531b Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 19:51:41 +0200 Subject: [PATCH 30/31] feat(Engines): refactor filtering logic to use attempt method for improved error handling --- src/Engines/Expression.php | 23 ++++++++++------------- src/Engines/Invokable.php | 27 ++++++++++++--------------- src/Engines/Ruleset.php | 17 ++++++++--------- src/Engines/Tree.php | 14 ++++++-------- 4 files changed, 36 insertions(+), 45 deletions(-) diff --git a/src/Engines/Expression.php b/src/Engines/Expression.php index 92b19e5..a9a9c18 100644 --- a/src/Engines/Expression.php +++ b/src/Engines/Expression.php @@ -30,23 +30,20 @@ public function execute(Builder $builder): Builder $filters = $this->context->getData(); foreach ($filters as $field => $condition) { + $this->attempt(function () use ($builder, $field, $condition) { - // Normalize the condition to [ operator => value ]. - $condition = ConditionNormalizer::normalize($condition, $this->defaultOperator()); + // Normalize the condition to [ operator => value ]. + $condition = ConditionNormalizer::normalize($condition, $this->defaultOperator()); - $dissector = Dissector::parse($condition, $this->defaultOperator()); + $dissector = Dissector::parse($condition, $this->defaultOperator()); - $clause = (new ClauseFactory($this))->make( - new Payload($field, $dissector->operator, $this->sanitizeValue($field, $dissector->value), $dissector->value) - ); + $clause = (new ClauseFactory($this))->make( + new Payload($field, $dissector->operator, $this->sanitizeValue($field, $dissector->value), $dissector->value) + ); - if (! $clause->validated) { - continue; // skip disallowed field - } - - $this->commit($field, $clause); - - Applier::apply(new ClauseApplier($clause), $builder); + Applier::apply(new ClauseApplier($clause), $builder); + return $this->commit($field, $clause); + }); } return $builder; diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index bb3c046..f1a2462 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -43,27 +43,24 @@ public function execute(Builder $builder): Builder $this->context->setAllowedFields($this->context->getFilterAttributes()); foreach ($this->context->getFilterAttributes() as $filter) { + $this->attempt(function () use ($filter) { + $dissector = Dissector::parse($this->context->getRequest()->get($filter), $this->defaultOperator()); - $dissector = Dissector::parse($this->context->getRequest()->get($filter), $this->defaultOperator()); + $payload = new Payload($filter, $dissector->operator, $this->sanitizeValue($filter, $dissector->value), $dissector->value); - $payload = new Payload($filter, $dissector->operator, $this->sanitizeValue($filter, $dissector->value), $dissector->value); + $clause = (new ClauseFactory($this))->make($payload); - $clause = (new ClauseFactory($this))->make($payload); + $method = $this->getMethodName($filter); - if (($this->context->hasIgnoredEmptyValues() || config('filterable.engines.invokable.ignore_empty_values')) && !$clause->value) { - continue; - } + // Check for method name conflicts with Filterable core methods. + if (method_exists(Filterable::class, $method)) { + throw new \RuntimeException(sprintf("Filter method [%s] conflicts with core Filterable method.", [$method])); + } - $method = $this->getMethodName($filter); + $this->applyFilterMethod($filter, $method, $payload); - // Check for method name conflicts with Filterable core methods. - if (method_exists(Filterable::class, $method)) { - throw new \RuntimeException(sprintf("Filter method [%s] conflicts with core Filterable method.", [$method])); - } - - $this->applyFilterMethod($filter, $method, $payload); - - $this->commit($method, $clause); + $this->commit($method, $clause); + }); } return $this->builder; diff --git a/src/Engines/Ruleset.php b/src/Engines/Ruleset.php index bdf9521..0a3322d 100644 --- a/src/Engines/Ruleset.php +++ b/src/Engines/Ruleset.php @@ -31,18 +31,17 @@ public function execute(Builder $builder): Builder $data = $this->context->getData(); foreach ($data as $field => $dissector) { + $this->attempt(function () use ($builder, $dissector, $field): bool { + $dissector = Dissector::parse($dissector, $this->defaultOperator()); - $dissector = Dissector::parse($dissector, $this->defaultOperator()); + $clause = (new ClauseFactory($this))->make( + new Payload($field, $dissector->operator, $this->sanitizeValue($field, $dissector->value), $dissector->value) + ); - $clause = (new ClauseFactory($this))->make( - new Payload($field, $dissector->operator, $this->sanitizeValue($field, $dissector->value), $dissector->value) - ); + Applier::apply(new ClauseApplier($clause), $builder); - if (! $clause->validated) continue; - - Applier::apply(new ClauseApplier($clause), $builder); - - $this->commit($field, $clause); + return $this->commit($field, $clause); + }); } return $builder; diff --git a/src/Engines/Tree.php b/src/Engines/Tree.php index 9746536..0419c44 100644 --- a/src/Engines/Tree.php +++ b/src/Engines/Tree.php @@ -42,12 +42,14 @@ public function execute(Builder $builder): Builder private function applyNode(Builder $builder, TreeNode $node) { if ($node->isGroup()) { - $builder->where(function (Builder $q) use ($node) { + $builder->where(function (Builder $query) use ($node) { foreach ($node->children as $child) { - $method = strtolower($child->logical) === 'and' ? 'where' : 'orWhere'; + $this->attempt(function () use ($child, $query) { + $method = strtolower($child->logical) === 'and' ? 'where' : 'orWhere'; - $q->{$method}(function ($sub) use ($child) { - $this->applyNode($sub, $child); + $query->{$method}(function ($sub) use ($child) { + $this->applyNode($sub, $child); + }); }); } }); @@ -57,10 +59,6 @@ private function applyNode(Builder $builder, TreeNode $node) new Payload($node->field, $node->operator ?? $this->defaultOperator(), $this->sanitizeValue($node->field, $node->value), $node->value) ); - if (! $clause->validated) { - return $builder; // skip disallowed field - } - if ($clause->isRelational()) { $clause->relation($this->getResources()->relations)->resolve($builder, $clause); } else { From f5fbcd7824e76dad7e6c4d641408e3b214d2a433 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Wed, 19 Nov 2025 20:26:32 +0200 Subject: [PATCH 31/31] feat(Exceptions): add structured exception handling documentation --- docs/.vuepress/config.js | 4 + docs/exceptions.md | 277 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 docs/exceptions.md diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 1b3af55..12e7dad 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -233,6 +233,10 @@ export default defineUserConfig({ }, ], }, + { + text: "Exceptions", + link: "exceptions", + }, { text: "Event System", link: "events", diff --git a/docs/exceptions.md b/docs/exceptions.md new file mode 100644 index 0000000..f9bc3cd --- /dev/null +++ b/docs/exceptions.md @@ -0,0 +1,277 @@ +--- +title: Exception Handling +sidebarDepth: 2 +--- + +# Exception Handling + +Filterable provides a structured and predictable exception-handling system that +allows engines to decide whether filtering should stop, skip the current filter, +or continue normally. +This mechanism was redesigned to offer clearer behavior, improved safety, and +better extensibility. + +The system is built around three main components: + +- **Exception types** (how engines signal different situations) +- **Handlers** (how exceptions are processed) +- **Configuration** (how strict or lenient the system should behave) + +--- + +## Exception Flow Overview + +During filtering, an engine may encounter invalid, empty, or malformed input. +Instead of halting the entire process, the engine throws a specific exception +to indicate what happened. + +The handler then decides—based on the exception type and strict configuration— +whether the exception should be: + +- **thrown** (stop filtering), +- **or skipped** (ignore this filter and continue with the next one). + +If a handler returns `false`, the current filter is skipped. + +--- + +## Exception Types + +Filterable defines two fundamental exception categories. +Each one represents a different kind of failure and implies different behavior. + +### **SkipExecution** + +`SkipExecution` is used when the engine cannot apply the filter, but the situation +is not considered critical. + +Typical scenarios include: + +- empty values when the engine does not accept empty input, +- unsupported operators, +- incomplete data structures. + +**Behavior:** + +- If strict mode is enabled → **the exception is thrown** +- If strict mode is disabled → **the filter is skipped** + +This allows engines to ignore irrelevant or incomplete input without failing the +whole filtering pipeline. + +--- + +### **StrictnessException** + +`StrictnessException` represents invalid or unsafe input. +This type signals that the engine cannot proceed safely with the given data. + +Examples include: + +- corrupted or malformed values, +- invalid structure or types, +- contradictory or logically impossible conditions. + +**Behavior:** + +- strict mode enabled → **always thrown** +- strict mode disabled → handler may return `false` to skip, but the exception + indicates a more serious issue + +This class of exceptions enforces higher input correctness. + +--- + +## Exception Handlers + +Handlers determine what happens when an exception is thrown. +They receive both the exception and the engine instance. + +Returning `false` means: +**"Skip this filter and continue."** + +Throwing the exception stops filtering immediately. + +### **ExceptionHandlerInterface** + +Every handler must implement: + +```php +interface ExceptionHandlerInterface +{ + public function handle(\Throwable $exception, Engine $engine): bool; +} +``` + +This gives full control to define how exceptions are processed. + +--- + +## Helper Base Class: FilterableExceptionHandler + +`FilterableExceptionHandler` provides shared logic that custom handlers +can use to simplify implementation. + +Key helper methods: + +- `isStrictThrowing()` + Checks whether global strict mode is enabled via config. + +- `hasSkipping($exception)` + Detects `SkipExecution`. + +- `isStrictness($exception)` + Detects strictness-related exceptions. + +Custom handlers may extend this abstract class to avoid duplicating logic. + +```php +abstract class FilterableExceptionHandler implements ExceptionHandlerInterface +{ + abstract public function handle(\Throwable $exception, Engine $engine): bool; + + protected function isStrictThrowing(): bool + { + return config('filterable.exception.strict', false); + } + + protected function hasSkipping($exception): bool + { + return $exception instanceof SkipExecution; + } + + protected function isStrictness($exception): bool + { + return $exception instanceof StrictnessException; + } +} +``` + +--- + +## DefaultHandler Behavior + +The default handler implements the standard strategy for both exception types: + +```php +class DefaultHandler extends FilterableExceptionHandler +{ + public function handle(\Throwable|SkipExecution $exception, Engine $engine): bool + { + // SkipExecution: skip if not strict + if ($this->hasSkipping($exception)) { + if ($engine->isStrict() || $this->isStrictThrowing()) { + throw $exception; + } + return false; // skip current filter + } + + // StrictnessException: throw when strict + if ($this->isStrictness($exception) || $this->isStrictThrowing()) { + throw $exception; + } + + return false; // default: skip non-critical cases + } +} +``` + +### Summary of Behavior + +| Exception Type | Strict Mode | Behavior | +| ------------------- | ----------- | --------------- | +| SkipExecution | Enabled | Throw exception | +| SkipExecution | Disabled | Skip filter | +| StrictnessException | Enabled | Throw exception | +| StrictnessException | Disabled | Skip filter | + +--- + +## Configuration + +Exception handling is defined in the `filterable.exceptions` config section: + +```php +'exceptions' => [ + + 'handler' => Kettasoft\Filterable\Exceptions\Handlers\DefaultHandler::class, + + 'strict' => env('FILTERABLE_EXCEPTION_STRICT', false), +] +``` + +### `handler` + +Defines the class responsible for handling exceptions. + +Must implement: +`ExceptionHandlerInterface`. + +### `strict` + +When enabled: + +- exceptions are always thrown, +- skipping behavior is disabled, +- engine-level strict settings are overridden. + +--- + +## How Filter Skipping Works + +If the handler returns `false`, the current filter is skipped and the next filter +is processed. + +Example: + +Filters: **status**, **name**, **is_active** +Suppose: + +- `status` receives empty data, +- engine does not accept empty values → throws `SkipExecution`. + +If strict mode is disabled: + +- `status` is skipped, +- filtering continues with `name` then `is_active`. + +This allows the filtering pipeline to continue gracefully without failing +because of optional or incomplete input. + +--- + +## Creating Custom Handlers + +To implement your own rules: + +```php +class MyCustomHandler extends FilterableExceptionHandler +{ + public function handle(\Throwable $exception, Engine $engine): bool + { + // custom logic + } +} +``` + +Register it in the config: + +```php +'exceptions' => [ + 'handler' => App\Filters\Handlers\MyCustomHandler::class, +] +``` + +--- + +## Conclusion + +This unified exception-handling pipeline provides: + +- clear distinction between skip-level and failure-level issues, +- configurable strictness, +- customizable handlers, +- consistent engine behavior, +- predictable filter skipping. + +It enables robust and flexible filtering without breaking existing APIs.