diff --git a/config/filterable.php b/config/filterable.php index bf94dc9..8f216d3 100644 --- a/config/filterable.php +++ b/config/filterable.php @@ -861,4 +861,44 @@ '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' => [ + /* + |-------------------------------------------------------------------------- + | 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 + |-------------------------------------------------------------------------- + | + | When enabled, exceptions will always be thrown instead of skipped. + | This overrides per-engine strict settings. + | + */ + 'strict' => env('FILTERABLE_EXCEPTION_STRICT', false), + ] ]; 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. diff --git a/phpunit.xml b/phpunit.xml index 4970d6f..c790b67 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + ./tests 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 @@ +clause; + } + + /** + * Determine if this exception should be reported. + * @return bool + */ + public function shouldReport(): bool + { + return false; + } +} diff --git a/src/Engines/Expression.php b/src/Engines/Expression.php index e1222f6..a9a9c18 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,31 +22,28 @@ 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 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); - - return Applier::apply(new ClauseApplier($clause), $builder); + Applier::apply(new ClauseApplier($clause), $builder); + return $this->commit($field, $clause); + }); } return $builder; 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/src/Engines/Foundation/Clause.php b/src/Engines/Foundation/Clause.php index 7c44f6a..153aa93 100644 --- a/src/Engines/Foundation/Clause.php +++ b/src/Engines/Foundation/Clause.php @@ -29,13 +29,7 @@ class Clause implements Arrayable, Jsonable * Original value. * @var string */ - public readonly string|null $value; - - /** - * Check if the clause is validated. - * @var bool - */ - public bool $validated = false; + public readonly mixed $value; /** * 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 */ diff --git a/src/Engines/Foundation/ClauseFactory.php b/src/Engines/Foundation/ClauseFactory.php index eee3ef0..08e42ff 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\Exceptions\InvalidOperatorException; -use Kettasoft\Filterable\Exceptions\NotAllowedFieldException; +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,35 @@ 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 +78,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 +90,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; } /* ----------------------------------------------------------------- diff --git a/src/Engines/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index 6c9f461..30d3781 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -2,18 +2,19 @@ namespace Kettasoft\Filterable\Engines\Foundation; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Arr; -use Kettasoft\Filterable\Contracts\FilterableContext; +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. @@ -34,6 +35,29 @@ 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 + */ + public function skip(string $message, mixed $clause = null): never + { + throw new SkipExecution($message, $clause); + } + /** * Get allowed fields to filtering. * @return array 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), }; } } diff --git a/src/Engines/Foundation/Handlers/AllowedFieldValidator.php b/src/Engines/Foundation/Handlers/AllowedFieldValidator.php index c261471..11833ab 100644 --- a/src/Engines/Foundation/Handlers/AllowedFieldValidator.php +++ b/src/Engines/Foundation/Handlers/AllowedFieldValidator.php @@ -3,7 +3,7 @@ namespace Kettasoft\Filterable\Engines\Foundation\Handlers; use Kettasoft\Filterable\Engines\Foundation\Engine; -use Kettasoft\Filterable\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; /** * Validate if field is allowed to apply filtering. diff --git a/src/Engines/Foundation/OperatorDefinition.php b/src/Engines/Foundation/OperatorDefinition.php index ef71c8c..fdd53a0 100644 --- a/src/Engines/Foundation/OperatorDefinition.php +++ b/src/Engines/Foundation/OperatorDefinition.php @@ -5,7 +5,7 @@ use Illuminate\Support\Arr; use Kettasoft\Filterable\Foundation\Bags\OperatorBag; use Kettasoft\Filterable\Engines\Foundation\Enums\Operators; -use Kettasoft\Filterable\Exceptions\InvalidOperatorException; +use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; use Kettasoft\Filterable\Engines\Contracts\HasInteractsWithOperators; use Kettasoft\Filterable\Engines\Contracts\OperatorDefinitionContract; diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index eea76fc..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->initializeFilters($filter, $method, $payload); - - $this->commit($method, $clause); + $this->commit($method, $clause); + }); } return $this->builder; @@ -76,7 +73,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; 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 cb797cc..0419c44 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 execute(Builder $builder): Builder { $data = $this->context->getData(); @@ -42,12 +42,14 @@ public function execute(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 { 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 @@ +hasSkipping($exception)) { + /** @var SkipExecution $exception */ + + if ($engine->isStrict() || $this->isStrictThrowing()) { + throw $exception; + } + + return false; + } + + if ($this->isStrictness($exception) || $this->isStrictThrowing()) { + throw $exception; + } + + return false; + } +} diff --git a/src/Exceptions/InvalidDataFormatException.php b/src/Exceptions/InvalidDataFormatException.php deleted file mode 100644 index 55f55ac..0000000 --- a/src/Exceptions/InvalidDataFormatException.php +++ /dev/null @@ -1,11 +0,0 @@ -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 diff --git a/src/Support/AllowedFieldChecker.php b/src/Support/AllowedFieldChecker.php index 4435bb9..d258547 100644 --- a/src/Support/AllowedFieldChecker.php +++ b/src/Support/AllowedFieldChecker.php @@ -3,7 +3,7 @@ namespace Kettasoft\Filterable\Support; use Kettasoft\Filterable\Engines\Contracts\HasAllowedFieldChecker; -use Kettasoft\Filterable\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; class AllowedFieldChecker { diff --git a/src/Support/TreeBasedRelationsResolver.php b/src/Support/TreeBasedRelationsResolver.php index 0e39c29..675b7b3 100644 --- a/src/Support/TreeBasedRelationsResolver.php +++ b/src/Support/TreeBasedRelationsResolver.php @@ -2,7 +2,7 @@ namespace Kettasoft\Filterable\Support; -use Kettasoft\Filterable\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; use Kettasoft\Filterable\Filterable; class TreeBasedRelationsResolver diff --git a/src/Support/TreeBasedSignelConditionResolver.php b/src/Support/TreeBasedSignelConditionResolver.php index 5bc3380..52a322a 100644 --- a/src/Support/TreeBasedSignelConditionResolver.php +++ b/src/Support/TreeBasedSignelConditionResolver.php @@ -3,7 +3,7 @@ namespace Kettasoft\Filterable\Support; use Kettasoft\Filterable\Engines\Contracts\QueryResolverContract; -use Kettasoft\Filterable\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; use Kettasoft\Filterable\Filterable; class TreeBasedSignelConditionResolver diff --git a/src/Support/TreeNode.php b/src/Support/TreeNode.php index 3daf4d6..39055a8 100644 --- a/src/Support/TreeNode.php +++ b/src/Support/TreeNode.php @@ -2,7 +2,7 @@ namespace Kettasoft\Filterable\Support; -use Kettasoft\Filterable\Exceptions\InvalidDataFormatException; +use Kettasoft\Filterable\Engines\Exceptions\InvalidDataFormatException; /** * TreeNode represents a filter node in a logical tree. 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/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/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/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/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([ 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 { 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) { diff --git a/tests/Unit/Engines/ExpressionEngineTest.php b/tests/Unit/Engines/ExpressionEngineTest.php index 87fe901..c918874 100644 --- a/tests/Unit/Engines/ExpressionEngineTest.php +++ b/tests/Unit/Engines/ExpressionEngineTest.php @@ -9,8 +9,8 @@ use Kettasoft\Filterable\Tests\Models\Post; use Kettasoft\Filterable\Engines\Expression; use Symfony\Component\HttpFoundation\InputBag; -use Kettasoft\Filterable\Exceptions\InvalidOperatorException; -use Kettasoft\Filterable\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; class ExpressionEngineTest extends TestCase { 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); + } } diff --git a/tests/Unit/Engines/RulesetEngineTest.php b/tests/Unit/Engines/RulesetEngineTest.php index 80d8653..74f296f 100644 --- a/tests/Unit/Engines/RulesetEngineTest.php +++ b/tests/Unit/Engines/RulesetEngineTest.php @@ -7,8 +7,8 @@ use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Engines\Ruleset; use Kettasoft\Filterable\Tests\Models\Post; -use Kettasoft\Filterable\Exceptions\InvalidOperatorException; -use Kettasoft\Filterable\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; use Symfony\Component\HttpFoundation\InputBag; class RulesetEngineTest extends TestCase diff --git a/tests/Unit/Engines/TreeEngineTest.php b/tests/Unit/Engines/TreeEngineTest.php index b4578eb..cf99899 100644 --- a/tests/Unit/Engines/TreeEngineTest.php +++ b/tests/Unit/Engines/TreeEngineTest.php @@ -12,9 +12,9 @@ use Kettasoft\Filterable\Tests\Models\Post; use Symfony\Component\HttpFoundation\InputBag; use Illuminate\Foundation\Testing\RefreshDatabase; -use Kettasoft\Filterable\Exceptions\InvalidOperatorException; -use Kettasoft\Filterable\Exceptions\NotAllowedFieldException; -use Kettasoft\Filterable\Exceptions\InvalidDataFormatException; +use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; +use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; +use Kettasoft\Filterable\Engines\Exceptions\InvalidDataFormatException; class TreeEngineTest extends TestCase {