Expose scope, filter, and sort operations on ResourceType

This commit is contained in:
Toby Zerner 2021-08-29 15:43:35 +10:00
parent 416a9c80b0
commit aa2754d458
2 changed files with 113 additions and 90 deletions

View File

@ -17,13 +17,10 @@ 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 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;
use Tobyz\JsonApiServer\ResourceType; use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Schema\Attribute;
use Tobyz\JsonApiServer\Schema\Meta;
use Tobyz\JsonApiServer\Serializer; use Tobyz\JsonApiServer\Serializer;
use function Tobyz\JsonApiServer\evaluate; use function Tobyz\JsonApiServer\evaluate;
@ -48,15 +45,22 @@ class Index
$query = $adapter->query(); $query = $adapter->query();
$resourceType->scope($query, $context); $resourceType->applyScopes($query, $context);
$include = $this->getInclude($context, $resourceType); $include = $this->getInclude($context, $resourceType);
[$offset, $limit] = $this->paginate($resourceType, $query, $context); [$offset, $limit] = $this->paginate($resourceType, $query, $context);
$this->sort($resourceType, $query, $context);
if ($sortString = $context->getRequest()->getQueryParams()['sort'] ?? $schema->getDefaultSort()) {
$resourceType->applySort($query, $sortString, $context);
}
if ($filter = $context->getRequest()->getQueryParams()['filter'] ?? null) { if ($filter = $context->getRequest()->getQueryParams()['filter'] ?? null) {
$resourceType->filter($query, $filter, $context); if (! is_array($filter)) {
throw (new BadRequestException('filter must be an array'))->setSourceParameter('filter');
}
$resourceType->applyFilters($query, $filter, $context);
} }
run_callbacks($schema->getListeners('listing'), [$query, $context]); run_callbacks($schema->getListeners('listing'), [$query, $context]);
@ -74,21 +78,36 @@ class Index
[$primary, $included] = $serializer->serialize(); [$primary, $included] = $serializer->serialize();
$meta = array_values(array_map(function (Meta $meta) use ($context) { $paginationLinks = $this->buildPaginationLinks(
return new Structure\Meta($meta->getName(), $meta->getValue()($context)); $resourceType,
}, $context->getMeta())); $context->getRequest(),
$offset,
$limit,
count($models),
$total
);
$meta = [
new Structure\Meta('offset', $offset),
new Structure\Meta('limit', $limit),
];
if ($total !== null) {
$meta[] = new Structure\Meta('total', $total);
}
foreach ($context->getMeta() as $item) {
$meta[] = new Structure\Meta($item->getName(), $item->getValue()($context));
}
return json_api_response( return json_api_response(
new Structure\CompoundDocument( new Structure\CompoundDocument(
new Structure\PaginatedCollection( new Structure\PaginatedCollection(
new Structure\Pagination(...$this->buildPaginationLinks($resourceType, $context->getRequest(), $offset, $limit, count($models), $total)), new Structure\Pagination(...$paginationLinks),
new Structure\ResourceCollection(...$primary) new Structure\ResourceCollection(...$primary)
), ),
new Structure\Included(...$included), new Structure\Included(...$included),
new Structure\Link\SelfLink($this->buildUrl($context->getRequest())), new Structure\Link\SelfLink($this->buildUrl($context->getRequest())),
new Structure\Meta('offset', $offset),
new Structure\Meta('limit', $limit),
...($total !== null ? [new Structure\Meta('total', $total)] : []),
...$meta ...$meta
) )
); );
@ -145,55 +164,6 @@ class Index
return $paginationLinks; return $paginationLinks;
} }
private function sort(ResourceType $resourceType, $query, Context $context): void
{
$schema = $resourceType->getSchema();
if (! $sort = $context->getRequest()->getQueryParams()['sort'] ?? $schema->getDefaultSort()) {
return;
}
$adapter = $resourceType->getAdapter();
$sorts = $schema->getSorts();
$fields = $schema->getFields();
foreach ($this->parseSort($sort) as $name => $direction) {
if (isset($sorts[$name]) && evaluate($sorts[$name]->getVisible(), [$context])) {
$sorts[$name]->getCallback()($query, $direction, $context);
continue;
}
if (
isset($fields[$name])
&& $fields[$name] instanceof Attribute
&& evaluate($fields[$name]->getSortable(), [$context])
) {
$adapter->sortByAttribute($query, $fields[$name], $direction);
continue;
}
throw (new BadRequestException("Invalid sort field [$name]"))->setSourceParameter('sort');
}
}
private function parseSort(string $string): array
{
$sort = [];
foreach (explode(',', $string) as $field) {
if ($field[0] === '-') {
$field = substr($field, 1);
$direction = 'desc';
} else {
$direction = 'asc';
}
$sort[$field] = $direction;
}
return $sort;
}
private function paginate(ResourceType $resourceType, $query, Context $context): array private function paginate(ResourceType $resourceType, $query, Context $context): array
{ {
$schema = $resourceType->getSchema(); $schema = $resourceType->getSchema();

View File

@ -11,7 +11,6 @@
namespace Tobyz\JsonApiServer; namespace Tobyz\JsonApiServer;
use ReflectionClass;
use Tobyz\JsonApiServer\Adapter\AdapterInterface; use Tobyz\JsonApiServer\Adapter\AdapterInterface;
use Tobyz\JsonApiServer\Exception\BadRequestException; use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Schema\Attribute; use Tobyz\JsonApiServer\Schema\Attribute;
@ -55,62 +54,116 @@ final class ResourceType
return $this->schema; return $this->schema;
} }
public function scope($query, Context $context) /**
* Apply the resource type's scopes to a query.
*/
public function applyScopes($query, Context $context): void
{ {
run_callbacks($this->getSchema()->getListeners('scope'), [$query, $context]); run_callbacks(
$this->getSchema()->getListeners('scope'),
[$query, $context]
);
} }
public function filter($query, $filter, Context $context): void /**
* Apply the resource type's filters to a query.
*/
public function applySort($query, string $sortString, Context $context): void
{ {
if (! is_array($filter)) {
throw (new BadRequestException('filter must be an array'))->setSourceParameter('filter');
}
$schema = $this->getSchema(); $schema = $this->getSchema();
$adapter = $this->getAdapter(); $customSorts = $schema->getSorts();
$filters = $schema->getFilters();
$fields = $schema->getFields(); $fields = $schema->getFields();
foreach ($filter as $name => $value) { foreach ($this->parseSortString($sortString) as [$name, $direction]) {
if ($name === 'id') { if (
$adapter->filterByIds($query, explode(',', $value)); isset($customSorts[$name])
&& evaluate($customSorts[$name]->getVisible(), [$context])
) {
$customSorts[$name]->getCallback()($query, $direction, $context);
continue; continue;
} }
if (isset($filters[$name]) && evaluate($filters[$name]->getVisible(), [$context])) { $field = $fields[$name] ?? null;
$filters[$name]->getCallback()($query, $value, $context);
if (
$field instanceof Attribute
&& evaluate($field->getSortable(), [$context])
) {
$this->adapter->sortByAttribute($query, $field, $direction);
continue;
}
throw (new BadRequestException("Invalid sort field: $name"))->setSourceParameter('sort');
}
}
private function parseSortString(string $string): array
{
return array_map(function ($field) {
if ($field[0] === '-') {
return [substr($field, 1), 'desc'];
} else {
return [$field, 'asc'];
}
}, explode(',', $string));
}
/**
* Apply the resource type's filters to a query.
*/
public function applyFilters($query, array $filters, Context $context): void
{
$schema = $this->getSchema();
$customFilters = $schema->getFilters();
$fields = $schema->getFields();
foreach ($filters as $name => $value) {
if ($name === 'id') {
$this->adapter->filterByIds($query, explode(',', $value));
continue;
}
if (
isset($customFilters[$name])
&& evaluate($customFilters[$name]->getVisible(), [$context])
) {
$customFilters[$name]->getCallback()($query, $value, $context);
continue; continue;
} }
[$name, $sub] = explode('.', $name, 2) + [null, null]; [$name, $sub] = explode('.', $name, 2) + [null, null];
$field = $fields[$name] ?? null;
if (isset($fields[$name]) && evaluate($fields[$name]->getFilterable(), [$context])) { if ($field && evaluate($field->getFilterable(), [$context])) {
if ($fields[$name] instanceof Attribute && $sub === null) { if ($field instanceof Attribute && $sub === null) {
$this->filterByAttribute($adapter, $query, $fields[$name], $value); $this->filterByAttribute($query, $field, $value);
continue; continue;
} elseif ($fields[$name] instanceof Relationship) { }
if (is_string($relatedType = $fields[$name]->getType())) {
if ($field instanceof Relationship) {
if (is_string($relatedType = $field->getType())) {
$relatedResource = $context->getApi()->getResourceType($relatedType); $relatedResource = $context->getApi()->getResourceType($relatedType);
$adapter->filterByRelationship($query, $fields[$name], function ($query) use ($relatedResource, $sub, $value, $context) {
$relatedResource->filter($query, [($sub ?? 'id') => $value], $context); $this->adapter->filterByRelationship($query, $field, function ($query) use ($relatedResource, $sub, $value, $context) {
$relatedResource->applyFilters($query, [($sub ?? 'id') => $value], $context);
}); });
} }
continue; continue;
} }
} }
throw (new BadRequestException("Invalid filter [$name]"))->setSourceParameter("filter[$name]"); throw (new BadRequestException("Invalid filter: $name"))->setSourceParameter("filter[$name]");
} }
} }
private function filterByAttribute(AdapterInterface $adapter, $query, Attribute $attribute, $value): void private function filterByAttribute($query, Attribute $attribute, $value): void
{ {
if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) { if (preg_match('/(.+)\.\.(.+)/', $value, $matches)) {
if ($matches[1] !== '*') { if ($matches[1] !== '*') {
$adapter->filterByAttribute($query, $attribute, $value, '>='); $this->adapter->filterByAttribute($query, $attribute, $value, '>=');
} }
if ($matches[2] !== '*') { if ($matches[2] !== '*') {
$adapter->filterByAttribute($query, $attribute, $value, '<='); $this->adapter->filterByAttribute($query, $attribute, $value, '<=');
} }
return; return;
@ -118,12 +171,12 @@ final class ResourceType
foreach (['>=', '>', '<=', '<'] as $operator) { foreach (['>=', '>', '<=', '<'] as $operator) {
if (strpos($value, $operator) === 0) { if (strpos($value, $operator) === 0) {
$adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator); $this->adapter->filterByAttribute($query, $attribute, substr($value, strlen($operator)), $operator);
return; return;
} }
} }
$adapter->filterByAttribute($query, $attribute, $value); $this->adapter->filterByAttribute($query, $attribute, $value);
} }
} }