diff --git a/.gitignore b/.gitignore
index a2b4c51..4d275c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@
composer.lock
build/
.coveralls.yml
+.settings
+.project
+.buildpath
diff --git a/README.md b/README.md
index edd9bdc..45635ab 100644
--- a/README.md
+++ b/README.md
@@ -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)
@@ -1025,7 +1025,7 @@ And notice how response will be empty:
-## 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.
@@ -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.
diff --git a/composer.json b/composer.json
index 7b30476..7a474f6 100644
--- a/composer.json
+++ b/composer.json
@@ -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": {
@@ -35,5 +37,11 @@
},
"config": {
"preferred-install": "dist"
- }
+ },
+ "repositories": [
+ {
+ "type": "vcs",
+ "url": "https://github.com/srottem/php-json-api"
+ }
+ ]
}
diff --git a/src/NilPortugues/Laravel5/JsonApi/Controller/JsonApiController.php b/src/NilPortugues/Laravel5/JsonApi/Controller/JsonApiController.php
index 713b6d5..d7b50a2 100644
--- a/src/NilPortugues/Laravel5/JsonApi/Controller/JsonApiController.php
+++ b/src/NilPortugues/Laravel5/JsonApi/Controller/JsonApiController.php
@@ -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.
@@ -45,10 +47,11 @@ public function index()
$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);
+ $this->createQuery(urldecode($filters));
$totalAmount = $this->totalAmountResourceCallable();
$results = $this->listResourceCallable();
diff --git a/src/NilPortugues/Laravel5/JsonApi/Controller/JsonApiTrait.php b/src/NilPortugues/Laravel5/JsonApi/Controller/JsonApiTrait.php
index 133f49a..0500cb0 100644
--- a/src/NilPortugues/Laravel5/JsonApi/Controller/JsonApiTrait.php
+++ b/src/NilPortugues/Laravel5/JsonApi/Controller/JsonApiTrait.php
@@ -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
{
@@ -34,6 +37,11 @@ trait JsonApiTrait
*/
protected $pageSize = 10;
+ /**
+ * @var \Illuminate\Database\Eloquent\Builder
+ */
+ protected $query;
+
/**
* @param JsonApiSerializer $serializer
*/
@@ -63,7 +71,7 @@ protected function totalAmountResourceCallable()
return function () {
$idKey = $this->getDataModel()->getKeyName();
- return $this->getDataModel()->query()->count([$idKey]);
+ return $this->query->count([$idKey]);
};
}
@@ -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.
*
@@ -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();
};
}
@@ -146,6 +177,7 @@ protected function createResourceCallable()
/**
* @param Request $request
* @param $id
+ *
* @return Response
*/
protected function putAction(Request $request, $id)
@@ -187,6 +219,7 @@ protected function updateResourceCallable()
/**
* @param Request $request
* @param $id
+ *
* @return Response
*/
protected function patchAction(Request $request, $id)
@@ -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)
);
diff --git a/src/NilPortugues/Laravel5/JsonApi/Eloquent/EloquentNodeVisitor.php b/src/NilPortugues/Laravel5/JsonApi/Eloquent/EloquentNodeVisitor.php
new file mode 100644
index 0000000..a44ccc1
--- /dev/null
+++ b/src/NilPortugues/Laravel5/JsonApi/Eloquent/EloquentNodeVisitor.php
@@ -0,0 +1,167 @@
+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()));
+ }
+ }
+}
diff --git a/tests/NilPortugues/App/Controller/EmployeesController.php b/tests/NilPortugues/App/Controller/EmployeesController.php
index 47e0c2f..a66cbe4 100644
--- a/tests/NilPortugues/App/Controller/EmployeesController.php
+++ b/tests/NilPortugues/App/Controller/EmployeesController.php
@@ -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) {
diff --git a/tests/NilPortugues/App/Transformers/EmployeesTransformer.php b/tests/NilPortugues/App/Transformers/EmployeesTransformer.php
index d80717d..4e8391c 100644
--- a/tests/NilPortugues/App/Transformers/EmployeesTransformer.php
+++ b/tests/NilPortugues/App/Transformers/EmployeesTransformer.php
@@ -98,4 +98,9 @@ public function getRelationships()
{
return [];
}
+
+ public function getRequiredProperties()
+ {
+ return [];
+ }
}
diff --git a/tests/NilPortugues/App/Transformers/OrdersTransformer.php b/tests/NilPortugues/App/Transformers/OrdersTransformer.php
index cc7a2c3..d0ddb5f 100644
--- a/tests/NilPortugues/App/Transformers/OrdersTransformer.php
+++ b/tests/NilPortugues/App/Transformers/OrdersTransformer.php
@@ -93,4 +93,9 @@ public function getRelationships()
{
return [];
}
+
+ public function getRequiredProperties()
+ {
+ return [];
+ }
}
diff --git a/tests/NilPortugues/Laravel5/JsonApi/JsonApiControllerTest.php b/tests/NilPortugues/Laravel5/JsonApi/JsonApiControllerTest.php
index ec0f9cf..d9feeb9 100644
--- a/tests/NilPortugues/Laravel5/JsonApi/JsonApiControllerTest.php
+++ b/tests/NilPortugues/Laravel5/JsonApi/JsonApiControllerTest.php
@@ -31,7 +31,7 @@ public function testListActionCanSort()
$this->assertContains('&sort=-id', $response->getContent());
}
- public function testListActionCanFilterMembers()
+ public function testListActionCanRetrieveSparseFieldsets()
{
$this->call('GET', 'http://localhost/employees?fields[employee]=company,first_name');
$response = $this->response;
@@ -41,6 +41,16 @@ public function testListActionCanFilterMembers()
$this->assertContains('&fields[employee]=company,first_name', $response->getContent());
}
+ public function testListActionCanFilter()
+ {
+ $this->call('GET', 'http://localhost/employees?filter=ne(a,b)');
+ $response = $this->response;
+
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEquals('application/vnd.api+json', $response->headers->get('Content-type'));
+ $this->assertContains('&filter=ne(a,b)', $response->getContent());
+ }
+
public function testListAction()
{
$response = $this->call('GET', 'http://localhost/employees');
@@ -62,7 +72,7 @@ public function testPatchAction()
{
$this->createNewEmployee();
- $content = <<call('PATCH', 'http://localhost/employees/1', json_decode($content, true), [], [], [], '');
-
+
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('application/vnd.api+json', $response->headers->get('Content-type'));
}
@@ -82,7 +92,7 @@ public function testPutAction()
{
$this->createNewEmployee();
- $content = <<app['config']->set('app.debug', false);
- $content = <<app['config']->set('app.debug', false);
- $content = <<app['config']->set('app.debug', false);
- $content = <<app['config']->set('app.debug', false);
- $content = <<