From 4fe4efc03635ea7fd57f501c9edd83b50623ca22 Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Thu, 22 Jul 2021 12:30:22 +0930 Subject: [PATCH] Support filtering by nested relationships/attributes ie. support `filter[relationship.attribute]=value` --- src/Adapter/AdapterInterface.php | 16 +++---- src/Adapter/EloquentAdapter.php | 32 +++++--------- src/Endpoint/Index.php | 73 +++++++++++++++++++++++++++++++- src/ResourceType.php | 63 --------------------------- 4 files changed, 90 insertions(+), 94 deletions(-) diff --git a/src/Adapter/AdapterInterface.php b/src/Adapter/AdapterInterface.php index a31ba44..567c6a3 100644 --- a/src/Adapter/AdapterInterface.php +++ b/src/Adapter/AdapterInterface.php @@ -53,26 +53,26 @@ interface AdapterInterface public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void; /** - * Manipulate the query to only include resources with any one of the given - * resource IDs in a has-one relationship. + * Manipulate the query to only include resources with a has-one + * relationship within the given scope. * * @param $query * @param HasOne $relationship - * @param array $ids + * @param Closure $scope * @return mixed */ - public function filterByHasOne($query, HasOne $relationship, array $ids): void; + public function filterByHasOne($query, HasOne $relationship, Closure $scope): void; /** - * Manipulate the query to only include resources with any one of the given - * resource IDs in a has-many relationship. + * Manipulate the query to only include resources with a has-many + * relationship within the given scope. * * @param $query * @param HasMany $relationship - * @param array $ids + * @param Closure $scope * @return mixed */ - public function filterByHasMany($query, HasMany $relationship, array $ids): void; + public function filterByHasMany($query, HasMany $relationship, Closure $scope): void; /** * Manipulate the query to sort by the given attribute in the given direction. diff --git a/src/Adapter/EloquentAdapter.php b/src/Adapter/EloquentAdapter.php index 995eedf..4973e9f 100644 --- a/src/Adapter/EloquentAdapter.php +++ b/src/Adapter/EloquentAdapter.php @@ -11,6 +11,7 @@ namespace Tobyz\JsonApiServer\Adapter; +use Closure; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -164,34 +165,21 @@ class EloquentAdapter implements AdapterInterface $query->where($column, $operator, $value); } - public function filterByHasOne($query, HasOne $relationship, array $ids): void + public function filterByHasOne($query, HasOne $relationship, Closure $scope): void { - $relation = $this->getEloquentRelation($query->getModel(), $relationship); - - if ($relation instanceof HasOneThrough) { - $query->whereHas($this->getRelationshipProperty($relationship), function ($query) use ($relation, $ids) { - $query->whereIn($relation->getQualifiedParentKeyName(), $ids); - }); - } else { - $query->whereIn($relation->getQualifiedForeignKeyName(), $ids); - } + $this->filterByRelationship($query, $relationship, $scope); } - public function filterByHasMany($query, HasMany $relationship, array $ids): void + public function filterByHasMany($query, HasMany $relationship, Closure $scope): void + { + $this->filterByRelationship($query, $relationship, $scope); + } + + private function filterByRelationship($query, Relationship $relationship, Closure $scope): void { $property = $this->getRelationshipProperty($relationship); - $relation = $this->getEloquentRelation($query->getModel(), $relationship); - $relatedKey = $relation->getRelated()->getQualifiedKeyName(); - if (count($ids)) { - foreach ($ids as $id) { - $query->whereHas($property, function ($query) use ($relatedKey, $id) { - $query->where($relatedKey, $id); - }); - } - } else { - $query->whereDoesntHave($property); - } + $query->whereHas($property, $scope); } public function sortByAttribute($query, Attribute $attribute, string $direction): void diff --git a/src/Endpoint/Index.php b/src/Endpoint/Index.php index 04627e3..4e18e87 100644 --- a/src/Endpoint/Index.php +++ b/src/Endpoint/Index.php @@ -17,6 +17,8 @@ use JsonApiPhp\JsonApi\Link\NextLink; use JsonApiPhp\JsonApi\Link\PrevLink; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface as Request; +use ReflectionClass; +use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\ForbiddenException; @@ -24,6 +26,7 @@ use Tobyz\JsonApiServer\JsonApi; use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\Meta; +use Tobyz\JsonApiServer\Schema\Relationship; use Tobyz\JsonApiServer\Serializer; use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\json_api_response; @@ -64,7 +67,7 @@ class Index $this->sort($query, $context); if ($filter = $context->getRequest()->getQueryParams()['filter'] ?? null) { - $this->resource->filter($query, $filter, $context); + $this->filter($this->resource, $query, $filter, $context); } run_callbacks($schema->getListeners('listing'), [$query, $context]); @@ -234,4 +237,72 @@ class Index return [$offset, $limit]; } + + private function filter(ResourceType $resource, $query, $filter, Context $context): void + { + if (! is_array($filter)) { + throw new BadRequestException('filter must be an array', 'filter'); + } + + $schema = $resource->getSchema(); + $adapter = $resource->getAdapter(); + $filters = $schema->getFilters(); + $fields = $schema->getFields(); + + foreach ($filter as $name => $value) { + if ($name === 'id') { + $adapter->filterByIds($query, explode(',', $value)); + continue; + } + + if (isset($filters[$name]) && evaluate($filters[$name]->getVisible(), [$context])) { + $filters[$name]->getCallback()($query, $value, $context); + continue; + } + + [$name, $sub] = explode('.', $name, 2) + [null, null]; + + if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) { + if ($fields[$name] instanceof Attribute && $sub === null) { + $this->filterByAttribute($adapter, $query, $fields[$name], $value); + continue; + } elseif ($fields[$name] instanceof Relationship) { + if (is_string($relatedType = $fields[$name]->getType())) { + $relatedResource = $this->api->getResource($relatedType); + $method = 'filterBy'.(new ReflectionClass($fields[$name]))->getShortName(); + $adapter->$method($query, $fields[$name], function ($query) use ($relatedResource, $sub, $value, $context) { + $this->filter($relatedResource, $query, [($sub ?? 'id') => $value], $context); + }); + } + continue; + } + } + + throw new BadRequestException("Invalid filter [$name]", "filter[$name]"); + } + } + + private function filterByAttribute(AdapterInterface $adapter, $query, Attribute $attribute, $value): void + { + if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) { + if ($matches[1] !== '*') { + $adapter->filterByAttribute($query, $attribute, $value, '>='); + } + if ($matches[2] !== '*') { + $adapter->filterByAttribute($query, $attribute, $value, '<='); + } + + return; + } + + foreach (['>=', '>', '<=', '<'] as $operator) { + if (strpos($value, $operator) === 0) { + $adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator); + + return; + } + } + + $adapter->filterByAttribute($query, $attribute, $value); + } } diff --git a/src/ResourceType.php b/src/ResourceType.php index cc9df44..2cb1607 100644 --- a/src/ResourceType.php +++ b/src/ResourceType.php @@ -59,67 +59,4 @@ final class ResourceType { run_callbacks($this->getSchema()->getListeners('scope'), [$query, $context]); } - - public function filter($query, $filter, Context $context) - { - if (! is_array($filter)) { - throw new BadRequestException('filter must be an array', 'filter'); - } - - $schema = $this->getSchema(); - $adapter = $this->getAdapter(); - $filters = $schema->getFilters(); - $fields = $schema->getFields(); - - foreach ($filter as $name => $value) { - if ($name === 'id') { - $adapter->filterByIds($query, explode(',', $value)); - continue; - } - - if (isset($filters[$name]) && evaluate($filters[$name]->getVisible(), [$context])) { - $filters[$name]->getCallback()($query, $value, $context); - continue; - } - - if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) { - if ($fields[$name] instanceof Attribute) { - $this->filterByAttribute($adapter, $query, $fields[$name], $value); - } elseif ($fields[$name] instanceof HasOne) { - $value = array_filter(explode(',', $value)); - $adapter->filterByHasOne($query, $fields[$name], $value); - } elseif ($fields[$name] instanceof HasMany) { - $value = array_filter(explode(',', $value)); - $adapter->filterByHasMany($query, $fields[$name], $value); - } - continue; - } - - throw new BadRequestException("Invalid filter [$name]", "filter[$name]"); - } - } - - private function filterByAttribute(AdapterInterface $adapter, $query, Attribute $attribute, $value) - { - if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) { - if ($matches[1] !== '*') { - $adapter->filterByAttribute($query, $attribute, $value, '>='); - } - if ($matches[2] !== '*') { - $adapter->filterByAttribute($query, $attribute, $value, '<='); - } - - return; - } - - foreach (['>=', '>', '<=', '<'] as $operator) { - if (strpos($value, $operator) === 0) { - $adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator); - - return; - } - } - - $adapter->filterByAttribute($query, $attribute, $value); - } }