Skip to content

Commit 0faa929

Browse files
committed
Add support for boolean filters
1 parent 88948ab commit 0faa929

13 files changed

+523
-109
lines changed

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
*/

src/Laravel/Filter/EloquentFilter.php

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
abstract class EloquentFilter extends Filter
99
{
1010
protected ?string $column = null;
11-
protected bool $asBoolean = false;
1211

1312
public static function make(string $name): static
1413
{
@@ -26,24 +25,4 @@ protected function getColumn(): string
2625
{
2726
return $this->column ?: Str::snake($this->name);
2827
}
29-
30-
public function asBoolean(): static
31-
{
32-
$this->asBoolean = true;
33-
34-
return $this;
35-
}
36-
37-
protected function parseValue(string|array $value): string|array
38-
{
39-
if ($this->asBoolean) {
40-
if (is_array($value)) {
41-
return array_map($this->parseValue(...), $value);
42-
}
43-
44-
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
45-
}
46-
47-
return $value;
48-
}
4928
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace Tobyz\JsonApiServer\Laravel\Filter;
4+
5+
use Tobyz\JsonApiServer\Exception\BadRequestException;
6+
7+
trait SupportsOperators
8+
{
9+
private function resolveOperator(array|string $value): array
10+
{
11+
if (!is_array($value) || array_is_list($value)) {
12+
return ['eq', $value];
13+
}
14+
15+
$keys = array_keys($value);
16+
17+
if (count($keys) !== 1) {
18+
throw new BadRequestException('Operator groups cannot combine with other values');
19+
}
20+
21+
$operator = $keys[0];
22+
23+
return [$operator, $value[$operator]];
24+
}
25+
}

src/Laravel/Filter/Where.php

Lines changed: 78 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
namespace Tobyz\JsonApiServer\Laravel\Filter;
44

55
use Tobyz\JsonApiServer\Context;
6+
use Tobyz\JsonApiServer\Exception\BadRequestException;
67

78
class Where extends EloquentFilter
89
{
9-
protected bool $asNumber = false;
10+
use SupportsOperators;
11+
12+
protected bool $asBoolean = false;
1013
protected bool $commaSeparated = false;
1114

1215
public static function make(string $name): static
@@ -17,15 +20,6 @@ public static function make(string $name): static
1720
public function asBoolean(): static
1821
{
1922
$this->asBoolean = true;
20-
$this->asNumber = false;
21-
22-
return $this;
23-
}
24-
25-
public function asNumber(): static
26-
{
27-
$this->asNumber = true;
28-
$this->asBoolean = false;
2923

3024
return $this;
3125
}
@@ -39,48 +33,86 @@ public function commaSeparated(): static
3933

4034
public function apply(object $query, array|string $value, Context $context): void
4135
{
42-
$value = $this->parseValue($value);
36+
if ($this->asBoolean) {
37+
$query->where($this->getColumn(), filter_var($value, FILTER_VALIDATE_BOOLEAN));
38+
return;
39+
}
40+
41+
[$operator, $resolved] = $this->resolveOperator($value);
42+
43+
switch ($operator) {
44+
case 'eq':
45+
case 'in':
46+
$this->applyEquals($query, $resolved);
47+
break;
4348

44-
if ($this->commaSeparated) {
45-
$value = array_merge(...array_map(fn($v) => explode(',', $v), (array) $value));
49+
case 'ne':
50+
$this->applyNotEquals($query, $resolved);
51+
break;
52+
53+
case 'lt':
54+
case 'lte':
55+
case 'gt':
56+
case 'gte':
57+
$this->applyComparison($query, $operator, $resolved);
58+
break;
59+
60+
case 'like':
61+
$this->applyLike($query, $resolved);
62+
break;
63+
64+
default:
65+
throw new BadRequestException("Unsupported operator: $operator");
4666
}
67+
}
4768

48-
if ($this->asNumber) {
49-
$this->filterNumber($query, $value);
50-
} else {
51-
$query->whereIn($this->getColumn(), (array) $value);
69+
private function splitCommaSeparated(array|string $value): array|string
70+
{
71+
if ($this->commaSeparated && is_string($value)) {
72+
return explode(',', $value);
5273
}
74+
75+
return $value;
5376
}
5477

55-
private function filterNumber(object $query, array|string $value): void
78+
private function applyEquals(object $query, array|string $value): void
5679
{
57-
$query->where(function ($query) use ($value) {
58-
foreach ((array) $value as $v) {
59-
$query->orWhere(function ($query) use ($v) {
60-
if (preg_match('/(.+)\.\.(.+)/', $v, $matches)) {
61-
if ($matches[1] !== '*') {
62-
$query->where($this->getColumn(), '>=', $matches[1]);
63-
}
64-
if ($matches[2] !== '*') {
65-
$query->where($this->getColumn(), '<=', $matches[2]);
66-
}
67-
return;
68-
}
69-
70-
foreach (['>=', '>', '<=', '<'] as $operator) {
71-
if (str_starts_with($v, $operator)) {
72-
$query->where(
73-
$this->getColumn(),
74-
$operator,
75-
substr($v, strlen($operator)),
76-
);
77-
return;
78-
}
79-
}
80-
81-
$query->where($this->getColumn(), $v);
82-
});
83-
}
84-
});
80+
$value = $this->splitCommaSeparated($value);
81+
82+
$query->whereIn($this->getColumn(), (array) $value);
83+
}
84+
85+
private function applyNotEquals(object $query, array|string $value): void
86+
{
87+
$value = $this->splitCommaSeparated($value);
88+
89+
$query->whereNotIn($this->getColumn(), (array) $value);
90+
}
91+
92+
private function applyComparison(object $query, string $operator, array|string $value): void
93+
{
94+
$value = $this->firstValue($value);
95+
96+
$query->where(
97+
$this->getColumn(),
98+
['lt' => '<', 'lte' => '<=', 'gt' => '>', 'gte' => '>='][$operator],
99+
$value,
100+
);
101+
}
102+
103+
private function applyLike(object $query, array|string $value): void
104+
{
105+
$value = $this->firstValue($value);
106+
107+
$query->where($this->getColumn(), 'like', $value);
108+
}
109+
110+
private function firstValue(array|string $value): mixed
111+
{
112+
if (is_array($value)) {
113+
return $value[0] ?? null;
114+
}
115+
116+
return $value;
85117
}
86118
}

src/Laravel/Filter/WhereDoesntHave.php

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

0 commit comments

Comments
 (0)