├── .gitignore ├── phpstan.neon.dist ├── src ├── Exceptions │ ├── FilterParameterUnhandledException.php │ ├── ParameterStrategyInvalidException.php │ └── FilterDataValidationFailedException.php ├── Enums │ ├── JoinType.php │ └── JoinKey.php ├── CountableResults.php ├── Contracts │ ├── ValidatableTraitInterface.php │ ├── ParameterCounterInterface.php │ ├── FilterDataInterface.php │ ├── ParameterFilterInterface.php │ ├── FilterInterface.php │ └── CountableFilterInterface.php ├── ParameterFilters │ ├── NotEmpty.php │ ├── IsEmpty.php │ ├── SimpleString.php │ ├── SimpleInteger.php │ └── SimpleTranslatedString.php ├── Traits │ └── Validatable.php ├── ParameterCounters │ ├── SimpleDistinctValue.php │ └── SimpleBelongsTo.php ├── FilterData.php ├── CountableFilter.php └── Filter.php ├── .travis.yml ├── .editorconfig ├── tests ├── Helpers │ ├── Models │ │ ├── TestSimpleModelTranslation.php │ │ ├── TestRelatedModel.php │ │ └── TestSimpleModel.php │ ├── TestCountableFilterData.php │ ├── TestParameterFilterByString.php │ ├── TestFilterData.php │ ├── TestCountableFilter.php │ ├── TranslatableConfig.php │ └── TestFilter.php ├── TestCase.php └── Src │ ├── FilterDataTest.php │ ├── ParameterFiltersTest.php │ ├── CountableFilterTest.php │ └── FilterTest.php ├── phpunit.xml ├── CONTRIBUTING.md ├── LICENSE.md ├── CHANGELOG.md ├── composer.json ├── EXAMPLE_DETAILS.md ├── EXAMPLES.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | - ./vendor/phpstan/phpstan-phpunit/extension.neon 4 | - ./vendor/phpstan/phpstan-mockery/extension.neon 5 | 6 | parameters: 7 | paths: 8 | - src 9 | -------------------------------------------------------------------------------- /src/Exceptions/FilterParameterUnhandledException.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class JoinType extends Enum 13 | { 14 | public const INNER = 'join'; 15 | public const LEFT = 'left'; 16 | public const RIGHT = 'right'; 17 | } 18 | -------------------------------------------------------------------------------- /src/CountableResults.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class CountableResults extends Collection 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /tests/Helpers/Models/TestSimpleModelTranslation.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class JoinKey extends Enum 15 | { 16 | public const TRANSLATIONS = 'translations'; 17 | public const PARENT = 'parent'; 18 | public const CHILDREN = 'children'; 19 | public const CHILD = 'child'; 20 | } 21 | -------------------------------------------------------------------------------- /src/Contracts/ValidatableTraitInterface.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public function getRules(): array; 17 | 18 | /** 19 | * @param array $rules 20 | */ 21 | public function setRules(array $rules): void; 22 | } 23 | -------------------------------------------------------------------------------- /src/Contracts/ParameterCounterInterface.php: -------------------------------------------------------------------------------- 1 | $query 17 | * @param CountableFilterInterface $filter 18 | * @return mixed 19 | */ 20 | public function count(string $name, Model|Builder|EloquentBuilder $query, CountableFilterInterface $filter): mixed; 21 | } 22 | -------------------------------------------------------------------------------- /tests/Helpers/TestCountableFilterData.php: -------------------------------------------------------------------------------- 1 | 'string', 16 | 'relateds' => 'array', 17 | 'position' => 'integer', 18 | 'with_inactive' => 'boolean', 19 | ]; 20 | 21 | /** 22 | * {@inheritDoc} 23 | */ 24 | protected array $defaults = [ 25 | 'name' => null, 26 | 'relateds' => [], 27 | 'position' => null, 28 | 'with_inactive' => false, 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /tests/Helpers/TestParameterFilterByString.php: -------------------------------------------------------------------------------- 1 | where('second_field', 'some more'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Contracts/FilterDataInterface.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | interface FilterDataInterface extends Arrayable 11 | { 12 | /** 13 | * Returns the default values for each applicable attribute. 14 | * 15 | * @return array 16 | */ 17 | public function getDefaults(): array; 18 | 19 | /** 20 | * Gets the attribute names which may be applied. 21 | * 22 | * @return string[] 23 | */ 24 | public function getApplicableAttributes(): array; 25 | 26 | public function getParameterValue(string $name): mixed; 27 | 28 | /** 29 | * @return array 30 | */ 31 | public function getAttributes(): array; 32 | } 33 | -------------------------------------------------------------------------------- /src/Exceptions/FilterDataValidationFailedException.php: -------------------------------------------------------------------------------- 1 | messages = $messages; 21 | 22 | return $this; 23 | } 24 | 25 | public function hasMessagesSet(): bool 26 | { 27 | return isset($this->messages); 28 | } 29 | 30 | public function getMessages(): MessageBag 31 | { 32 | return $this->messages; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Helpers/Models/TestRelatedModel.php: -------------------------------------------------------------------------------- 1 | belongsTo(TestSimpleModel::class); 29 | } 30 | 31 | public function testSimpleModels(): HasMany 32 | { 33 | return $this->hasMany(TestSimpleModel::class); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/Src 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/Helpers/Models/TestSimpleModel.php: -------------------------------------------------------------------------------- 1 | belongsTo(TestRelatedModel::class); 31 | } 32 | 33 | public function relatedModels(): HasMany 34 | { 35 | return $this->hasMany(TestRelatedModel::class); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Contracts/ParameterFilterInterface.php: -------------------------------------------------------------------------------- 1 | $query 18 | * @param FilterInterface $filter 19 | * @return TModel|Builder|EloquentBuilder 20 | */ 21 | public function apply( 22 | string $name, 23 | mixed $value, 24 | Model|Builder|EloquentBuilder $query, 25 | FilterInterface $filter, 26 | ): Model|Builder|EloquentBuilder; 27 | } 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/czim/laravel-filter). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)**. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Create feature branches** - Don't ask us to pull from your master branch. 17 | 18 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 19 | 20 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Coen Zimmerman 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### [5.0.0] - 2023-04-09 4 | 5 | Laravel 10 upgrade. 6 | 7 | ### [4.0.0] - 2022-10-06 8 | 9 | Now requires PHP 8.1 and Laravel 9 (at minimum). 10 | 11 | Breaking changes: 12 | - Stricter types. 13 | - PHP 8.1 language features used to clean things up and enforce types. 14 | 15 | Other changes: 16 | - Implemented generic type templates to indicate the Eloquent Model. 17 | - Style cleanup, consistency fixes. 18 | - Fixed various issues indicated by PHPStan. 19 | 20 | 21 | ### [3.0.0] - 2021-03-21 22 | 23 | Code cleanup. Only supports PHP 7.2 and up, and Laravel 6 and up. Laravel 8 support added. 24 | 25 | Breaking changes: 26 | - Fluent syntax support removed in many places. For the sake of cleaner method signatures, many setter (and similar) methods now return `void` instead of `$this`. 27 | - Stricter type hints added. Many methods now have stricter type hints. Some methods that accepted `string|string]]` are now split up into separate methods accepting `string` and `string[]` parameters separately. 28 | - Stricter parameter types. Some cases where `array|Arrayable` was flexibly allowed have now been restricted to `array`. 29 | 30 | 31 | [4.0.0]: https://github.com/czim/laravel-filter/compare/3.1.0...4.0.0 32 | [3.0.0]: https://github.com/czim/laravel-filter/compare/3.0.0...2.0.3 33 | -------------------------------------------------------------------------------- /src/Contracts/FilterInterface.php: -------------------------------------------------------------------------------- 1 | $query 24 | * @return TModel|Builder|EloquentBuilder 25 | */ 26 | public function apply(Model|Builder|EloquentBuilder $query): Model|Builder|EloquentBuilder; 27 | 28 | /** 29 | * Adds a query join to be added after all parameters are applied. 30 | * 31 | * @param string $key identifying key, used to prevent duplicates 32 | * @param array $parameters 33 | * @param string|null $joinType 'inner', 'right', defaults to left join 34 | */ 35 | public function addJoin(string $key, array $parameters, string $joinType = null): void; 36 | } 37 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "czim/laravel-filter", 3 | "description": "Filter for Laravel Eloquent queries, with support for modular filter building", 4 | "keywords": [ 5 | "laravel", 6 | "filter", 7 | "filters", 8 | "eloquent", 9 | "database" 10 | ], 11 | "homepage": "https://github.com/czim/laravel-filter", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Coen Zimmerman", 16 | "email": "coen.zimmerman@endeavour.nl", 17 | "homepage": "https://endeavour.nl", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "php" : "^8.1", 23 | "illuminate/support": "^10 || ^11 || ^12", 24 | "illuminate/database": "^10 || ^11 || ^12", 25 | "myclabs/php-enum": "^1.8" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit" : "^10.0", 29 | "orchestra/testbench": "^8.0", 30 | "nunomaduro/larastan": "^2.2", 31 | "phpstan/phpstan-mockery": "^1.1", 32 | "phpstan/phpstan-phpunit": "^1.1" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Czim\\Filter\\": "src" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Czim\\Filter\\Test\\": "tests" 42 | } 43 | }, 44 | "scripts": { 45 | "test": "phpunit" 46 | }, 47 | "minimum-stability": "dev", 48 | "prefer-stable": true 49 | } 50 | -------------------------------------------------------------------------------- /tests/Helpers/TestFilterData.php: -------------------------------------------------------------------------------- 1 | 'string', 16 | 'relateds' => 'array', 17 | 'position' => 'integer', 18 | 'with_inactive' => 'boolean', 19 | 20 | 'closure_strategy' => 'array|size:2', 21 | 'closure_strategy_array' => 'array|size:2', 22 | 23 | 'global_setting' => 'string', 24 | ]; 25 | 26 | /** 27 | * {@inheritDoc} 28 | */ 29 | protected array $defaults = [ 30 | 'name' => null, 31 | 'relateds' => [], 32 | 'position' => null, 33 | 'with_inactive' => false, 34 | 35 | // For tests of the strategy interpretation. 36 | 'no_strategy_set' => null, 37 | 'no_strategy_set_no_fallback' => null, 38 | 'parameter_filter_instance' => null, 39 | 'parameter_filter_string' => null, 40 | 'closure_strategy' => null, 41 | 'closure_strategy_array' => null, 42 | 43 | 'global_setting' => null, 44 | 45 | // Testing exceptions for invalid strategies. 46 | 'invalid_strategy_string' => null, 47 | 'invalid_strategy_general' => null, 48 | 'invalid_strategy_interface' => null, 49 | 50 | 'adding_joins' => null, 51 | 'no_duplicate_joins' => null, 52 | ]; 53 | } 54 | -------------------------------------------------------------------------------- /src/ParameterFilters/NotEmpty.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class NotEmpty implements ParameterFilterInterface 19 | { 20 | /** 21 | * @param string|null $table 22 | * @param string|null $column if given, overrules the attribute name 23 | */ 24 | public function __construct( 25 | protected readonly ?string $table = null, 26 | protected readonly ?string $column = null, 27 | ) { 28 | } 29 | 30 | /** 31 | * @param string $name 32 | * @param mixed $value 33 | * @param TModel|Builder|EloquentBuilder $query 34 | * @param FilterInterface $filter 35 | * @return TModel|Builder|EloquentBuilder 36 | */ 37 | public function apply( 38 | string $name, 39 | mixed $value, 40 | Model|Builder|EloquentBuilder $query, 41 | FilterInterface $filter, 42 | ): Model|Builder|EloquentBuilder { 43 | $column = (! empty($this->table) ? $this->table . '.' : null) 44 | . (! empty($this->column) ? $this->column : $name); 45 | 46 | return $query 47 | ->whereNotNull($column) 48 | ->where($column, '!=', ''); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ParameterFilters/IsEmpty.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | class IsEmpty implements ParameterFilterInterface 19 | { 20 | /** 21 | * @param string|null $table 22 | * @param string|null $column if given, overrules the attribute name 23 | */ 24 | public function __construct( 25 | protected readonly ?string $table = null, 26 | protected readonly ?string $column = null, 27 | ) { 28 | } 29 | 30 | /** 31 | * @param string $name 32 | * @param mixed $value 33 | * @param TModel|Builder|EloquentBuilder $query 34 | * @param FilterInterface $filter 35 | * @return TModel|Builder|EloquentBuilder 36 | */ 37 | public function apply( 38 | string $name, 39 | mixed $value, 40 | Model|Builder|EloquentBuilder $query, 41 | FilterInterface $filter, 42 | ): Model|Builder|EloquentBuilder { 43 | $column = (! empty($this->table) ? $this->table . '.' : null) 44 | . (! empty($this->column) ? $this->column : $name); 45 | 46 | return $query->where( 47 | fn (Model|Builder|EloquentBuilder $query) => $query 48 | ->whereNull($column) 49 | ->orWhere($column, '') 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Contracts/CountableFilterInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface CountableFilterInterface extends FilterInterface 13 | { 14 | /** 15 | * Returns a list of the countable parameters to get counts for. 16 | * 17 | * @return array 18 | */ 19 | public function getCountables(): array; 20 | 21 | /** 22 | * Gets alternative counts per (relevant) attribute for the filter data. 23 | * 24 | * @param string[] $countables when provided, limits the result to theses countables 25 | * @return CountableResults 26 | */ 27 | public function getCounts(array $countables = []): CountableResults; 28 | 29 | /** 30 | * Disables a countable when getCounts() is invoked. 31 | * 32 | * @param string $countable 33 | */ 34 | public function ignoreCountable(string $countable): void; 35 | 36 | /** 37 | * Disables a number of countables when getCounts() is invoked. 38 | * 39 | * @param string[] $countables 40 | */ 41 | public function ignoreCountables(array $countables): void; 42 | 43 | /** 44 | * Re-enables a countable when getCounts() is invoked. 45 | * 46 | * @param string $countable 47 | */ 48 | public function unignoreCountable(string $countable): void; 49 | 50 | /** 51 | * Re-enables a number of countables when getCounts() is invoked. 52 | * 53 | * @param string[] $countables 54 | */ 55 | public function unignoreCountables(array $countables): void; 56 | 57 | /** 58 | * Returns whether a given countable is currently being ignored/omitted 59 | * 60 | * @param string $countableName 61 | * @return bool 62 | */ 63 | public function isCountableIgnored(string $countableName): bool; 64 | } 65 | -------------------------------------------------------------------------------- /src/ParameterFilters/SimpleString.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class SimpleString implements ParameterFilterInterface 22 | { 23 | /** 24 | * @param string|null $table 25 | * @param string|null $column if given, overrules the attribute name 26 | * @param bool $exact whether this should not be a loosy comparison 27 | */ 28 | public function __construct( 29 | protected readonly ?string $table = null, 30 | protected readonly ?string $column = null, 31 | protected readonly bool $exact = false, 32 | ) { 33 | } 34 | 35 | /** 36 | * @param string $name 37 | * @param mixed $value 38 | * @param TModel|Builder|EloquentBuilder $query 39 | * @param FilterInterface $filter 40 | * @return TModel|Builder|EloquentBuilder 41 | */ 42 | public function apply( 43 | string $name, 44 | mixed $value, 45 | Model|Builder|EloquentBuilder $query, 46 | FilterInterface $filter, 47 | ): Model|Builder|EloquentBuilder { 48 | $column = (! empty($this->table) ? $this->table . '.' : null) 49 | . (! empty($this->column) ? $this->column : $name); 50 | 51 | $operator = '='; 52 | 53 | if (! $this->exact) { 54 | $operator = 'like'; 55 | $value = '%' . $value . '%'; 56 | } 57 | 58 | return $query->where($column, $operator, $value); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/ParameterFilters/SimpleInteger.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class SimpleInteger implements ParameterFilterInterface 20 | { 21 | /** 22 | * @param string|null $table 23 | * @param string|null $column if given, overrules the attribute name 24 | * @param string $operator =, !=, <, >; only used when the value is not a list 25 | */ 26 | public function __construct( 27 | protected readonly ?string $table = null, 28 | protected readonly ?string $column = null, 29 | protected readonly string $operator = '=', 30 | ) { 31 | } 32 | 33 | /** 34 | * @param string $name 35 | * @param mixed $value 36 | * @param TModel|Builder|EloquentBuilder $query 37 | * @param FilterInterface $filter 38 | * @return TModel|Builder|EloquentBuilder 39 | */ 40 | public function apply( 41 | string $name, 42 | mixed $value, 43 | Model|Builder|EloquentBuilder $query, 44 | FilterInterface $filter, 45 | ): Model|Builder|EloquentBuilder { 46 | $column = (! empty($this->table) ? $this->table . '.' : null) 47 | . (! empty($this->column) ? $this->column : $name); 48 | 49 | 50 | // If the value is a list, do a whereIn query: 51 | if ($value instanceof Arrayable) { 52 | $value = $value->toArray(); 53 | } 54 | 55 | if (is_array($value)) { 56 | $query->whereIn($column, $value); 57 | return $query; 58 | } 59 | 60 | // Otherwise, do a normal comparison. 61 | return $query->where($column, $this->operator, $value); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Traits/Validatable.php: -------------------------------------------------------------------------------- 1 | validator = $this->makeValidatorInstance(); 30 | 31 | return ! $this->validator->fails(); 32 | } 33 | 34 | protected function makeValidatorInstance(): ValidatorContract 35 | { 36 | return ValidatorFacade::make($this->getAttributes(), $this->getRules()); 37 | } 38 | 39 | public function messages(): MessageBagContract 40 | { 41 | if (! isset($this->validator)) { 42 | $this->validate(); 43 | } 44 | 45 | if ($this->validator->fails()) { 46 | return $this->validator->errors(); 47 | } 48 | 49 | return $this->makeEmptyMessageBag(); 50 | } 51 | 52 | protected function makeEmptyMessageBag(): MessageBagContract 53 | { 54 | return new MessageBag(); 55 | } 56 | 57 | /** 58 | * @return array 59 | */ 60 | public function getRules(): array 61 | { 62 | if (! property_exists($this, 'rules')) { 63 | return []; 64 | } 65 | 66 | return $this->rules ?? []; 67 | } 68 | 69 | /** 70 | * @param array $rules 71 | */ 72 | public function setRules(array $rules): void 73 | { 74 | if (! property_exists($this, 'rules')) { 75 | // Don't allow dynamic property assignment anymore. 76 | throw new UnexpectedValueException('No rules property available to set rules on'); 77 | } 78 | 79 | $this->rules = $rules; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/Helpers/TestCountableFilter.php: -------------------------------------------------------------------------------- 1 | new ParameterFilters\SimpleString(), 37 | 'position' => new ParameterFilters\SimpleInteger(), 38 | 'relateds' => new ParameterFilters\SimpleInteger(null, 'test_related_model_id'), 39 | ]; 40 | } 41 | 42 | /** 43 | * {@inheritDoc} 44 | */ 45 | protected function applyParameter(string $name, mixed $value, Model|Builder|EloquentBuilder $query): void 46 | { 47 | // Typical with inactive lookup. 48 | // Make sure we don't get the 'no fallback strategy' exception. 49 | if ($name === 'with_inactive') { 50 | if (! $value) { 51 | $query->where('active', true); 52 | } 53 | 54 | return; 55 | } 56 | 57 | parent::applyParameter($name, $value, $query); 58 | } 59 | 60 | protected function getCountableBaseQuery(?string $parameter = null): Model|Builder|EloquentBuilder 61 | { 62 | return TestSimpleModel::query(); 63 | } 64 | 65 | /** 66 | * {@inheritDoc} 67 | */ 68 | protected function countStrategies(): array 69 | { 70 | return [ 71 | 'position' => new ParameterCounters\SimpleDistinctValue(), 72 | 'relateds' => new ParameterCounters\SimpleBelongsTo('test_related_model_id'), 73 | ]; 74 | } 75 | 76 | /** 77 | * {@inheritDoc} 78 | */ 79 | protected function countParameter(string $parameter, Model|Builder|EloquentBuilder $query): mixed 80 | { 81 | return parent::countParameter($parameter, $query); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/ParameterCounters/SimpleDistinctValue.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class SimpleDistinctValue implements ParameterCounterInterface 23 | { 24 | /** 25 | * @param string|null $columnName the column name to count, always used unless null 26 | * @param bool $includeEmpty whether to also count for NULL, default is to exclude 27 | * @param string $countRaw the raw SQL count statement ('COUNT(*)') 28 | * @param string $columnAlias an alias for the column ('id') 29 | * @param string $countAlias an alias for the count ('count') 30 | */ 31 | public function __construct( 32 | protected readonly ?string $columnName = null, 33 | protected readonly bool $includeEmpty = false, 34 | protected readonly string $countRaw = 'count(*)', 35 | protected readonly string $columnAlias = 'value', 36 | protected readonly string $countAlias = 'count', 37 | ) { 38 | } 39 | 40 | /** 41 | * Returns the count for a countable parameter, given the query provided. 42 | * 43 | * @param string $name 44 | * @param TModel|Builder|EloquentBuilder $query 45 | * @param CountableFilterInterface $filter 46 | * @return Collection 47 | */ 48 | public function count( 49 | string $name, 50 | Model|Builder|EloquentBuilder $query, 51 | CountableFilterInterface $filter, 52 | ): Collection { 53 | $columnName = $this->determineColumnName($name); 54 | 55 | if (! $this->includeEmpty) { 56 | $query->whereNotNull($columnName); 57 | } 58 | 59 | return $query 60 | ->select([ 61 | "{$columnName} as {$this->columnAlias}", 62 | DB::raw("{$this->countRaw} as {$this->countAlias}"), 63 | ]) 64 | ->groupBy($columnName) 65 | ->pluck($this->countAlias, $this->columnAlias); 66 | } 67 | 68 | protected function determineColumnName(string $name): string 69 | { 70 | // If the columnname is not set, assume an id field based on a table name. 71 | if (empty($this->columnName)) { 72 | return $name; 73 | } 74 | 75 | return $this->columnName; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'testbench'); 27 | $app['config']->set('database.connections.testbench', [ 28 | 'driver' => 'sqlite', 29 | 'database' => ':memory:', 30 | 'prefix' => '', 31 | ]); 32 | 33 | $app['config']->set('translatable', (new TranslatableConfig())->getConfig()); 34 | } 35 | 36 | 37 | protected function setUp(): void 38 | { 39 | parent::setUp(); 40 | 41 | $this->migrateDatabase(); 42 | $this->seedDatabase(); 43 | } 44 | 45 | 46 | protected function migrateDatabase(): void 47 | { 48 | // Model we can test anything but translations with. 49 | Schema::create(self::TABLE_NAME_SIMPLE, function ($table): void { 50 | $table->increments('id'); 51 | $table->string('unique_field', 20); 52 | $table->integer('second_field')->unsigned()->nullable(); 53 | $table->string('name', 255)->nullable(); 54 | $table->integer('test_related_model_id'); 55 | $table->integer('position')->nullable(); 56 | $table->boolean('active')->nullable()->default(false); 57 | $table->timestamps(); 58 | }); 59 | 60 | // Model we can also test translations with. 61 | Schema::create(self::TABLE_NAME_RELATED, function ($table): void { 62 | $table->increments('id'); 63 | $table->string('name', 255)->nullable(); 64 | $table->string('some_property', 20)->nullable(); 65 | $table->integer('test_simple_model_id')->nullable(); 66 | $table->integer('position')->nullable(); 67 | $table->boolean('active')->nullable()->default(false); 68 | $table->timestamps(); 69 | }); 70 | 71 | Schema::create(self::TABLE_NAME_TRANSLATIONS, function ($table): void { 72 | $table->increments('id'); 73 | $table->integer('test_simple_model_id')->unsigned(); 74 | $table->string('locale', 12); 75 | $table->string('translated_string', 255); 76 | $table->timestamps(); 77 | }); 78 | } 79 | 80 | protected function seedDatabase(): void 81 | { 82 | // noop 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ParameterCounters/SimpleBelongsTo.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | class SimpleBelongsTo implements ParameterCounterInterface 31 | { 32 | /** 33 | * @param string|null $columnName the column name to count, always used unless null 34 | * @param bool $includeEmpty whether to also count for NULL, default is to exclude 35 | * @param string $countRaw the raw SQL count statement ('COUNT(*)') 36 | * @param string $columnAlias an alias for the column ('id') 37 | * @param string $countAlias an alias for the count ('count') 38 | */ 39 | public function __construct( 40 | protected readonly ?string $columnName = null, 41 | protected readonly bool $includeEmpty = false, 42 | protected readonly string $countRaw = 'COUNT(*)', 43 | protected readonly string $columnAlias = 'id', 44 | protected readonly string $countAlias = 'count', 45 | ) { 46 | } 47 | 48 | /** 49 | * Returns the count for a countable parameter, given the query provided. 50 | * 51 | * @param string $name 52 | * @param TModel|Builder|EloquentBuilder $query 53 | * @param CountableFilterInterface $filter 54 | * @return Collection 55 | */ 56 | public function count( 57 | string $name, 58 | Model|Builder|EloquentBuilder $query, 59 | CountableFilterInterface $filter, 60 | ): Collection { 61 | $columnName = $this->determineColumnName($name); 62 | 63 | if (! $this->includeEmpty) { 64 | $query->whereNotNull($columnName); 65 | } 66 | 67 | return $query 68 | ->select([ 69 | "{$columnName} as {$this->columnAlias}", 70 | DB::raw("{$this->countRaw} as {$this->countAlias}") 71 | ]) 72 | ->groupBy($columnName) 73 | ->pluck($this->countAlias, $this->columnAlias); 74 | } 75 | 76 | protected function determineColumnName(string $name): string 77 | { 78 | // If the columnname is not set, assume an id field based on a table name. 79 | if (empty($this->columnName)) { 80 | return Str::singular($name) . '_id'; 81 | } 82 | 83 | return $this->columnName; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Helpers/TranslatableConfig.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function getConfig(): array 13 | { 14 | return [ 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Application Locales 19 | |-------------------------------------------------------------------------- 20 | | 21 | | Contains an array with the applications available locales. 22 | | 23 | */ 24 | 'locales' => ['en', 'nl'], 25 | 26 | /* 27 | |-------------------------------------------------------------------------- 28 | | Use fallback 29 | |-------------------------------------------------------------------------- 30 | | 31 | | Determine if fallback locales are returned by default or not. To add 32 | | more flexibility and configure this option per "translatable" 33 | | instance, this value will be overridden by the property 34 | | $useTranslationFallback when defined 35 | */ 36 | 'use_fallback' => true, 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Fallback Locale 41 | |-------------------------------------------------------------------------- 42 | | 43 | | A fallback locale is the locale being used to return a translation 44 | | when the requested translation is not existing. To disable it 45 | | set it to false. 46 | | 47 | */ 48 | 'fallback_locale' => 'nl', 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Translation Suffix 53 | |-------------------------------------------------------------------------- 54 | | 55 | | Defines the default 'Translation' class suffix. For example, if 56 | | you want to use CountryTrans instead of CountryTranslation 57 | | application, set this to 'Trans'. 58 | | 59 | */ 60 | 'translation_suffix' => 'Translation', 61 | 62 | /* 63 | |-------------------------------------------------------------------------- 64 | | Locale key 65 | |-------------------------------------------------------------------------- 66 | | 67 | | Defines the 'locale' field name, which is used by the 68 | | translation model. 69 | | 70 | */ 71 | 'locale_key' => 'locale', 72 | 73 | /* 74 | |-------------------------------------------------------------------------- 75 | | Make translated attributes always fillable 76 | |-------------------------------------------------------------------------- 77 | | 78 | | If true, translatable automatically sets 79 | | translated attributes as fillable. 80 | | 81 | | WARNING! 82 | | Set this to true only if you understand the security risks. 83 | | 84 | */ 85 | 'always_fillable' => false, 86 | ]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/Helpers/TestFilter.php: -------------------------------------------------------------------------------- 1 | new SimpleString(), 29 | 'relateds' => new SimpleInteger(null, 'test_related_model_id'), 30 | 31 | 'parameter_filter_instance' => new SimpleString('test_simple_models', 'name', true), 32 | 'parameter_filter_string' => TestParameterFilterByString::class, 33 | 'closure_strategy' => fn ($name, $value, $query) => $this->closureTestMethod($name, $value, $query), 34 | 'closure_strategy_array' => [ $this, 'closureTestMethod' ], 35 | 36 | 'global_setting' => Filter::SETTING, 37 | 38 | 'invalid_strategy_string' => 'uninstantiable_string_that_is_not_a_parameter_filter', 39 | 'invalid_strategy_general' => 13323909823, 40 | 'invalid_strategy_interface' => TranslatableConfig::class, // just any class that is not a ParameterFilterInterface 41 | ]; 42 | } 43 | 44 | /** 45 | * {@inheritDoc} 46 | */ 47 | protected function applyParameter(string $name, mixed $value, Model|Builder|EloquentBuilder $query): void 48 | { 49 | // Typical with inactive lookup. 50 | // Make sure we don't get the 'no fallback strategy' exception. 51 | if ($name === 'with_inactive') { 52 | if (! $value) { 53 | $query->where('active', true); 54 | } 55 | return; 56 | } 57 | 58 | // Testing joins addition: 59 | switch ($name) { 60 | case 'adding_joins': 61 | case 'no_duplicate_joins': 62 | $this->addJoin( 63 | 'UNIQUE_JOIN_KEY', 64 | [ 'test_related_models', 'test_related_models.id', '=', 'test_simple_models.test_related_model_id' ] 65 | ); 66 | 67 | $query->where($name, '=', $value); 68 | return; 69 | } 70 | 71 | parent::applyParameter($name, $value, $query); 72 | } 73 | 74 | /** 75 | * Simple method to test whether closure strategies work. 76 | * 77 | * Note that this cannot be a private method, or the [] syntax won't work. 78 | * 79 | * @param string $name 80 | * @param mixed $value 81 | * @param Model|Builder|EloquentBuilder $query 82 | * @return Model|Builder|EloquentBuilder 83 | */ 84 | protected function closureTestMethod( 85 | string $name, 86 | mixed $value, 87 | Model|Builder|EloquentBuilder $query, 88 | ): Model|Builder|EloquentBuilder { 89 | if (! is_array($value) || count($value) !== 2) { 90 | throw new RuntimeException("Value for '{$name}' not correctly passed through closure!"); 91 | } 92 | 93 | return $query 94 | ->where('name', '=', $value[0]) 95 | ->where('test_related_model_id', '=', $value[1]); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/Src/FilterDataTest.php: -------------------------------------------------------------------------------- 1 | 'some name', 27 | 'relateds' => [1, 2, 3], 28 | 'position' => 20, 29 | 'with_inactive' => false, 30 | ]) 31 | ); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | public function it_throws_an_exception_if_invalid_data_is_passed_in(): void 38 | { 39 | // See if we get the messages correctly. 40 | try { 41 | new TestFilterData([ 42 | 'name' => 'some name', 43 | 'relateds' => 'string which should be an array', 44 | 'position' => 'string which should be an integer', 45 | 'with_inactive' => 'not even a boolean here', 46 | ]); 47 | } catch (FilterDataValidationFailedException $e) { 48 | $messages = $e->getMessages(); 49 | static::assertCount(3, $messages, 'Exception getMessages should have 3 messages'); 50 | } 51 | 52 | $this->expectException(FilterDataValidationFailedException::class); 53 | 54 | // Throw the exception, but don't catch it this time. 55 | new TestFilterData([ 56 | 'name' => 'some name', 57 | 'relateds' => 'string which should be an array', 58 | 'position' => 'string which should be an integer', 59 | 'with_inactive' => 'not even a boolean here', 60 | ]); 61 | } 62 | 63 | // -------------------------------------------- 64 | // Getters, setters and defaults 65 | // -------------------------------------------- 66 | 67 | /** 68 | * @test 69 | */ 70 | public function it_sets_default_values_for_parameters_not_provided(): void 71 | { 72 | $data = new TestFilterData([ 73 | 'name' => 'some name', 74 | ]); 75 | 76 | static::assertFalse($data->getDefaults()['with_inactive'], 'Defaults not correct for test'); 77 | 78 | static::assertFalse($data->getParameterValue('with_inactive'), 'Defaults were not set for parametervalues'); 79 | } 80 | 81 | /** 82 | * @test 83 | */ 84 | public function it_accepts_custom_defaults_through_constructor_parameter(): void 85 | { 86 | $data = new TestFilterData( 87 | [ 88 | 'name' => 'some name', 89 | ], 90 | // Custom defaults: 91 | [ 92 | 'name' => null, 93 | 'with_inactive' => true, 94 | ] 95 | ); 96 | 97 | static::assertTrue($data->getDefaults()['with_inactive'], 'Defaults not correct for test'); 98 | 99 | static::assertTrue($data->getParameterValue('with_inactive'), 'Defaults were not set for parametervalues'); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/FilterData.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected array $attributes = []; 22 | 23 | /** 24 | * Validation rules for filter. 25 | * 26 | * @var array 27 | */ 28 | protected array $rules = []; 29 | 30 | /** 31 | * Default values. Anything NOT listed here will NOT be applied in queries. 32 | * Make sure there are defaults for every filterable attribute. 33 | * 34 | * @var array 35 | */ 36 | protected array $defaults = []; 37 | 38 | 39 | /** 40 | * Constructor: validate filter data. 41 | * 42 | * @param array $attributes 43 | * @param array|null $defaults if provided, overrides internal defaults 44 | * @throws FilterDataValidationFailedException 45 | */ 46 | public function __construct(array $attributes, ?array $defaults = null) 47 | { 48 | // Validate and sanitize the attribute values passed in. 49 | $this->attributes = $this->sanitizeAttributes($attributes); 50 | 51 | $this->validateAttributes(); 52 | 53 | if ($defaults !== null) { 54 | $this->defaults = $defaults; 55 | } 56 | 57 | // Set attributes, filling in defaults. 58 | $this->attributes = array_merge($this->defaults, $this->attributes); 59 | } 60 | 61 | /** 62 | * @return array 63 | */ 64 | public function toArray(): array 65 | { 66 | return $this->attributes; 67 | } 68 | 69 | /** 70 | * @return array 71 | */ 72 | public function getDefaults(): array 73 | { 74 | return $this->defaults; 75 | } 76 | 77 | /** 78 | * Gets the attribute names which may be applied. 79 | * 80 | * @return string[] 81 | */ 82 | public function getApplicableAttributes(): array 83 | { 84 | return array_keys($this->defaults); 85 | } 86 | 87 | /** 88 | * Sanitizes the attributes passed in. 89 | * 90 | * Override this to apply sanitization to any attributes passed into the class. 91 | * 92 | * @param array $attributes 93 | * @return array 94 | */ 95 | protected function sanitizeAttributes(array $attributes): array 96 | { 97 | return $attributes; 98 | } 99 | 100 | /** 101 | * Validates currently set attributes (not including defaults) 102 | * against the given validation rules. 103 | * 104 | * @throws FilterDataValidationFailedException 105 | */ 106 | protected function validateAttributes(): void 107 | { 108 | if (empty($this->getRules())) { 109 | return; 110 | } 111 | 112 | if (! $this->validate()) { 113 | throw (new FilterDataValidationFailedException()) 114 | ->setMessages($this->messages()); 115 | } 116 | } 117 | 118 | public function getParameterValue(string $name): mixed 119 | { 120 | return $this->attributes[ $name ] ?? null; 121 | } 122 | 123 | /** 124 | * @return array 125 | */ 126 | public function getAttributes(): array 127 | { 128 | return $this->attributes; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /EXAMPLE_DETAILS.md: -------------------------------------------------------------------------------- 1 | # Migrations 2 | 3 | ```php 4 | // ..._create_products_table.php 5 | class CreateProductsTable extends Migration 6 | { 7 | public function up(): void 8 | { 9 | Schema::create('products', function (Blueprint $table) { 10 | $table->increments('id'); 11 | $table->string('name', 255); 12 | $table->string('ean', 20)->nullable(); 13 | $table->text('description'); 14 | $table->integer('brand_id')->unsigned(); 15 | $table->timestamps(); 16 | }); 17 | } 18 | 19 | public function down(): void 20 | { 21 | Schema::drop('products'); 22 | } 23 | } 24 | 25 | 26 | // ..._create_brands_table.php 27 | class CreateBrandsTable extends Migration 28 | { 29 | public function up(): void 30 | { 31 | Schema::create('brands', function (Blueprint $table) { 32 | $table->increments('id'); 33 | $table->string('name', 255); 34 | $table->timestamps(); 35 | }); 36 | 37 | Schema::table('products', function(Blueprint $table) { 38 | 39 | $table->foreign('brand_id', 'fk_products_brands1') 40 | ->references('id') 41 | ->on('brands') 42 | ->onDelete('cascade'); 43 | }); 44 | } 45 | 46 | public function down(): void 47 | { 48 | Schema::table('products', function(Blueprint $table) { 49 | 50 | $table->dropForeign('fk_products_brands1'); 51 | }); 52 | 53 | Schema::drop('brands'); 54 | } 55 | } 56 | 57 | 58 | // ..._create_categories_table.php 59 | class CreateCategoriesTable extends Migration 60 | { 61 | public function up(): void 62 | { 63 | Schema::create('categories', function (Blueprint $table) { 64 | $table->increments('id'); 65 | $table->string('name', 255); 66 | $table->timestamps(); 67 | }); 68 | 69 | Schema::create('category_product', function(Blueprint $table) { 70 | $table->integer('category_id')->unsigned(); 71 | $table->integer('product_id')->unsigned(); 72 | 73 | $table->foreign('category_id', 'fk_category_product_categories1') 74 | ->references('id') 75 | ->on('categories') 76 | ->onDelete('cascade'); 77 | 78 | $table->foreign('product_id', 'fk_category_product_products1') 79 | ->references('id') 80 | ->on('products') 81 | ->onDelete('cascade'); 82 | 83 | $table->primary(['category_id', 'product_id']); 84 | }); 85 | } 86 | 87 | public function down(): void 88 | { 89 | Schema::drop('category_product'); 90 | Schema::drop('categories'); 91 | } 92 | } 93 | ``` 94 | 95 | # Models 96 | 97 | ## Product 98 | 99 | ```php 100 | belongsTo(Brand::class); 119 | } 120 | 121 | public function categories(): BelongsToMany 122 | { 123 | return $this->belongsToMany(Category::class); 124 | } 125 | } 126 | ``` 127 | 128 | 129 | ## Brand 130 | 131 | ```php 132 | hasMany(Product::class); 147 | } 148 | } 149 | ``` 150 | 151 | ## Category 152 | 153 | ```php 154 | belongsToMany(Product::class); 169 | } 170 | } 171 | ``` 172 | -------------------------------------------------------------------------------- /tests/Src/ParameterFiltersTest.php: -------------------------------------------------------------------------------- 1 | apply('testcol', 'value', TestSimpleModel::query(), $filter); 29 | 30 | static::assertMatchesRegularExpression( 31 | '#where ["`]testcol["`] like#i', 32 | $query->toSql(), 33 | 'Query SQL wrong for loosy default match' 34 | ); 35 | static::assertEquals('%value%', $query->getBindings()[0], 'Binding not correct for loosy default match'); 36 | 37 | // Exact match. 38 | $pfilter = new SimpleString(null, null, true); 39 | $query = $pfilter->apply('testcol', 'value', TestSimpleModel::query(), $filter); 40 | 41 | static::assertMatchesRegularExpression( 42 | '#where ["`]testcol["`] =#i', 43 | $query->toSql(), 44 | 'Query SQL wrong for exact match' 45 | ); 46 | static::assertEquals('value', $query->getBindings()[0], 'Binding not correct for exact match'); 47 | 48 | // Custom table and column name. 49 | $pfilter = new SimpleString('custom_table', 'custom_column'); 50 | $query = $pfilter->apply('testcol', 'value', TestSimpleModel::query(), $filter); 51 | 52 | static::assertMatchesRegularExpression( 53 | '#where ["`]custom_table["`]\.["`]custom_column["`] like#i', 54 | $query->toSql(), 55 | 'Query SQL wrong for custom names match' 56 | ); 57 | static::assertEquals('%value%', $query->getBindings()[0], 'Binding not correct for custom names match'); 58 | } 59 | 60 | 61 | /** 62 | * @test 63 | */ 64 | public function simple_integer_parameter_filter(): void 65 | { 66 | $filter = new TestFilter([]); 67 | 68 | // Simple single integer. 69 | $pfilter = new SimpleInteger(); 70 | $query = $pfilter->apply('testcol', 13, TestSimpleModel::query(), $filter); 71 | 72 | static::assertMatchesRegularExpression( 73 | '#where ["`]testcol["`] =#i', 74 | $query->toSql(), 75 | 'Query SQL wrong for default single integer' 76 | ); 77 | static::assertEquals(13, $query->getBindings()[0], 'Binding not correct for default single integer'); 78 | 79 | // Custom operator. 80 | $pfilter = new SimpleInteger(null, null, '>'); 81 | $query = $pfilter->apply('testcol', 13, TestSimpleModel::query(), $filter); 82 | 83 | static::assertMatchesRegularExpression( 84 | '#where ["`]testcol["`] >#i', 85 | $query->toSql(), 86 | 'Query SQL wrong for custom operator match' 87 | ); 88 | static::assertEquals(13, $query->getBindings()[0], 'Binding not correct for custom operator match'); 89 | 90 | // Custom table and column name. 91 | $pfilter = new SimpleInteger('custom_table', 'custom_column'); 92 | $query = $pfilter->apply('testcol', 13, TestSimpleModel::query(), $filter); 93 | 94 | static::assertMatchesRegularExpression( 95 | '#where ["`]custom_table["`]\.["`]custom_column["`] =#i', 96 | $query->toSql(), 97 | 'Query SQL wrong for integer custom names match' 98 | ); 99 | static::assertEquals(13, $query->getBindings()[0], 'Binding not correct for integer custom names match'); 100 | 101 | // WhereIn match (array argument). 102 | $pfilter = new SimpleInteger(); 103 | $query = $pfilter->apply('testcol', [ 13, 14 ], TestSimpleModel::query(), $filter); 104 | 105 | static::assertMatchesRegularExpression( 106 | '#where ["`]testcol["`] in\s*\(\s*\?\s*,\s*\?\s*\)#i', 107 | $query->toSql(), 108 | 'Query SQL wrong for wherein match' 109 | ); 110 | static::assertEquals([13, 14], $query->getBindings(), 'Bindings not correct for wherein match'); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/ParameterFilters/SimpleTranslatedString.php: -------------------------------------------------------------------------------- 1 | _translations'. If you want to 21 | * override this behavior, simply pass in the full translation table name for 22 | * the translationTable parameter. 23 | * 24 | * Standard Laravel conventions are required for this to work, so the 25 | * translated table ``.`id` should be referred to in the foreign key 26 | * on the translations table as something_translations.something_id. 27 | * 28 | * @template TModel of \Illuminate\Database\Eloquent\Model 29 | * 30 | * @implements ParameterFilterInterface 31 | */ 32 | class SimpleTranslatedString implements ParameterFilterInterface 33 | { 34 | protected const TRANSLATION_TABLE_POSTFIX = '_translations'; 35 | 36 | protected string $table; 37 | protected string $translationTable; 38 | protected ?string $column; 39 | protected string $locale; 40 | protected bool $exact; 41 | 42 | 43 | /** 44 | * @param string $table 45 | * @param string|null $translationTable 46 | * @param string|null $column if given, overrules the attribute name 47 | * @param string|null $locale 48 | * @param bool $exact whether this should not be a loosy (like) comparison 49 | */ 50 | public function __construct( 51 | string $table, 52 | ?string $translationTable = null, 53 | ?string $column = null, 54 | ?string $locale = null, 55 | bool $exact = false, 56 | ) { 57 | if (empty($translationTable)) { 58 | $translationTable = Str::singular($table) . self::TRANSLATION_TABLE_POSTFIX; 59 | } 60 | 61 | if (empty($locale)) { 62 | $locale = app()->getLocale(); 63 | } 64 | 65 | $this->table = $table; 66 | $this->translationTable = $translationTable; 67 | $this->column = $column; 68 | $this->locale = $locale; 69 | $this->exact = $exact; 70 | } 71 | 72 | /** 73 | * @param string $name 74 | * @param string|Stringable $value 75 | * @param TModel|Builder|EloquentBuilder $query 76 | * @param FilterInterface $filter 77 | * @return TModel|Builder|EloquentBuilder 78 | */ 79 | public function apply( 80 | string $name, 81 | mixed $value, 82 | Model|Builder|EloquentBuilder $query, 83 | FilterInterface $filter, 84 | ): Model|Builder|EloquentBuilder { 85 | $column = $this->translationTable . '.' 86 | . (! empty($this->column) ? $this->column : $name); 87 | 88 | $query 89 | ->where($this->qualifiedLocaleColumn(), $this->locale) 90 | ->where($column, $this->getComparisonOperator(), $this->makeValueToCompare($value)); 91 | 92 | 93 | // Add a join for the translations, using the generic join key. 94 | $filter->addJoin( 95 | $this->joinKeyForTranslations(), 96 | [$this->translationTable, $this->qualifiedForeignKeyName(), '=', $this->qualifiedTableKeyName()] 97 | ); 98 | 99 | return $query; 100 | } 101 | 102 | protected function getComparisonOperator(): string 103 | { 104 | if ($this->exact) { 105 | return '='; 106 | } 107 | 108 | return 'like'; 109 | } 110 | 111 | protected function makeValueToCompare(string|Stringable $value): string 112 | { 113 | if ($this->exact) { 114 | return (string) $value; 115 | } 116 | 117 | return '%' . $value . '%'; 118 | } 119 | 120 | protected function qualifiedLocaleColumn(): string 121 | { 122 | return $this->translationTable . '.' . $this->localeColumnName(); 123 | } 124 | 125 | protected function localeColumnName(): string 126 | { 127 | return 'locale'; 128 | } 129 | 130 | protected function qualifiedTableKeyName(): string 131 | { 132 | return $this->table . '.' . $this->localKeyName(); 133 | } 134 | 135 | protected function localKeyName(): string 136 | { 137 | return 'id'; 138 | } 139 | 140 | protected function qualifiedForeignKeyName(): string 141 | { 142 | return $this->translationTable . '.' . $this->foreignKeyName(); 143 | } 144 | 145 | protected function foreignKeyName(): string 146 | { 147 | return Str::singular($this->table) . '_id'; 148 | } 149 | 150 | protected function joinKeyForTranslations(): string 151 | { 152 | return JoinKey::TRANSLATIONS; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /tests/Src/CountableFilterTest.php: -------------------------------------------------------------------------------- 1 | '11', 23 | 'second_field' => null, 24 | 'name' => 'simple name', 25 | 'test_related_model_id' => 1, 26 | 'position' => 0, 27 | 'active' => true, 28 | ]); 29 | 30 | TestSimpleModel::create([ 31 | 'unique_field' => '123', 32 | 'second_field' => 'random string', 33 | 'name' => 'random name', 34 | 'test_related_model_id' => 2, 35 | 'position' => 1, 36 | 'active' => true, 37 | ]); 38 | 39 | TestSimpleModel::create([ 40 | 'unique_field' => '1337', 41 | 'second_field' => 'some more', 42 | 'name' => 'special name', 43 | 'test_related_model_id' => 3, 44 | 'position' => 14, 45 | 'active' => true, 46 | ]); 47 | 48 | TestSimpleModel::create([ 49 | 'unique_field' => '1980', 50 | 'second_field' => 'yet more fun', 51 | 'name' => 'another name', 52 | 'test_related_model_id' => 1, 53 | 'position' => 14, 54 | 'active' => true, 55 | ]); 56 | 57 | TestRelatedModel::create([ 58 | 'name' => 'related A', 59 | 'some_property' => 'super', 60 | 'test_simple_model_id' => 3, 61 | 'active' => true, 62 | ]); 63 | 64 | TestRelatedModel::create([ 65 | 'name' => 'related B', 66 | 'some_property' => 'generic', 67 | 'test_simple_model_id' => 3, 68 | 'active' => true, 69 | ]); 70 | 71 | TestRelatedModel::create([ 72 | 'name' => 'related C', 73 | 'some_property' => 'mild', 74 | 'test_simple_model_id' => 2, 75 | 'active' => true, 76 | ]); 77 | } 78 | 79 | 80 | // -------------------------------------------- 81 | // Instantiation / Init 82 | // -------------------------------------------- 83 | 84 | /** 85 | * @test 86 | */ 87 | public function it_can_be_instantiated(): void 88 | { 89 | static::assertInstanceOf( 90 | CountableFilterInterface::class, 91 | new TestCountableFilter([]) 92 | ); 93 | 94 | static::assertInstanceOf( 95 | CountableFilterInterface::class, 96 | new TestCountableFilter(['name' => 'test']) 97 | ); 98 | } 99 | 100 | 101 | /** 102 | * @test 103 | */ 104 | public function it_returns_list_of_countable_parameter_names(): void 105 | { 106 | $filter = new TestCountableFilter([]); 107 | 108 | static::assertCount( 109 | 2, 110 | $filter->getCountables(), 111 | 'Wrong count for getCountables()' 112 | ); 113 | } 114 | 115 | 116 | // -------------------------------------------- 117 | // getCounts 118 | // -------------------------------------------- 119 | 120 | /** 121 | * @test 122 | */ 123 | public function it_returns_correct_counts(): void 124 | { 125 | $filter = new TestCountableFilter([]); 126 | 127 | $counts = $filter->getCounts(); 128 | 129 | static::assertCount(2, $counts, 'getCounts() results should have 2 items'); 130 | 131 | // Position 14 appears twice, 0 and 1 once. 132 | static::assertEquals( 133 | [0 => 1, 1 => 1, 14 => 2], 134 | $counts->get('position')->toArray(), 135 | 'getCounts() first (distinct value) results incorrect' 136 | ); 137 | 138 | // Related model id 1 appears twice, the rest once. 139 | static::assertEquals( 140 | [1 => 2, 2 => 1, 3 => 1], 141 | $counts->get('relateds')->toArray(), 142 | 'getCounts() first (belongsto) results incorrect' 143 | ); 144 | } 145 | 146 | 147 | // -------------------------------------------- 148 | // ignored countables 149 | // -------------------------------------------- 150 | 151 | /** 152 | * @test 153 | */ 154 | public function it_returns_only_unignored_countable_results(): void 155 | { 156 | $filter = new TestCountableFilter([]); 157 | 158 | $filter->ignoreCountable('relateds'); 159 | 160 | $counts = $filter->getCounts(); 161 | 162 | static::assertCount(1, $counts, 'getCounts() results should have 1 item (the other is ignored)'); 163 | 164 | // Position 14 appears twice, 0 and 1 once. 165 | static::assertEquals( 166 | [0 => 1, 1 => 1, 14 => 2], 167 | $counts->get('position')->toArray(), 168 | 'getCounts() result should be correct position only' 169 | ); 170 | 171 | 172 | // After unignoring, all countables should be there. 173 | $filter->unignoreCountable('relateds'); 174 | 175 | $counts = $filter->getCounts(); 176 | 177 | static::assertCount(2, $counts, 'getCounts() results should have 2 items after unignoring countable'); 178 | } 179 | 180 | /** 181 | * @test 182 | */ 183 | public function it_returns_counts_for_selected_keys_only(): void 184 | { 185 | $filter = new TestCountableFilter([]); 186 | 187 | $counts = $filter->getCounts(['position']); 188 | 189 | static::assertCount(1, $counts, 'getCounts() results should have 1 item (the other is ignored)'); 190 | 191 | // Position 14 appears twice, 0 and 1 once. 192 | static::assertEquals( 193 | [0 => 1, 1 => 1, 14 => 2], 194 | $counts->get('position')->toArray(), 195 | 'getCounts() result should be correct position only' 196 | ); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Filter 4 | 5 | The following is a hypothetical situation where a product catalog should be filterable on common qualities of products. 6 | 7 | What we want to be able to do is pass in the following array to a filter to get the products that match: 8 | 9 | ```php 10 | [ 11 | 'name' => '', // a string to loosely match product names by 12 | 'ean' => '', // a string to exactly match product EAN codes by 13 | 'products' => [], // an array of product id integers 14 | 'brands' => [], // an array of brand id integers to which the product must belong 15 | 'categories' => [], // an array of category id integers to which the product must belong 16 | ] 17 | ``` 18 | 19 | So that, for instance, to find all products that have 'enhanced' in their name, you would provide: 20 | 21 | ```php 22 | [ 23 | 'name' => 'enhanced', 24 | ] 25 | ``` 26 | 27 | Or to find all products belonging *either* to the category with id #3 *and/or* the categoy with id #4: 28 | 29 | ```php 30 | [ 31 | 'categories' => [ 3, 4 ], 32 | ] 33 | ``` 34 | 35 | 36 | ### Data structure 37 | 38 | Let's say we have the following: 39 | 40 | - A `products` table with very basic product information, a name, EAN code and description. Model: *Product*. 41 | - A `brands` table. A *Product* has one *Brand*. 42 | - A `categories` table. A *Product* can belong to zero or more *Categories*. 43 | The pivot table follows the Laravel convention: `category_product`. 44 | 45 | For details, see the [migrations and models](EXAMPLE_DETAILS.md) for this setup. 46 | 47 | ### Filter Data 48 | 49 | ```php 50 | 'string|max:255', 59 | 'ean' => 'string|max:20', 60 | 'products' => 'array|each:exists,products,id', 61 | 'brands' => 'array|each:exists,brands,id', 62 | 'categories' => 'array|each:exists,categories,id', 63 | ]; 64 | 65 | protected $defaults = [ 66 | 'name' => null, 67 | 'ean' => null, 68 | 'products' => [], 69 | 'brands' => [], 70 | 'categories' => [], 71 | ]; 72 | } 73 | 74 | ``` 75 | 76 | ### Filter Class 77 | 78 | ```php 79 | new ParameterFilters\SimpleString($this->table), 101 | // Exact string match 102 | 'ean' => new ParameterFilters\SimpleString($this->table, null, true), 103 | // simple integer column id matches 104 | 'products' => new ParameterFilters\SimpleInteger($this->table, 'id'), 105 | 'brands' => new ParameterFilters\SimpleInteger($this->table, 'brand_id'), 106 | ]; 107 | 108 | // Note that 'categories' is not present here, 109 | // so it will have to be handled in the applyParameter method. 110 | } 111 | 112 | 113 | /** 114 | * @param string $name 115 | * @param mixed $value 116 | * @param EloquentBuilder $query 117 | */ 118 | protected function applyParameter(string $name, $value, $query) 119 | { 120 | switch ($name) { 121 | 122 | // Categories requires a special implementation, it needs to join a pivot table. 123 | // This could have also been implemented as a custom ParameterFilter class, 124 | // but adding it to the applyParameter method works too. 125 | 126 | case 'categories': 127 | 128 | // The addJoin method will let the Filter add the join statements to the 129 | // query builder when all filter parameters are applied. 130 | // If you were to call addJoin with the same ('category_product') key name 131 | // again, it would only be added to the query once. 132 | 133 | $this->addJoin('category_product', [ 134 | 'category_product', 135 | 'category_product.product_id', '=', 'products.id' 136 | ]); 137 | 138 | $query->whereIn('category_product.product_category_id', $value) 139 | ->distinct(); // Might have multiple matches per product 140 | return; 141 | } 142 | 143 | // fallback to default: throws exception for unhandled filter parameter 144 | parent::applyParameter($name, $value, $query); 145 | } 146 | 147 | } 148 | ``` 149 | 150 | ## CountableFilter 151 | 152 | It might make sense to make this ProductFilter into a CountableFilter, which can return counts for `brands` and `categories`. 153 | For instance, you would pass in as filter data the following: 154 | 155 | ```php 156 | [ 157 | 'categories' => [ 3, 4 ], 158 | ] 159 | ``` 160 | 161 | And receive the alternative counts by calling `getCountables()` on the filter: 162 | 163 | ```php 164 | // the toArray() of the CountResult returned: 165 | [ 166 | 'brands' => [ 167 | 1 => 2, // For products belonging to either Category #3 or #4, 168 | 2 => 1, // there are two Products for Brand #1, one for #2 169 | 4 => 10, // and ten for Brand #4. None for #3 or any other, in this case. 170 | ], 171 | 'categories' => [ 172 | 1 => 5, // These counts are the results for all products, 173 | 2 => 3, // since no other filter parameters are active but 174 | 3 => 11, // the one on categories. So this list gives the product 175 | 4 => 8, // counts for when the categories filter would not be applied. 176 | ], 177 | ] 178 | ``` 179 | 180 | 181 | To make this Filter work as a CountableFilter, change the `ProductFilter` class so that it extends `CountableFilter` instead: 182 | 183 | ```php 184 | use Czim\Filter\CountableFilter; 185 | 186 | class ProductFilter extends CountableFilter 187 | { 188 | ``` 189 | 190 | And add the following to it: 191 | 192 | ```php 193 | 194 | // Only return counts for the brands and categories related 195 | protected $countables = [ 196 | 'brands', 197 | 'categories', 198 | ]; 199 | 200 | 201 | /** 202 | * @param string $parameter name of the countable parameter 203 | * @return EloquentBuilder 204 | */ 205 | protected function getCountableBaseQuery(?string $parameter = null) 206 | { 207 | return \App\Product::query(); 208 | } 209 | 210 | protected function countStrategies(): array 211 | { 212 | return [ 213 | 214 | // For the given example call, this would return all 215 | // Brand id's with product counts for each; but only for 216 | // the subset that results from filtering by categories 217 | // (or any other filter parameter other than brands itself). 218 | 219 | 'brands' => new ParameterCounters\SimpleBelongsTo(), 220 | ]; 221 | 222 | // 'categories' is not present here either, since it 223 | // will similarly be handled in the countParameter method. 224 | } 225 | 226 | 227 | /** 228 | * @param string $parameter countable name 229 | * @param EloquentBuilder $query 230 | * @return mixed 231 | */ 232 | protected function countParameter(string $parameter, $query) 233 | { 234 | switch ($parameter) { 235 | 236 | case 'categories': 237 | 238 | // The query that will be executed for this is modified to include 239 | // all parameters (in the example, none will be applied for categories, 240 | // so it would be the same as executing it on Product:: instead of the 241 | // $query parameter here. 242 | 243 | return $query->select('category_product.category_id AS id', \DB::raw('COUNT(*) AS count')) 244 | ->groupBy('category_product.category_id') 245 | ->join('category_product', 'category_product.product_id', '=', 'products.id') 246 | ->pluck('count', 'id'); 247 | 248 | } 249 | 250 | return parent::countParameter($parameter, $query); 251 | } 252 | ``` 253 | -------------------------------------------------------------------------------- /src/CountableFilter.php: -------------------------------------------------------------------------------- 1 | 28 | * @implements CountableFilterInterface 29 | */ 30 | abstract class CountableFilter extends Filter implements CountableFilterInterface 31 | { 32 | /** 33 | * Which filter parameters are 'countable' -- (should) have implementations 34 | * for the getCounts() method. This is what's used to determine which other 35 | * filter options (f.i. brands, product lines) to show for the current selection 36 | * 37 | * @var string[] 38 | */ 39 | protected array $countables = []; 40 | 41 | /** 42 | * Application strategies for all countables to get counts for. 43 | * 44 | * Just like the strategies property, but now for getCount() 45 | * 46 | * These can be either: 47 | * an instance of ParameterCounterInterface, 48 | * a string classname of an instantiatable ParameterCounterFilter, 49 | * a callback that follows the same logic as ParameterCounterFilter->count() 50 | * null, which means that getCountForParameter() will be called on the Filter 51 | * itself, which MUST then be able to handle it! 52 | * 53 | * @var array|class-string>|callable|null> by name 54 | */ 55 | protected array $countStrategies = []; 56 | 57 | /** 58 | * List of countables that should not be included in getCount() results. 59 | * 60 | * @var string[] 61 | */ 62 | protected array $ignoreCountables = []; 63 | 64 | /** 65 | * List of countables that should be applied even when performing a count for that same countable. 66 | * 67 | * Set this, for instance, for plural AND-applied checkbox filters where every check should further 68 | * restrict the available options. 69 | * 70 | * @var string[] 71 | */ 72 | protected array $includeSelfInCount = []; 73 | 74 | 75 | /** 76 | * Returns new base query object to build countable query on. 77 | * 78 | * This will be called for each countable parameter, and could be 79 | * something like: EloquentModelName::query(); 80 | * 81 | * @param string|null $parameter name of the countable parameter 82 | * @return TModel|Builder|EloquentBuilder 83 | */ 84 | abstract protected function getCountableBaseQuery(?string $parameter = null): Model|Builder|EloquentBuilder; 85 | 86 | 87 | /** 88 | * {@inheritDoc} 89 | */ 90 | public function __construct(array|FilterDataInterface $data) 91 | { 92 | parent::__construct($data); 93 | 94 | $this->countStrategies = $this->countStrategies(); 95 | } 96 | 97 | /** 98 | * Initializes strategies for counting countables. 99 | * 100 | * Override this to set the countable strategies for your filter. 101 | * 102 | * @return array|class-string>|callable|null> by name 103 | */ 104 | protected function countStrategies(): array 105 | { 106 | return []; 107 | } 108 | 109 | /** 110 | * Returns a list of the countable parameters to get counts for. 111 | * 112 | * @return string[] 113 | */ 114 | public function getCountables(): array 115 | { 116 | return $this->countables; 117 | } 118 | 119 | /** 120 | * Returns a list of the countable parameters that are not ignored 121 | * 122 | * @return string[] 123 | */ 124 | protected function getActiveCountables(): array 125 | { 126 | return array_diff($this->getCountables(), $this->ignoreCountables); 127 | } 128 | 129 | /** 130 | * Gets alternative counts per (relevant) attribute for the filter data. 131 | * 132 | * @param string[] $countables overrides ignoredCountables 133 | * @return CountableResults 134 | * @throws ParameterStrategyInvalidException 135 | */ 136 | public function getCounts(array $countables = []): CountableResults 137 | { 138 | $counts = new CountableResults(); 139 | 140 | $strategies = $this->normalizeCountableStrategies(); 141 | 142 | // Determine which countables to count for. 143 | if (! empty($countables)) { 144 | $countables = array_intersect($this->getCountables(), $countables); 145 | } else { 146 | $countables = $this->getActiveCountables(); 147 | } 148 | 149 | foreach ($countables as $parameterName) { 150 | // Should we skip it no matter what? 151 | if ($this->isCountableIgnored($parameterName)) { 152 | continue; 153 | } 154 | 155 | $strategy = $strategies[ $parameterName ] ?? null; 156 | 157 | // normalize the strategy so that we can call_user_func on it 158 | if ($strategy instanceof ParameterCounterInterface) { 159 | $strategy = [ $strategy, 'count' ]; 160 | } elseif ($strategy === null) { 161 | // default, let it be handled by applyParameter 162 | $strategy = [ $this, 'countParameter' ]; 163 | } elseif (! is_callable($strategy)) { 164 | throw new ParameterStrategyInvalidException( 165 | "Invalid counting strategy defined for parameter '{$parameterName}'," 166 | . ' must be ParameterFilterInterface, classname, callable or null' 167 | ); 168 | } 169 | 170 | // Start with a fresh query. 171 | $query = $this->getCountableBaseQuery(); 172 | 173 | // Apply the filter while temporarily ignoring the current countable parameter, 174 | // unless it is forced to be included. 175 | $includeSelf = in_array($parameterName, $this->includeSelfInCount); 176 | 177 | if (! $includeSelf) { 178 | $this->ignoreParameter($parameterName); 179 | } 180 | 181 | $this->apply($query); 182 | 183 | if (! $includeSelf) { 184 | $this->unignoreParameter($parameterName); 185 | } 186 | 187 | /** @var callable $strategy */ 188 | 189 | // Retrieve the count and put it in the results. 190 | $counts->put($parameterName, $strategy($parameterName, $query, $this)); 191 | } 192 | 193 | return $counts; 194 | } 195 | 196 | /** 197 | * Get count result for a parameter's records, given the filter settings for other parameters. 198 | * this is the fall-back for when no other strategy is configured in $this->countStrategies. 199 | * 200 | * Override this if you need to use it in a specific Filter instance 201 | * 202 | * @param string $parameter countable name 203 | * @param TModel|Builder|EloquentBuilder $query 204 | * @return mixed 205 | * @throws FilterParameterUnhandledException 206 | */ 207 | protected function countParameter(string $parameter, Model|Builder|EloquentBuilder $query): mixed 208 | { 209 | // Default is to always warn that we don't have a strategy. 210 | throw new FilterParameterUnhandledException( 211 | "No fallback strategy determined for for countable parameter '{$parameter}'" 212 | ); 213 | } 214 | 215 | /** 216 | * Builds up the strategies so that all instantiatable strategies are instantiated. 217 | * 218 | * @return array|callable|null> by name 219 | * @throws ParameterStrategyInvalidException 220 | */ 221 | protected function normalizeCountableStrategies(): array 222 | { 223 | foreach ($this->countStrategies as &$strategy) { 224 | // check if the strategy is a string that should be instantiated as a class 225 | if (! is_string($strategy)) { 226 | continue; 227 | } 228 | 229 | /** @var class-string> $strategy */ 230 | 231 | try { 232 | $reflection = new ReflectionClass($strategy); 233 | 234 | if (! $reflection->isInstantiable()) { 235 | throw new ParameterStrategyInvalidException( 236 | "Uninstantiable string provided as countStrategy for '{$strategy}'" 237 | ); 238 | } 239 | 240 | $strategy = new $strategy(); 241 | } catch (Throwable $e) { 242 | throw new ParameterStrategyInvalidException( 243 | 'Exception thrown while trying to reflect or instantiate string ' 244 | . "provided as countStrategy for '{$strategy}'", 245 | 0, 246 | $e 247 | ); 248 | } 249 | 250 | // Check if it is of the correct type. 251 | if (! $strategy instanceof ParameterCounterInterface) { 252 | throw new ParameterStrategyInvalidException( 253 | "Instantiated string provided is not a ParameterFilter: '" . get_class($strategy) . "'" 254 | ); 255 | } 256 | } 257 | 258 | unset($strategy); 259 | 260 | return $this->countStrategies; 261 | } 262 | 263 | /** 264 | * Disables a countable when getCounts() is invoked. 265 | * 266 | * Note that this differs from ignoreParameter in that the count itself is omitted, but it does not 267 | * affect what parameters get applied to the queries for the other countables! 268 | * 269 | * @param string $countable 270 | */ 271 | public function ignoreCountable(string $countable): void 272 | { 273 | $this->ignoreCountables = array_merge($this->ignoreCountables, [$countable]); 274 | } 275 | 276 | /** 277 | * Disables a number of countables when getCounts() is invoked. 278 | * 279 | * @param string[] $countables 280 | */ 281 | public function ignoreCountables(array $countables): void 282 | { 283 | array_map([$this, 'ignoreCountable'], $countables); 284 | } 285 | 286 | /** 287 | * Re-enables a countable when getCounts() is invoked. 288 | * 289 | * @param string $countable 290 | */ 291 | public function unignoreCountable(string $countable): void 292 | { 293 | $this->ignoreCountables = array_diff($this->ignoreCountables, [$countable]); 294 | } 295 | 296 | /** 297 | * Re-enables a number of countables when getCounts() is invoked. 298 | * 299 | * @param string[] $countables 300 | */ 301 | public function unignoreCountables(array $countables): void 302 | { 303 | array_map([$this, 'unignoreCountable'], $countables); 304 | } 305 | 306 | public function isCountableIgnored(string $countableName): bool 307 | { 308 | if (empty($this->ignoreCountables)) { 309 | return false; 310 | } 311 | 312 | return in_array($countableName, $this->ignoreCountables, true); 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/Filter.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class Filter implements FilterInterface 26 | { 27 | public const SETTING = '_setting_'; 28 | 29 | protected const JOIN_METHOD_INNER = 'join'; 30 | protected const JOIN_METHOD_LEFT = 'leftJoin'; 31 | protected const JOIN_METHOD_RIGHT = 'rightJoin'; 32 | 33 | /** 34 | * The classname for the FilterData that should be constructed when the filter is 35 | * constructed with plain array data. 36 | * 37 | * @var class-string 38 | */ 39 | protected string $filterDataClass = FilterData::class; 40 | 41 | /** 42 | * Application strategies for all parameters/attributes to apply for 43 | * 44 | * These can be either: 45 | * an instance of ParameterFilter, 46 | * a string equal to Filter::SETTING, in which case its value will be 47 | * stored as a 'global setting' for the filter instead 48 | * a string classname of an instantiatable ParameterFilter, 49 | * a callback that follows the same logic as ParameterFilter->apply() 50 | * null, which means that applyParameter() will be called on the Filter 51 | * itself, which MUST then be able to handle it! 52 | * 53 | * @var array|class-string>|string|callable|null> by name 54 | */ 55 | protected array $strategies = []; 56 | 57 | protected FilterDataInterface $data; 58 | 59 | /** 60 | * Settings for the filter, filled automatically for parameters that have the Filter::SETTING strategy flag set. 61 | * 62 | * @var array 63 | */ 64 | protected array $settings = []; 65 | 66 | /** 67 | * Join memory: set join parameters for query->join() calls here, so they may be applied once and 68 | * without unnecessary or problematic duplication. 69 | * 70 | * @var array> keyed by identifying string/name 71 | */ 72 | protected array $joins = []; 73 | 74 | /** 75 | * Join memory for type of join, defaults to left. 76 | * Must be keyed by join identifier key. 77 | * 78 | * @var array 79 | */ 80 | protected array $joinTypes = []; 81 | 82 | /** 83 | * Parameter names to be ignored while applying the filter 84 | * Used by CountableFilter to look up every parameter but the active one. 85 | * If you use this for other things, be careful. 86 | * 87 | * @var string[] 88 | */ 89 | protected array $ignoreParameters = []; 90 | 91 | 92 | /** 93 | * @param array|FilterDataInterface $data 94 | */ 95 | public function __construct(array|FilterDataInterface $data) 96 | { 97 | // create FilterData if provided data is not already 98 | if (! $data instanceof FilterDataInterface) { 99 | $data = $this->instantiateFilterData($data); 100 | } 101 | 102 | $this->setFilterData($data); 103 | 104 | $this->strategies = $this->strategies(); 105 | } 106 | 107 | 108 | public function setFilterData(FilterDataInterface $data): void 109 | { 110 | assert(is_a($data, $this->filterDataClass), 'Filter data must match configured data class'); 111 | 112 | $this->data = $data; 113 | } 114 | 115 | public function getFilterData(): FilterDataInterface 116 | { 117 | return $this->data; 118 | } 119 | 120 | public function setSetting(string $key, mixed $value = null): void 121 | { 122 | $this->settings[$key] = $value; 123 | } 124 | 125 | public function setting(string $key): mixed 126 | { 127 | return $this->settings[$key] ?? null; 128 | } 129 | 130 | public function parameterValue(string $name): mixed 131 | { 132 | return $this->data->getParameterValue($name); 133 | } 134 | 135 | /** 136 | * Applies the loaded FilterData to a query (builder). 137 | * 138 | * @param TModel|Builder|EloquentBuilder $query 139 | * @return TModel|Builder|EloquentBuilder 140 | * @throws ParameterStrategyInvalidException 141 | */ 142 | public function apply(Model|Builder|EloquentBuilder $query): Model|Builder|EloquentBuilder 143 | { 144 | $this->forgetJoins(); 145 | $this->applyParameters($query); 146 | $this->applyJoins($query); 147 | 148 | return $query; 149 | } 150 | 151 | /** 152 | * Applies all filter parameters to the query, using the configured strategies. 153 | * 154 | * @param TModel|Builder|EloquentBuilder $query 155 | * @throws ParameterStrategyInvalidException 156 | */ 157 | protected function applyParameters(Model|Builder|EloquentBuilder $query): void 158 | { 159 | $this->storeGlobalSettings(); 160 | 161 | $strategies = $this->buildStrategies(); 162 | 163 | foreach ($this->data->getApplicableAttributes() as $parameterName) { 164 | if ($this->isParameterIgnored($parameterName)) { 165 | continue; 166 | } 167 | 168 | // Get the value for the filter parameter and if it is empty, 169 | // we're not filtering by it and should skip it. 170 | $parameterValue = $this->data->getParameterValue($parameterName); 171 | 172 | if ($this->isParameterValueUnset($parameterName, $parameterValue)) { 173 | continue; 174 | } 175 | 176 | 177 | // Find the strategy to be used for applying the filter for this parameter 178 | // then normalize the strategy so that we can call_user_func on it. 179 | $strategy = $strategies[$parameterName] ?? null; 180 | 181 | // Is it a global setting, not a normal parameter? Skip it. 182 | if ($strategy === static::SETTING) { 183 | continue; 184 | } 185 | 186 | 187 | if ($strategy instanceof ParameterFilterInterface) { 188 | $strategy = [ $strategy, 'apply' ]; 189 | } elseif ($strategy === null) { 190 | // Default, let it be handled by applyParameter 191 | $strategy = [ $this, 'applyParameter' ]; 192 | } elseif (! is_callable($strategy)) { 193 | throw new ParameterStrategyInvalidException( 194 | "Invalid strategy defined for parameter '{$parameterName}'," 195 | . ' must be ParameterFilterInterface, classname, callable or null' 196 | ); 197 | } 198 | 199 | /** @var callable $strategy */ 200 | $strategy($parameterName, $parameterValue, $query, $this); 201 | } 202 | } 203 | 204 | /** 205 | * Builds up the strategies so that all instantiatable strategies are instantiated. 206 | * 207 | * @return array|string|callable|null> by name 208 | * @throws ParameterStrategyInvalidException 209 | */ 210 | protected function buildStrategies(): array 211 | { 212 | foreach ($this->strategies as $parameterName => &$strategy) { 213 | if ($this->isParameterIgnored($parameterName)) { 214 | continue; 215 | } 216 | 217 | // Get the value for the filter parameter and if it is empty, 218 | // we're not filtering by it and should skip it. 219 | $parameterValue = $this->parameterValue($parameterName); 220 | 221 | if ($this->isParameterValueUnset($parameterName, $parameterValue)) { 222 | continue; 223 | } 224 | 225 | // Check if the strategy is a string that should be instantiated as a class. 226 | if (! is_string($strategy) || $strategy === static::SETTING) { 227 | continue; 228 | } 229 | 230 | /** @var class-string> $strategy */ 231 | 232 | try { 233 | $reflection = new ReflectionClass($strategy); 234 | 235 | if (! $reflection->IsInstantiable()) { 236 | throw new ParameterStrategyInvalidException( 237 | "Uninstantiable string provided as strategy for '{$strategy}'" 238 | ); 239 | } 240 | 241 | $strategy = new $strategy(); 242 | } catch (Throwable $exception) { 243 | throw new ParameterStrategyInvalidException( 244 | "Exception thrown while trying to reflect or instantiate strategy string for '{$strategy}'", 245 | 0, 246 | $exception 247 | ); 248 | } 249 | 250 | // check if it is of the correct type 251 | if (! $strategy instanceof ParameterFilterInterface) { 252 | throw new ParameterStrategyInvalidException( 253 | "Instantiated string provided is not a ParameterFilter: '" . get_class($strategy) . "'" 254 | ); 255 | } 256 | } 257 | 258 | unset($strategy); 259 | 260 | return $this->strategies; 261 | } 262 | 263 | /** 264 | * Interprets parameters with the SETTING string and stores their current values in the settings property. 265 | * 266 | * This must be done before the parameters are applied, if the settings are to have any effect. 267 | * Note that you must add your own interpretation & effect for settings in your FilterParameter 268 | * methods/classes (use the setting() getter). 269 | */ 270 | protected function storeGlobalSettings(): void 271 | { 272 | foreach ($this->strategies as $setting => &$strategy) { 273 | if ($strategy !== static::SETTING) { 274 | continue; 275 | } 276 | 277 | $this->settings[$setting] = $this->parameterValue($setting); 278 | } 279 | } 280 | 281 | /** 282 | * Applies filter to the query for an attribute/parameter with the given parameter value, 283 | * this is the fall-back for when no other strategy is configured in $this->strategies. 284 | * 285 | * Override this if you need to use it in a specific Filter instance. 286 | * 287 | * @param string $name 288 | * @param mixed|null $value 289 | * @param TModel|Builder|EloquentBuilder $query 290 | * @throws FilterParameterUnhandledException 291 | */ 292 | protected function applyParameter(string $name, mixed $value, Model|Builder|EloquentBuilder $query): void 293 | { 294 | // Default is to always warn that we don't have a strategy. 295 | throw new FilterParameterUnhandledException( 296 | "No fallback strategy determined for for filter parameter '{$name}'" 297 | ); 298 | } 299 | 300 | protected function forgetJoins(): void 301 | { 302 | $this->joins = []; 303 | } 304 | 305 | /** 306 | * Adds a query join to be added after all parameters are applied. 307 | * 308 | * @param string $key identifying key, used to prevent duplicates 309 | * @param array $parameters [...string] or [string, Closure] 310 | * @param string|null $joinType {@link JoinType} 'join'/'inner', 'right'; defaults to left join 311 | */ 312 | public function addJoin(string $key, array $parameters, ?string $joinType = null): void 313 | { 314 | if ($joinType !== null) { 315 | if ($joinType === JoinType::INNER || str_contains($joinType, 'inner')) { 316 | $this->joinTypes[$key] = static::JOIN_METHOD_INNER; 317 | } elseif ($joinType === JoinType::RIGHT) { 318 | $this->joinTypes[$key] = static::JOIN_METHOD_RIGHT; 319 | } else { 320 | unset($this->joinTypes[$key]); // let it default to left join. 321 | } 322 | } 323 | 324 | $this->joins[$key] = $parameters; 325 | } 326 | 327 | /** 328 | * @param Model|Builder|EloquentBuilder $query 329 | */ 330 | protected function applyJoins(Model|Builder|EloquentBuilder $query): void 331 | { 332 | foreach ($this->joins as $key => $join) { 333 | $joinMethod = $this->joinTypes[ $key ] ?? static::JOIN_METHOD_LEFT; 334 | 335 | $query->{$joinMethod}(...$join); 336 | } 337 | } 338 | 339 | protected function isParameterValueUnset(string $parameter, mixed $value): bool 340 | { 341 | return $value !== false && empty($value); 342 | } 343 | 344 | protected function ignoreParameter(string $parameter): void 345 | { 346 | $this->ignoreParameters = array_merge($this->ignoreParameters, [$parameter]); 347 | } 348 | 349 | protected function unignoreParameter(string $parameter): void 350 | { 351 | $this->ignoreParameters = array_diff($this->ignoreParameters, [$parameter]); 352 | } 353 | 354 | protected function isParameterIgnored(string $parameterName): bool 355 | { 356 | if (empty($this->ignoreParameters)) { 357 | return false; 358 | } 359 | 360 | return in_array($parameterName, $this->ignoreParameters, true); 361 | } 362 | 363 | 364 | /** 365 | * @param array $data 366 | * @return FilterDataInterface 367 | */ 368 | protected function instantiateFilterData(array $data): FilterDataInterface 369 | { 370 | return new $this->filterDataClass($data); 371 | } 372 | 373 | /** 374 | * Initializes strategies for filtering. 375 | * 376 | * Override this to set the strategies for your filter. 377 | * 378 | * @return array|class-string>|string|callable|null> 379 | */ 380 | protected function strategies(): array 381 | { 382 | return []; 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /tests/Src/FilterTest.php: -------------------------------------------------------------------------------- 1 | '11', 26 | 'second_field' => null, 27 | 'name' => 'simple name', 28 | 'test_related_model_id' => 1, 29 | 'active' => true, 30 | ]); 31 | 32 | TestSimpleModel::create([ 33 | 'unique_field' => '123', 34 | 'second_field' => 'random string', 35 | 'name' => 'random name', 36 | 'test_related_model_id' => 2, 37 | 'active' => false, 38 | ]); 39 | 40 | TestSimpleModel::create([ 41 | 'unique_field' => '1337', 42 | 'second_field' => 'some more', 43 | 'name' => 'special name', 44 | 'test_related_model_id' => 3, 45 | 'active' => true, 46 | ]); 47 | 48 | TestRelatedModel::create([ 49 | 'name' => 'related A', 50 | 'some_property' => 'super', 51 | 'test_simple_model_id' => 3, 52 | 'active' => true, 53 | ]); 54 | 55 | TestRelatedModel::create([ 56 | 'name' => 'related B', 57 | 'some_property' => 'generic', 58 | 'test_simple_model_id' => 3, 59 | 'active' => true, 60 | ]); 61 | 62 | TestRelatedModel::create([ 63 | 'name' => 'related C', 64 | 'some_property' => 'mild', 65 | 'test_simple_model_id' => 2, 66 | 'active' => true, 67 | ]); 68 | } 69 | 70 | 71 | // -------------------------------------------- 72 | // Instantiation / Init 73 | // -------------------------------------------- 74 | 75 | /** 76 | * @test 77 | */ 78 | public function it_can_be_instantiated_with_array_data(): void 79 | { 80 | static::assertInstanceOf( 81 | FilterInterface::class, 82 | new TestFilter([ 83 | 'name' => 'some name', 84 | 'relateds' => [1, 2, 3], 85 | 'position' => 20, 86 | 'with_inactive' => false, 87 | ]) 88 | ); 89 | } 90 | 91 | /** 92 | * @test 93 | */ 94 | public function it_can_be_instantiated_with_a_filter_data_object(): void 95 | { 96 | $filterData = new TestFilterData([ 97 | 'name' => 'some name', 98 | 'relateds' => [1, 2, 3], 99 | 'position' => 20, 100 | 'with_inactive' => false, 101 | ]); 102 | 103 | 104 | static::assertInstanceOf( 105 | FilterInterface::class, 106 | new TestFilter($filterData) 107 | ); 108 | } 109 | 110 | 111 | // -------------------------------------------- 112 | // Getters/Setters and such 113 | // -------------------------------------------- 114 | 115 | /** 116 | * @test 117 | */ 118 | public function it_can_get_and_set_filter_data_objects(): void 119 | { 120 | $filter = new TestFilter(['name' => 'first name filter']); 121 | 122 | static::assertEquals( 123 | 'first name filter', 124 | $filter->getFilterData()->toArray()['name'], 125 | 'Incorrect name for first set of filterdata' 126 | ); 127 | 128 | $filterData = new TestFilterData([ 129 | 'name' => 'some name', 130 | 'relateds' => [1, 2, 3], 131 | 'position' => 20, 132 | 'with_inactive' => false, 133 | ]); 134 | 135 | $filter->setFilterData($filterData); 136 | 137 | static::assertEquals( 138 | 'some name', 139 | $filter->getFilterData()->toArray()['name'], 140 | 'Filter data did not change after setFilterData()' 141 | ); 142 | } 143 | 144 | /** 145 | * @test 146 | */ 147 | public function it_can_get_and_set_global_settings(): void 148 | { 149 | $filter = new TestFilter(['name' => 'first name filter']); 150 | 151 | static::assertEmpty($filter->setting('does_not_exist'), 'Setting that was never set should be empty'); 152 | 153 | $filter->setSetting('some_setting', 'some value'); 154 | 155 | static::assertEquals( 156 | 'some value', 157 | $filter->setting('some_setting'), 158 | 'Setting that was set did not have correct value' 159 | ); 160 | 161 | // Returns null if never defined. 162 | static::assertNull($filter->setting('never_defined_this_key_at_all'), 'Undefined settings should return null'); 163 | } 164 | 165 | /** 166 | * @test 167 | */ 168 | public function it_can_set_global_settings_by_way_of_filter_parameter_strategy(): void 169 | { 170 | $filter = new TestFilter(['global_setting' => 'SWEET SETTING VALUE']); 171 | 172 | // Only happens when it applies the setting! 173 | // This should especially NOT throw the exception for 'no fallback'. 174 | $filter->apply(TestSimpleModel::query()); 175 | 176 | static::assertEquals( 177 | 'SWEET SETTING VALUE', 178 | $filter->setting('global_setting'), 179 | 'Setting that was set as filter parameter strategy did not have correct value' 180 | ); 181 | } 182 | 183 | // -------------------------------------------- 184 | // Exceptions for strategies 185 | // -------------------------------------------- 186 | 187 | /** 188 | * @test 189 | */ 190 | public function it_throws_an_exception_if_no_strategy_was_defined_for_a_parameter(): void 191 | { 192 | $this->expectException(FilterParameterUnhandledException::class); 193 | 194 | (new TestFilter(['no_strategy_set_no_fallback' => 'something to activate it'])) 195 | ->apply(TestSimpleModel::query()); 196 | } 197 | 198 | /** 199 | * @test 200 | */ 201 | public function it_throws_an_exception_if_a_strategy_string_is_not_instantiable(): void 202 | { 203 | $this->expectException(ParameterStrategyInvalidException::class); 204 | $this->expectExceptionMessageMatches('#uninstantiable_string_that_is_not_a_parameter_filter#i'); 205 | 206 | (new TestFilter(['invalid_strategy_string' => 'ignored'])) 207 | ->apply(TestSimpleModel::query()); 208 | } 209 | 210 | /** 211 | * @test 212 | */ 213 | public function it_throws_an_exception_if_a_strategy_value_is_of_wrong_type(): void 214 | { 215 | $this->expectException(ParameterStrategyInvalidException::class); 216 | 217 | (new TestFilter(['invalid_strategy_general' => 'ignored'])) 218 | ->apply(TestSimpleModel::query()); 219 | } 220 | 221 | /** 222 | * @test 223 | */ 224 | public function it_throws_an_exception_if_an_instantiated_strategy_string_does_not_implement_parameterfilterinterface(): void 225 | { 226 | $this->expectException(ParameterStrategyInvalidException::class); 227 | $this->expectExceptionMessageMatches('#is not a?\s*ParameterFilter#i'); 228 | 229 | (new TestFilter(['invalid_strategy_interface' => 'ignored'])) 230 | ->apply(TestSimpleModel::query()); 231 | } 232 | 233 | 234 | // -------------------------------------------- 235 | // Applying parameters 236 | // -------------------------------------------- 237 | 238 | /** 239 | * @test 240 | */ 241 | public function it_applies_parameters_to_a_query(): void 242 | { 243 | // Uses the defaults even if no parameters set. 244 | $result = (new TestFilter([]))->apply(TestSimpleModel::query())->get(); 245 | 246 | static::assertCount(2, $result, "Count for no parameters set result incorrect (should be 2 with 'active' = 1)"); 247 | 248 | // simple single filter 249 | $result = (new TestFilter(['name' => 'special']))->apply(TestSimpleModel::query())->get(); 250 | 251 | static::assertCount( 252 | 1, 253 | $result, 254 | 'Count for single filter parameter (loosy string, strategy parameterfilter) incorrect' 255 | ); 256 | static::assertEquals( 257 | '1337', 258 | $result->first()->{self::UNIQUE_FIELD}, 259 | 'Result incorrect for single filter parameter' 260 | ); 261 | 262 | // Double filter, with relation ID parameter filter. 263 | $result = (new TestFilter([ 264 | 'name' => 'name', 265 | 'relateds' => [1, 2], 266 | 'with_inactive' => true, 267 | ]))->apply(TestSimpleModel::query())->get(); 268 | 269 | static::assertCount( 270 | 2, 271 | $result, 272 | 'Count for multiple filter parameters (loosy string and relation ids, and inactive) incorrect' 273 | ); 274 | } 275 | 276 | /** 277 | * @test 278 | */ 279 | public function it_applies_parameters_by_strategy_of_instantiated_parameter_filter(): void 280 | { 281 | $result = (new TestFilter(['parameter_filter_instance' => 'special name'])) 282 | ->apply(TestSimpleModel::query())->get(); 283 | 284 | static::assertCount( 285 | 1, 286 | $result, 287 | 'Count for single filter parameter incorrect (exact string, strategy parameterfilter)' 288 | ); 289 | static::assertEquals( 290 | '1337', 291 | $result->first()->{self::UNIQUE_FIELD}, 292 | 'Result incorrect for single filter parameter (exact string, strategy parameterfilter)' 293 | ); 294 | } 295 | 296 | /** 297 | * @test 298 | */ 299 | public function it_applies_parameters_by_strategy_of_instantiable_parameter_filter_class_string(): void 300 | { 301 | $result = (new TestFilter(['parameter_filter_string' => 'ignored, hardcoded test filter'])) 302 | ->apply(TestSimpleModel::query())->get(); 303 | 304 | static::assertCount( 305 | 1, 306 | $result, 307 | 'Count for single filter parameter incorrect (strategy parameterfilter by string)' 308 | ); 309 | static::assertEquals( 310 | '1337', 311 | $result->first()->{self::UNIQUE_FIELD}, 312 | 'Result incorrect for single filter parameter (strategy parameterfilter by string)' 313 | ); 314 | } 315 | 316 | /** 317 | * @test 318 | */ 319 | public function it_applies_parameters_by_strategy_of_closure(): void 320 | { 321 | // Closure as anonymous function. 322 | $result = (new TestFilter(['closure_strategy' => ['special name', 3]])) 323 | ->apply(TestSimpleModel::query())->get(); 324 | 325 | static::assertCount( 326 | 1, 327 | $result, 328 | 'Count for single filter parameter incorrect (strategy closure with parameters)' 329 | ); 330 | static::assertEquals( 331 | '1337', 332 | $result->first()->{self::UNIQUE_FIELD}, 333 | 'Result incorrect for single filter parameter (strategy closure with parameters)' 334 | ); 335 | 336 | // Closure as [ object, method ] array. 337 | $result = (new TestFilter(['closure_strategy_array' => ['special name', 3]])) 338 | ->apply(TestSimpleModel::query())->get(); 339 | 340 | static::assertCount( 341 | 1, 342 | $result, 343 | 'Count for single filter parameter incorrect (strategy closure with parameters, array syntax)' 344 | ); 345 | static::assertEquals( 346 | '1337', 347 | $result->first()->{self::UNIQUE_FIELD}, 348 | 'Result incorrect for single filter parameter (strategy closure with parameters, array syntax)' 349 | ); 350 | } 351 | 352 | 353 | // -------------------------------------------- 354 | // Joins handling 355 | // -------------------------------------------- 356 | 357 | /** 358 | * @test 359 | */ 360 | public function it_adds_joins_and_applies_them_after_all_filters(): void 361 | { 362 | // Add joins using addJoin method. 363 | $query = (new TestFilter([ 364 | 'adding_joins' => 'okay', 365 | ]))->apply(TestSimpleModel::query())->toSql(); 366 | 367 | static::assertMatchesRegularExpression( 368 | '#adding_joins#i', 369 | $query, 370 | 'Query SQL did not have parameter check in where clause' 371 | ); 372 | 373 | static::assertMatchesRegularExpression( 374 | '#(inner )?join [`"]test_related_models[`"] on [`"]test_related_models[`"].[`"]id[`"] ' 375 | . '= [`"]test_simple_models[`"].[`"]test_related_model_id[`"]#i', 376 | $query, 377 | 'Query SQL does not feature expected join clause' 378 | ); 379 | 380 | 381 | // Check if joins are not duplicated. 382 | $query = (new TestFilter([ 383 | 'adding_joins' => 'okay', 384 | 'no_duplicate_joins' => 'please', 385 | ]))->apply(TestSimpleModel::query())->toSql(); 386 | 387 | static::assertMatchesRegularExpression( 388 | '#no_duplicate_joins#i', 389 | $query, 390 | 'Query SQL did not have parameter check in where clause (second param)' 391 | ); 392 | 393 | static::assertMatchesRegularExpression( 394 | '#(inner )?join [`"]test_related_models[`"] on [`"]test_related_models[`"].[`"]id[`"] ' 395 | . '= [`"]test_simple_models[`"].[`"]test_related_model_id[`"]#i', 396 | $query, 397 | 'Query SQL does not feature expected join clause (with second param)' 398 | ); 399 | 400 | static::assertEquals( 401 | 1, 402 | substr_count(strtolower($query), ' join '), 403 | 'Query SQL should have only one join clause' 404 | ); 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Filter 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Build Status](https://travis-ci.org/czim/laravel-filter.svg?branch=master)](https://travis-ci.org/czim/laravel-filter) 6 | [![Latest Stable Version](http://img.shields.io/packagist/v/czim/laravel-filter.svg)](https://packagist.org/packages/czim/laravel-filter) 7 | [![SensioLabsInsight](https://insight.sensiolabs.com/projects/d7fa4bf3-4f79-4095-9dda-abb3611d9a1c/mini.png)](https://insight.sensiolabs.com/projects/d7fa4bf3-4f79-4095-9dda-abb3611d9a1c) 8 | 9 | 10 | Configurable and modular Filter setup for Laravel. 11 | This is intended to make it easy to search for and filter by records using a typical web shop filter. 12 | For example, if you want to filter a catalog of products by product attributes, brand names, product lines and so forth. 13 | 14 | The standard Filter class provided is set up to apply filters to a given (Eloquent) query builder. 15 | Additionally a CountableFilter extension of the class is provided for offering typical counts for determining what alternative filter settings should be displayed to visitors. 16 | 17 | This is not a ready-to-use package, but a framework you can extend for your own specific applications. 18 | 19 | ## Version Compatibility 20 | 21 | | Laravel | PHP | Package | 22 | |:--------------|:-----|:--------| 23 | | 5.8 and below | 7.0+ | 1.1 | 24 | | 6.0 to 7.0 | 7.1+ | 2.0 | 25 | | 6.0 to 8.0 | 7.2+ | 3.1 | 26 | | 9.0 | 8.1+ | 4.0 | 27 | | 10 and up | 8.1+ | 5.0 | 28 | 29 | ## Changelog 30 | 31 | [Changelog here](CHANGELOG.md). 32 | 33 | ## Install 34 | 35 | Via Composer 36 | 37 | ``` bash 38 | $ composer require czim/laravel-filter 39 | ``` 40 | 41 | ## Basic Usage 42 | 43 | Make a class that extends `Czim\FilterData` and set the protected properties for validation rules `$rules` and the default values for these attributes `$defaults`. 44 | Note that `$defaults` are used as the main means to detect which filter parameters need to be applied to the query, so make sure all filter parameters you want to implement are present in it. 45 | 46 | Simply extend the (abstract) filter class of your choice, either `Czim\Filter\Filter` or `Czim\Filter\CountableFilter`. 47 | 48 | Each has abstract methods that must be provided for the class to work. Once they are all set (see below), you can simply apply filter settings to a query: 49 | 50 | ``` php 51 | $filterValues = [ 'attributename' => 'value', ... ]; 52 | 53 | $filter = new SomeFilter($filterValues); 54 | 55 | // get an Eloquent builder query for a model 56 | $query = SomeEloquentModel::query(); 57 | 58 | // apply the filter to the query 59 | $filteredQuery = $filter->apply($query); 60 | 61 | // normal get() call on the now filtered query 62 | $results = $filteredQuery->get(); 63 | ``` 64 | 65 | A `CountableFilter` has an additional method that may be called: 66 | 67 | ``` php 68 | $countResults = $filter->count(); 69 | ``` 70 | 71 | You can find more about countable filters below. 72 | 73 | 74 | ## Filter Data 75 | 76 | You may pass any array or Arrayable data directly into the filter, and it will create a `FilterData` object for you. 77 | If you do not have the `$filterDataClass` property overridden, however, your filter will do nothing (because no attributes and defaults are set for it, the FilterData will always be empty). 78 | In your extension of the `Filter` class, override the property like so in order to be able to let the Filter create it automatically: 79 | 80 | ``` php 81 | class YourFilter extends \Czim\Filter\Filter 82 | { 83 | protected $filterDataClass = \Your\FilterDataClass::class; 84 | 85 | ... 86 | } 87 | ``` 88 | 89 | Your `FilterData` class should then look something like this: 90 | 91 | ``` php 92 | class FilterDataClass extends \Czim\Filter\FilterData 93 | { 94 | // Validation rules for filter attributes passed in 95 | protected $rules = [ 96 | 'name' => 'string|required', 97 | 'brands' => 'array|each:integer', 98 | 'before' => 'date', 99 | 'active' => 'boolean', 100 | ]; 101 | 102 | // Default values and the parameter names accessible to the Filter class 103 | // If (optional) filter attributes are not provided, these defaults will be used: 104 | protected $defaults = [ 105 | 'name' => null, 106 | 'brands' => [], 107 | 'before' => null, 108 | 'active' => true, 109 | ]; 110 | } 111 | ``` 112 | 113 | Filter validation rules are optional. If no rules are provided, validation always passes. 114 | Defaults are *required*, and define which parameter keys are applied by the filter. 115 | 116 | Then, passing array(able) data into the constructor of your filter will automatically instantiate that FilterData class for you. 117 | If it is an (unmodified) extension of `Czim\FilterData`, it will also validate the data and throw an exception if the data does not match the `$rules` defined in your Data class. 118 | 119 | Alternatively, you can make your own implementation of the provided `FilterDataInterface` and pass it into the Filter directly. 120 | 121 | ``` php 122 | $filter = new YourFilter( new YourFilterData($someData) ); 123 | ``` 124 | 125 | All it needs to do is implement the interface; if you pass in data this way, the data will be set without any further checks or validation, unless you handle it in your FilterData implementation yourself. 126 | 127 | 128 | ## Filters 129 | 130 | Basic Filters take a query and apply filter parameters to it, before handing it back. 131 | (Note that the query object passed in will be modified; it is not cloned in the Filter before making modifications). 132 | 133 | For example, if you'd do the following: 134 | 135 | ``` php 136 | $query = SomeModel::where('some_column', 1); 137 | 138 | $query = (new YourFilter([ 'name' => 'random' ]))->apply($query); 139 | 140 | echo $query->toSql(); 141 | ``` 142 | 143 | You might expect the result to be something like `select * from some_models where some_column = 1 and name LIKE '%random%'`. 144 | 145 | 146 | What a filter exactly does with the filter data you pass into its constructor must be defined in your implementation. 147 | This may be done in two main ways, which can be freely combined: 148 | 149 | * By defining *strategies* (overriding the public `strategies()` method) 150 | * By overriding the `applyParameter()` method as a fallback option 151 | 152 | *Important*: filter logic is only invoked if the parameter's provided value is **not empty**. 153 | Regardless of the method you choose to make your filter application, it will *only* be applied if: `! empty($value) || $value === false`. 154 | 155 | 156 | ### Strategies and ParameterFilters 157 | 158 | You can define strategies for each filter parameter by adding a strategies method to your filter as follows: 159 | 160 | ``` php 161 | protected function strategies(): array 162 | { 163 | return [ 164 | // as a ParameterFilter instance 165 | 'parameter_name_here' => new \Czim\Filter\ParameterFilters\SimpleString(), 166 | 167 | // as a ParameterFilter class string 168 | 'another_parameter' => \Czim\Filter\ParameterFilters\SimpleString::class, 169 | 170 | // as an anonymous function 171 | 'yet_another' => function($name, $value, $query) { 172 | return $query->where('some_column', '>', $value); 173 | }, 174 | 175 | // as an array (passable to call_user_func()) 176 | 'and_another' => [ $this, 'someMethodYouDefined' ], 177 | ]; 178 | } 179 | ``` 180 | 181 | If filter data is passed into the class with the same keyname as a strategy, that strategy method will be invoked. 182 | As shown above, there are different ways to provide a callable method for filters, but all methods mean passing data to a function that takes these parameters: 183 | 184 | ``` php 185 | /** 186 | * @param string $name the keyname of the parameter/strategy 187 | * @param mixed $value the value for this parameter set in the filter data 188 | * @param EloquentBuilder $query 189 | * @param FilterInterface $filter the filter from which the strategy was invoked 190 | */ 191 | public function apply(string $name, $value, $query); 192 | ``` 193 | 194 | A `ParameterFilter` is a class (any that implements the `ParameterFilterInterface`) which may be set as a filter strategy. 195 | The `apply()` method on this class will be called when the filter is applied. 196 | If the ParameterFilter is given as a string for the strategy, it will be instantiated when the filter is applied. 197 | 198 | Strategies may also be defined as closures or arrays (so long as they may be fed into a `call_user_func()`). 199 | The method called by this will receive the four parameters noted above. 200 | 201 | Only if no strategy has been defined for a parameter, the callback method `applyParameter()` will be called on the filter itself. 202 | By default, an exception will occur. 203 | 204 | Some common ParameterFilters are included in this package: 205 | 206 | * `SimpleInteger`: for looking up (integer) values with an optional operator ('=' by default) 207 | * `SimpleString`: for looking up string values, with a *LIKE % + value + %* match by default 208 | * `SimpleTranslatedString`: (uses `JoinKey::Translations` as the join key) 209 | 210 | 211 | ### The fallback option: applyParameter() 212 | 213 | If you prefer, you can also use the fallback method to handle any or all of the appliccable parameters. 214 | Simply add the following method to your filter class: 215 | 216 | ``` php 217 | protected function applyParameter(string $name, $value, $query) 218 | { 219 | switch ($name) { 220 | 221 | case 'parameter_name_here': 222 | 223 | // your implementation of the filter ... 224 | return $query; 225 | 226 | ... 227 | } 228 | 229 | // as a safeguard, you can call the parent method, 230 | // which will throw exceptions for unhandled parameters 231 | parent::applyParameter($name, $value, $query); 232 | } 233 | ``` 234 | 235 | You can freely combine this approach with the strategy definitions mentioned above. 236 | The only limitation is that when there is a strategy defined for a parameter, the `applyParameter()` fallback will not be called for it. 237 | 238 | 239 | ## Countable Filters 240 | 241 | The `CountableFilter` is an extension of the normal filter that helps write special filters for, say, web shops where it makes sense to show relevant alternatives based on the current filter choices. 242 | 243 | Take a product catalog, for instance, where you're filtering based on a particular brand name and a price range. 244 | In the filter options shown, you may want to display other brands that your visitor can filter on, but *only* the brands for which your have products in the chosen price range. 245 | The idea is to prevent your visitors from selecting a different brand only to find that there are no results. 246 | 247 | `CountableFilters` help you to do this, by using currently set filters to generate counts for alternative options. 248 | Say you have brand X, Y and Z, and are filtering products only for brand X and only in a given price range. 249 | The countable filter makes it easy to get a list of how many products also have matches for the price range of brand Y and Z. 250 | 251 | To set up a `CountableFilter`, set up the `Filter` as normal, but additionally configure `$countables` and `countStrategies()`. 252 | The counting strategies are similarly configurable/implementable as filtering strategies. 253 | 254 | The return value for `CountableFilter::count()` is an instance of `Czim\CountableResults`, which is basically a standard Laravel `Collection` instance. 255 | 256 | 257 | ### Counting Strategies 258 | 259 | Strategies may be defined for the effects of `count()` per parameter for your CountableFilter, in the same way as normal filter strategies. 260 | 261 | ``` php 262 | protected function countStrategies(): array 263 | { 264 | return [ 265 | 'parameter_name_here' => new \Czim\Filter\ParameterCounters\SimpleInteger(), 266 | ... 267 | ]; 268 | } 269 | ``` 270 | 271 | The same methods for defining strategies are available as with the `strategies()` methods above: instances (of ParameterCounters in this case), strings, closures and arrays. 272 | 273 | The fallback for parameters without defined strategies is `countParameter()`: 274 | 275 | ``` php 276 | /** 277 | * @param string $parameter countable name 278 | * @param EloquentBuilder $query 279 | */ 280 | protected function countParameter(string $parameter, $query) 281 | { 282 | // your implementation for each $parameter name 283 | } 284 | ``` 285 | 286 | 287 | ### ParameterCounters 288 | 289 | Just like ParameterFilters for `Filter`, ParameterCounters can be used as 'plugins' for your `CountableFilter`. 290 | 291 | ``` php 292 | 293 | protected function countStrategies(): array 294 | { 295 | return [ 296 | 'parameter_name_here' => new ParameterCounters\YourParameterCounter() 297 | ]; 298 | } 299 | ``` 300 | 301 | ParameterCounters must implement the `ParameterCounterInterface`, featuring this method: 302 | 303 | ``` php 304 | /** 305 | * @param string $name 306 | * @param EloquentBuilder $query 307 | * @param CountableFilterInterface $filter 308 | */ 309 | public function count(string $name, $query, CountableFilterInterface $filter); 310 | ``` 311 | 312 | ## Settings and Extra stuff 313 | 314 | ### Joins 315 | 316 | When joining tables for filter parameters, it may occur that different parameters require the same join(s). 317 | In order to prevent duplicate joining of tables, the Filter class has a built in helper for working with joins. 318 | 319 | ``` php 320 | // within your applyParameter implementation 321 | // the first parameter is a keyname you define, see the JoinKey enum provided 322 | // the second parameter is an array of parameters that is passed on directly 323 | // to Laravel's query builder join() method. 324 | $this->addJoin('keyname_for_your_join', [ 'table', 'column_a', '=', 'column_b' ]); 325 | 326 | // or within a ParameterFilter apply() method, call it on the filter 327 | $filter->addJoin( ... , [ ... ]); 328 | ``` 329 | 330 | Joins so added are automatically applied to the filter after all parameters are applied. 331 | 332 | 333 | ### Global Filter Settings 334 | 335 | Sometimes it may be useful to let filter-wide settings affect the way your filter works. 336 | You can set these through a setter directly on the filter class, `setSetting()`. 337 | Alternatively, you can define a filter parameter strategy as `Filter::SETTING`, and it will be loaded as a setting before the filter is applied. 338 | 339 | ``` php 340 | // in your Filter class: 341 | protected function strategies(): array 342 | { 343 | return [ 344 | ... 345 | 346 | 'global_setting_name' => static::SETTING 347 | ]; 348 | } 349 | ``` 350 | 351 | When a setting has been set in either way, you can check it with the `setting()` method. 352 | Note that the ParameterFilter/ParameterCounter also receives the `$filter` itself as a parameter and the method is public. 353 | 354 | If a setting has not been defined, the `setting()` method for it will return `null`. 355 | 356 | 357 | ## Examples 358 | 359 | Here are [some examples](EXAMPLES.md) of using the Filter and CountableFilter classes. 360 | 361 | 362 | ## Contributing 363 | 364 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 365 | 366 | 367 | ## Credits 368 | 369 | - [Coen Zimmerman][link-author] 370 | - [All Contributors][link-contributors] 371 | 372 | ## License 373 | 374 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 375 | 376 | [ico-version]: https://img.shields.io/packagist/v/czim/laravel-filter.svg?style=flat-square 377 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 378 | [ico-downloads]: https://img.shields.io/packagist/dt/czim/laravel-filter.svg?style=flat-square 379 | 380 | [link-packagist]: https://packagist.org/packages/czim/laravel-filter 381 | [link-downloads]: https://packagist.org/packages/czim/laravel-filter 382 | [link-author]: https://github.com/czim 383 | [link-contributors]: ../../contributors 384 | --------------------------------------------------------------------------------