diff --git a/README.md b/README.md index 4848ceff..6d8daa34 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ GET /posts?filter[id][in][]=1&filter[id][in][]=2 GET /posts?filter[tags][name]=featured ``` -Supported operators: `eq` `neq` `gt` `gte` `lt` `lte` `like` `nlike` `in` `between` +Supported operators: `eq` `neq` `gt` `gte` `lt` `lte` `like` `nlike` `in` `nin` `between` `nbetween` `null` `notnull` ### Expression Engine diff --git a/config/filterable.php b/config/filterable.php index 6551af2b..91232019 100644 --- a/config/filterable.php +++ b/config/filterable.php @@ -200,6 +200,19 @@ 'nulls_position' => null, ], + /* + |-------------------------------------------------------------------------- + | Custom Operator Strategies + |-------------------------------------------------------------------------- + | + | Map a resolved operator to a class implementing the Operator contract. + | Entries here override the built-in strategies. + | + */ + 'operator_strategies' => [ + // 'contains' => App\Filtering\Operators\ContainsOperator::class, + ], + /* |-------------------------------------------------------------------------- | Filter Engines @@ -264,6 +277,7 @@ 'null' => 'is null', 'notnull' => 'is not null', 'between' => 'between', + 'nbetween' => 'not between', ], ], @@ -312,6 +326,7 @@ 'null' => 'is null', 'notnull' => 'is not null', 'between' => 'between', + 'nbetween' => 'not between', ], /* @@ -411,6 +426,7 @@ 'null' => 'is null', 'notnull' => 'is not null', 'between' => 'between', + 'nbetween' => 'not between', ], /* @@ -478,6 +494,7 @@ 'null' => 'is null', 'notnull' => 'is not null', 'between' => 'between', + 'nbetween' => 'not between', ], /* diff --git a/docs/api/filterable.md b/docs/api/filterable.md index 95b59278..148840d6 100644 --- a/docs/api/filterable.md +++ b/docs/api/filterable.md @@ -259,7 +259,12 @@ Return the allowed fields. #### `allowedOperators(array $operators): static` -Override globally allowed operators for this instance. +Restrict the instance to selected operator aliases or resolved SQL values. + +```php +$filterable->allowedOperators(['gte', 'in']); +$filterable->allowedOperators(['>=', 'in']); +``` #### `getAllowedOperators(): array` diff --git a/docs/engines/rule-set.md b/docs/engines/rule-set.md index b2b66c20..6513cfcd 100644 --- a/docs/engines/rule-set.md +++ b/docs/engines/rule-set.md @@ -95,8 +95,11 @@ This default can be set via the engine configuration. | like | LIKE | `filter[title][like]=%laravel%` | | in | IN | `filter[id][in][]=1&filter[id][in][]=2` | | between | BETWEEN | `filter[price][between][]=100&filter[price][between][]=200` | +| nbetween | NOT BETWEEN | `filter[price][nbetween][]=100&filter[price][nbetween][]=200` | +| null | IS NULL | `filter[deleted_at][null]` | +| notnull | IS NOT NULL | `filter[published_at][notnull]` | -> Operators are customizable and extendable. You may add your own by overriding the engine's resolver. +> Operators are customizable through [operator strategies](/features/operators). --- diff --git a/docs/engines/tree.md b/docs/engines/tree.md index 90929ef8..356a8b15 100644 --- a/docs/engines/tree.md +++ b/docs/engines/tree.md @@ -56,6 +56,9 @@ This structure is then translated into an Eloquent query builder statement in La | `null` | is null | | `notnull` | is not null | | `between` | between | +| `nbetween` | not between | + +See [Operator Strategies](/features/operators) for value formats, per-filter allow-listing, and custom operators. ## Error Handling diff --git a/docs/features/operators.md b/docs/features/operators.md new file mode 100644 index 00000000..bb4799f3 --- /dev/null +++ b/docs/features/operators.md @@ -0,0 +1,65 @@ +# Operator Strategies + +Ruleset, Expression, and Tree filters share the same operator pipeline. An operator is first validated against the selected engine's `allowed_operators` map, resolved to its database representation, and then applied by an operator strategy. + +## Built-in operators + +| Alias | Resolved operator | Behavior | +| --- | --- | --- | +| `eq`, `neq` | `=`, `!=` | Comparison | +| `gt`, `gte`, `lt`, `lte` | `>`, `>=`, `<`, `<=` | Ordered comparison | +| `like`, `nlike` | `like`, `not like` | Pattern comparison | +| `in`, `nin` | `in`, `not in` | Accepts an array or comma-separated value | +| `between`, `nbetween` | `between`, `not between` | Requires exactly two values | +| `null`, `notnull` | `is null`, `is not null` | Does not require a value | + +`allowedOperators()` accepts either aliases or resolved values: + +```php +$filterable->allowedOperators(['gte', 'in']); +$filterable->allowedOperators(['>=', 'in']); +``` + +## Custom strategies + +A custom strategy implements the `Operator` contract: + +```php +use Illuminate\Contracts\Database\Eloquent\Builder; +use Kettasoft\Filterable\Support\Payload; +use Kettasoft\Filterable\Engines\Foundation\Operators\Contracts\Operator; + +final class ContainsOperator implements Operator +{ + public function apply(Builder $builder, Payload $payload): Builder + { + return $builder->where( + $payload->field, + 'like', + "%{$payload->value}%" + ); + } +} +``` + +Register its public alias and resolved name in the engine, then map that resolved name to the strategy: + +```php +// config/filterable.php +'operator_strategies' => [ + 'contains' => App\Filtering\Operators\ContainsOperator::class, +], + +'engines' => [ + 'ruleset' => [ + 'allowed_operators' => [ + // ... + 'contains' => 'contains', + ], + ], +], +``` + +Strategies are resolved through Laravel's container, so constructor dependencies can be injected. A configured class must implement `Operator`; invalid definitions fail explicitly instead of silently falling back to equality. + +The same strategy is used for direct and relational fields. diff --git a/src/Engines/Contracts/OperatorDefinitionContract.php b/src/Engines/Contracts/OperatorDefinitionContract.php deleted file mode 100644 index 0cc7f48a..00000000 --- a/src/Engines/Contracts/OperatorDefinitionContract.php +++ /dev/null @@ -1,35 +0,0 @@ -context->getData(), $this->context->getRelations(), - array_keys($this->allowedOperators()) + array_merge(array_keys($this->allowedOperators()), array_values($this->allowedOperators())) ); foreach ($filters as $field => $condition) { diff --git a/src/Engines/Foundation/Engine.php b/src/Engines/Foundation/Engine.php index aa9e0a42..79731579 100644 --- a/src/Engines/Foundation/Engine.php +++ b/src/Engines/Foundation/Engine.php @@ -2,7 +2,6 @@ namespace Kettasoft\Filterable\Engines\Foundation; -use Illuminate\Support\Arr; use Kettasoft\Filterable\Filterable; use Illuminate\Contracts\Database\Eloquent\Builder; use Kettasoft\Filterable\Foundation\Resources; @@ -119,7 +118,14 @@ public function allowedOperators(): array return $this->getOperatorsFromConfig(); } - return Arr::only($this->getOperatorsFromConfig(), $this->context->getAllowedOperators()); + $requested = $this->context->getAllowedOperators(); + + return array_filter( + $this->getOperatorsFromConfig(), + fn($operator, $alias) => in_array($alias, $requested, true) + || in_array($operator, $requested, true), + ARRAY_FILTER_USE_BOTH + ); } /** diff --git a/src/Engines/Foundation/Enums/Operators.php b/src/Engines/Foundation/Enums/Operators.php index 36cb46b2..37b9e21a 100644 --- a/src/Engines/Foundation/Enums/Operators.php +++ b/src/Engines/Foundation/Enums/Operators.php @@ -18,6 +18,8 @@ enum Operators: string case NOT_IN = 'NOT IN'; case IS_NULL = 'IS NULL'; case IS_NOT_NULL = 'IS NOT NULL'; + case BETWEEN = 'BETWEEN'; + case NOT_BETWEEN = 'NOT BETWEEN'; public function toString(): string { @@ -28,17 +30,19 @@ public static function fromString(string $operator): string { return match ($operator) { 'eq' => self::EQUALS->value, - 'ne' => self::NOT_EQUALS->value, + 'ne', 'neq' => self::NOT_EQUALS->value, 'gt' => self::GREATER_THAN->value, 'lt' => self::LESS_THAN->value, 'gte' => self::GREATER_THAN_OR_EQUAL->value, 'lte' => self::LESS_THAN_OR_EQUAL->value, 'like' => self::LIKE->value, - 'not_like' => self::NOT_LIKE->value, + 'nlike', 'not_like' => self::NOT_LIKE->value, 'in' => self::IN->value, - 'not_in' => self::NOT_IN->value, - 'is_null' => self::IS_NULL->value, - 'is_not_null' => self::IS_NOT_NULL->value, + 'nin', 'not_in' => self::NOT_IN->value, + 'null', 'is_null' => self::IS_NULL->value, + 'notnull', 'is_not_null' => self::IS_NOT_NULL->value, + 'between' => self::BETWEEN->value, + 'nbetween', 'not_between' => self::NOT_BETWEEN->value, default => throw new InvalidOperatorException($operator), }; } diff --git a/src/Engines/Foundation/Mappers/OperatorMapper.php b/src/Engines/Foundation/Mappers/OperatorMapper.php deleted file mode 100644 index 4ed6e5ea..00000000 --- a/src/Engines/Foundation/Mappers/OperatorMapper.php +++ /dev/null @@ -1,35 +0,0 @@ -definition->resolve($operator); - } -} diff --git a/src/Engines/Foundation/OperatorDefinition.php b/src/Engines/Foundation/OperatorDefinition.php deleted file mode 100644 index fdd53a0c..00000000 --- a/src/Engines/Foundation/OperatorDefinition.php +++ /dev/null @@ -1,68 +0,0 @@ -bag->has($operator); - } - - /** - * @inheritDoc - */ - public function resolve(string|null $operator = null): string|null - { - $operator = strtolower($operator); - - if ($this->isAllowed($operator)) { - return $this->bag->get($operator); - } - - if ($this->strict) { - $this->throw($operator); - } - - return Operators::fromString($this->bag->default); - } - - /** - * Get all defined operators - * @return array - */ - public function all($key = null): array|string|null - { - if ($key) { - return $this->bag->get($key); - } - - return $this->bag->all(); - } - - /** - * Throw InvalidOperatorException instance. - * @param mixed $operator - * @throws \Kettasoft\Filterable\Exceptions\InvalidOperatorException - * @return never - */ - protected function throw($operator) - { - throw new InvalidOperatorException($operator); - } -} diff --git a/src/Engines/Foundation/Operators/BetweenOperator.php b/src/Engines/Foundation/Operators/BetweenOperator.php new file mode 100644 index 00000000..f3befb4d --- /dev/null +++ b/src/Engines/Foundation/Operators/BetweenOperator.php @@ -0,0 +1,33 @@ +explode(); + + if (is_string($payload->value)) { + $values = array_map('trim', $values); + } + + if (count($values) !== 2) { + throw new InvalidOperatorValueException( + $payload, + 'The between operator requires exactly two values.' + ); + } + + if (OperatorResolver::normalize($payload->operator) === 'not between') { + return $builder->whereNotBetween($payload->field, $values); + } + + return $builder->whereBetween($payload->field, $values); + } +} diff --git a/src/Engines/Foundation/Operators/ComparisonOperator.php b/src/Engines/Foundation/Operators/ComparisonOperator.php new file mode 100644 index 00000000..aa9f5e65 --- /dev/null +++ b/src/Engines/Foundation/Operators/ComparisonOperator.php @@ -0,0 +1,15 @@ +where($payload->field, $payload->operator, $payload->value); + } +} diff --git a/src/Engines/Foundation/Operators/Contracts/Operator.php b/src/Engines/Foundation/Operators/Contracts/Operator.php new file mode 100644 index 00000000..ea268687 --- /dev/null +++ b/src/Engines/Foundation/Operators/Contracts/Operator.php @@ -0,0 +1,14 @@ +explode(); + + if (is_string($payload->value)) { + $values = array_map('trim', $values); + } + + if (OperatorResolver::normalize($payload->operator) === 'not in') { + return $builder->whereNotIn($payload->field, $values); + } + + return $builder->whereIn($payload->field, $values); + } +} diff --git a/src/Engines/Foundation/Operators/NullOperator.php b/src/Engines/Foundation/Operators/NullOperator.php new file mode 100644 index 00000000..742819a5 --- /dev/null +++ b/src/Engines/Foundation/Operators/NullOperator.php @@ -0,0 +1,19 @@ +operator) === 'is not null') { + return $builder->whereNotNull($payload->field); + } + + return $builder->whereNull($payload->field); + } +} diff --git a/src/Engines/Foundation/Operators/OperatorResolver.php b/src/Engines/Foundation/Operators/OperatorResolver.php new file mode 100644 index 00000000..323bc32d --- /dev/null +++ b/src/Engines/Foundation/Operators/OperatorResolver.php @@ -0,0 +1,87 @@ +> + */ + private array $operators; + + /** + * @param array> $operators + */ + public function __construct(array $operators = []) + { + $this->operators = array_replace($this->defaults(), $this->normalizeKeys($operators)); + } + + public static function fromConfig(): self + { + $operators = config('filterable.operator_strategies', []); + + return new self(is_array($operators) ? $operators : []); + } + + public function resolve(string $operator): Operator + { + $definition = $this->operators[self::normalize($operator)] ?? ComparisonOperator::class; + + if (! is_string($definition) || ! is_a($definition, Operator::class, true)) { + throw new InvalidOperatorDefinitionException($operator, $definition); + } + + try { + $instance = app($definition); + } catch (\Throwable) { + throw new InvalidOperatorDefinitionException($operator, $definition); + } + + if (! $instance instanceof Operator) { + throw new InvalidOperatorDefinitionException($operator, $instance); + } + + return $instance; + } + + public static function normalize(string $operator): string + { + $operator = strtolower(trim(str_replace('_', ' ', $operator))); + + return preg_replace('/\s+/', ' ', $operator) ?? $operator; + } + + /** + * @return array> + */ + private function defaults(): array + { + return [ + 'in' => InOperator::class, + 'not in' => InOperator::class, + 'between' => BetweenOperator::class, + 'not between' => BetweenOperator::class, + 'is null' => NullOperator::class, + 'is not null' => NullOperator::class, + ]; + } + + /** + * @param array> $operators + * @return array> + */ + private function normalizeKeys(array $operators): array + { + $normalized = []; + + foreach ($operators as $operator => $definition) { + $normalized[self::normalize((string) $operator)] = $definition; + } + + return $normalized; + } +} diff --git a/src/Engines/Foundation/Parsers/Dissector.php b/src/Engines/Foundation/Parsers/Dissector.php index e0a1edb0..e484e88e 100644 --- a/src/Engines/Foundation/Parsers/Dissector.php +++ b/src/Engines/Foundation/Parsers/Dissector.php @@ -62,6 +62,10 @@ protected static function extractOperatorAndValue(mixed $raw, mixed $defaultOper return [$raw['operator'], $raw['value']]; } + if (is_array($raw) && !array_is_list($raw) && count($raw) === 1) { + return [array_key_first($raw), reset($raw)]; + } + if (is_string($raw) && str_contains($raw, ':')) { return explode(':', $raw, 2); } diff --git a/src/Engines/Foundation/PayloadApplier.php b/src/Engines/Foundation/PayloadApplier.php index 68b7d6fa..c08c83ac 100644 --- a/src/Engines/Foundation/PayloadApplier.php +++ b/src/Engines/Foundation/PayloadApplier.php @@ -5,10 +5,16 @@ use Illuminate\Contracts\Database\Eloquent\Builder; use Kettasoft\Filterable\Engines\Contracts\Appliable; use Kettasoft\Filterable\Support\Payload; +use Kettasoft\Filterable\Engines\Foundation\Operators\OperatorResolver; class PayloadApplier implements Appliable { - public function __construct(protected Payload $payload) {} + protected OperatorResolver $operators; + + public function __construct(protected Payload $payload, ?OperatorResolver $operators = null) + { + $this->operators = $operators ?? OperatorResolver::fromConfig(); + } public function apply(Builder $builder): Builder { @@ -26,11 +32,7 @@ protected function isRelational(): bool protected function applyDirect(Builder $builder): Builder { - return $builder->where( - $this->payload->field, - $this->payload->operator, - $this->payload->value - ); + return $this->applyOperator($builder, $this->payload); } protected function applyRelational(Builder $builder): Builder @@ -40,7 +42,14 @@ protected function applyRelational(Builder $builder): Builder $relation = implode('.', $segments); return $builder->whereHas($relation, function (Builder $query) use ($field): Builder { - return $query->where($field, $this->payload->operator, $this->payload->value); + $payload = clone $this->payload; + + return $this->applyOperator($query, $payload->setField($field)); }); } + + protected function applyOperator(Builder $builder, Payload $payload): Builder + { + return $this->operators->resolve($payload->operator)->apply($builder, $payload); + } } diff --git a/src/Engines/Foundation/PayloadFactory.php b/src/Engines/Foundation/PayloadFactory.php index 3b0b8bfd..517ab28c 100644 --- a/src/Engines/Foundation/PayloadFactory.php +++ b/src/Engines/Foundation/PayloadFactory.php @@ -7,6 +7,7 @@ use Kettasoft\Filterable\Engines\Exceptions\InvalidOperatorException; use Kettasoft\Filterable\Engines\Exceptions\NotAllowedFieldException; use Kettasoft\Filterable\Engines\Exceptions\NotAllowedEmptyValueException; +use Kettasoft\Filterable\Engines\Foundation\Operators\OperatorResolver; /** * Validate and resolve payloads before they are applied. @@ -33,11 +34,14 @@ public function make(Payload $payload): Payload { $this->validateField($payload); $this->validateOperator($payload); - $this->validateValue($payload); - return $payload + $payload ->setField($this->resolveField($payload)) ->setOperator($this->resolveOperator($payload)); + + $this->validateValue($payload); + + return $payload; } /** @@ -70,8 +74,14 @@ protected function validateField(Payload $payload): void protected function validateOperator(Payload $payload): bool { $operator = $payload->operator; + $allowedOperators = $this->engine->allowedOperators(); + $isAllowed = array_key_exists($operator, $allowedOperators) + || in_array(OperatorResolver::normalize($operator), array_map( + [OperatorResolver::class, 'normalize'], + array_values($allowedOperators) + ), true); - if (! array_key_exists($operator, $this->engine->allowedOperators()) && $this->engine->isStrict()) { + if (! $isAllowed && $this->engine->isStrict()) { throw new InvalidOperatorException($operator, $payload); } @@ -85,6 +95,10 @@ protected function validateOperator(Payload $payload): bool */ protected function validateValue(Payload $payload): void { + if (in_array(OperatorResolver::normalize($payload->operator), ['is null', 'is not null'], true)) { + return; + } + if ($this->engine->isIgnoredEmptyValues() && $payload->isEmpty()) { throw new NotAllowedEmptyValueException('Empty values are not allowed.', $payload); } @@ -109,8 +123,19 @@ protected function resolveField(Payload $payload): string */ protected function resolveOperator(Payload $payload): string { - return $this->engine->allowedOperators()[$payload->operator] - ?? Operators::fromString($this->engine->defaultOperator()); + $allowedOperators = $this->engine->allowedOperators(); + + if (array_key_exists($payload->operator, $allowedOperators)) { + return $allowedOperators[$payload->operator]; + } + + foreach ($allowedOperators as $operator) { + if (OperatorResolver::normalize($operator) === OperatorResolver::normalize($payload->operator)) { + return $operator; + } + } + + return Operators::fromString($this->engine->defaultOperator()); } /** diff --git a/src/Engines/Ruleset.php b/src/Engines/Ruleset.php index 70ad9e5d..c0deea45 100644 --- a/src/Engines/Ruleset.php +++ b/src/Engines/Ruleset.php @@ -32,7 +32,7 @@ public function execute(Builder $builder): Builder $data = RelationFieldParser::parse( $this->context->getData(), $this->context->getRelations(), - array_keys($this->allowedOperators()) + array_merge(array_keys($this->allowedOperators()), array_values($this->allowedOperators())) ); foreach ($data as $field => $dissector) { diff --git a/src/Exceptions/InvalidOperatorDefinitionException.php b/src/Exceptions/InvalidOperatorDefinitionException.php new file mode 100644 index 00000000..1685a92a --- /dev/null +++ b/src/Exceptions/InvalidOperatorDefinitionException.php @@ -0,0 +1,19 @@ + value ]. - * @param string|array|null $condition + * @param mixed $condition * @param string $operator * @return array */ - public static function normalize(string|array|null $condition, string|null $operator = null): array + public static function normalize(mixed $condition, string|null $operator = null): array { - if (is_string($condition)) { - // If the condition is a string, we assume it's a value and use the operator. - return ['operator' => $operator, 'value' => $condition]; + if ( + is_array($condition) + && array_key_exists('operator', $condition) + && array_key_exists('value', $condition) + ) { + return [ + 'operator' => $condition['operator'], + 'value' => $condition['value'], + ]; } - if (is_array($condition) && !array_is_list($condition)) { - // If the condition is an associative array, we assume it already has the operator as a key. + if (is_array($condition) && !array_is_list($condition) && count($condition) === 1) { return [ 'operator' => array_key_first($condition), 'value' => array_values($condition)[0] ?? null ]; } + + return ['operator' => $operator, 'value' => $condition]; } } diff --git a/tests/Unit/Engines/Operators/OperatorStrategiesTest.php b/tests/Unit/Engines/Operators/OperatorStrategiesTest.php new file mode 100644 index 00000000..ded078f8 --- /dev/null +++ b/tests/Unit/Engines/Operators/OperatorStrategiesTest.php @@ -0,0 +1,286 @@ +seedPosts(); + + $posts = $this->ruleset([ + 'views' => ['in' => [10, 30]], + ])->get(); + + $this->assertSame([10, 30], $posts->pluck('views')->sort()->values()->all()); + } + + public function test_ruleset_applies_not_in_with_a_comma_separated_value(): void + { + $this->seedPosts(); + + $posts = $this->ruleset([ + 'views' => ['nin' => '10,30'], + ])->get(); + + $this->assertSame([20], $posts->pluck('views')->all()); + } + + public function test_expression_applies_between(): void + { + $this->seedPosts(); + $request = Request::create('/posts', 'GET', [ + 'filter' => ['views' => ['between' => [10, 20]]], + ]); + + $posts = Filterable::for(Post::class, $request) + ->using('expression') + ->setAllowedFields(['views']) + ->get(); + + $this->assertSame([10, 20], $posts->pluck('views')->sort()->values()->all()); + } + + public function test_not_between_excludes_the_requested_range(): void + { + $this->seedPosts(); + + $posts = $this->ruleset([ + 'views' => ['nbetween' => [15, 25]], + ])->get(); + + $this->assertSame([10, 30], $posts->pluck('views')->sort()->values()->all()); + } + + public function test_allowed_operators_accept_resolved_sql_symbols(): void + { + $this->seedPosts(); + $request = Request::create('/posts', 'GET', [ + 'views' => ['>=' => 20], + ]); + + $posts = Filterable::for(Post::class, $request) + ->using('ruleset') + ->setAllowedFields(['views']) + ->allowedOperators(['>=']) + ->get(); + + $this->assertSame([20, 30], $posts->pluck('views')->sort()->values()->all()); + } + + public function test_empty_set_operators_use_eloquent_set_semantics(): void + { + $this->seedPosts(); + + $included = $this->ruleset(['views' => ['in' => []]])->get(); + $excluded = $this->ruleset(['views' => ['nin' => []]])->get(); + + $this->assertCount(0, $included); + $this->assertCount(3, $excluded); + } + + public function test_tree_engine_uses_the_same_operator_pipeline(): void + { + $this->seedPosts(); + $request = Request::create('/posts'); + $request->setJson(new InputBag([ + 'filter' => [ + 'and' => [[ + 'field' => 'views', + 'operator' => 'between', + 'value' => [20, 30], + ]], + ], + ])); + + $posts = Filterable::for(Post::class, $request) + ->using('tree') + ->setAllowedFields(['views']) + ->get(); + + $this->assertSame([20, 30], $posts->pluck('views')->sort()->values()->all()); + } + + public function test_null_operator_does_not_require_a_value(): void + { + $this->seedPosts(); + config()->set('filterable.engines.ruleset.ignore_empty_values', true); + + $posts = $this->ruleset([ + 'description' => ['null' => null], + ], ['description'])->get(); + + $this->assertCount(2, $posts); + } + + public function test_not_null_operator_ignores_its_value(): void + { + $this->seedPosts(); + + $posts = $this->ruleset([ + 'description' => ['notnull' => 'ignored'], + ], ['description'])->get(); + + $this->assertCount(1, $posts); + $this->assertSame(20, $posts->first()->views); + } + + public function test_special_operators_work_on_relational_fields(): void + { + [$first, $second] = $this->seedPosts(); + Tag::factory()->create(['post_id' => $first->id, 'name' => 'featured']); + Tag::factory()->create(['post_id' => $second->id, 'name' => 'archived']); + + $request = Request::create('/posts', 'GET', [ + 'filter' => [ + 'tags' => ['name' => ['in' => ['featured', 'recommended']]], + ], + ]); + + $posts = Filterable::for(Post::class, $request) + ->using('expression') + ->allowRelations(['tags' => ['name']]) + ->get(); + + $this->assertCount(1, $posts); + $this->assertSame($first->id, $posts->first()->id); + } + + public function test_structured_expression_condition_is_preserved(): void + { + $this->seedPosts(); + $request = Request::create('/posts', 'GET', [ + 'filter' => [ + 'views' => ['operator' => 'gte', 'value' => 20], + ], + ]); + + $posts = Filterable::for(Post::class, $request) + ->using('expression') + ->setAllowedFields(['views']) + ->get(); + + $this->assertSame([20, 30], $posts->pluck('views')->sort()->values()->all()); + } + + public function test_between_rejects_an_invalid_value_with_payload_context(): void + { + $this->seedPosts(); + + try { + $this->ruleset(['views' => ['between' => [10]]])->strict()->get(); + $this->fail('Expected an invalid operator value exception.'); + } catch (InvalidOperatorValueException $exception) { + $this->assertSame('views', $exception->getPayload()?->field); + $this->assertSame([10], $exception->getPayload()?->value); + } + } + + public function test_invalid_between_value_is_tracked_when_permissive(): void + { + $this->seedPosts(); + $filterable = $this->ruleset(['views' => ['between' => [10]]])->permissive(); + + $this->assertCount(3, $filterable->get()); + $this->assertTrue($filterable->hasSkipped('views')); + } + + public function test_a_custom_operator_strategy_can_override_a_resolved_operator(): void + { + $this->seedPosts(); + config()->set('filterable.engines.ruleset.allowed_operators.contains', 'contains'); + config()->set('filterable.operator_strategies.contains', ContainsOperator::class); + + $posts = $this->ruleset([ + 'title' => ['contains' => 'cond'], + ], ['title'])->get(); + + $this->assertCount(1, $posts); + $this->assertSame('Second', $posts->first()->title); + } + + public function test_resolver_maps_special_and_comparison_operators(): void + { + $resolver = new OperatorResolver(); + + $this->assertInstanceOf(InOperator::class, $resolver->resolve('NOT_IN')); + $this->assertInstanceOf(BetweenOperator::class, $resolver->resolve('between')); + $this->assertInstanceOf(NullOperator::class, $resolver->resolve('IS NULL')); + $this->assertInstanceOf(ComparisonOperator::class, $resolver->resolve('>=')); + } + + public function test_invalid_custom_operator_definitions_fail_explicitly(): void + { + $this->expectException(InvalidOperatorDefinitionException::class); + + (new OperatorResolver(['contains' => \stdClass::class]))->resolve('contains'); + } + + private function ruleset(array $filters, array $fields = ['views']): Filterable + { + $request = Request::create('/posts', 'GET', $filters); + + return Filterable::for(Post::class, $request) + ->using('ruleset') + ->setAllowedFields($fields); + } + + private function seedPosts(): array + { + return [ + Post::factory()->create([ + 'title' => 'First', + 'status' => 'active', + 'views' => 10, + 'description' => null, + ]), + Post::factory()->create([ + 'title' => 'Second', + 'status' => 'pending', + 'views' => 20, + 'description' => 'Contains text', + ]), + Post::factory()->create([ + 'title' => 'Third', + 'status' => 'stopped', + 'views' => 30, + 'description' => null, + ]), + ]; + } +} + +class ContainsOperator implements Operator +{ + public function __construct(private ContainsPattern $pattern) {} + + public function apply(Builder $builder, Payload $payload): Builder + { + return $builder->where($payload->field, 'like', $this->pattern->wrap($payload->value)); + } +} + +class ContainsPattern +{ + public function wrap(mixed $value): string + { + return "%{$value}%"; + } +}