Skip to content

Commit 4fe4efc

Browse files
committed
Support filtering by nested relationships/attributes
ie. support `filter[relationship.attribute]=value`
1 parent f2aac7f commit 4fe4efc

File tree

4 files changed

+91
-95
lines changed

4 files changed

+91
-95
lines changed

src/Adapter/AdapterInterface.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,26 +53,26 @@ public function filterByIds($query, array $ids): void;
5353
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void;
5454

5555
/**
56-
* Manipulate the query to only include resources with any one of the given
57-
* resource IDs in a has-one relationship.
56+
* Manipulate the query to only include resources with a has-one
57+
* relationship within the given scope.
5858
*
5959
* @param $query
6060
* @param HasOne $relationship
61-
* @param array $ids
61+
* @param Closure $scope
6262
* @return mixed
6363
*/
64-
public function filterByHasOne($query, HasOne $relationship, array $ids): void;
64+
public function filterByHasOne($query, HasOne $relationship, Closure $scope): void;
6565

6666
/**
67-
* Manipulate the query to only include resources with any one of the given
68-
* resource IDs in a has-many relationship.
67+
* Manipulate the query to only include resources with a has-many
68+
* relationship within the given scope.
6969
*
7070
* @param $query
7171
* @param HasMany $relationship
72-
* @param array $ids
72+
* @param Closure $scope
7373
* @return mixed
7474
*/
75-
public function filterByHasMany($query, HasMany $relationship, array $ids): void;
75+
public function filterByHasMany($query, HasMany $relationship, Closure $scope): void;
7676

7777
/**
7878
* Manipulate the query to sort by the given attribute in the given direction.

src/Adapter/EloquentAdapter.php

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Tobyz\JsonApiServer\Adapter;
1313

14+
use Closure;
1415
use Illuminate\Database\Eloquent\Collection;
1516
use Illuminate\Database\Eloquent\Model;
1617
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -164,34 +165,21 @@ public function filterByAttribute($query, Attribute $attribute, $value, string $
164165
$query->where($column, $operator, $value);
165166
}
166167

167-
public function filterByHasOne($query, HasOne $relationship, array $ids): void
168+
public function filterByHasOne($query, HasOne $relationship, Closure $scope): void
168169
{
169-
$relation = $this->getEloquentRelation($query->getModel(), $relationship);
170-
171-
if ($relation instanceof HasOneThrough) {
172-
$query->whereHas($this->getRelationshipProperty($relationship), function ($query) use ($relation, $ids) {
173-
$query->whereIn($relation->getQualifiedParentKeyName(), $ids);
174-
});
175-
} else {
176-
$query->whereIn($relation->getQualifiedForeignKeyName(), $ids);
177-
}
170+
$this->filterByRelationship($query, $relationship, $scope);
171+
}
172+
173+
public function filterByHasMany($query, HasMany $relationship, Closure $scope): void
174+
{
175+
$this->filterByRelationship($query, $relationship, $scope);
178176
}
179177

180-
public function filterByHasMany($query, HasMany $relationship, array $ids): void
178+
private function filterByRelationship($query, Relationship $relationship, Closure $scope): void
181179
{
182180
$property = $this->getRelationshipProperty($relationship);
183-
$relation = $this->getEloquentRelation($query->getModel(), $relationship);
184-
$relatedKey = $relation->getRelated()->getQualifiedKeyName();
185-
186-
if (count($ids)) {
187-
foreach ($ids as $id) {
188-
$query->whereHas($property, function ($query) use ($relatedKey, $id) {
189-
$query->where($relatedKey, $id);
190-
});
191-
}
192-
} else {
193-
$query->whereDoesntHave($property);
194-
}
181+
182+
$query->whereHas($property, $scope);
195183
}
196184

197185
public function sortByAttribute($query, Attribute $attribute, string $direction): void

src/Endpoint/Index.php

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@
1717
use JsonApiPhp\JsonApi\Link\PrevLink;
1818
use Psr\Http\Message\ResponseInterface;
1919
use Psr\Http\Message\ServerRequestInterface as Request;
20+
use ReflectionClass;
21+
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
2022
use Tobyz\JsonApiServer\Context;
2123
use Tobyz\JsonApiServer\Exception\BadRequestException;
2224
use Tobyz\JsonApiServer\Exception\ForbiddenException;
2325
use Tobyz\JsonApiServer\JsonApi;
2426
use Tobyz\JsonApiServer\ResourceType;
2527
use Tobyz\JsonApiServer\Schema\Attribute;
2628
use Tobyz\JsonApiServer\Schema\Meta;
29+
use Tobyz\JsonApiServer\Schema\Relationship;
2730
use Tobyz\JsonApiServer\Serializer;
2831
use function Tobyz\JsonApiServer\evaluate;
2932
use function Tobyz\JsonApiServer\json_api_response;
@@ -64,7 +67,7 @@ public function handle(Context $context): ResponseInterface
6467
$this->sort($query, $context);
6568

6669
if ($filter = $context->getRequest()->getQueryParams()['filter'] ?? null) {
67-
$this->resource->filter($query, $filter, $context);
70+
$this->filter($this->resource, $query, $filter, $context);
6871
}
6972

7073
run_callbacks($schema->getListeners('listing'), [$query, $context]);
@@ -234,4 +237,72 @@ private function paginate($query, Context $context): array
234237

235238
return [$offset, $limit];
236239
}
240+
241+
private function filter(ResourceType $resource, $query, $filter, Context $context): void
242+
{
243+
if (! is_array($filter)) {
244+
throw new BadRequestException('filter must be an array', 'filter');
245+
}
246+
247+
$schema = $resource->getSchema();
248+
$adapter = $resource->getAdapter();
249+
$filters = $schema->getFilters();
250+
$fields = $schema->getFields();
251+
252+
foreach ($filter as $name => $value) {
253+
if ($name === 'id') {
254+
$adapter->filterByIds($query, explode(',', $value));
255+
continue;
256+
}
257+
258+
if (isset($filters[$name]) && evaluate($filters[$name]->getVisible(), [$context])) {
259+
$filters[$name]->getCallback()($query, $value, $context);
260+
continue;
261+
}
262+
263+
[$name, $sub] = explode('.', $name, 2) + [null, null];
264+
265+
if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) {
266+
if ($fields[$name] instanceof Attribute && $sub === null) {
267+
$this->filterByAttribute($adapter, $query, $fields[$name], $value);
268+
continue;
269+
} elseif ($fields[$name] instanceof Relationship) {
270+
if (is_string($relatedType = $fields[$name]->getType())) {
271+
$relatedResource = $this->api->getResource($relatedType);
272+
$method = 'filterBy'.(new ReflectionClass($fields[$name]))->getShortName();
273+
$adapter->$method($query, $fields[$name], function ($query) use ($relatedResource, $sub, $value, $context) {
274+
$this->filter($relatedResource, $query, [($sub ?? 'id') => $value], $context);
275+
});
276+
}
277+
continue;
278+
}
279+
}
280+
281+
throw new BadRequestException("Invalid filter [$name]", "filter[$name]");
282+
}
283+
}
284+
285+
private function filterByAttribute(AdapterInterface $adapter, $query, Attribute $attribute, $value): void
286+
{
287+
if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) {
288+
if ($matches[1] !== '*') {
289+
$adapter->filterByAttribute($query, $attribute, $value, '>=');
290+
}
291+
if ($matches[2] !== '*') {
292+
$adapter->filterByAttribute($query, $attribute, $value, '<=');
293+
}
294+
295+
return;
296+
}
297+
298+
foreach (['>=', '>', '<=', '<'] as $operator) {
299+
if (strpos($value, $operator) === 0) {
300+
$adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator);
301+
302+
return;
303+
}
304+
}
305+
306+
$adapter->filterByAttribute($query, $attribute, $value);
307+
}
237308
}

src/ResourceType.php

Lines changed: 0 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -59,67 +59,4 @@ public function scope($query, Context $context)
5959
{
6060
run_callbacks($this->getSchema()->getListeners('scope'), [$query, $context]);
6161
}
62-
63-
public function filter($query, $filter, Context $context)
64-
{
65-
if (! is_array($filter)) {
66-
throw new BadRequestException('filter must be an array', 'filter');
67-
}
68-
69-
$schema = $this->getSchema();
70-
$adapter = $this->getAdapter();
71-
$filters = $schema->getFilters();
72-
$fields = $schema->getFields();
73-
74-
foreach ($filter as $name => $value) {
75-
if ($name === 'id') {
76-
$adapter->filterByIds($query, explode(',', $value));
77-
continue;
78-
}
79-
80-
if (isset($filters[$name]) && evaluate($filters[$name]->getVisible(), [$context])) {
81-
$filters[$name]->getCallback()($query, $value, $context);
82-
continue;
83-
}
84-
85-
if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) {
86-
if ($fields[$name] instanceof Attribute) {
87-
$this->filterByAttribute($adapter, $query, $fields[$name], $value);
88-
} elseif ($fields[$name] instanceof HasOne) {
89-
$value = array_filter(explode(',', $value));
90-
$adapter->filterByHasOne($query, $fields[$name], $value);
91-
} elseif ($fields[$name] instanceof HasMany) {
92-
$value = array_filter(explode(',', $value));
93-
$adapter->filterByHasMany($query, $fields[$name], $value);
94-
}
95-
continue;
96-
}
97-
98-
throw new BadRequestException("Invalid filter [$name]", "filter[$name]");
99-
}
100-
}
101-
102-
private function filterByAttribute(AdapterInterface $adapter, $query, Attribute $attribute, $value)
103-
{
104-
if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) {
105-
if ($matches[1] !== '*') {
106-
$adapter->filterByAttribute($query, $attribute, $value, '>=');
107-
}
108-
if ($matches[2] !== '*') {
109-
$adapter->filterByAttribute($query, $attribute, $value, '<=');
110-
}
111-
112-
return;
113-
}
114-
115-
foreach (['>=', '>', '<=', '<'] as $operator) {
116-
if (strpos($value, $operator) === 0) {
117-
$adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator);
118-
119-
return;
120-
}
121-
}
122-
123-
$adapter->filterByAttribute($query, $attribute, $value);
124-
}
12562
}

0 commit comments

Comments
 (0)