Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/filters #100

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@
composer.lock
build/
.coveralls.yml
.settings
.project
.buildpath
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
- [PUT](#put)
- [PATCH](#patch)
- [DELETE](#delete)
- [GET Query Params: include, fields, sort and page](#get-query-params-include-fields-sort-and-page)
- [GET Query Params: include, fields, sort, page and filter](#get-query-params-include-fields-sort-page-and-filter)
- [POST/PUT/PATCH with Relationships](#postputpatch-with-relationships)
- [Custom Response Headers](#custom-response-headers)
- [Common Errors and Solutions](#common-errors-and-solutions)
Expand Down Expand Up @@ -1025,7 +1025,7 @@ And notice how response will be empty:

<br>

## GET Query Params: include, fields, sort and page
## GET Query Params: include, fields, sort, page and filter

According to the standard, for GET method, it is possible to:
- Show only those fields requested using `fields`query parameter.
Expand Down Expand Up @@ -1088,6 +1088,23 @@ For instance: `/employees?sort=surname,-first_name`

For instance: `/employees?page[number]=1&page[size]=10`

- The standard specifies that the `filter` query parameter can be provided in order to restrict the list of items that will be
returned. While standard doesn't specify how the contents of the `filter` parameter should be structured we've provided support
for the a subset of operators from the Resource Query Language (RQL) allowing you to form complex queries such as
*all employees whose surname starts with the letter 'b' and whose job title is not 'developer' or 'tester'*. This implementation
supports the following RQL operators:

- Scalar operators: `eq`, `ne`, `lt`, `le`, `gt`, `ge` and `like`
- Array operators: `in` and `out`
- Logical operators: `and`, `or` and `not`

For instance: `/employees?filter=and(like(surname,b*),not(or(eq(job_title,developer),eq(job_title,tester))))`
Or alternately:`/employees?filter=(like(surname,b*)%26not(eq(job_title,developer)|eq(job_title,tester)))`

More information on RQL including how operators and values should be encoded in a query string can be found at the
[RQL Parser](https://github.com/xiag-ag/rql-parser) project on GitHub.


## POST/PUT/PATCH with Relationships

The JSON API allows resource creation and modification and passing in `relationships` that will create or alter existing resources too.
Expand Down
18 changes: 13 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@
}
],
"require": {
"nilportugues/json-api": "^2.4",
"nilportugues/json-api": "dev-feature/filters",
"symfony/psr-http-message-bridge": "~0.1",
"nilportugues/serializer-eloquent": "~1.0"
"nilportugues/serializer-eloquent": "~1.0",
"xiag/rql-parser": "^2.0"
},
"require-dev": {
"laravel/laravel": "5.*",
"laravel/laravel": "5.2.*",
"laravel/lumen": "^5.2",
"phpunit/phpunit": "4.*",
"friendsofphp/php-cs-fixer": "^1.10"
"friendsofphp/php-cs-fixer": "^1.10",
"xiag/rql-command": "^2.0"
},
"autoload": {
"psr-4": {
Expand All @@ -35,5 +37,11 @@
},
"config": {
"preferred-install": "dist"
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/srottem/php-json-api"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
use NilPortugues\Laravel5\JsonApi\Actions\GetResource;
use NilPortugues\Laravel5\JsonApi\Actions\ListResource;
use Symfony\Component\HttpFoundation\Response;
use Xiag\Rql\Parser\Lexer;
use Xiag\Rql\Parser\Token;

/**
* Class JsonApiController.
Expand All @@ -45,10 +47,11 @@ public function index()
$fields = $apiRequest->getFields();
$sorting = $apiRequest->getSort();
$included = $apiRequest->getIncludedRelationships();
$filters = $apiRequest->getFilters();
$filters = $apiRequest->getRawFilter();
Copy link
Author

@srottem srottem Sep 24, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getRawFilter has been implemented on a modified version of the php-json-api package that I've rolled which returns the entire and unmodified text of the filter parameter in the URL. The method uses the $_SERVER['QUERY_STRING'] value to obtain the value as the ServerRequestInterface object's getQueryParams() method has run the values through urldecode which makes returning original filters provided by the user in URLs generated by the controller near impossible.


$resource = new ListResource($this->serializer, $page, $fields, $sorting, $included, $filters);

$this->createQuery(urldecode($filters));
$totalAmount = $this->totalAmountResourceCallable();
$results = $this->listResourceCallable();

Expand Down
43 changes: 38 additions & 5 deletions src/NilPortugues/Laravel5/JsonApi/Controller/JsonApiTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use NilPortugues\Laravel5\JsonApi\Actions\PatchResource;
use NilPortugues\Laravel5\JsonApi\Actions\PutResource;
use NilPortugues\Api\JsonApi\Server\Errors\Error;
use NilPortugues\Api\JsonApi\Server\Errors\ErrorBag;
use NilPortugues\Laravel5\JsonApi\Actions\PatchResource;
use NilPortugues\Laravel5\JsonApi\Actions\PutResource;
use NilPortugues\Laravel5\JsonApi\Eloquent\EloquentHelper;
use NilPortugues\Laravel5\JsonApi\Eloquent\EloquentNodeVisitor;
use NilPortugues\Laravel5\JsonApi\JsonApiSerializer;
use Symfony\Component\HttpFoundation\Response;
use Xiag\Rql\Parser\Lexer;
use Xiag\Rql\Parser\Parser;

trait JsonApiTrait
{
Expand All @@ -34,6 +37,11 @@ trait JsonApiTrait
*/
protected $pageSize = 10;

/**
* @var \Illuminate\Database\Eloquent\Builder
*/
protected $query;

/**
* @param JsonApiSerializer $serializer
*/
Expand Down Expand Up @@ -63,7 +71,7 @@ protected function totalAmountResourceCallable()
return function () {
$idKey = $this->getDataModel()->getKeyName();

return $this->getDataModel()->query()->count([$idKey]);
return $this->query->count([$idKey]);
};
}

Expand All @@ -74,6 +82,29 @@ protected function totalAmountResourceCallable()
*/
abstract public function getDataModel();

/**
* Creates the query to use for obtaining the resources to return.
*
* @param array $filter
*/
protected function createQuery($filter)
{
$queryBuilder = $this->getDataModel()->query();

if (isset($filter)) {
$lexer = new Lexer();
$parser = new Parser();

$tokens = $lexer->tokenize($filter);
$rqlQuery = $parser->parse($tokens);

$nodeVisitor = new EloquentNodeVisitor();
$nodeVisitor->visit($rqlQuery, $queryBuilder->getQuery());
}

$this->query = $queryBuilder;
}

/**
* Returns a list of resources based on pagination criteria.
*
Expand All @@ -83,7 +114,7 @@ abstract public function getDataModel();
protected function listResourceCallable()
{
return function () {
return EloquentHelper::paginate($this->serializer, $this->getDataModel()->query(), $this->pageSize)->get();
return EloquentHelper::paginate($this->serializer, $this->query, $this->pageSize)->get();
};
}

Expand Down Expand Up @@ -146,6 +177,7 @@ protected function createResourceCallable()
/**
* @param Request $request
* @param $id
*
* @return Response
*/
protected function putAction(Request $request, $id)
Expand Down Expand Up @@ -187,6 +219,7 @@ protected function updateResourceCallable()
/**
* @param Request $request
* @param $id
*
* @return Response
*/
protected function patchAction(Request $request, $id)
Expand All @@ -201,7 +234,7 @@ protected function patchAction(Request $request, $id)
if (array_key_exists('attributes', $data) && $model->timestamps) {
$data['attributes'][$model::UPDATED_AT] = Carbon::now()->toDateTimeString();
}

return $this->addHeaders(
$resource->get($id, $data, get_class($model), $find, $update)
);
Expand Down
167 changes: 167 additions & 0 deletions src/NilPortugues/Laravel5/JsonApi/Eloquent/EloquentNodeVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

namespace NilPortugues\Laravel5\JsonApi\Eloquent;

use Illuminate\Database\Query\Builder;
use Xiag\Rql\Parser\Glob;
use Xiag\Rql\Parser\Node\AbstractQueryNode;
use Xiag\Rql\Parser\Node\Query\AbstractArrayOperatorNode;
use Xiag\Rql\Parser\Node\Query\AbstractLogicalOperatorNode;
use Xiag\Rql\Parser\Node\Query\AbstractScalarOperatorNode;
use Xiag\Rql\Parser\Query;

/**
* RQL node visitor for constructing Eloquent queries.
*
* @author srottem
*/
class EloquentNodeVisitor
{
/**
* Populates the provided builder from the provided RQL query instance.
*
* @param Query $query The RQL query to populate the Eloquent builder from
* @param Builder $builder The Eloquent query builder to populate
*/
public function visit(Query $query, Builder $builder)
{
if ($query->getQuery() !== null) {
$this->visitQueryNode($query->getQuery(), $builder);
}
}

/**
* Processes a query node.
*
* @param AbstractQueryNode $node The node to process
* @param Builder $builder The Eloquent builder to populate
* @param string $boolean The operator to use when appending where clauses
*
* @throws \LogicException Thrown if the node is of an unknown type
*/
private function visitQueryNode(AbstractQueryNode $node, Builder $builder, $boolean = 'and')
{
if ($node instanceof AbstractScalarOperatorNode) {
$this->visitScalarNode($node, $builder, $boolean);
} elseif ($node instanceof AbstractArrayOperatorNode) {
$this->visitArrayNode($node, $builder, $boolean);
} elseif ($node instanceof AbstractLogicalOperatorNode) {
$this->visitLogicalNode($node, $builder, $boolean);
} else {
throw new \LogicException(sprintf('Unknown node "%s"', $node->getNodeName()));
}
}

/**
* Processes a scalar node.
*
* @param AbstractScalarOperatorNode $node The node to process
* @param Builder $builder The Eloquent builder to populate
* @param unknown $boolean The operator to use when appending where clauses
*
* @throws \LogicException Thrown if the node cannot be processed
*/
private function visitScalarNode(AbstractScalarOperatorNode $node, Builder $builder, $boolean)
{
static $operators = [
'like' => 'LIKE',
'eq' => '=',
'ne' => '<>',
'lt' => '<',
'gt' => '>',
'le' => '<=',
'ge' => '>=',
];

if (!isset($operators[$node->getNodeName()])) {
throw new \LogicException(sprintf('Unknown scalar node "%s"', $node->getNodeName()));
}

$value = $node->getValue();

if ($value instanceof Glob) {
$value = $value->toLike();
} elseif ($value instanceof \DateTimeInterface) {
$value = $value->format(DATE_ISO8601);
}

if ($value === null) {
if ($node->getNodeName() === 'eq') {
$builder->whereNull($node->getField(), $boolean);
} elseif ($node->getNodeName() === 'ne') {
$builder->whereNotNull($node->getField(), $boolean);
} else {
throw new \LogicException(sprintf("Only the 'eq' an 'ne' operators can be used when comparing to 'null()'."));
}
} else {
$builder->where(
$node->getField(),
$operators[$node->getNodeName()],
$value,
$boolean
);
}
}

/**
* Processes an array node.
*
* @param AbstractArrayOperatorNode $node The node to process
* @param Builder $builder The Eloquent builder to populate
* @param unknown $boolean The operator to use when appending where clauses
*
* @throws \LogicException Thrown if the node cannot be processed
*/
private function visitArrayNode(AbstractArrayOperatorNode $node, Builder $builder, $boolean)
{
static $operators = [
'in',
'out',
];

if (!in_array($node->getNodeName(), $operators)) {
throw new \LogicException(sprintf('Unknown array node "%s"', $node->getNodeName()));
}

$negate = false;

if ($node->getNodeName() === 'out') {
$negate = true;
}

$builder->whereIn(
$node->getField(),
$node->getValues(),
$boolean,
$negate
);
}

/**
* Processes a logical node.
*
* @param AbstractLogicalOperatorNode $node The node to process
* @param Builder $builder The Eloquent builder to populate
* @param unknown $boolean The operator to use when appending where clauses
*
* @throws \LogicException Thrown if the node cannot be processed
*/
private function visitLogicalNode(AbstractLogicalOperatorNode $node, Builder $builder, $boolean)
{
if ($node->getNodeName() === 'and' || $node->getNodeName() === 'or') {
$builder->where(\Closure::bind(function ($constraintGroupBuilder) use ($node) {
foreach ($node->getQueries() as $query) {
$this->visitQueryNode($query, $constraintGroupBuilder, $node->getNodeName());
}
}, $this), null, null, $boolean);
} elseif ($node->getNodeName() === 'not') {
$builder->where(\Closure::bind(function ($constraintGroupBuilder) use ($node, $boolean) {
foreach ($node->getQueries() as $query) {
$this->visitQueryNode($query, $constraintGroupBuilder, $boolean);
}
}, $this), null, null, $boolean.' not');
} else {
throw new \LogicException(sprintf('Unknown or unsupported logical node "%s"', $node->getNodeName()));
}
}
}
4 changes: 2 additions & 2 deletions tests/NilPortugues/App/Controller/EmployeesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ public function getOrdersByEmployee($id)
$fields = $apiRequest->getFields();
$sorting = $apiRequest->getSort();
$included = $apiRequest->getIncludedRelationships();
$filters = $apiRequest->getFilters();

$filters = $apiRequest->getRawFilter();
$resource = new ListResource($this->serializer, $page, $fields, $sorting, $included, $filters);

$totalAmount = function () use ($id) {
Expand Down
5 changes: 5 additions & 0 deletions tests/NilPortugues/App/Transformers/EmployeesTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,9 @@ public function getRelationships()
{
return [];
}

public function getRequiredProperties()
{
return [];
}
}
Loading