From d34408652e5ff99f84e0afcebc81b7bc8938e788 Mon Sep 17 00:00:00 2001 From: Iraklis Georgas Date: Tue, 8 Oct 2024 17:19:48 +0300 Subject: [PATCH] Implement custom filters and refactor core components This commit introduces custom filtering capabilities and includes several improvements: - Add support for custom filters in the Filterable trait - Create MakeCustomFilterCommand for generating custom filter classes - Refactor FiltersBuilder for better performance and maintainability - Move FiltererServiceProvider to Providers namespace - Enhance error handling and type checks throughout the package - Update README with custom filter documentation and examples - Add tests for new custom filter functionality --- README.md | 128 +++++++++++++++++++--- composer.json | 4 +- src/Commands/MakeCustomFilterCommand.php | 49 +++++++++ src/Contracts/CustomFilter.php | 23 ++++ src/Filterable.php | 43 ++++++-- src/FiltererServiceProvider.php | 16 --- src/FiltersBuilder.php | 106 +++++++++++++----- src/Providers/FiltererServiceProvider.php | 14 +++ src/stubs/custom-filter.stub | 27 +++++ tests/FiltererTest.php | 14 +-- tests/FiltersBuilderTest.php | 45 ++++++++ tests/fixtures/ActiveClientsFilter.php | 25 +++++ tests/fixtures/Client.php | 4 + 13 files changed, 421 insertions(+), 77 deletions(-) create mode 100644 src/Commands/MakeCustomFilterCommand.php create mode 100644 src/Contracts/CustomFilter.php delete mode 100644 src/FiltererServiceProvider.php create mode 100644 src/Providers/FiltererServiceProvider.php create mode 100644 src/stubs/custom-filter.stub create mode 100644 tests/fixtures/ActiveClientsFilter.php diff --git a/README.md b/README.md index 4fc8abd..40c73ec 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This package provides an easy way to add **filtering**, **sorting** and **paging** functionality to Eloquent models. -## Installation +# Installation Via [Composer](https://getcomposer.org): @@ -14,7 +14,7 @@ Via [Composer](https://getcomposer.org): $ composer require culturegr/filterer ``` -## Usage +# Usage Assume the follwing database scheme: @@ -53,7 +53,7 @@ class Client extends Model > **IMPORTANT** Filterer package strongly relies on Laravel conventions for naming the relationships between models. Therefore, in order for the package to work as expected, the defined **model relationships should be named according to these convantions** -### Using the Trait +## Using the Trait Filtering, sorting and paging functionality can be added to the `Client` model by using the `Filterable` trait provided by Filterer package: @@ -72,7 +72,7 @@ class Client extends Model } ``` -### Defining filterable and sortable fields +## Defining filterable and sortable fields Fields that can be filtered and/or sorted must be explicitly defined using, respectively, the `fiterable` and `sortable` properties on the model. Both filterable and sortable fields may exist on the model itself or on its first-level relationships: - Fields that exist on the model itself are defined using the name of the column, i.e `columnName` @@ -99,7 +99,7 @@ class Client extends Model } ``` -### Supported data types and operators for filtering +## Supported data types and operators for filtering The supported data types and their corresponding operations that can be performed when filtering resources are listed in the following table: @@ -110,7 +110,7 @@ The supported data types and their corresponding operations that can be performe | String (*such as varchar, text etc*) | equal, not_equal, contains | -### Filtering models +## Filtering models Filtered results can be obtained using the filter method (provided by Filterable trait) and passing an array as an argument that has a filters property which contains a list of desired filters, as shown below: ```php @@ -166,7 +166,108 @@ Client::filter([ ])->get(); ``` -### Sorting models +### Custom filters + +Filterer now supports custom filters, allowing you to define complex or specific filtering logic for your models. + +#### Creating a Custom Filter + +Use the artisan command to generate a new custom filter: + +```bash +php artisan make:custom-filter ActiveClientsFilter +``` + +This will create a new `ActiveClientsFilter` class in `app/CustomFilters` directory that implements the `CustomFilter` interface. +Then you can define your custom filter logic in the `apply` method. + +For example, the following `ActiveClientsFilter` will filters clients who have made an order in the last 30 days.: + +```php + $filter The filter array + * + * @phpstan-param array{ + * column: string, + * operator: string, + * query_1: string, + * query_2: string + * } $filter + */ + public function apply(Builder $builder, $filter): Builder + { + // Your custom filter logic here... + + $queryValue = $filter['query_1'] + + if ($queryValue === '1') { + $builder->whereHas('orders', function($q) { + $q->where('created_at', '>=', Carbon::now()->subDays(30)); + }); + } else { + $builder->where(function($query) { + $query->whereDoesntHave('orders') + ->orWhereHas('orders', function($q) { + $q->where('created_at', '<', Carbon::now()->subDays(30)); + }); + }); + } + } +} +``` + +#### Registering Custom Filters + +In your model, add your custom filter to the `$customFilters` array property: + +```php +use CultureGr\Filterer\Filterable; +use Illuminate\Database\Eloquent\Model; + +class Client extends Model +{ + use Filterable; + + //... + + protected $customFilters = [ + 'active' => ActiveClientsFilter::class, + ]; + + //... +} +``` + +#### Using Custom Filters + +Now you can use your custom filter in your queries, like you would use any other filter: + +```php +Client::filter([ + 'filters' => [ + [ + 'column' => 'active', + 'operator' => 'equals', + 'query_1' => '1', + 'query_2' => null, + ], + ], +])->get(); +``` + +## Sorting models Sorted results can be obtained using the `filter` method and passing an array as an argument that has a `sorts` property which contains a list of desired sorts, as shown below: @@ -211,7 +312,7 @@ Client::filter([ ])->get(); ``` -### Paging models +## Paging models Paginated results can be obtained using the `filterPaginate` method, as shown below: @@ -223,7 +324,8 @@ Client::filterPaginate([ > **IMPORTANT:** The `filterPaginate` method always returns an instance of `Illuminate\Pagination\LengthAwarePaginator`. It uses the `limit` from the query string if provided, otherwise it uses the `$defaultLimit` if set, and falls back to a default of 10 if neither is specified. > By default, the current page is detected by the value of the page query string argument on the HTTP request. This value is automatically detected by Laravel, and is also automatically inserted into links generated by the paginator. For more information on how Laravel handles pagination see [here](https://laravel.com/docs/pagination) -### Combining filtering, sorting and paging + +## Combining filtering, sorting and paging Filtering, sorting and paging functionality can be combined using the `filterPaginate` method provided by `Filterable` trait and passing as an argument an array that contains any three of the `filters`, `sorts` and `limit`/`page` properties: @@ -242,7 +344,7 @@ Client::filterPaginate([ ]); ``` -### Query string format +## Query string format The argument of the `filter` method can be easily obtained by parsing a query string with the following format: @@ -257,17 +359,17 @@ GET http://example.com/clients &page= ``` -## Testing +# Testing ``` bash $ composer test ``` -## License +# License Please see the [license file](LICENSE.md) for more information. -## Credits +# Credits - [Code Kerala](https://github.com/codekerala) - Awesome Laravel/PHP community diff --git a/composer.json b/composer.json index e7df881..b75be9c 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "culturegr/filterer", + "name": "yppo/filterer", "description": "Add filtering, sorting and paging functionality to Eloquent models.", "license": "MIT", "authors": [ @@ -38,7 +38,7 @@ "extra": { "laravel": { "providers": [ - "CultureGr\\Filterer\\FiltererServiceProvider" + "CultureGr\\Filterer\\Providers\\FiltererServiceProvider" ] } } diff --git a/src/Commands/MakeCustomFilterCommand.php b/src/Commands/MakeCustomFilterCommand.php new file mode 100644 index 0000000..4967e33 --- /dev/null +++ b/src/Commands/MakeCustomFilterCommand.php @@ -0,0 +1,49 @@ + $filter The filter array + * + * @phpstan-param array{ + * column: string, + * operator: string, + * query_1: string, + * query_2: string + * } $filter + */ + public function apply(Builder $builder, array $filter): void; +} diff --git a/src/Filterable.php b/src/Filterable.php index 24223b2..37dee44 100644 --- a/src/Filterable.php +++ b/src/Filterable.php @@ -10,7 +10,6 @@ trait Filterable { /** * Apply filters and sorting to the query, then paginate the results. - * * @param Builder $query The query builder instance * @param array $queryString An array containing filter, sort, and pagination parameters * @return Builder The filtered results @@ -21,8 +20,8 @@ public function scopeFilter(Builder $query, array $queryString): Builder $this->validateQueryString($queryString); return $query - ->when(isset($queryString['filters']), fn ($q) => $this->applyFiltersToBuilder($q, $queryString['filters'])) - ->when(isset($queryString['sorts']), fn ($q) => $this->applySortsToBuilder($q, $queryString['sorts'])); + ->when(isset($queryString['filters']), fn($q) => $this->applyFiltersToBuilder($q, $queryString['filters'])) + ->when(isset($queryString['sorts']), fn($q) => $this->applySortsToBuilder($q, $queryString['sorts'])); } /** @@ -36,8 +35,8 @@ public function scopeFilterPaginate(Builder $query, array $queryString, $default return $this->scopeFilter($query, $queryString) ->when( isset($queryString['limit']), - fn ($q) => $q->paginate($queryString['limit']), - fn ($q) => $q->paginate($defaultLimit) + fn($q) => $q->paginate($queryString['limit']), + fn($q) => $q->paginate($defaultLimit) ); } @@ -46,12 +45,12 @@ protected function validateQueryString(array $queryString): void $validator = validator()->make($queryString, [ // TODO: 'filter_match' => 'sometimes|required|in:and,or', 'filters' => 'sometimes|required|array', - 'filters.*.column' => 'required_with:f|in:'.$this->allowedFilterable(), - 'filters.*.operator' => 'required_with:f.*.column|in:'.$this->allowedOperators(), + 'filters.*.column' => 'required_with:f.*.column|in:' . $this->allowedFilterables(), + 'filters.*.operator' => 'required_with:f.*.column|in:' . $this->allowedOperators(), 'filters.*.query_1' => 'required_with:f.*.column', 'filters.*.query_2' => 'required_if:f.*.operator,between,not_between', 'sorts' => 'sometimes|required|array', - 'sorts.*.column' => 'required_with:f|in:'.$this->allowedSortable(), + 'sorts.*.column' => 'required_with:f|in:' . $this->allowedSortable(), 'sorts.*.direction' => 'required_with:f.*.column', ]); @@ -62,7 +61,7 @@ protected function validateQueryString(array $queryString): void protected function applyFiltersToBuilder(Builder $builder, array $filters): Builder { - return (new FiltersBuilder($builder))->apply($filters); + return (new FiltersBuilder($builder, $this->getCustomFilters()))->apply($filters); } protected function applySortsToBuilder(Builder $builder, array $orders): Builder @@ -70,14 +69,19 @@ protected function applySortsToBuilder(Builder $builder, array $orders): Builder return (new SortsBuilder($builder))->apply($orders); } - protected function allowedFilterable(): string + protected function allowedFilterables(): string { - return implode(',', $this->filterable); + return implode(',', array_merge($this->getFilterables(), array_keys($this->getCustomFilters()))); + } + + protected function allowedCustomFilters(): string + { + return implode(',', array_keys($this->getCustomFilters())); } protected function allowedSortable(): string { - return implode(',', $this->sortable); + return implode(',', $this->getSortables()); } protected function allowedOperators(): string @@ -95,4 +99,19 @@ protected function allowedOperators(): string 'in', ]); } + + protected function getFilterables(): array + { + return $this->filterable ?? []; + } + + protected function getSortables(): array + { + return $this->sortable ?? []; + } + + protected function getCustomFilters(): array + { + return $this->customFilters ?? []; + } } diff --git a/src/FiltererServiceProvider.php b/src/FiltererServiceProvider.php deleted file mode 100644 index 15eb80c..0000000 --- a/src/FiltererServiceProvider.php +++ /dev/null @@ -1,16 +0,0 @@ -builder = $builder; - } + public function __construct( + protected Builder $builder, + protected array $customFilters = [] + ){} public function apply(array $filters): Builder { @@ -25,43 +23,97 @@ public function apply(array $filters): Builder return $this->builder; } - protected function applyFilterToBuilder(array $filter): void + private function applyFilterToBuilder(array $filter): void { - if ('' === $filter['column'] || '' === $filter['operator']) { + if ($this->isInvalidFilter($filter)) { return; } - if (false !== strpos($filter['column'], '.')) { - [$relation, $filter['column']] = explode('.', $filter['column']); - $this->builder->whereHas($relation, function ($q) use ($filter) { - $this->{Str::camel($filter['operator'])}($filter, $q); - }); + if ($this->isCustomFilter($filter)) { + $this->applyCustomFilter($filter); + } else { + $this->applyStandardFilter($filter); + } + } + + private function isInvalidFilter(array $filter): bool + { + return empty($filter['column']) || empty($filter['operator']); + } + + private function isCustomFilter(array $filter): bool + { + return isset($this->customFilters[$filter['column']]); + } + + private function applyCustomFilter(array $filter): void + { + $customFilterClass = $this->customFilters[$filter['column']]; + if (!class_exists($customFilterClass)) { + throw new \RuntimeException("Custom filter class '{$customFilterClass}' does not exist."); + } + (new $customFilterClass)->apply($this->builder, $filter); + } + + private function applyStandardFilter(array $filter): void + { + if ($this->isRelationalFilter($filter['column'])) { + $this->applyRelationalFilter($filter); } else { - $this->{Str::camel($filter['operator'])}($filter, $this->builder); + $this->applyDirectFilter($filter); + } + } + + private function isRelationalFilter(string $column): bool + { + return str_contains($column, '.'); + } + + private function applyRelationalFilter(array $filter): void + { + [$relation, $column] = explode('.', $filter['column'], 2); + $filter['column'] = $column; + + $this->builder->whereHas($relation, function ($query) use ($filter) { + $this->applyOperator($filter, $query); + }); + } + + private function applyDirectFilter(array $filter): void + { + $this->applyOperator($filter, $this->builder); + } + + private function applyOperator(array $filter, $query): void + { + $operator = Str::camel($filter['operator']); + if (!method_exists($this, $operator)) { + throw new \InvalidArgumentException("Unsupported filter operator: {$filter['operator']}"); } + $this->$operator($filter, $query); } - protected function equalTo(array $filter, Builder $query): Builder + private function equalTo(array $filter, Builder $query): Builder { return $query->where($filter['column'], '=', $filter['query_1'], $filter['match']); } - protected function notEqualTo(array $filter, Builder $query): Builder + private function notEqualTo(array $filter, Builder $query): Builder { return $query->where($filter['column'], '<>', $filter['query_1'], $filter['match']); } - protected function lessThan(array $filter, Builder $query): Builder + private function lessThan(array $filter, Builder $query): Builder { return $query->where($filter['column'], '<', $filter['query_1'], $filter['match']); } - protected function greaterThan(array $filter, Builder $query): Builder + private function greaterThan(array $filter, Builder $query): Builder { return $query->where($filter['column'], '>', $filter['query_1'], $filter['match']); } - protected function between(array $filter, Builder $query): Builder + private function between(array $filter, Builder $query): Builder { return $query->whereBetween($filter['column'], [ $filter['query_1'], @@ -69,7 +121,7 @@ protected function between(array $filter, Builder $query): Builder ], $filter['match']); } - protected function notBetween(array $filter, Builder $query): Builder + private function notBetween(array $filter, Builder $query): Builder { return $query->whereNotBetween($filter['column'], [ $filter['query_1'], @@ -77,17 +129,17 @@ protected function notBetween(array $filter, Builder $query): Builder ], $filter['match']); } - protected function contains(array $filter, Builder $query): Builder + private function contains(array $filter, Builder $query): Builder { - return $query->where($filter['column'], 'like', '%'.$filter['query_1'].'%', $filter['match']); + return $query->where($filter['column'], 'like', '%' . $filter['query_1'] . '%', $filter['match']); } - protected function startsWith(array $filter, Builder $query): Builder + private function startsWith(array $filter, Builder $query): Builder { - return $query->where($filter['column'], 'like', $filter['query_1'].'%', $filter['match']); + return $query->where($filter['column'], 'like', $filter['query_1'] . '%', $filter['match']); } - protected function betweenDate(array $filter, Builder $query): Builder + private function betweenDate(array $filter, Builder $query): Builder { return $query->whereBetween($filter['column'], [ Carbon::parse($filter['query_1']), @@ -95,7 +147,7 @@ protected function betweenDate(array $filter, Builder $query): Builder ], $filter['match']); } - protected function in(array $filter, Builder $query): Builder + private function in(array $filter, Builder $query): Builder { return $query->whereIn($filter['column'], $filter['query_1'], $filter['match']); } diff --git a/src/Providers/FiltererServiceProvider.php b/src/Providers/FiltererServiceProvider.php new file mode 100644 index 0000000..79e511c --- /dev/null +++ b/src/Providers/FiltererServiceProvider.php @@ -0,0 +1,14 @@ +commands([MakeCustomFilterCommand::class]); + } +} diff --git a/src/stubs/custom-filter.stub b/src/stubs/custom-filter.stub new file mode 100644 index 0000000..09b5bc9 --- /dev/null +++ b/src/stubs/custom-filter.stub @@ -0,0 +1,27 @@ + $filter The filter array + * + * @phpstan-param array{ + * column: string, + * operator: string, + * query_1: string, + * query_2: string + * } $filter + */ + public function apply(Builder $builder, array $filter): void + { + // Implement your custom filter logic here... + } +} diff --git a/tests/FiltererTest.php b/tests/FiltererTest.php index c19d180..028c14f 100644 --- a/tests/FiltererTest.php +++ b/tests/FiltererTest.php @@ -10,7 +10,7 @@ class FiltererTest extends TestCase { /** @test */ - public function it_provides_a_filter_scope_to_eloquent_models_using_the_trait(): void + public function it_adds_filter_scope_to_models_with_trait(): void { $client = factory(Client::class)->create(); @@ -18,7 +18,7 @@ public function it_provides_a_filter_scope_to_eloquent_models_using_the_trait(): } /** @test */ - public function it_provides_a_filterPaginate_scope_to_eloquent_models_using_the_trait(): void + public function it_adds_filterPaginate_scope_to_models_with_trait(): void { $client = factory(Client::class)->create(); @@ -75,7 +75,7 @@ public function it_throws_a_validation_exception_if_sort_field_has_not_been_defi } /** @test */ - public function it_returns_custom_builder_instance_if_filter_scope_is_called(): void + public function it_returns_builder_for_filter_scope(): void { factory(Client::class, 10)->create(); @@ -100,7 +100,7 @@ public function it_returns_custom_builder_instance_if_filter_scope_is_called(): } /** @test */ - public function it_returns_paginator_instance_if_filterPaginate_scope_is_called(): void + public function it_returns_paginator_for_filterPaginate_scope(): void { factory(Client::class, 10)->create(); @@ -110,7 +110,7 @@ public function it_returns_paginator_instance_if_filterPaginate_scope_is_called( } /** @test */ - public function filterPaginate_scope_returns_paginated_results_according_to_limit_query_string_parameter(): void + public function it_respects_query_string_limit_in_filterPaginate(): void { factory(Client::class, 10)->create(); @@ -122,7 +122,7 @@ public function filterPaginate_scope_returns_paginated_results_according_to_limi } /** @test */ - public function filterPaginate_scope_returns_paginated_results_according_to_defaultLimit_parameter_if_limit_query_string_parameter_is_not_set(): void + public function it_uses_defaultLimit_when_query_limit_not_set_in_filterPaginate(): void { factory(Client::class, 10)->create(); $defaultLimit = 8; @@ -133,7 +133,7 @@ public function filterPaginate_scope_returns_paginated_results_according_to_defa } /** @test */ - public function filterPaginate_scope_returns_paginated_results_by_ten_if_neither_limit_query_string_parameter_nor_defaultLimit_parameter_are_set(): void + public function it_defaults_to_ten_items_when_no_limit_specified_in_filterPaginate(): void { factory(Client::class, 20)->create(); diff --git a/tests/FiltersBuilderTest.php b/tests/FiltersBuilderTest.php index 94c19e1..ac27f38 100644 --- a/tests/FiltersBuilderTest.php +++ b/tests/FiltersBuilderTest.php @@ -2,6 +2,7 @@ namespace CultureGr\Filterer\Tests; +use CultureGr\Filterer\Tests\Fixtures\ActiveClientsFilter; use CultureGr\Filterer\Tests\Fixtures\Order; use CultureGr\Filterer\Tests\Fixtures\Client; use CultureGr\Filterer\Tests\Fixtures\Country; @@ -464,4 +465,48 @@ public function it_combines_multiple_fitlers_using_the_AND_operator(): void self::assertNotTrue($results->contains($this->client2)); self::assertNotTrue($results->contains($this->client3)); } + + /** @test */ + public function it_applies_active_clients_custom_filter(): void + { + // Active clients considered those who have at least one order + // shipped on 2019-06-15 09:30:00 and after + + $activeClients = Client::filter([ + 'filters' => [ + [ + 'column' => 'active', + 'operator' => 'equal_to', + 'query_1' => true + ] + ] + ])->get(); + + $inactiveClients = Client::filter([ + 'filters' => [ + [ + 'column' => 'active', + 'operator' => 'equal_to', + 'query_1' => false + ] + ] + ])->get(); + + $reflector = new \ReflectionClass(Client::class); + $customFiltersProperty = $reflector->getProperty('customFilters'); + $customFilters = $customFiltersProperty->getValue(new Client()); + + $this->assertArrayHasKey('active', $customFilters); + $this->assertEquals(ActiveClientsFilter::class, $customFilters['active']); + + $activeClientIds = $activeClients->pluck('id'); + $this->assertCount(1, $activeClientIds); + $this->assertTrue($activeClientIds->contains($this->client1->id)); + + $inactiveClientIds = $inactiveClients->pluck('id'); + $this->assertCount(2, $inactiveClientIds); + $this->assertTrue($inactiveClientIds->contains($this->client2->id)); + $this->assertTrue($inactiveClientIds->contains($this->client3->id)); + } + } diff --git a/tests/fixtures/ActiveClientsFilter.php b/tests/fixtures/ActiveClientsFilter.php new file mode 100644 index 0000000..b7efaec --- /dev/null +++ b/tests/fixtures/ActiveClientsFilter.php @@ -0,0 +1,25 @@ +whereHas('orders', function($q) { + $q->where('shipped_at', '>=', '2019-06-15 09:30:00'); + }); + } else { + $builder->where(function($query) { + $query->whereDoesntHave('orders') + ->orWhereHas('orders', function($q) { + $q->where('shipped_at', '<', '2019-06-15 09:30:00'); + }); + }); + } + } +} \ No newline at end of file diff --git a/tests/fixtures/Client.php b/tests/fixtures/Client.php index 1207f20..e640851 100644 --- a/tests/fixtures/Client.php +++ b/tests/fixtures/Client.php @@ -15,6 +15,10 @@ class Client extends Model protected $sortable = ['name', 'country.name', 'orders.items', 'favoriteProducts.price']; + protected $customFilters = [ + 'active' => ActiveClientsFilter::class, + ]; + protected $casts = [ 'registered_at' => 'datetime', ];