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 = <<