Support filtering by nested relationships/attributes

ie. support `filter[relationship.attribute]=value`
This commit is contained in:
Toby Zerner 2021-07-22 12:30:22 +09:30
parent f2aac7f78e
commit 4fe4efc036
4 changed files with 90 additions and 94 deletions

View File

@ -53,26 +53,26 @@ interface AdapterInterface
public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void; public function filterByAttribute($query, Attribute $attribute, $value, string $operator = '='): void;
/** /**
* Manipulate the query to only include resources with any one of the given * Manipulate the query to only include resources with a has-one
* resource IDs in a has-one relationship. * relationship within the given scope.
* *
* @param $query * @param $query
* @param HasOne $relationship * @param HasOne $relationship
* @param array $ids * @param Closure $scope
* @return mixed * @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 * Manipulate the query to only include resources with a has-many
* resource IDs in a has-many relationship. * relationship within the given scope.
* *
* @param $query * @param $query
* @param HasMany $relationship * @param HasMany $relationship
* @param array $ids * @param Closure $scope
* @return mixed * @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. * Manipulate the query to sort by the given attribute in the given direction.

View File

@ -11,6 +11,7 @@
namespace Tobyz\JsonApiServer\Adapter; namespace Tobyz\JsonApiServer\Adapter;
use Closure;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -164,34 +165,21 @@ class EloquentAdapter implements AdapterInterface
$query->where($column, $operator, $value); $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); $this->filterByRelationship($query, $relationship, $scope);
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);
}
} }
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); $property = $this->getRelationshipProperty($relationship);
$relation = $this->getEloquentRelation($query->getModel(), $relationship);
$relatedKey = $relation->getRelated()->getQualifiedKeyName();
if (count($ids)) { $query->whereHas($property, $scope);
foreach ($ids as $id) {
$query->whereHas($property, function ($query) use ($relatedKey, $id) {
$query->where($relatedKey, $id);
});
}
} else {
$query->whereDoesntHave($property);
}
} }
public function sortByAttribute($query, Attribute $attribute, string $direction): void public function sortByAttribute($query, Attribute $attribute, string $direction): void

View File

@ -17,6 +17,8 @@ use JsonApiPhp\JsonApi\Link\NextLink;
use JsonApiPhp\JsonApi\Link\PrevLink; use JsonApiPhp\JsonApi\Link\PrevLink;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use ReflectionClass;
use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Context; use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ForbiddenException; use Tobyz\JsonApiServer\Exception\ForbiddenException;
@ -24,6 +26,7 @@ use Tobyz\JsonApiServer\JsonApi;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\Meta; use Tobyz\JsonApiServer\Schema\Meta;
use Tobyz\JsonApiServer\Schema\Relationship;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\json_api_response; use function Tobyz\JsonApiServer\json_api_response;
@ -64,7 +67,7 @@ class Index
$this->sort($query, $context); $this->sort($query, $context);
if ($filter = $context->getRequest()->getQueryParams()['filter'] ?? null) { 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]); run_callbacks($schema->getListeners('listing'), [$query, $context]);
@ -234,4 +237,72 @@ class Index
return [$offset, $limit]; 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);
}
} }

View File

@ -59,67 +59,4 @@ final class ResourceType
{ {
run_callbacks($this->getSchema()->getListeners('scope'), [$query, $context]); 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);
}
} }