Skip to content

Commit 641d961

Browse files
committed
Add support for boolean filter groups and operators
1 parent f3669bd commit 641d961

23 files changed

+850
-211
lines changed

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
### ⚠️ Breaking Changes
12+
13+
- `Tobyz\\JsonApiServer\\Laravel\\Filter\\EloquentFilter` renamed to
14+
`ColumnFilter`.
15+
- Remove the `Has` Laravel filter; use `WhereExists` instead.
16+
- Remove the `WhereDoesntHave` Laravel filter; use the operator support on
17+
`WhereHas` instead.
18+
- Remove `Where::asNumber()`; express numeric comparisons with operators such as
19+
`filter[score][gt]=...` or `filter[score][lte]=...`.
20+
21+
### Added
22+
23+
- Add support for boolean filter groups (`filter[and]`, `filter[or]`,
24+
`filter[not]`) for resources that implement the `SupportsBooleanFilters`
25+
interface
26+
- Laravel: Add support for boolean filter groups to `EloquentResource`
27+
- Laravel: Overhaul filter implementations to support operators like `eq`, `ne`,
28+
`in`, `lt`, `lte`, `gt`, `gte`, `like`, `notlike`, `null`, and `notnull`
29+
1130
## [1.0.0-beta.4] - 2025-05-02
1231

1332
### Added

docs/laravel.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -166,23 +166,28 @@ BooleanDateTime::make('isDeleted')
166166
The Laravel integration provides a number of filters for use in your Eloquent
167167
resources.
168168

169-
### Where
169+
### Where Filters
170170

171171
```php
172172
Where::make('name');
173173
Where::make('id')->commaSeparated();
174174
Where::make('isConfirmed')->asBoolean();
175-
Where::make('score')->asNumeric();
176-
WhereBelongsTo::make('user');
177-
Has::make('hasComments');
178-
WhereHas::make('comments');
179-
WhereDoesntHave::make('comments');
180-
WhereNull::make('draft')->property('published_at');
181-
WhereNotNull::make('published')->property('published_at');
182-
Scope::make('withTrashed');
175+
WhereBelongsTo::make('author')->relationship('user');
176+
WhereExists::make('comments');
177+
WhereCount::make('commentCount')->relationship('comments');
178+
WhereHas::make('tags');
179+
WhereNull::make('draft')->column('published_at');
180+
WhereNotNull::make('published')->column('published_at');
181+
Scope::make('withTrashed')->asBoolean();
183182
Scope::make('trashed')->scope('onlyTrashed');
184183
```
185184

185+
### Boolean Filters
186+
187+
`EloquentResource` implements
188+
`Tobyz\\JsonApiServer\\Resource\\SupportsBooleanFilters`, so you can combine
189+
filters with `[and]`, `[or]`, and `[not]` groups without extra configuration.
190+
186191
## Sort Fields
187192

188193
The Laravel integration provides a number of sort fields for use in your

docs/list.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,38 @@ GET /posts?filter[name]=Toby
218218
GET /posts?filter[name][]=Toby&filter[name][]=Franz
219219
```
220220

221+
### Boolean Filters
222+
223+
By default it is assumed that each filter applied to the query will be combined
224+
with a logical `AND`. When a resource implements
225+
`Tobyz\\JsonApiServer\\Resource\\SupportsBooleanFilters` you can express more
226+
complex logic with `AND`, `OR`, and `NOT` groups.
227+
228+
Boolean groups are expressed by nesting objects under the `filter` parameter.
229+
You may use either associative objects or indexed lists of clauses. Each clause
230+
can be another filter or another boolean group.
231+
232+
```http
233+
GET /posts
234+
?filter[and][0][status]=published
235+
&filter[and][1][or][0][views][gt]=100
236+
&filter[and][1][or][1][not][status]=archived
237+
```
238+
239+
In this request every result must be published, and it must also either have
240+
more than 100 views or it is not archived.
241+
242+
```http
243+
GET /posts
244+
?filter[or][0][status]=draft
245+
&filter[or][1][status]=published
246+
&filter[or][1][not][comments]=0
247+
```
248+
249+
This request returns drafts, or posts that are published and have comments. The
250+
second example also shows that in certain cases you can omit `[and]` groups and
251+
numeric indices; sibling filters at the same level default to `AND` behaviour.
252+
221253
### Writing Filters
222254

223255
To create your own filter class, extend the `Tobyz\JsonApiServer\Schema\Filter`

src/Filterer.php

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
namespace Tobyz\JsonApiServer;
4+
5+
use Tobyz\JsonApiServer\Exception\BadRequestException;
6+
use Tobyz\JsonApiServer\Resource\Collection;
7+
use Tobyz\JsonApiServer\Resource\Listable;
8+
use Tobyz\JsonApiServer\Resource\SupportsBooleanFilters;
9+
10+
class Filterer
11+
{
12+
public function __construct(
13+
private readonly Collection&Listable $collection,
14+
private Context $context,
15+
) {
16+
$this->context = $context->withCollection($collection);
17+
}
18+
19+
public function apply($query, array $filters): void
20+
{
21+
$this->applyGroup($query, $filters, 'and', []);
22+
}
23+
24+
private function applyGroup($query, array $filters, string $boolean, array $path): void
25+
{
26+
$clauses = [];
27+
$availableFilters = $this->resolveAvailableFilters();
28+
29+
foreach ($filters as $key => $value) {
30+
$keyPath = [...$path, $key];
31+
32+
if (
33+
$this->collection instanceof SupportsBooleanFilters &&
34+
in_array($key, ['and', 'or', 'not'])
35+
) {
36+
if (!is_array($value)) {
37+
throw $this->badRequest(
38+
'Boolean groups must be objects or lists of clauses',
39+
$keyPath,
40+
);
41+
}
42+
43+
$clauses[] = fn($query) => $this->applyGroup($query, $value, $key, $keyPath);
44+
45+
continue;
46+
}
47+
48+
if (is_int($key)) {
49+
if (!is_array($value)) {
50+
throw $this->badRequest(
51+
'Filter clauses must be expressed as objects',
52+
$keyPath,
53+
);
54+
}
55+
56+
$clauses[] = fn($query) => $this->applyGroup($query, $value, 'and', $keyPath);
57+
58+
continue;
59+
}
60+
61+
if (!($filter = $availableFilters[$key] ?? null)) {
62+
throw $this->badRequest("Invalid filter: $key", $keyPath);
63+
}
64+
65+
$clauses[] = fn($query) => $filter->apply($query, $value, $this->context);
66+
}
67+
68+
if (!$clauses) {
69+
return;
70+
}
71+
72+
if ($this->collection instanceof SupportsBooleanFilters) {
73+
if ($boolean === 'or') {
74+
$this->collection->filterOr($query, $clauses);
75+
76+
return;
77+
}
78+
79+
if ($boolean === 'not') {
80+
$this->collection->filterNot($query, $clauses);
81+
82+
return;
83+
}
84+
}
85+
86+
foreach ($clauses as $clause) {
87+
$clause($query);
88+
}
89+
}
90+
91+
private function resolveAvailableFilters(): array
92+
{
93+
$filters = [];
94+
95+
foreach ($this->collection->filters() as $filter) {
96+
if ($filter->isVisible($this->context)) {
97+
$filters[$filter->name] = $filter;
98+
}
99+
}
100+
101+
return $filters;
102+
}
103+
104+
private function badRequest(string $message, array $path): BadRequestException
105+
{
106+
return (new BadRequestException($message))->setSource([
107+
'parameter' =>
108+
'[' . implode('][', array_map(fn($segment) => (string) $segment, $path)) . ']',
109+
]);
110+
}
111+
}

src/Laravel/EloquentResource.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Tobyz\JsonApiServer\Resource\Findable;
1919
use Tobyz\JsonApiServer\Resource\Listable;
2020
use Tobyz\JsonApiServer\Resource\Paginatable;
21+
use Tobyz\JsonApiServer\Resource\SupportsBooleanFilters;
2122
use Tobyz\JsonApiServer\Resource\Updatable;
2223
use Tobyz\JsonApiServer\Schema\Field\Attribute;
2324
use Tobyz\JsonApiServer\Schema\Field\Field;
@@ -32,7 +33,8 @@ abstract class EloquentResource extends AbstractResource implements
3233
Paginatable,
3334
Creatable,
3435
Updatable,
35-
Deletable
36+
Deletable,
37+
SupportsBooleanFilters
3638
{
3739
public function resource(object $model, Context $context): ?string
3840
{
@@ -240,6 +242,34 @@ public function delete(object $model, Context $context): void
240242
$model->delete();
241243
}
242244

245+
public function filterOr(object $query, array $clauses): void
246+
{
247+
if (!$clauses) {
248+
return;
249+
}
250+
251+
$query->where(function ($query) use ($clauses) {
252+
foreach ($clauses as $clause) {
253+
$query->orWhere(function ($nested) use ($clause) {
254+
$clause($nested);
255+
});
256+
}
257+
});
258+
}
259+
260+
public function filterNot(object $query, array $clauses): void
261+
{
262+
if (!$clauses) {
263+
return;
264+
}
265+
266+
$query->whereNot(function ($nested) use ($clauses) {
267+
foreach ($clauses as $clause) {
268+
$clause($nested);
269+
}
270+
});
271+
}
272+
243273
/**
244274
* Get the model property that a field represents.
245275
*/
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace Tobyz\JsonApiServer\Laravel\Filter;
4+
5+
use Illuminate\Contracts\Database\Query\Expression;
6+
use Illuminate\Support\Str;
7+
use Tobyz\JsonApiServer\Schema\Filter;
8+
9+
abstract class ColumnFilter extends Filter
10+
{
11+
protected string|Expression|null $column = null;
12+
13+
public static function make(string $name): static
14+
{
15+
return new static($name);
16+
}
17+
18+
public function column(string|Expression|null $column): static
19+
{
20+
$this->column = $column;
21+
22+
return $this;
23+
}
24+
25+
protected function getColumn(): string|Expression
26+
{
27+
return $this->column ?: Str::snake($this->name);
28+
}
29+
}

src/Laravel/Filter/EloquentFilter.php

Lines changed: 0 additions & 49 deletions
This file was deleted.

src/Laravel/Filter/Has.php

Lines changed: 0 additions & 41 deletions
This file was deleted.

0 commit comments

Comments
 (0)