From 9ebdd9a1e415de1451c09639fff6b7a7a7f5b315 Mon Sep 17 00:00:00 2001 From: Johannes Schobel Date: Tue, 3 Jan 2017 13:11:22 +0100 Subject: [PATCH] refactoring --- CHANGELOG.md | 6 + README.md | 103 ++++---- .../DingoQueryMapperServiceProvider.php | 6 +- .../DingoQueryMapper/LumenServiceProvider.php | 12 - .../Operators/CollectionOperator.php | 114 +++++++++ .../DingoQueryMapper/Operators/Operations.php | 38 +++ .../Parser/DingoQueryMapper.php | 231 ++++++++++++++++++ .../Parser/DingoQueryMapperBuilder.php | 68 ++++-- .../DingoQueryMapper/Parser/UriParser.php | 42 ++-- 9 files changed, 504 insertions(+), 116 deletions(-) delete mode 100644 src/JohannesSchobel/DingoQueryMapper/LumenServiceProvider.php create mode 100644 src/JohannesSchobel/DingoQueryMapper/Operators/CollectionOperator.php create mode 100644 src/JohannesSchobel/DingoQueryMapper/Operators/Operations.php create mode 100644 src/JohannesSchobel/DingoQueryMapper/Parser/DingoQueryMapper.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e55438..07fff75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +#### 2017-08-01 +Doing some refactoring. Plugin is not compatible with old version anymore. +Please see the installation instruction on the readme file. + +The plugin, however, still provides the same functionality. + #### 2016-09-09 Initial version * configuration file diff --git a/README.md b/README.md index 64681bd..3b0dcc6 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,65 @@ # dingo-query-mapper -Uses Dingo/API Request Query Parameters to filter Laravel Models +Uses Dingo/API Request Query Parameters to filter Laravel Collections. +For example, you are able to automatically filter, sort, and limit collections based on query parameters of the URI. # Installation First, add the respective line to your composer file -```json +``` json "require" : { - ..., - "johannesschobel/dingoquerymapper": "dev-master" + ... , + "johannesschobel/dingoquerymapper": "dev-master" , } ``` -and run `composer install` to install the new component. +and run `composer install` to install the new component. Then add respective `ServiceProvider` from the package to your `config/app.php` configuration file, like this: -```php + +``` php 'providers' => [ - ..., + ... , JohannesSchobel\DingoQueryMapper\DingoQueryMapperServiceProvider::class, ], ``` If you want, you can overwrite the basic configuration using the following command: + ```php php artisan vendor:publish --provider="JohannesSchobel\DingoQueryMapper\DingoQueryMapperServiceProvider" --tag="config" ``` + This will copy the `dingoquerymapper` configuration file to your `config` folder. Using this file, you can customize the `limit` parameter or the query parameters to be excluded from the service provider. +This is handy, if you need to omit parameters from automatically parsed for filtering (e.g., you may want to +exclude the `token` parameter if you are using `JWT`). # Usage ## Example -In order to use the plugin, simply create a new instance from `DingoQueryMapperBuilder` and pass the request. -The rest is handled by the `Builder` itself. +In order to use the plugin, simply create a new instance from `DingoQueryMapper` and pass the request. +Furthermore, you want to call respective `createFromXXX()` method from the `DingoQueryMapper`, which basically +allows to create the mapper from various inputs (e.g., `Collection`, `Builder`, ...) Consider the following example: + ```php - public function getAllUsers(Request $request) { +public function getAllUsers(Request $request) { - $users = new DingoQueryMapperBuilder(new User, $request); - $users = $users->build()->getAndPaginate(); + $users = User::all()->get(); + $qm = new DingoQueryMapper($request); + $users = $qm->createFromCollection($users)->paginate(); - // now return the result - return response->json($users); - } + // now return the result + return response->json($users); +} ``` If you call the respective URI, for example like so: ```php -/index?name=j*&age>=18&limit=10 +/index?name=j*&age>=18&limit=10&page=3 ``` -the `$users` will only contain (maximum) `10` users, where the `name` starts with `j` and the `age` is above `18`. -The corresponding eloquent request would look like this: -```php -$users = User::where('name', 'like', 'j%') - ->where('age', '>=', '18') - ->take(10); -``` - -Of course you can pass an existing `Builder` instead of a `Model`. This way, you can append to the builder. - -Consider the following example: -```php - public function getAllActiveUsers(Request $request) { - - $builder = User::where('is_active', '=', true); - $users = new DingoQueryMapperBuilder($builder, $request); - $users = $users->build()->getAndPaginate(); - - // now return the result - return response->json($users); - } -``` - -would first filter all `active` users and then apply the custom filters (or sort order), like this: -```php -$users = User::where('is_active', '=', true) - ->where('name', 'like', 'j%') - ->where('age', '>=', '18') - ->take(10); -``` +it will output (a maximum of) `10 User`s per page, where the `name` attribute starts with `j` and the `age` is +greater or equal than `18` starting on `page 3`. ## Dingo/API Example If you use [Dingo/API](https://github.com/dingo/api) as your preferred API framework, you can use this package right @@ -89,8 +70,17 @@ varies because you need to use Dingo's response objects. You can simply return your results using ```php +public function getAllUsers(Request $request) { + + $users = User::all()->get(); + $qm = new DingoQueryMapper($request); + $users = $qm->createFromCollection($users)->paginate(); + + // now return the result + return $this->response ->paginator($users, new UserTransformer()); +} ``` That's all - really! @@ -105,12 +95,12 @@ In order to limit the amount of response elements, simply add respective `limit` /index?limit=20 ``` -This will only return the (first) `20` entries of the resultset. -In order to request the next `20` entries, simply add a `page` parameter, for example +This will only return the (first) `20` entries of the result. +In order to request the next `20` entries, simply add a `page` parameter to the query, like this: ```php /index?limit=20&page=2 ``` -will return the next `20` entries, located on page `2`. +will return the next `20` entries that are located on `page 2`. ### `sort` @@ -120,7 +110,7 @@ For example ```php /index?sort=age,-name ``` -sorts the results by age (ascending), then by name (descending; note the `-`before the `name` field!) +sorts the results by age (ascending), then by name (descending; note the `-` before the `name` field!) ### `custom filters` Of course you may pass custom query parameters to the builder in order to `filter` the requested data. @@ -130,12 +120,19 @@ For example: * `age>=18` : filters all elements, where the age is `18` or higher, * `city!=berlin` : filters all elements, where the city is not `berlin` -If you try to filter with a column that does not exist in the respective model, an `UnknownColumnException` will be thrown. +If you try to filter using a column that does not exist in the respective model, it will be ignored. -Thereby, this plugin offers the following operators: `=`, `!=`, `<`, `<=`, `=>`. `>`. +At the moment, this plugin offers the following operators: +* `=` --> compare equality +* `!=` --> compare unequality +* `<` --> smaller than +* `<=` --> smaller or equal than +* `=>` --> greater or equal than +* `>` --> greater than Of course you can combine the filters with the other query parameters: ```php /index?name=j*&age>=18&sort=age,-name&limit=20 ``` -would return the first `20` elements, where the `name` starts with `j`, the `age` is `>= 18` sorted by `age ASC` and `name DESC`. +would return the first `20` elements, where the `name` starts with `j`, the `age` is `>= 18` +sorted by `age ASC` and `name DESC`. diff --git a/src/JohannesSchobel/DingoQueryMapper/DingoQueryMapperServiceProvider.php b/src/JohannesSchobel/DingoQueryMapper/DingoQueryMapperServiceProvider.php index 444f557..46f0b6c 100644 --- a/src/JohannesSchobel/DingoQueryMapper/DingoQueryMapperServiceProvider.php +++ b/src/JohannesSchobel/DingoQueryMapper/DingoQueryMapperServiceProvider.php @@ -6,15 +6,13 @@ class DingoQueryMapperServiceProvider extends ServiceProvider { - public function boot() - { + public function boot() { $this->publishes([ __DIR__.'/../../config/dingoquerymapper.php' => config_path('dingoquerymapper.php'), ], 'config'); } - public function register() - { + public function register() { $this->setupConfig(); } diff --git a/src/JohannesSchobel/DingoQueryMapper/LumenServiceProvider.php b/src/JohannesSchobel/DingoQueryMapper/LumenServiceProvider.php deleted file mode 100644 index bdb6203..0000000 --- a/src/JohannesSchobel/DingoQueryMapper/LumenServiceProvider.php +++ /dev/null @@ -1,12 +0,0 @@ -collection = $collection; + $this->request = $request; + } + + public function get() { + return $this->collection; + } + + public function paginate($page, $limit) { + $slice = $this->collection->forPage($page, $limit); + + return new LengthAwarePaginator( + $slice, // only the items needed + count($this->collection), // total amount of items + $limit, // items per page + $page, // current page + ['path' => $this->request->url(), 'query' => $this->request->query()] // We need this so we can keep all old query parameters from the url + ); + } + + public function sort(array $sorts) { + $comparer = $this->callbackSearchable($sorts); + $this->collection = $this->collection->sort($comparer); + } + + public function filter(array $filters) { + $filterer = $this->callbackFilterable($filters); + $this->collection = $this->collection->filter($filterer); + } + + private function callbackSearchable($criteria) { + $callback = function ($first, $second) use ($criteria) { + foreach ($criteria as $c) { + // normalize sort direction + $orderType = strtolower($c['direction']); + if ($first[$c['column']] < $second[$c['column']]) { + return $orderType === "asc" ? -1 : 1; + } else if ($first[$c['column']] > $second[$c['column']]) { + return $orderType === "asc" ? 1 : -1; + } + } + // all elements were equal + return 0; + }; + return $callback; + } + + private function callbackFilterable($criteria) { + $callback = function($item) use ($criteria) { + $attributes = $item->getAttributes(); + + foreach ($criteria as $c) { + // check, if the criteria to check is present + if(! array_key_exists($c['key'], $attributes)) { + // attribute does not exist - continue with the next one + continue; + } + + // the attribute exists - so check the operator and value + $attribute = $item->getAttribute($c['key']); + $rule = $this->createEvaluationRule($attribute, $c['operator'], $c['value']); + + $evalString = 'return(' . $rule . ');'; + $result = (boolean) eval($evalString); + + if($result === false) { + return false; + } + } + + return true; + }; + + return $callback; + } + + private function createEvaluationRule($key, $operator, $value) { + // first, check the operator type! + if($operator == '=') $operator = '=='; + $rule = "'%s' %s '%s'"; // key, operator, value + $rule = sprintf($rule, $key, $operator, $value); + + // now check if the operator was "(not) like"? + if (strpos($operator, 'like') !== false) { + $value = str_replace('%', '', $value); + $rule = "substr('%s', 0, strlen('%s')) %s '%s'"; // haystack, $needle, $comparable, $needle + $expectedResult = '==='; + + if(stripos($operator, 'not') !== false) { + // it is a NOT LIKE operator + $expectedResult = '!=='; + } + + $rule = sprintf($rule, $key, $value, $expectedResult, $value); + } + + return $rule; + } + +} \ No newline at end of file diff --git a/src/JohannesSchobel/DingoQueryMapper/Operators/Operations.php b/src/JohannesSchobel/DingoQueryMapper/Operators/Operations.php new file mode 100644 index 0000000..42a827d --- /dev/null +++ b/src/JohannesSchobel/DingoQueryMapper/Operators/Operations.php @@ -0,0 +1,38 @@ +request = $request; + + $this->uriParser = new UriParser($request); + + $this->sort = config('dingoquerymapper.defaults.sort'); + $this->limit = config('dingoquerymapper.defaults.limit'); + + $this->excludedParameters = array_merge($this->excludedParameters, config('dingoquerymapper.excludedParameters')); + + $this->setFilters($this->uriParser->whereParameters()); + } + + /** + * Create the result based on a collection + * + * @param Collection $collection + * @return $this + */ + public function createFromCollection(Collection $collection) { + $this->operator = new CollectionOperator($collection, $this->request); + $this->prepare(); + return $this; + } + + /** + * Prepare the result (e.g., sort, filter, ...) + * + * @return $this + */ + private function prepare() { + $constantParameters = $this->uriParser->predefinedParameters(); + array_map([$this, 'prepareConstant'], $constantParameters); + + $this->filter(); + $this->sort(); + + return $this; + } + + /** + * Filter the result. Operator implements actual logic. + * + * @return mixed + */ + public function filter() { + if ($this->allowsFilter()) { + if ($this->hasFilters()) { + $tmp = []; + foreach($this->filters as $filter) { + // check, if it is a "forbidden" query parameter + if($this->isExcludedParameter($filter['key'])) { + continue; + } + $tmp[] = $filter; + } + + $this->filters = $tmp; + + return $this->operator->filter($this->filters); + } + } + } + + /** + * Get the entire result. + * + * @return mixed + */ + public function get() { + return $this->operator->get(); + } + + /** + * Returns the paginated result. + * + * @return mixed + */ + public function paginate() { + return $this->operator->paginate($this->page, $this->limit); + } + + /** + * Sorts the result + * + * @return mixed + */ + private function sort() { + return $this->operator->sort($this->sort); + } + + /** + * Calls respective setXXX Method for the predefined parameters + * @param $parameter + */ + private function prepareConstant($parameter) { + if (! $this->uriParser->hasQueryParameter($parameter)) return; + + $callback = [$this, $this->setterMethodName($parameter)]; + $callbackParameter = $this->uriParser->queryParameter($parameter); + + call_user_func($callback, $callbackParameter['value']); + } + + /** + * setter for query parameter page. Is called by by prepareConstant() method + * + * @param $page + */ + private function setPage($page) { + $this->page = (int) $page; + $this->offset = ($page - 1) * $this->limit; + } + + /** + * setter for query parameter limit. Is called by by prepareConstant() method + * + * @param $limit + */ + private function setLimit($limit) { + $this->limit = (int) $limit; + } + + /** + * setter for query parameter sort. Is called by by prepareConstant() method + * + * @param $sort + */ + private function setSort($sort) { + $this->sort = []; + $orders = array_filter(explode(',', $sort)); + array_map([$this, 'appendSort'], $orders); + } + + /** + * setter for all additional query parameters, which are used to filter the result. + * + * @param $filters + */ + private function setFilters($filters) { + $this->filters = $filters; + } + + /** + * Appends sort-criteria to the sort-list based on their orientation (asc / desc) + * + * @param $sort + */ + private function appendSort($sort) { + $column = $sort; + $direction = 'asc'; + + if($sort[0] == '-') { + $column = substr($sort, 1); + $direction = 'desc'; + } + + $this->sort[] = [ + 'column' => $column, + 'direction' => $direction + ]; + } + + /** + * returns the SETTER method name for respective parameters + * + * @param $parameter + * @return string + */ + private function setterMethodName($parameter) { + return 'set' . studly_case($parameter); + } + + /** + * Checks if the parameter is an parameter to be excluded (e.g., "token") + * + * @param $parameter + * @return bool + */ + private function isExcludedParameter($parameter) { + return in_array($parameter, $this->excludedParameters); + } + + /** + * Checks, if the requester is allowed to filter the result + * + * @return mixed + */ + private function allowsFilter() { + return config('dingoquerymapper.allowFilters'); + } + + /** + * Checks, if filters are set + * + * @return bool + */ + private function hasFilters() { + return (count($this->filters) > 0); + } +} \ No newline at end of file diff --git a/src/JohannesSchobel/DingoQueryMapper/Parser/DingoQueryMapperBuilder.php b/src/JohannesSchobel/DingoQueryMapper/Parser/DingoQueryMapperBuilder.php index 5c5dba3..880a463 100644 --- a/src/JohannesSchobel/DingoQueryMapper/Parser/DingoQueryMapperBuilder.php +++ b/src/JohannesSchobel/DingoQueryMapper/Parser/DingoQueryMapperBuilder.php @@ -3,10 +3,14 @@ namespace JohannesSchobel\DingoQueryMapper\Parser; use Exception; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Support\Facades\Schema; use JohannesSchobel\DingoQueryMapper\Exceptions\EmptyColumnException; use JohannesSchobel\DingoQueryMapper\Exceptions\UnknownColumnException; +use JohannesSchobel\DingoQueryMapper\Operators\CollectionOperator; class DingoQueryMapperBuilder { @@ -22,7 +26,7 @@ class DingoQueryMapperBuilder * * @var \Illuminate\Database\Eloquent\Builder */ - protected $builder; + //protected $builder; /** * The uri parser to extract the query parameters @@ -32,12 +36,16 @@ class DingoQueryMapperBuilder protected $wheres = []; + // ok protected $sort = []; + // ok protected $limit; + // ok protected $page = 1; + // ok protected $offset = 0; protected $columns = ['*']; @@ -52,18 +60,19 @@ class DingoQueryMapperBuilder protected $query; + // ok protected $result; + // ok + protected $operator; + /** * DingoQueryMapperBuilder constructor. * - * @param \Illuminate\Database\Eloquent\Builder | \Illuminate\Database\Eloquent\Model $builder an existing builder or a new model * @param Request $request the request with query parameters */ - public function __construct($builder, Request $request) + public function __construct(Request $request) { - $this->builder = $builder; - $this->uriParser = new UriParser($request); $this->sort = config('dingoquerymapper.defaults.sort'); @@ -71,15 +80,40 @@ public function __construct($builder, Request $request) $this->limit = config('dingoquerymapper.defaults.limit'); $this->excludedParameters = array_merge($this->excludedParameters, config('dingoquerymapper.excludedParameters')); + } - if($builder instanceof \Illuminate\Database\Eloquent\Model) { - $this->model = $builder; - $this->query = $this->model->newQuery(); - } - elseif ($builder instanceof \Illuminate\Database\Eloquent\Builder) { - $this->model = $builder->getModel(); - $this->query = $builder; - } + /** + * Create the Query from an existing builder + * + * @param Builder $builder + * @return $this + */ + public function createFromBuilder(Builder $builder) { + $this->model = $builder->getModel(); + $this->query = $builder; + + $this->build(); + return $this; + } + + /** + * Create the query from an empty model + * + * @param Model $model + * @return $this + */ + public function createFromModel(Model $model) { + $this->model = $model; + $this->query = $this->model->newQuery(); + + return $this; + } + + public function createFromCollection(Collection $collection) { + + $this->operator = new CollectionOperator($collection); + + return $this; } public function build() @@ -110,14 +144,6 @@ public function build() return $this; } - public function getAndPaginate() { - if($this->hasLimit()) { - return $this->paginate(); - } - - return $this->get(); - } - public function get() { return $this->query->get(); diff --git a/src/JohannesSchobel/DingoQueryMapper/Parser/UriParser.php b/src/JohannesSchobel/DingoQueryMapper/Parser/UriParser.php index c2782ed..7c0028c 100644 --- a/src/JohannesSchobel/DingoQueryMapper/Parser/UriParser.php +++ b/src/JohannesSchobel/DingoQueryMapper/Parser/UriParser.php @@ -23,8 +23,8 @@ class UriParser 'sort', 'limit', 'page', - 'columns', - 'rels', + //'columns', + //'rels', ]; /** @@ -52,8 +52,7 @@ class UriParser * * @param Request $request the given request */ - public function __construct(Request $request) - { + public function __construct(Request $request) { $this->request = $request; $this->uri = $request->getRequestUri(); @@ -73,10 +72,8 @@ public function __construct(Request $request) * @param $key * @return mixed */ - public function queryParameter($key) - { + public function queryParameter($key) { $keys = array_pluck($this->queryParameters, 'key'); - $queryParameters = array_combine($keys, $this->queryParameters); return $queryParameters[$key]; @@ -87,8 +84,7 @@ public function queryParameter($key) * * @return array */ - public function predefinedParameters() - { + public function predefinedParameters() { return $this->predefinedParams; } @@ -97,8 +93,7 @@ public function predefinedParameters() * * @return array */ - public function whereParameters() - { + public function whereParameters() { return array_filter( $this->queryParameters, function($queryParameter) @@ -114,8 +109,7 @@ function($queryParameter) * * @param $query */ - private function setQueryParameters($query) - { + private function setQueryParameters($query) { $queryParameters = array_filter(explode('&', $query)); array_map([$this, 'appendQueryParameter'], $queryParameters); @@ -126,8 +120,7 @@ private function setQueryParameters($query) * * @param $parameter */ - private function appendQueryParameter($parameter) - { + private function appendQueryParameter($parameter) { preg_match($this->pattern, $parameter, $matches); if(empty($matches)) { @@ -143,7 +136,9 @@ private function appendQueryParameter($parameter) } if (( ! $this->isPredefinedParameter($key)) && $this->isLikeQuery($value)) { - $operator = 'like'; + if ($operator == '=') $operator = 'like'; + if ($operator == '!=') $operator = 'not like'; + $value = str_replace('*', '%', $value); } @@ -159,8 +154,7 @@ private function appendQueryParameter($parameter) * * @return string */ - protected function hasQueryUri() - { + protected function hasQueryUri() { return ($this->query); } @@ -168,8 +162,7 @@ protected function hasQueryUri() * Checks if the URI has query parameters * @return bool */ - public function hasQueryParameters() - { + public function hasQueryParameters() { return (count($this->queryParameters) > 0); } @@ -179,8 +172,7 @@ public function hasQueryParameters() * @param $key * @return bool */ - public function hasQueryParameter($key) - { + public function hasQueryParameter($key) { $keys = array_pluck($this->queryParameters, 'key'); return (in_array($key, $keys)); @@ -192,8 +184,7 @@ public function hasQueryParameter($key) * @param $query * @return int */ - private function isLikeQuery($query) - { + private function isLikeQuery($query) { $pattern = "/^\*|\*$/"; return (preg_match($pattern, $query, $matches)); @@ -205,8 +196,7 @@ private function isLikeQuery($query) * @param $key * @return bool */ - private function isPredefinedParameter($key) - { + private function isPredefinedParameter($key) { return (in_array($key, $this->predefinedParams)); } } \ No newline at end of file