Skip to content

Feature/filters #100

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

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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
8 changes: 5 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
"require": {
"nilportugues/json-api": "^2.4",
"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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,21 @@ public function index()
$included = $apiRequest->getIncludedRelationships();
$filters = $apiRequest->getFilters();

$resource = new ListResource($this->serializer, $page, $fields, $sorting, $included, $filters);
//HACK: JsonAPI is specifying this as an array but it should be a string at this point for RQL processing.
switch (count($filters)) {
case 0:
$filters = null;
break;
case 1:
$filters = $filters[0];
break;
default:
throw new \Exception('Only a single filter is supported at present.');
}

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

Choose a reason for hiding this comment

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

Whitespace found at end of line


$this->createQuery($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
163 changes: 163 additions & 0 deletions src/NilPortugues/Laravel5/JsonApi/Eloquent/EloquentNodeVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?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 $operator 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, $operator = 'and')
{
if ($node instanceof AbstractScalarOperatorNode) {
$this->visitScalarNode($node, $builder, $operator);
} elseif ($node instanceof AbstractArrayOperatorNode) {
$this->visitArrayNode($node, $builder, $operator);
} elseif ($node instanceof AbstractLogicalOperatorNode) {
$this->visitLogicalNode($node, $builder, $operator);
} 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 $operator The operator to use when appending where clauses
*
* @throws \LogicException Thrown if the node cannot be processed
*/
private function visitScalarNode(AbstractScalarOperatorNode $node, Builder $builder, $operator)
{
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(), $operator);
} elseif ($node->getNodeName() === 'ne') {
$builder->whereNotNull($node->getField(), $operator);
} 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,
$operator
);
}
}

/**
* Processes an array node.
*
* @param AbstractArrayOperatorNode $node The node to process
* @param Builder $builder The Eloquent builder to populate
* @param unknown $operator The operator to use when appending where clauses
*
* @throws \LogicException Thrown if the node cannot be processed
*/
private function visitArrayNode(AbstractArrayOperatorNode $node, Builder $builder, $operator)
{
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(),
$operator,
$negate
);
}

/**
* Processes a logical node.
*
* @param AbstractLogicalOperatorNode $node The node to process
* @param Builder $builder The Eloquent builder to populate
* @param unknown $operator The operator to use when appending where clauses
*
* @throws \LogicException Thrown if the node cannot be processed
*/
private function visitLogicalNode(AbstractLogicalOperatorNode $node, Builder $builder, $operator)
{
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, $operator);
} else {
throw new \LogicException(sprintf('Unknown or unsupported logical node "%s"', $node->getNodeName()));
}


}
}
12 changes: 12 additions & 0 deletions tests/NilPortugues/App/Controller/EmployeesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ public function getOrdersByEmployee($id)
$sorting = $apiRequest->getSort();
$included = $apiRequest->getIncludedRelationships();
$filters = $apiRequest->getFilters();

//HACK: JsonAPI is specifying this as an array but it should be a string at this point for RQL processing.
switch(count($filters)){

Choose a reason for hiding this comment

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

  • Spaces must be used to indent lines; tabs are not allowed
  • Expected 1 space after SWITCH keyword; 0 found
  • Expected 1 space after closing parenthesis; found 0

case 0:

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

$filters = null;

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

break;

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

case 1:

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

$filters = $filters[0];

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

break;

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

default:

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

throw new \Exception('Only a single filter is supported at present.');

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

}

Choose a reason for hiding this comment

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

Whitespace found at end of line


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

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 [];

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

}
}
5 changes: 5 additions & 0 deletions tests/NilPortugues/App/Transformers/OrdersTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,9 @@ public function getRelationships()
{
return [];
}

public function getRequiredProperties()
{
return [];

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

}
}
12 changes: 11 additions & 1 deletion tests/NilPortugues/Laravel5/JsonApi/JsonApiControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,6 +40,16 @@ public function testListActionCanFilterMembers()
$this->assertEquals('application/vnd.api+json', $response->headers->get('Content-type'));
$this->assertContains('&fields[employee]=company,first_name', $response->getContent());
}

public function testListActionCanFilter()
{
$this->call('GET', 'http://localhost/employees?filter=ne(a,b)');

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

$response = $this->response;

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed


$this->assertEquals(200, $response->getStatusCode());

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

$this->assertEquals('application/vnd.api+json', $response->headers->get('Content-type'));

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

$this->assertContains('&filter=ne(a,b)', $response->getContent());

Choose a reason for hiding this comment

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

Spaces must be used to indent lines; tabs are not allowed

}

public function testListAction()
{
Expand Down