From a55cccda13adfb3be9b436fffa416c6b15060b3d Mon Sep 17 00:00:00 2001 From: henzeb Date: Thu, 24 Feb 2022 20:03:10 +0100 Subject: [PATCH] add laravel FormRequest support --- CHANGELOG.md | 5 + README.md | 7 +- composer.json | 7 + doc/LARAVEL.md | 80 ++++++ src/Builders/Contracts/QueryBuilder.php | 4 + src/Filters/Contracts/QueryFilter.php | 4 + src/Filters/Query.php | 10 + src/Illuminate/Builders/Builder.php | 10 + src/Illuminate/Config/filter.php | 27 ++ src/Illuminate/Facades/Filter.php | 12 + src/Illuminate/Factories/FilterFactory.php | 79 ++++++ src/Illuminate/Factories/RulesFactory.php | 60 ++++ src/Illuminate/Mixins/FormRequestMixin.php | 118 ++++++++ .../Providers/QueryFilterServiceProvider.php | 36 +++ .../Validation/Contracts/RuleSet.php | 6 + .../Validation/Contracts/RuleSetDecorator.php | 8 + .../Decorators/PaginationValidation.php | 32 +++ .../Validation/Decorators/Rules.php | 13 + .../Decorators/SortingValidation.php | 30 ++ .../Validation/Rules/SortingAllowed.php | 32 +++ tests/Helpers/DataProviders.php | 14 +- .../Unit/Illuminate/Builders/BuilderTest.php | 8 +- tests/Unit/Illuminate/Facades/QueryTest.php | 31 +++ .../Unit/Illuminate/Mixins/Concerns/Mocks.php | 33 +++ .../FormRequestMxin/FormRequestMixinTest.php | 134 +++++++++ .../Mixins/FormRequestMxin/PaginationTest.php | 260 ++++++++++++++++++ .../Mixins/FormRequestMxin/SortingTest.php | 153 +++++++++++ 27 files changed, 1202 insertions(+), 11 deletions(-) create mode 100644 doc/LARAVEL.md create mode 100644 src/Illuminate/Config/filter.php create mode 100644 src/Illuminate/Facades/Filter.php create mode 100644 src/Illuminate/Factories/FilterFactory.php create mode 100644 src/Illuminate/Factories/RulesFactory.php create mode 100644 src/Illuminate/Mixins/FormRequestMixin.php create mode 100644 src/Illuminate/Providers/QueryFilterServiceProvider.php create mode 100644 src/Illuminate/Validation/Contracts/RuleSet.php create mode 100644 src/Illuminate/Validation/Contracts/RuleSetDecorator.php create mode 100644 src/Illuminate/Validation/Decorators/PaginationValidation.php create mode 100644 src/Illuminate/Validation/Decorators/Rules.php create mode 100644 src/Illuminate/Validation/Decorators/SortingValidation.php create mode 100644 src/Illuminate/Validation/Rules/SortingAllowed.php create mode 100644 tests/Unit/Illuminate/Facades/QueryTest.php create mode 100644 tests/Unit/Illuminate/Mixins/Concerns/Mocks.php create mode 100644 tests/Unit/Illuminate/Mixins/FormRequestMxin/FormRequestMixinTest.php create mode 100644 tests/Unit/Illuminate/Mixins/FormRequestMxin/PaginationTest.php create mode 100644 tests/Unit/Illuminate/Mixins/FormRequestMxin/SortingTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed70cb..5dcc1df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `Query Filter Builder` will be documented in this file +## 1.1.0 - 2022-02-24 + +- added FormRequest functionality to ease development in Laravel +- added asc/desc sorting functionality + ## 1.0.0 - 2022-02-22 - initial release diff --git a/README.md b/README.md index b245b33..8f1316f 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,10 @@ a nice and simple interface that allows you to add filters without the need of a thousand parameters passed to your methods or writing SQL queries inside your controllers. -This comes with support for Laravel's query builder. No other builders as of yet. -If you'd like to contribute, see [Contributing](CONTRIBUTING.md). +This comes with support for Laravel. If you'd like to contribute +for other frameworks, see [Contributing](CONTRIBUTING.md). ## Installation - You can install the package via composer: ```bash @@ -20,6 +19,8 @@ composer require henzeb/query-filter-builder ``` ## Usage +See [here](doc/LARAVEL.md) for Laravel specific usage. + In your controller you may build up something like this, based on parameters given by the user of your application. diff --git a/composer.json b/composer.json index 4cbefd2..dca24a9 100644 --- a/composer.json +++ b/composer.json @@ -48,5 +48,12 @@ }, "config": { "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "Henzeb\\Query\\Illuminate\\Providers\\QueryFilterServiceProvider" + ] + } } } diff --git a/doc/LARAVEL.md b/doc/LARAVEL.md new file mode 100644 index 0000000..feaf48e --- /dev/null +++ b/doc/LARAVEL.md @@ -0,0 +1,80 @@ +# Laravel + +## Usage +you can use this package easily with laravel FormRequests. Out of the box +the package will parse and validate query-parameters for sorting and +paginating based on the +[JSON:API specification](https://jsonapi.org/format/1.1/). + +### configuration +The configuration file can be published using `php artisan vendor:publish` +```php +return [ + /** + * @see https://jsonapi.org/format/1.1/#fetching-filtering + */ + 'key' => 'filter', /** The key used as query-family for filtering. like filter[]= */ + /** + * @see https://jsonapi.org/format/1.1/#fetching-pagination + */ + 'pagination' => [ + 'auto' => true, /** automatically adds pagination to your filters if set to true */ + 'key' => 'page', /** The key used as query-family for sorting. like page[]= */ + 'limit' => 'size', /** The key used for query-family-member limit, like: page[size]=1 */ + 'offset' => 'number', /** The key used for query-family-member offset, like: page[number]=1 */ + 'defaults' => [ + 'limit' => 50, /** the default limit */ + 'max_limit' => 100 /** The maximum allowed limit */ + ] + ], + /** + * @see https://jsonapi.org/format/1.1/#fetching-sorting + */ + 'sorting' => [ + 'auto'=> true, /** automatically adds sorting to your filters if set to true */ + 'key'=> 'sort', /** the key used as query-family for sorting, like sort= */ + ] +]; +``` +In your FormRequest, you also have some control. +```php +class YourFormRequest extends FormRequest { + protected bool $enablePagination = false; /** disable or enable pagination */ + + protected bool $enableSorting = false; /** disable or enable sorting */ + protected array $allowSorting = []; /** fields that are allowed for sorting */ + protected int $defaultLimit = 10; /** The default limit */ + protected int $maxLimit = 50; /** The maximum allowed limit */ +} +``` +By default, the package does not allow any fields for sorting. +You have to add the fields you want to allow into the `$allowSorting` property. +The format is the same as specified in the +[JSON:API specification: Sorting](https://jsonapi.org/format/1.1/#fetching-sorting), +except it's listed as an array: + +```php +protected array $allowSorting = [ + 'animal', /** ascending */ + '-animal' /** descending */ +]; +``` +### Add your own filters +To add your own filters, simply add the following method in your FormRequest. +You can use `filter` and `hasFilter` methods as shortcut to the filter +query parameter family as specified in [JSON:API specification: Filtering](https://jsonapi.org/format/1.1/#fetching-filtering) + + +```php +private function filters(Query $query): void +{ + if($this->hasFilter('animal')) { + $query->is('animal_field', $this->filter('animal')); + } +} +``` +Note: You need to add your own validations in your rules method. + + + + diff --git a/src/Builders/Contracts/QueryBuilder.php b/src/Builders/Contracts/QueryBuilder.php index 35f3cf4..b4f688e 100644 --- a/src/Builders/Contracts/QueryBuilder.php +++ b/src/Builders/Contracts/QueryBuilder.php @@ -83,4 +83,8 @@ public function orNest(QueryFilter $query): void; public function limit(int $limit): void; public function offset(int $offset): void; + + public function asc(string $key): void; + + public function desc(string $key): void; } diff --git a/src/Filters/Contracts/QueryFilter.php b/src/Filters/Contracts/QueryFilter.php index 91adb76..0f2965a 100644 --- a/src/Filters/Contracts/QueryFilter.php +++ b/src/Filters/Contracts/QueryFilter.php @@ -46,6 +46,10 @@ public function limit(int $limit): self; public function offset(int $offset): self; + public function asc(string $key): self; + + public function desc(string $key): self; + public function and(): self; public function or(): self; diff --git a/src/Filters/Query.php b/src/Filters/Query.php index 2d83f03..0220d7b 100644 --- a/src/Filters/Query.php +++ b/src/Filters/Query.php @@ -161,6 +161,16 @@ public function offset(int $offset): QueryFilter return $this->addFilter(__FUNCTION__, get_defined_vars()); } + public function asc(string $key): QueryFilter + { + return $this->addFilter(__FUNCTION__, get_defined_vars()); + } + + public function desc(string $key): QueryFilter + { + return $this->addFilter(__FUNCTION__, get_defined_vars()); + } + public function and(): QueryFilter { return $this->addFilter(__FUNCTION__); diff --git a/src/Illuminate/Builders/Builder.php b/src/Illuminate/Builders/Builder.php index 07f278f..c3628a0 100644 --- a/src/Illuminate/Builders/Builder.php +++ b/src/Illuminate/Builders/Builder.php @@ -193,6 +193,16 @@ public function offset(int $offset): void $this->getBuilder()->offset($offset); } + public function asc(string $key): void + { + $this->getBuilder()->orderBy($key); + } + + public function desc(string $key): void + { + $this->getBuilder()->orderByDesc($key); + } + public function nest(QueryFilter $query): void { $this->getBuilder()->where( diff --git a/src/Illuminate/Config/filter.php b/src/Illuminate/Config/filter.php new file mode 100644 index 0000000..629457a --- /dev/null +++ b/src/Illuminate/Config/filter.php @@ -0,0 +1,27 @@ + 'filter', /** The key used as query-family for filtering. like filter[]= */ + /** + * @see https://jsonapi.org/format/1.1/#fetching-pagination + */ + 'pagination' => [ + 'auto' => true, /** automatically adds pagination to your filters if set to true */ + 'key' => 'page', /** The key used as query-family for sorting. like page[]= */ + 'limit' => 'size', /** The key used for query-family-member limit, like: page[size]=1 */ + 'offset' => 'number', /** The key used for query-family-member offset, like: page[number]=1 */ + 'defaults' => [ + 'limit' => 50, /** the default limit */ + 'max_limit' => 100 /** The maximum allowed limit */ + ] + ], + /** + * @see https://jsonapi.org/format/1.1/#fetching-sorting + */ + 'sorting' => [ + 'auto'=> true, /** automatically adds sorting to your filters if set to true */ + 'key'=> 'sort', /** the key used as query-family for sorting, like sort= */ + ] +]; diff --git a/src/Illuminate/Facades/Filter.php b/src/Illuminate/Facades/Filter.php new file mode 100644 index 0000000..79d735a --- /dev/null +++ b/src/Illuminate/Facades/Filter.php @@ -0,0 +1,12 @@ +all()); + + if ($enablePagination || config('filter.pagination.auto')) { + $filterFactory->parsePagination($defaultLimit); + } + + if ($enableSorting || config('filter.sorting.auto')) { + $filterFactory->parseSorting(); + } + + return $query; + } + + private function parsePagination(int $defaultLimit = null): void + { + $key = config('filter.pagination.key'); + + $limit = $this->createConfigKey([$key, config('filter.pagination.limit')]); + $defaultLimit = $defaultLimit ?? config('filter.pagination.defaults.limit'); + + $offset = $this->createConfigKey([$key, config('filter.pagination.offset')]); + + if (Arr::has($this->parameters, $limit) || $defaultLimit) { + $this->query->limit(Arr::get($this->parameters, $limit, $defaultLimit)); + } + + if (Arr::has($this->parameters, $offset)) { + $this->query->offset(Arr::get($this->parameters, $offset)); + } + } + + private function createConfigKey(array $paths): string + { + return join('.', array_filter($paths)); + } + + private function parseSorting(): void + { + $key = config('filter.sorting.key', 'sort'); + + if (Arr::has($this->parameters, $key)) { + $sorts = explode(',', $this->parameters[$key]); + + foreach ($sorts as $sort) { + + if (str_starts_with($sort, '-')) { + $this->query->desc(ltrim($sort, '-')); + continue; + } + $this->query->asc($sort); + } + + } + } + +} diff --git a/src/Illuminate/Factories/RulesFactory.php b/src/Illuminate/Factories/RulesFactory.php new file mode 100644 index 0000000..717412e --- /dev/null +++ b/src/Illuminate/Factories/RulesFactory.php @@ -0,0 +1,60 @@ +decoratePaginationValidation($rules, $maxLimit, $enablePagination); + + return $factory->decorateSortingValidation($rules, $enableSorting, $allowedSorting); + } + + private function decoratePaginationValidation( + RuleSet $rules, + ?int $maxLimit, + bool $enablePagination, + ): RuleSet + { + if ($enablePagination && config('filter.pagination.auto')) { + + return new PaginationValidation( + $rules, + $maxLimit + ); + } + + return $rules; + } + + public function decorateSortingValidation(RuleSet $rules, bool $enableSorting, array $allowedSorting): RuleSet + { + if ($enableSorting && config('filter.sorting.auto')) { + + return new SortingValidation( + $rules, + $allowedSorting + ); + } + + return $rules; + } +} diff --git a/src/Illuminate/Mixins/FormRequestMixin.php b/src/Illuminate/Mixins/FormRequestMixin.php new file mode 100644 index 0000000..6f102ba --- /dev/null +++ b/src/Illuminate/Mixins/FormRequestMixin.php @@ -0,0 +1,118 @@ +enableSorting ?? true, + $this->allowedSorting ?? [], + $this->enablePagination ?? true, + $this->maxLimit ?? null, + ); + + $validator = Validator::make( + $this->query->all(), + $rules->getRules() + ); + + /** in case the user has some custom logic */ + if ($validator->fails()) { + $this->failedValidation($validator); + } + + /** in case failed Validation does not throw exceptions */ + if ($validator->fails()) { + throw ValidationException::withMessages( + $validator->getMessageBag()->toArray() + ); + } + }; + } + + public function getFilter(): Closure + { + return function (): Query { + /** + * @var $this FormRequest + */ + $this->validateFilters(); + + $filters = FilterFactory::get( + $this->query, + $this->enableSorting ?? false, + $this->enablePagination ?? false, + $this->defaultLimit ?? null + ); + + if (method_exists($this, 'filters')) { + $this->filters($filters); + } + + return $filters; + }; + } + + public function filter(): Closure + { + return function (string $key, mixed $default = null): mixed { + /** + * @var $this FormRequest + */ + $key = join( + '.', + array_filter( + [ + config('filter.key'), + $key + ] + ) + ); + + return Arr::get( + $this->query->all(), + $key, $default + ); + }; + } + + public function hasFilter(): Closure + { + return function (string $key): bool { + /** + * @var $this FormRequest + */ + + $key = join( + '.', + array_filter( + [ + config('filter.key'), + $key + ] + ) + ); + + return Arr::has( + $this->query->all(), + $key + ); + }; + } + +} diff --git a/src/Illuminate/Providers/QueryFilterServiceProvider.php b/src/Illuminate/Providers/QueryFilterServiceProvider.php new file mode 100644 index 0000000..7d3a4f5 --- /dev/null +++ b/src/Illuminate/Providers/QueryFilterServiceProvider.php @@ -0,0 +1,36 @@ +app->bind('filter.query', fn() => new Query()); + + $loader = AliasLoader::getInstance(); + $loader->alias('Filter', Filter::class); + + $this->mergeConfigFrom($this->configFilePath, 'filter'); + + } + + public function boot() + { + $this->publishes([$this->configFilePath => config_path('filter.php')]); + + FormRequest::mixin(new FormRequestMixin()); + + + } +} diff --git a/src/Illuminate/Validation/Contracts/RuleSet.php b/src/Illuminate/Validation/Contracts/RuleSet.php new file mode 100644 index 0000000..fb12d5c --- /dev/null +++ b/src/Illuminate/Validation/Contracts/RuleSet.php @@ -0,0 +1,6 @@ + 'integer|max:' . ($this->maxSize ?? config('filter.pagination.defaults.max_limit')), + $offset => 'integer', + ]; + + return $this->rules->getRules() + $rules; + } +} diff --git a/src/Illuminate/Validation/Decorators/Rules.php b/src/Illuminate/Validation/Decorators/Rules.php new file mode 100644 index 0000000..fe29df2 --- /dev/null +++ b/src/Illuminate/Validation/Decorators/Rules.php @@ -0,0 +1,13 @@ +rules->getRules() + [ + config('filter.sorting.key') => [ + 'bail', + 'regex:/^-{0,1}[0-9a-zA-Z_]+(,-{0,1}[0-9a-zA-Z_]+)*$/', + new SortingAllowed($this->allowedSorting) + ] + ]; + } +} diff --git a/src/Illuminate/Validation/Rules/SortingAllowed.php b/src/Illuminate/Validation/Rules/SortingAllowed.php new file mode 100644 index 0000000..af53172 --- /dev/null +++ b/src/Illuminate/Validation/Rules/SortingAllowed.php @@ -0,0 +1,32 @@ +allowed)) { + $this->messages[] = trans('filter.pagination.sorting_not_allowed', ['value' => $value]); + } + } + + return empty($this->messages); + } + + public function message(): array + { + return $this->messages; + } +} diff --git a/tests/Helpers/DataProviders.php b/tests/Helpers/DataProviders.php index 3cc2c55..93a685e 100644 --- a/tests/Helpers/DataProviders.php +++ b/tests/Helpers/DataProviders.php @@ -64,6 +64,9 @@ public function providesFilterTestcases(): array 'limit' => ['method' => 'limit', 'parameters' => ['limit' => 100]], 'offset' => ['method' => 'offset', 'parameters' => ['offset' => 50]], + + 'asc' => ['method' => 'asc', 'parameters' => ['key' => 'animal']], + 'desc' => ['method' => 'desc', 'parameters' => ['key' => 'animal']], ]; } @@ -110,10 +113,13 @@ public function providesFilterWithQueryTestcases(): array 'dateBetween' => ['query' => '`age` between ? and ?'], 'dateNotBetween' => ['query' => '`age` not between ? and ?'], - 'filter-object' => ['query' => ['query'=>'(`owner` = ?)', 'parameters'=>['Jason']]], + 'filter-object' => ['query' => ['query' => '(`owner` = ?)', 'parameters' => ['Jason']]], + + 'asc' => ['query' => 'order by `animal` asc', 'noParameters' => true], + 'desc' => ['query' => 'order by `animal` desc', 'noParameters' => true], - 'limit' => ['query' => 'limit 100', 'isLimit' => true], - 'offset' => ['query' => 'offset 50', 'isLimit' => true], + 'limit' => ['query' => 'limit 100', 'noParameters' => true], + 'offset' => ['query' => 'offset 50', 'noParameters' => true], ] ); } @@ -124,7 +130,7 @@ public function providesFilterWithQueryTestcases(): array */ protected function flattenArray(array $parameters): array { - $return = array(); + $return = []; array_walk_recursive( $parameters, diff --git a/tests/Unit/Illuminate/Builders/BuilderTest.php b/tests/Unit/Illuminate/Builders/BuilderTest.php index 75d150e..5dc9e5c 100644 --- a/tests/Unit/Illuminate/Builders/BuilderTest.php +++ b/tests/Unit/Illuminate/Builders/BuilderTest.php @@ -18,7 +18,7 @@ class BuilderTest extends TestCase /** * @param string $method * @param array $parameters - * @param string $query + * @param string|array $query * @param bool $noParameters * @return void * @@ -48,7 +48,7 @@ public function testShouldBuild(string $method, array $parameters, string|array /** * @param string $method * @param array $parameters - * @param string $query + * @param array|string $query * @param bool $noParameters * @return void * @@ -80,7 +80,7 @@ public function testShouldBuildWithOr(string $method, array $parameters, array|s /** * @param string $method * @param array $parameters - * @param string $query + * @param array|string $query * @param bool $noParameters * @return void * @@ -110,7 +110,7 @@ public function testShouldBuildWithGroup(string $method, array $parameters, arra /** * @param string $method * @param array $parameters - * @param string $query + * @param array|string $query * @param bool $noParameters * @return void * diff --git a/tests/Unit/Illuminate/Facades/QueryTest.php b/tests/Unit/Illuminate/Facades/QueryTest.php new file mode 100644 index 0000000..37b0976 --- /dev/null +++ b/tests/Unit/Illuminate/Facades/QueryTest.php @@ -0,0 +1,31 @@ +assertEquals( + (new expectedQuery())->limit(1), + FilterWithPath::limit(1) + ); + } + + public function testShouldLoadWithAlias() + { + $this->assertEquals( + (new expectedQuery())->limit(1), \Filter::limit(1) + ); + } +} diff --git a/tests/Unit/Illuminate/Mixins/Concerns/Mocks.php b/tests/Unit/Illuminate/Mixins/Concerns/Mocks.php new file mode 100644 index 0000000..a53dd30 --- /dev/null +++ b/tests/Unit/Illuminate/Mixins/Concerns/Mocks.php @@ -0,0 +1,33 @@ +shouldAllowMockingProtectedMethods() + ->makePartial(); + + $partial->query = new InputBag(); + $partial->request = new InputBag(); + + $partial->server = new InputBag(); + $partial->files = new InputBag(); + $partial->headers = new InputBag(); + + return $partial; + } +} diff --git a/tests/Unit/Illuminate/Mixins/FormRequestMxin/FormRequestMixinTest.php b/tests/Unit/Illuminate/Mixins/FormRequestMxin/FormRequestMixinTest.php new file mode 100644 index 0000000..ff9881f --- /dev/null +++ b/tests/Unit/Illuminate/Mixins/FormRequestMxin/FormRequestMixinTest.php @@ -0,0 +1,134 @@ +in('animal', 'cat', 'dog'); + } + }; + + $this->assertEquals( + (new Query)->limit(50)->in('animal', 'cat', 'dog'), + $request->getFilter() + ); + } + + public function providesTestCasesForFilter(): array + { + return [ + 'basic' => [['filter' => ['animal' => 'dog']], 'animal', 'dog'], + 'boolean' => [['filter' => ['alive' => true]], 'alive', true], + 'integer' => [['filter' => ['age' => 100]], 'age', 100], + 'nested-array' => [['filter' => ['animal' => ['type' => 'mammal']]], 'animal.type', 'mammal'], + 'default' => [['filter' => []], 'animal', 'cat', 'cat'], + ]; + } + + public function providesTestCasesForHasFilter(): array + { + return [ + 'basic' => [['filter' => ['animal' => 'dog']], 'animal', true], + 'basic-fail' => [['filter' => []], 'animal', false], + 'nested-array' => [['filter' => ['animal' => ['type' => 'mammal']]], 'animal', true], + 'nested-array-fail' => [['filter' => []], 'animal.type', false], + ]; + } + + /** + * @param array $input + * @param string $filter + * @param mixed $expected + * @param mixed|null $default + * @return void + * + * @dataProvider providesTestCasesForFilter + */ + public function testFilterShouldReturnValue(array $input, string $filter, mixed $expected, mixed $default = null) + { + $mock = $this->getFormRequest(); + $mock->query = new InputBag($input); + + $this->assertEquals( + $expected, + $mock->filter($filter, $default) + ); + } + + /** + * @param array $input + * @param string $filter + * @param mixed $expected + * @return void + * + * @dataProvider providesTestCasesForHasFilter + */ + public function testHasFilterShouldReturnBoolean(array $input, string $filter, bool $expected) + { + $mock = $this->getFormRequest(); + $mock->query = new InputBag($input); + + $this->assertEquals( + $expected, + $mock->hasFilter($filter) + ); + } + + public function testFilterShouldUseConfiguredBaseName() { + Config::set('filter.key', 'myOwn'); + $mock = $this->getFormRequest(); + $mock->query = new InputBag(['myOwn'=>['animal'=>'dog']]); + + $this->assertEquals( + 'dog', + $mock->filter('animal') + ); + } + + public function testFilterShouldUseConfiguredEmptyBaseName() { + Config::set('filter.key', null); + $mock = $this->getFormRequest(); + $mock->query = new InputBag(['animal'=>'dog']); + + $this->assertEquals( + 'dog', + $mock->filter('animal') + ); + } + + public function testHasFilterShouldUseConfiguredBaseName() { + Config::set('filter.key', 'myOwn'); + $mock = $this->getFormRequest(); + $mock->query = new InputBag(['myOwn'=>['animal'=>'dog']]); + + $this->assertEquals( + true, + $mock->hasFilter('animal') + ); + } + + public function testHasFilterShouldUseConfiguredEmptyBaseName() { + Config::set('filter.key', ''); + $mock = $this->getFormRequest(); + $mock->query = new InputBag(['animal'=>'dog']); + + $this->assertEquals( + true, + $mock->hasFilter('animal') + ); + } +} diff --git a/tests/Unit/Illuminate/Mixins/FormRequestMxin/PaginationTest.php b/tests/Unit/Illuminate/Mixins/FormRequestMxin/PaginationTest.php new file mode 100644 index 0000000..817b76f --- /dev/null +++ b/tests/Unit/Illuminate/Mixins/FormRequestMxin/PaginationTest.php @@ -0,0 +1,260 @@ + [ + 'inputBag' => ['page' => []], + 'expectedQuery' => (new Query())->limit(100), + 'path' => 'page', + 'limitPath' => 'number', + 'offsetPath' => 'size', + 'defaultLimit' => 100, + ], + 'no-default-limit' => [ + 'inputBag' => ['page' => []], + 'expectedQuery' => (new Query()), + 'path' => 'page', + 'limitPath' => 'number', + 'offsetPath' => 'size', + 'defaultLimit' => null, + ], + 'just-size' => [ + 'inputBag' => ['page' => ['size' => 1]], + 'expectedQuery' => (new Query())->limit(1) + ], + 'just-number' => [ + 'inputBag' => ['page' => ['number' => 1]], + 'expectedQuery' => (new Query())->limit(50)->offset(1) + ], + 'both' => [ + 'inputBag' => ['page' => ['number' => 1, 'size' => 20]], + 'expectedQuery' => (new Query())->limit(20)->offset(1) + ], + 'empty-page-path' => [ + 'inputBag' => ['number' => 1, 'size' => 20], + 'expectedQuery' => (new Query())->limit(20)->offset(1), + 'path' => '', + ], + 'any-page-path' => [ + 'inputBag' => ['random' => ['number' => 1, 'size' => 20]], + 'expectedQuery' => (new Query())->limit(20)->offset(1), + 'path' => 'random', + ], + 'size-offset-path' => [ + 'inputBag' => ['page' => ['myOffset' => 1, 'mylimit' => 20]], + 'expectedQuery' => (new Query())->limit(20)->offset(1), + 'path' => 'page', + 'limitPath' => 'mylimit', + 'offsetPath' => 'myOffset' + ] + ]; + } + + /** + * @param array $inputBag + * @param Query $expected + * @param string $path + * @param string $limitPath + * @param string $offsetPath + * @param int|null $defaultLimit + * @return void + * + * @dataProvider providesPaginationInputBags + */ + public function testShouldGetQueryInstanceWithPagination( + array $inputBag, + Query $expected, + string $path = 'page', + string $limitPath = 'size', + string $offsetPath = 'number', + ?int $defaultLimit = 50 + ) + { + Config::set('filter.pagination.key', $path); + Config::set('filter.pagination.limit', $limitPath); + Config::set('filter.pagination.offset', $offsetPath); + Config::set('filter.pagination.defaults.limit', $defaultLimit); + + $formRequest = $this->getFormRequest(); + $formRequest->query = new InputBag($inputBag); + + $this->assertEquals( + $expected, + $formRequest->getFilter() + ); + } + + public function testShouldNotParsePaginationWhenTurnedOff() + { + Config::set('filter.pagination.auto', false); + + $formRequest = $this->getFormRequest(); + + $formRequest->query = new InputBag(['page' => ['offset' => 1]]); + + $this->assertEquals( + (new Query()), + $formRequest->getFilter() + ); + } + + public function testShouldAllowFormRequestToManipulateLimitSize() + { + $formRequest = $this->getFormRequest(); + + $formRequest->defaultLimit = 150; + $formRequest->query = new InputBag([]); + + $this->assertEquals( + (new Query())->limit(150), + $formRequest->getFilter() + ); + + } + + public function testShouldAllowFormRequestOverrideManuallyAddedLimit() + { + $formRequest = $this->getFormRequest(); + + $formRequest->defaultLimit = 150; + $formRequest->query = new InputBag(['page' => ['size' => 30]]); + + $this->assertEquals( + (new Query())->limit(30), + $formRequest->getFilter() + ); + } + + protected function providesValidationFailures() + { + return [ + 'size-as-string' => [ + 'input' => ['page' => ['size' => 'NOT A NUMBER']] + ], + 'size-with-maximum' => [ + 'input' => ['page' => ['size' => 101]] + ], + + 'size-with-maximum-set-in-request' => [ + 'input' => ['page' => ['size' => 55]], + 'config' => ['max-size' => 50] + ], + + 'offset-as-string' => [ + 'input' => ['page' => ['number' => 'NOT A NUMBER']] + ], + + 'size-as-string-alt-path' => [ + 'input' => ['myPage' => ['mySize' => 'NOT A NUMBER']], + 'config' => ['member' => 'myPage', 'limit' => 'mySize'] + ], + 'size-with-maximum-alt-path' => [ + 'input' => ['myPage' => ['mySize' => 101]], + 'config' => ['member' => 'myPage', 'limit' => 'mySize'], + ], + 'offset-as-string-alt-path' => [ + 'input' => ['myPage' => ['myOffset' => 'NOT A NUMBER']], + 'config' => ['member' => 'myPage', 'offset' => 'myOffset'] + ], + + 'size-as-string-root-path' => [ + 'input' => ['mySize' => 'NOT A NUMBER'], + 'config' => ['member' => '', 'limit' => 'mySize'] + ], + 'size-with-maximum-root-path' => [ + 'input' => ['mySize' => 101], + 'config' => ['member' => '', 'limit' => 'mySize'], + ], + 'offset-as-string-root-path' => [ + 'input' => ['myOffset' => 'NOT A NUMBER'], + 'config' => ['member' => '', 'offset' => 'myOffset'] + ], + + ]; + } + + /** + * @return void + * + * @dataProvider providesValidationFailures + */ + public function testShouldValidateInputForPage(array $input, array $config = []) + { + Config::set('filter.pagination.key', $config['member'] ?? 'page'); + Config::set('filter.pagination.limit', $config['limit'] ?? 'size'); + Config::set('filter.pagination.offset', $config['offset'] ?? 'number'); + + $formRequest = $this->getFormRequest(); + $formRequest->query = new InputBag($input); + + if (isset($config['max-size'])) { + $formRequest->maxLimit = $config['max-size']; + } + + $formRequest->expects('failedValidation') + ->once(); + + $this->expectException(ValidationException::class); + + $formRequest->getFilter(); + } + + public function testShouldIgnoreGetInstanceWithPagination() + { + Config::set('filter.pagination.auto', false); + $formRequest = $this->getFormRequest(); + $formRequest->query = new InputBag(['page' => ['size' => 12]]); + + $this->assertEquals( + (new Query()), + $formRequest->getFilter() + ); + Config::set('filter.pagination.auto', true); + } + + public function providesEnablePaginationTestcases() + { + return [ + 'config-disabled-fq-enabled' => [false, true,(new Query())->limit(12)], + 'config-enabled-fq-disabled' => [true, false, (new Query())->limit(12)], + 'both-disabled' => [false, false, (new Query())], + ]; + } + + /** + * @return void + * + * @dataProvider providesEnablePaginationTestcases + */ + public function testShouldIgnoreSortingInFormRequest( + bool $config, bool $enablePagination, Query $expected + ) + { + Config::set('filter.pagination.auto', $config); + $formRequest = $this->getFormRequest(); + $formRequest->query = new InputBag(['page' => ['size' => 12]]); + + $formRequest->enablePagination = $enablePagination; + + $this->assertEquals( + $expected, + $formRequest->getFilter() + ); + } +} diff --git a/tests/Unit/Illuminate/Mixins/FormRequestMxin/SortingTest.php b/tests/Unit/Illuminate/Mixins/FormRequestMxin/SortingTest.php new file mode 100644 index 0000000..ec4bc05 --- /dev/null +++ b/tests/Unit/Illuminate/Mixins/FormRequestMxin/SortingTest.php @@ -0,0 +1,153 @@ + ['input' => ['sort' => 'animal'], 'expected' => (new Query())->limit(50)->asc('animal')], + 'asc-other-member' => [ + 'input' => ['anotherSortField' => 'animal'], + 'expected' => (new Query())->limit(50)->asc('animal'), + 'member' => 'anotherSortField' + ], + 'asc-twice' => [ + 'input' => ['sort' => 'animal,age'], + 'expected' => (new Query())->limit(50)->asc('animal')->asc('age') + ], + 'desc' => ['input' => ['sort' => '-animal'], 'expected' => (new Query())->limit(50)->desc('animal')], + 'desc-twice' => [ + 'input' => ['sort' => '-animal,-age'], + 'expected' => (new Query())->limit(50)->desc('animal')->desc('age') + ], + 'desc-asc-mixed' => [ + 'input' => ['sort' => '-animal,age'], + 'expected' => (new Query())->limit(50)->desc('animal')->asc('age') + ], + 'asc-desc-mixed' => [ + 'input' => ['sort' => 'animal,-age'], + 'expected' => (new Query())->limit(50)->asc('animal')->desc('age') + ], + + ]; + } + + /** + * @return void + * + * @dataProvider providesSortingSituations + */ + public function testShouldGetInstanceWithSorting(array $input, Query $expected, string $member = 'sort') + { + Config::set('filter.sorting.key', $member); + $formRequest = $this->getFormRequest(); + $formRequest->query = new InputBag($input); + + $formRequest->allowedSorting = [ + 'age', + 'animal', + '-age', + '-animal' + ]; + + $this->assertEquals( + $expected, + $formRequest->getFilter() + ); + } + + public function provideSortingValidationFailures(): array + { + return [ + 'boolean' => [true], + 'boolean-member' => [true, 'sorting'], + 'comma-ended' => ['animal,'], + 'comma-started' => [',animal'], + 'not-even-trying' => ['-,-'], + 'starts-weird' => ['-,animal'], + 'ends-weird' => ['animal,-'], + ]; + } + + /** + * @param array $input + * @return void + * + * @dataProvider provideSortingValidationFailures + */ + public function testShouldFailWithIncorrectValues(mixed $input, string $member = 'sort') + { + Config::set('filter.sorting.key', $member); + $formRequest = $this->getFormRequest() + ->shouldAllowMockingProtectedMethods(); + $formRequest->makePartial(); + + $formRequest->expects('failedValidation') + ->once(); + + $formRequest->query = new InputBag([$member => $input]); + + $this->expectException(ValidationException::class); + + $formRequest->getFilter(); + } + + public function providesEnablePaginationTestcases() + { + return [ + 'config-disabled-fq-enabled' => + [false, true, (new Query())->limit(50)->asc('animal')], + 'config-enabled-fq-disabled' => + [true, false, (new Query())->limit(50)->asc('animal')], + 'both-enabled' => + [true, true, (new Query())->limit(50)->asc('animal')], + 'both-disabled' => + [false, false, (new Query())->limit(50)], + ]; + } + + /** + * @return void + * + * @dataProvider providesEnablePaginationTestcases + */ + public function testShouldListenToConfigurationOptions( + bool $config, bool $enableSorting, Query $expected + ) + { + Config::set('filter.sorting.auto', $config); + $formRequest = $this->getFormRequest(); + $formRequest->query = new InputBag(['sort' => 'animal']); + + $formRequest->enableSorting = $enableSorting; + $formRequest->allowedSorting = ['animal']; + + $this->assertEquals( + $expected, + $formRequest->getFilter() + ); + } + + public function testShouldThrowValidationExceptionForNotAllowedFields() + { + $formRequest = $this->getFormRequest(); + $formRequest->expects('failedValidation')->once(); + + $formRequest->query = new InputBag(['sort' => 'animal']); + + $this->expectException(ValidationException::class); + + $formRequest->getFilter(); + } +}