Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/2.filtering-and-sorting/1.filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,39 @@ GET /api/products?filter[created_at]=2025-01-15
GET /api/products?filter[created_at][from]=2025-01-01&filter[created_at][to]=2025-01-31
```

### OperatorFilter

Supports comparison operators for range queries. Accepts `eq`, `neq`, `gt`, `gte`, `lt`, `lte` as nested keys. Falls back to exact match when a scalar value is passed.

```php
use BlueBeetle\ApiToolkit\Parsers\Filters\OperatorFilter;

public function allowedFilters(): array
{
return [
'price' => new OperatorFilter(),
'stock' => new OperatorFilter(),
];
}
```

```
GET /api/products?filter[price][gte]=10&filter[price][lte]=50
GET /api/products?filter[stock][gt]=0
GET /api/products?filter[price]=100
```

| Operator | SQL |
|---|---|
| `eq` | `=` |
| `neq` | `!=` |
| `gt` | `>` |
| `gte` | `>=` |
| `lt` | `<` |
| `lte` | `<=` |

Multiple operators on the same field are combined with AND, making range queries straightforward.

### ScopeFilter

Delegates filtering to a query scope on the model.
Expand Down
41 changes: 41 additions & 0 deletions src/Parsers/Filters/OperatorFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types = 1);

namespace BlueBeetle\ApiToolkit\Parsers\Filters;

use Illuminate\Database\Eloquent\Builder;

final readonly class OperatorFilter implements Filter
{
private const array OPERATORS = [
'eq' => '=',
'neq' => '!=',
'gt' => '>',
'gte' => '>=',
'lt' => '<',
'lte' => '<=',
];

public function apply(Builder $query, string $field, mixed $value): void
{
if (is_array($value)) {
$this->applyOperators($query, $field, $value);

return;
}

$query->where($field, '=', $value);
}

private function applyOperators(Builder $query, string $field, array $value): void
{
foreach ($value as $operator => $operand) {
if (! is_string($operator) || ! isset(self::OPERATORS[$operator])) {
continue;
}

$query->where($field, self::OPERATORS[$operator], $operand);
}
}
}
136 changes: 136 additions & 0 deletions tests/Feature/Parsers/OperatorFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types = 1);

namespace BlueBeetle\ApiToolkit\Tests\Feature\Parsers;

use BlueBeetle\ApiToolkit\Parsers\Filters\OperatorFilter;
use BlueBeetle\ApiToolkit\Tests\Fixtures\Models\Product;

it('filters with eq operator', function () {
Product::create(['public_id' => 'p1', 'name' => 'Widget', 'code' => 'W01', 'price_in_cents' => 1000]);
Product::create(['public_id' => 'p2', 'name' => 'Gadget', 'code' => 'G01', 'price_in_cents' => 2000]);

$filter = new OperatorFilter();
$query = Product::query();
$filter->apply($query, 'price_in_cents', ['eq' => 1000]);

expect($query->get())->toHaveCount(1);
expect($query->first()->public_id)->toBe('p1');
});

it('filters with neq operator', function () {
Product::create(['public_id' => 'p1', 'name' => 'Widget', 'code' => 'W01', 'price_in_cents' => 1000]);
Product::create(['public_id' => 'p2', 'name' => 'Gadget', 'code' => 'G01', 'price_in_cents' => 2000]);

$filter = new OperatorFilter();
$query = Product::query();
$filter->apply($query, 'price_in_cents', ['neq' => 1000]);

expect($query->get())->toHaveCount(1);
expect($query->first()->public_id)->toBe('p2');
});

it('filters with gt operator', function () {
Product::create(['public_id' => 'p1', 'name' => 'Cheap', 'code' => 'C01', 'price_in_cents' => 500]);
Product::create(['public_id' => 'p2', 'name' => 'Mid', 'code' => 'M01', 'price_in_cents' => 1000]);
Product::create(['public_id' => 'p3', 'name' => 'Expensive', 'code' => 'E01', 'price_in_cents' => 5000]);

$filter = new OperatorFilter();
$query = Product::query();
$filter->apply($query, 'price_in_cents', ['gt' => 1000]);

expect($query->get())->toHaveCount(1);
expect($query->first()->public_id)->toBe('p3');
});

it('filters with gte operator', function () {
Product::create(['public_id' => 'p1', 'name' => 'Cheap', 'code' => 'C01', 'price_in_cents' => 500]);
Product::create(['public_id' => 'p2', 'name' => 'Mid', 'code' => 'M01', 'price_in_cents' => 1000]);
Product::create(['public_id' => 'p3', 'name' => 'Expensive', 'code' => 'E01', 'price_in_cents' => 5000]);

$filter = new OperatorFilter();
$query = Product::query();
$filter->apply($query, 'price_in_cents', ['gte' => 1000]);

expect($query->get())->toHaveCount(2);
});

it('filters with lt operator', function () {
Product::create(['public_id' => 'p1', 'name' => 'Cheap', 'code' => 'C01', 'price_in_cents' => 500]);
Product::create(['public_id' => 'p2', 'name' => 'Mid', 'code' => 'M01', 'price_in_cents' => 1000]);
Product::create(['public_id' => 'p3', 'name' => 'Expensive', 'code' => 'E01', 'price_in_cents' => 5000]);

$filter = new OperatorFilter();
$query = Product::query();
$filter->apply($query, 'price_in_cents', ['lt' => 1000]);

expect($query->get())->toHaveCount(1);
expect($query->first()->public_id)->toBe('p1');
});

it('filters with lte operator', function () {
Product::create(['public_id' => 'p1', 'name' => 'Cheap', 'code' => 'C01', 'price_in_cents' => 500]);
Product::create(['public_id' => 'p2', 'name' => 'Mid', 'code' => 'M01', 'price_in_cents' => 1000]);
Product::create(['public_id' => 'p3', 'name' => 'Expensive', 'code' => 'E01', 'price_in_cents' => 5000]);

$filter = new OperatorFilter();
$query = Product::query();
$filter->apply($query, 'price_in_cents', ['lte' => 1000]);

expect($query->get())->toHaveCount(2);
});

it('combines multiple operators as range', function () {
Product::create(['public_id' => 'p1', 'name' => 'Cheap', 'code' => 'C01', 'price_in_cents' => 500]);
Product::create(['public_id' => 'p2', 'name' => 'Mid', 'code' => 'M01', 'price_in_cents' => 1000]);
Product::create(['public_id' => 'p3', 'name' => 'Expensive', 'code' => 'E01', 'price_in_cents' => 5000]);

$filter = new OperatorFilter();
$query = Product::query();
$filter->apply($query, 'price_in_cents', ['gte' => 500, 'lte' => 1000]);

expect($query->get())->toHaveCount(2);
});

it('falls back to exact match for scalar values', function () {
Product::create(['public_id' => 'p1', 'name' => 'Widget', 'code' => 'W01', 'price_in_cents' => 1000]);
Product::create(['public_id' => 'p2', 'name' => 'Gadget', 'code' => 'G01', 'price_in_cents' => 2000]);

$filter = new OperatorFilter();
$query = Product::query();
$filter->apply($query, 'price_in_cents', '1000');

expect($query->get())->toHaveCount(1);
expect($query->first()->public_id)->toBe('p1');
});

it('ignores unknown operators', function () {
Product::create(['public_id' => 'p1', 'name' => 'Widget', 'code' => 'W01', 'price_in_cents' => 1000]);

$filter = new OperatorFilter();
$query = Product::query();
$filter->apply($query, 'price_in_cents', ['invalid' => 500]);

expect($query->get())->toHaveCount(1);
});

it('works through the query builder', function () {
Product::create(['public_id' => 'p1', 'name' => 'Cheap', 'code' => 'C01', 'price_in_cents' => 500]);
Product::create(['public_id' => 'p2', 'name' => 'Mid', 'code' => 'M01', 'price_in_cents' => 1000]);
Product::create(['public_id' => 'p3', 'name' => 'Expensive', 'code' => 'E01', 'price_in_cents' => 5000]);

$request = \Illuminate\Http\Request::create('/', 'GET', [
'filter' => ['price_in_cents' => ['gte' => 1000, 'lt' => 5000]],
]);

$result = \BlueBeetle\ApiToolkit\QueryBuilder::for(Product::class, $request)
->allowedFilters(['price_in_cents' => new OperatorFilter()])
->apply()
->getQuery()
->get()
;

expect($result)->toHaveCount(1);
expect($result->first()->name)->toBe('Mid');
});