From ca6303a338d400695f2014276155d9c056e259fa Mon Sep 17 00:00:00 2001 From: Johannes Schobel Date: Thu, 25 Aug 2016 15:16:27 +0200 Subject: [PATCH] refactoring --- .gitignore | 1 + CHANGELOG.md | 4 + README.md | 117 ++++++ composer.json | 25 ++ .../DingoQueryMapperServiceProvider.php | 27 ++ .../Exceptions/EmptyColumnException.php | 10 + .../Exceptions/UnknownColumnException.php | 10 + .../DingoQueryMapper/LumenServiceProvider.php | 12 + .../Parser/DingoQueryMapperBuilder.php | 375 ++++++++++++++++++ .../DingoQueryMapper/Parser/UriParser.php | 212 ++++++++++ src/config/dingoquerymapper.php | 31 ++ 11 files changed, 824 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 composer.json create mode 100644 src/JohannesSchobel/DingoQueryMapper/DingoQueryMapperServiceProvider.php create mode 100644 src/JohannesSchobel/DingoQueryMapper/Exceptions/EmptyColumnException.php create mode 100644 src/JohannesSchobel/DingoQueryMapper/Exceptions/UnknownColumnException.php create mode 100644 src/JohannesSchobel/DingoQueryMapper/LumenServiceProvider.php create mode 100644 src/JohannesSchobel/DingoQueryMapper/Parser/DingoQueryMapperBuilder.php create mode 100644 src/JohannesSchobel/DingoQueryMapper/Parser/UriParser.php create mode 100644 src/config/dingoquerymapper.php diff --git a/.gitignore b/.gitignore index 1cd717b..a8100c1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ bootstrap/cache/ # Rocketeer PHP task runner and deployment package. https://github.com/rocketeers/rocketeer .rocketeer/ +.idea/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..79053e2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log +All notable changes to this project will be documented in this file. +This project adheres to [Semantic Versioning](http://semver.org/). + diff --git a/README.md b/README.md index a6863e5..1c3b626 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,119 @@ # dingo-query-mapper Uses Dingo/API Request Query Parameters to filter Laravel Models + +# Installation +First, add the respective line to your composer file +```json +"require" : { + ... + "johannesschobel/dingoquerymapper": "0.1" +} +``` +and run `composer install` to install the new component. + + +Then add the `ServiceProvider` to your `app.php` configuration file, like this: +```php +'providers' => [ + ..., + JohannesSchobel\DingoQueryMapper\DingoQueryMapperServiceProvider::class, +], +``` + +If you want, you can overwrite the provided configuration with the following command: +```php +php artisan vendor:publish --provider="JohannesSchobel\DingoQueryMapper\DingoQueryMapperServiceProvider" --tag="config" +``` +You can customize the `limit` parameter or the query parameters to be excluded from the service provider. + +# 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`. + +Consider the following example: +```php + public function getAllUsers(Request $request) { + + $users = new DingoQueryMapperBuilder(new User, $request); + $users = $users->build()->getAndPaginate(); + + // 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 +``` +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); +``` + +## Parameters +This plugin provides some pre-defined parameter names to be automatically filled. + +### `Limit` and `Page` +In order to limit the amount of response elements, simply add respective `limit` query parameter, like this: + +```php +/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 +```php +/index?limit=20&page=2 +``` +will return the next `20` entries, located on page `2`. + + +### `sort` +In order to sort the results using different parameters, you can simply concatenate them using `,`. In order to provide `ASC` and `DESC` sorting, you may prepend a `-` before respective attribute. + +For example +```php +/index?sort=age,-name +``` +sorts the results by age (ascending), then by name (descending; note the `-`before the `name` field!) + +### `filter` +Of course you may pass custom query parameters to the builder in order to `filter` the requested data. +For example: + +* `name=j*` : filters all elements, where the name starts with `j`, +* `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. + +Thereby, this plugin offers the following operators: `=`, `!=`, `<`, `<=`, `=>`. `>`. + +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`. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5793d62 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "johannesschobel/dingoquerymapper", + "description": "Uses Dingo/API Request Query Parameters to filter Laravel Models", + "license": "MIT", + "keywords": [ + "laravel", "API", "dingo", "query string" + ], + "authors": [ + { + "name": "Johannes Schobel", + "email": "johannes.schobel@googlemail.com" + } + ], + "require": { + "php": ">=5.5.0", + "laravel/framework": "5.1.*" + }, + "require-dev": { + }, + "autoload": { + "psr-0": { + "JohannesSchobel\\DingoQueryMapper": "src/" + } + } +} \ No newline at end of file diff --git a/src/JohannesSchobel/DingoQueryMapper/DingoQueryMapperServiceProvider.php b/src/JohannesSchobel/DingoQueryMapper/DingoQueryMapperServiceProvider.php new file mode 100644 index 0000000..444f557 --- /dev/null +++ b/src/JohannesSchobel/DingoQueryMapper/DingoQueryMapperServiceProvider.php @@ -0,0 +1,27 @@ +publishes([ + __DIR__.'/../../config/dingoquerymapper.php' => config_path('dingoquerymapper.php'), + ], 'config'); + } + + public function register() + { + $this->setupConfig(); + } + + /** + * Get the Configuration + */ + private function setupConfig() { + $this->mergeConfigFrom(realpath(__DIR__ . '/../../config/dingoquerymapper.php'), 'dingoquerymapper'); + } +} \ No newline at end of file diff --git a/src/JohannesSchobel/DingoQueryMapper/Exceptions/EmptyColumnException.php b/src/JohannesSchobel/DingoQueryMapper/Exceptions/EmptyColumnException.php new file mode 100644 index 0000000..b0f973d --- /dev/null +++ b/src/JohannesSchobel/DingoQueryMapper/Exceptions/EmptyColumnException.php @@ -0,0 +1,10 @@ +builder = $builder; + + $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')); + + 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; + } + } + + public function build() + { + $this->prepare(); + + if (config('dingoquerymapper.allowFilters')) + { + if ($this->hasWheres()) { + array_map([$this, 'addWhereToQuery'], $this->wheres); + } + } + + if ($this->hasLimit()) { + $this->query->take($this->limit); + } + + if ($this->hasOffset()) { + $this->query->skip($this->offset); + } + + array_map([$this, 'addSortToQuery'], $this->sort); + + $this->query->with($this->rels); + + $this->query->select($this->columns); + + return $this; + } + + public function getAndPaginate() { + if($this->hasLimit()) { + return $this->paginate(); + } + + return $this->get(); + } + + public function get() + { + return $this->query->get(); + } + + public function paginate() + { + if (! $this->hasLimit()) { + throw new Exception("You can't use unlimited option for pagination", 1); + } + + return $this->query->paginate($this->limit); + } + + public function lists($value, $key) + { + return $this->query->lists($value, $key); + } + + protected function prepare() + { + $this->setWheres($this->uriParser->whereParameters()); + + $constantParameters = $this->uriParser->predefinedParameters(); + + array_map([$this, 'prepareConstant'], $constantParameters); + + if ($this->hasRels() && $this->hasRelationColumns()) { + $this->fixRelationColumns(); + } + + return $this; + } + + 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']); + } + + private function setRels($rels) + { + $this->rels = array_filter(explode(',', $rels)); + } + + private function setPage($page) + { + $this->page = (int) $page; + + $this->offset = ($page - 1) * $this->limit; + } + + private function setColumns($columns) + { + $columns = array_filter(explode(',', $columns)); + + $this->columns = $this->relationColumns = []; + + array_map([$this, 'setColumn'], $columns); + + if( $this->hasColumns($columns) == 0) { + throw new EmptyColumnException("Columns are empty"); + } + } + + private function setColumn($column) + { + if ($this->isRelationColumn($column)) { + return $this->appendRelationColumn($column); + } + + if (! $this->hasTableColumn($column)) { + throw new UnknownColumnException("Unknown column '{$column}'"); + } + + $this->columns[] = $column; + } + + private function appendRelationColumn($keyAndColumn) + { + list($key, $column) = explode('.', $keyAndColumn); + + $this->relationColumns[$key][] = $column; + } + + private function fixRelationColumns() + { + $keys = array_keys($this->relationColumns); + + $callback = [$this, 'fixRelationColumn']; + + array_map($callback, $keys, $this->relationColumns); + } + + private function fixRelationColumn($key, $columns) + { + $index = array_search($key, $this->rels); + + unset($this->rels[$index]); + + $this->rels[$key] = $this->closureRelationColumns($columns); + } + + private function closureRelationColumns($columns) + { + return function($q) use ($columns) { + $q->select($columns); + }; + } + + private function setSort($sort) + { + $this->sort = []; + + $orders = array_filter(explode(',', $sort)); + + array_map([$this, 'appendSort'], $orders); + } + + private function appendSort($sort) + { + $column = $sort; + $direction = 'asc'; + + if($sort[0] == '-') { + $column = substr($sort, 1); + $direction = 'desc'; + } + + $this->sort[] = [ + 'column' => $column, + 'direction' => $direction + ]; + } + + private function setGroupBy($groups) + { + $this->groupBy = array_filter(explode(',', $groups)); + } + + private function setLimit($limit) + { + $this->limit = (int) $limit; + } + + private function setWheres($parameters) + { + $this->wheres = $parameters; + } + + private function addWhereToQuery($where) + { + extract($where); + + if ($this->isExcludedParameter($key)) { + return; + } + + if ($this->hasCustomFilter($key)) { + return $this->applyCustomFilter($key, $operator, $value); + } + + if (! $this->hasTableColumn($key)) { + throw new UnknownColumnException("Unknown column '{$key}'"); + } + + $this->query->where($key, $operator, $value); + } + + private function addSortToQuery($order) + { + extract($order); + + $this->query->orderBy($column, $direction); + } + + private function applyCustomFilter($key, $operator, $value) + { + $callback = [$this, $this->customFilterName($key)]; + + $this->query = call_user_func($callback, $this->query, $value, $operator); + } + + private function isRelationColumn($column) + { + return (count(explode('.', $column)) > 1); + } + + private function isExcludedParameter($key) + { + return in_array($key, $this->excludedParameters); + } + + private function hasWheres() + { + return (count($this->wheres) > 0); + } + + private function hasRels() + { + return (count($this->rels) > 0); + } + + private function hasGroupBy() + { + return (count($this->groupBy) > 0); + } + + private function hasLimit() + { + return ($this->limit); + } + + private function hasOffset() + { + return ($this->offset != 0); + } + + private function hasRelationColumns() + { + return (count($this->relationColumns) > 0); + } + + private function hasTableColumn($column) + { + return (Schema::hasColumn($this->model->getTable(), $column)); + } + + private function hasCustomFilter($key) + { + $methodName = $this->customFilterName($key); + + return (method_exists($this, $methodName)); + } + + private function setterMethodName($key) + { + return 'set' . studly_case($key); + } + + private function customFilterName($key) + { + return 'filterBy' . studly_case($key); + } + + private function hasColumns($columns) + { + return (count($columns) > 0); + } +} \ No newline at end of file diff --git a/src/JohannesSchobel/DingoQueryMapper/Parser/UriParser.php b/src/JohannesSchobel/DingoQueryMapper/Parser/UriParser.php new file mode 100644 index 0000000..c2782ed --- /dev/null +++ b/src/JohannesSchobel/DingoQueryMapper/Parser/UriParser.php @@ -0,0 +1,212 @@ +=|>/'; + + /** + * @var array the keywords which are handled individually + */ + protected $predefinedParams = [ + 'sort', + 'limit', + 'page', + 'columns', + 'rels', + ]; + + /** + * @var string the request uri + */ + protected $uri; + + /** + * @var string the path of the uri + */ + protected $path; + + /** + * @var string the query string (already encoded) + */ + protected $query; + + /** + * @var array the extracted query parameters + */ + protected $queryParameters = []; + + /** + * UriParser constructor. + * + * @param Request $request the given request + */ + public function __construct(Request $request) + { + $this->request = $request; + + $this->uri = $request->getRequestUri(); + + $this->path = $request->getPathInfo(); + + $this->query = rawurldecode($request->getQueryString()); + + if ($this->hasQueryUri()) { + $this->setQueryParameters($this->query); + } + } + + /** + * Gets the respective parameter + * + * @param $key + * @return mixed + */ + public function queryParameter($key) + { + $keys = array_pluck($this->queryParameters, 'key'); + + $queryParameters = array_combine($keys, $this->queryParameters); + + return $queryParameters[$key]; + } + + /** + * Gets the predefined parameters + * + * @return array + */ + public function predefinedParameters() + { + return $this->predefinedParams; + } + + /** + * Gets the WHERE parameters + * + * @return array + */ + public function whereParameters() + { + return array_filter( + $this->queryParameters, + function($queryParameter) + { + $key = $queryParameter['key']; + return (! in_array($key, $this->predefinedParams)); + } + ); + } + + /** + * Sets the query parameters + * + * @param $query + */ + private function setQueryParameters($query) + { + $queryParameters = array_filter(explode('&', $query)); + + array_map([$this, 'appendQueryParameter'], $queryParameters); + } + + /** + * Appends one parameter to the builder + * + * @param $parameter + */ + private function appendQueryParameter($parameter) + { + preg_match($this->pattern, $parameter, $matches); + + if(empty($matches)) { + return; + } + + $operator = $matches[0]; + + list($key, $value) = explode($operator, $parameter); + + if(empty($value)) { + return; + } + + if (( ! $this->isPredefinedParameter($key)) && $this->isLikeQuery($value)) { + $operator = 'like'; + $value = str_replace('*', '%', $value); + } + + $this->queryParameters[] = [ + 'key' => $key, + 'operator' => $operator, + 'value' => $value + ]; + } + + /** + * Checks if the URI has a query string appended + * + * @return string + */ + protected function hasQueryUri() + { + return ($this->query); + } + + /** + * Checks if the URI has query parameters + * @return bool + */ + public function hasQueryParameters() + { + return (count($this->queryParameters) > 0); + } + + /** + * Checks, if the given query parameter exists + * + * @param $key + * @return bool + */ + public function hasQueryParameter($key) + { + $keys = array_pluck($this->queryParameters, 'key'); + + return (in_array($key, $keys)); + } + + /** + * Checks, if the query parameter contains an asteriks (*) symbol and must be treated as like parameter + * + * @param $query + * @return int + */ + private function isLikeQuery($query) + { + $pattern = "/^\*|\*$/"; + + return (preg_match($pattern, $query, $matches)); + } + + /** + * Checks if the key is a predefined parameter + * + * @param $key + * @return bool + */ + private function isPredefinedParameter($key) + { + return (in_array($key, $this->predefinedParams)); + } +} \ No newline at end of file diff --git a/src/config/dingoquerymapper.php b/src/config/dingoquerymapper.php new file mode 100644 index 0000000..cedcaec --- /dev/null +++ b/src/config/dingoquerymapper.php @@ -0,0 +1,31 @@ +18 are allowed + */ + 'allowFilters' => true, + + /* + * The default values + */ + 'defaults' => [ + 'limit' => 15, + 'sort' => [ + [ + 'column' => 'id', + 'direction' => 'asc' + ] + ], + ], + + /* + * The parameters to be excluded + */ + 'excludedParameters' => [ + 'include', // because of Fractal Transformers + 'token', // because of JWT Auth + ], + +]; \ No newline at end of file