├── .styleci.yml ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── funnel.php └── src ├── Attribute.php ├── Console ├── FilterCommand.php └── Stubs │ ├── filter-group-by.stub │ ├── filter-order-by.stub │ └── filter-where.stub ├── Filter.php ├── FunnelServiceProvider.php ├── HasFilters.php └── Parameter.php /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.2 5 | - 7.3 6 | - 7.4 7 | 8 | env: 9 | matrix: 10 | - COMPOSER_FLAGS="--prefer-lowest" 11 | - COMPOSER_FLAGS="" 12 | 13 | before_script: 14 | - travis_retry composer update 15 | 16 | script: 17 | - vendor/bin/phpunit 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | ### Added 9 | - N/A 10 | ### Changed 11 | - N/A 12 | ### Removed 13 | - N/A 14 | ## [v0.2.1] - 2020-04-08 15 | ### Added 16 | - Allow ability to eager load 17 | - Add optional getEagerSafe() macro on \Illuminate\Database\Eloquent\Builder class to automatically convert RelationNotFoundException to ValidationException 18 | ### Changed 19 | - Modify Filter class and check for parameter naming collision. 20 | ### Removed 21 | - N/A 22 | ## [v0.2.0] - 2020-06-14 23 | ### Added 24 | - Allow empty query string 25 | - Add more tests 26 | ### Changed 27 | - Refactor code base 28 | ### Removed 29 | - N/A 30 | ## [v0.1.4] - 2020-06-07 31 | ### Added 32 | - Binding for related model's attribute 33 | ### Changed 34 | - Refactor base Filter class 35 | ### Removed 36 | - N/A 37 | ## [v0.1.3] - 2020-06-05 38 | ### Added 39 | - Support for comma-delimited multi-valued GET params. 40 | ### Changed 41 | - N/A 42 | ### Removed 43 | - N/A 44 | ## [v0.1.2] - 2020-06-04 45 | ### Added 46 | - Support for multi-valued GET params. 47 | - `getDefaultWhereBuilder()` method. 48 | ### Changed 49 | - Base Filter class. 50 | ### Removed 51 | - N/A 52 | ## [v0.1.1] - 2020-05-18 53 | ### Added 54 | - N/A 55 | ### Changed 56 | - N/A 57 | ### Removed 58 | - `get()` call from `filtered()` method to allow query chain 59 | ## [v0.1.0] - 2020-01-21 60 | ### Added 61 | - The funnel:filter command 62 | - Support for where, groupBy and orderBy clause 63 | ### Changed 64 | - N/A 65 | ### Removed 66 | - N/A 67 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tanmay Mishu 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Funnel 2 | 3 | Filtering results based on the http query string parameter (`?key=value`) is one of the common tasks of everyday web development. 4 | 5 | Laravel Funnel is an attempt to reduce the cognitive burden of applying and maintaining the filters. 6 | 7 | ## Features 8 | - [x] **Param-Attr binding:** Binds query string _parameters_ to eloquent model _attributes_. 9 | - [x] **Code generation:** Generates filter classes with a simple command. 10 | - [x] **Multi-value params:** Makes multi-value parameters painless by allowing comma-delimited list in URL. Example: `http://example.com/posts?title=foo,bar`. 11 | - [x] **Sorting:** Creates "sort-aware" filters with a simple `--clause=orderBy` argument. 12 | - [x] **Searching:** Creates "search-aware" with a simple `--operator=like` argument. 13 | - [x] **Related model's attr binding:** Binds attribute from a related model easily with `relation.attribute` format: `--attribute=comments.body` 14 | - [x] **Eager-loading:** Funnel comes with eager-loading support out of the box. Pass your relation to the default `?with` query param. Example: `http://example.com/posts?with=comments,categories`. 15 | - [x] **Customization:** Query logic in generated filter classes can be overridden according to your need. 16 | 17 | ## Installation 18 | 19 | Use the package manager [composer](https://getcomposer.org/) to install laravel-funnel. 20 | 21 | ```bash 22 | composer require tanmaymishu/laravel-funnel 23 | ``` 24 | 25 | ## Usage 26 | ### Quick Start: 27 | Let's say you have a `Post` model and an attribute `published` and you want to filter all the posts that are published. The URL representation might look like this: 28 | 29 | `http://example.com/posts?published=1` 30 | 31 | Step 1: Run `php artisan funnel:filter Published`. A new `Published` class inside `app/Filters` directory will be created and the following configurations will be assumed: 32 | 33 | - You have an attribute named `published` 34 | - Your have a query string identifier named `published` 35 | - Your desired query clause is `WHERE` 36 | - The operator for the `WHERE` clause is `=` 37 | 38 | Don't worry, all these "assumed defaults" can be overridden (See [CLI options](https://github.com/tanmaymishu/laravel-funnel#cli-options) below). 39 | 40 | Step 2: Open the model (e.g. Post.php) where you want to use this filter in. Add these two lines in your class: 41 | 42 | ```php 43 | use HasFilters; 44 | protected $filters = []; 45 | ``` 46 | 47 | Then add the filter class in the `$filters` array. Example: 48 | 49 | ```php 50 | with('comments')->get()`. You have to append `->get()` as you normally would, to return the result as a collection. 68 | 69 | You can add as many filters as you want in the `$filters` array. Append the parameter in your query string: `?title=foo&published=1` and Funnel will pick up the appropriate filter for you. 70 | ### CLI Options 71 | - This package ships with a `funnel:filter` command. The following command will display all the details including the argument and option it accepts: 72 | ```php 73 | php artisan -h funnel:filter 74 | ``` 75 | ```Description: 76 | Create a new filter 77 | 78 | Usage: 79 | funnel:filter [options] [--] 80 | 81 | Arguments: 82 | name The name of the filter class. 83 | 84 | Options: 85 | -a, --attribute[=ATTRIBUTE] The attribute name of the model (e.g. is_active). Default: Snake cased filter_class 86 | -p, --parameter[=PARAMETER] The name of the request query parameter (e.g. active). Default: Snake cased filter_class 87 | -o, --operator[=OPERATOR] The operator for the WHERE clause (e.g. >, like, =, <). Default: = 88 | -c, --clause[=CLAUSE] The clause for the query (e.g. where, orderBy, groupBy). Default: where 89 | ``` 90 | - The `funnel:filter` command takes 1 _argument_ (the name of the filter class) and 4 _options_ (sometimes known as _flags_): 91 | 1) `--attribute=` (short form: `-a`): The attribute of the model. If this option is not provided, the default _attribute_ will be the snake_cased form of the filter class' name that was provided as the _argument_. 92 | 2) `--parameter=` (short form: `-p`): The query string parameter that will be received from the URL. If this option is not provided, the default _parameter_ will be the snake_cased form of the filter class' name that was provided as the _argument_. 93 | 3) `--operator=` (short form: `-o`): The operator to be used in the `WHERE` clause. If this option is not provided, `=` will be used as the default operator. 94 | 4) `--clause=` (short form: `-c`): The clause to be used in the query. If `WHERE` clause doesn't suit your need, you can specify a different clause (currently supported: `orderBy`, `groupBy`) 95 | 96 | Notes: 97 | - If the operator is `like`, the parameter's value will be surrounded by the `%` wildcard on both sides of the value. This behaviour may be customized in future. 98 | - If the clause is `orderBy`, only one of the following two parameter values are expected: a) **asc** b) **desc** 99 | 100 | ### Examples 101 | Let's take a look at some funnel commands and what result they produce based on the URL: 102 | 103 | **Model and Relation Considerations:** 104 | ```php 105 | // A Post hasMany Comments and a Comment hasMany Replies 106 | // Post is the model that we want to query. 107 | 108 | Post::createMany([ 109 | ['title' => 'Foo', 'body' => 'Lorem ipsum'], // We'll call it Post 1 110 | ['title' => 'Bar', 'body' => 'Dolor sit amet'], // We'll call it Post 2 111 | ]); 112 | Comment::createMany([ 113 | ['body' => 'Comment A', 'post_id' => 1], 114 | ['body' => 'Comment B', 'post_id' => 2], 115 | ]); 116 | Reply::createMany([ 117 | ['content' => 'Reply A', 'comment_id' => 1], 118 | ['content' => 'Reply B', 'comment_id' => 2], 119 | ]); 120 | ``` 121 | 122 | **[Note: The examples below use a mixture of long form options and short form options. Feel free to use any form you like.]** 123 | 124 | | **Command** | **URL** | **Result** | 125 | |:--------------|:------------------|-------------| 126 | | `funnel:filter Title` | example.com?title=Foo | Fetches Post 1 | 127 | | `funnel:filter Title` | example.com?title=Foo,Bar | Fetches Post 1 and 2 | 128 | | `funnel:filter Title` | example.com?title[]=Foo&title[]=Bar | Fetches Post 1 and 2 | 129 | | `funnel:filter Title --clause=orderBy` | example.com?title=asc | Fetches all the posts sorted by title in ascending order | 130 | | `funnel:filter Title -c orderBy` (Shorthand) | example.com?title=desc | Fetches all the posts sorted by title in descending order | 131 | | `funnel:filter Body --operator=like` | example.com?body=Lorem | Fetches Post 1 | 132 | | `funnel:filter Search -a body -o like` | example.com?search=Dolor | Fetches Post 2. Specified attr (body) and operator (like) is used instead of defaults. | 133 | | `funnel:filter Comment -a comments.body -o like` | example.com?comment=Comment B | Fetches Post 2. Will return all the posts that contain "Comment B" in their comment's body. `body` attr of Comment model is used instead of the `body` attr of the Post model. | 134 | | `funnel:filter Reply -a comments.replies.content -o like` | example.com?reply=Reply A | Fetches Post 1. Will return all the posts that contain "Reply A" in their replies to a comment. `content` attr of Reply model is used. | 135 | 136 | ### Multi-value parameters (`[]` notation) 137 | - Funnel can understand multi-value query string parameters: 138 | `http://example.com/posts?title[]=foo&title[]=bar`. You don't have to take any extra steps for that. 139 | - As you can see, you will need to append the array notation `[]` to each of your query parameters. 140 | - Funnel will pass the parameter values (foo & bar) through the `OR` sub-queries. 141 | - A get request like `http://example.com/posts?title[]=foo&title[]=bar` will indicate that we want to fetch all the posts that has a title _foo_ or _bar_. 142 | ### Multi-value parameters (`,` notation) 143 | - In addition to the `[]` notation, Funnel provides an easier, alternative comma (`,`) syntax for multi-value parameters: `http://example.com/posts?title=foo,bar` 144 | - The advantage of `,` over `[]` notation is that you don't have to keep repeating `param[]` for each parameter. 145 | ### Binding related model's attribute 146 | - The attribute doesn't necessarily have to reside in the model being queried. If you're in a situation where you want to filter all the posts with the comment body "Foo", assuming your Post model has a `comments()` relation, you should pass the `--attribute=comments.body` option: 147 | - Example Command: `php artisan funnel:filter Comment --attribute=comments.body`. Funnel will filter all the posts with the specified comment body even though the `body` attribute lives in the `Comment` model and not in the `Post` model. 148 | - Example URL: `http://example.com/posts?comment=Foo` 149 | - Even nested related model's attribute can be bound to a parameter. If we want to fetch all the posts that have the reply body `Bar` in the comments, we can achieve that too as long as the relationships exist: 150 | - Example Command: `php artisan funnel:filter Reply --attribute=comments.replies.body` 151 | - Example URL: `http://example.com/posts?reply=Bar` 152 | 153 | ### Eager-loading 154 | - Funnel comes with eager-loading support out of the box. Pass your relation to the default `?with` query param. Example: `http://example.com/posts?with=comments,categories`. 155 | - If you need to customize the eager key name, which is by default `with`, you can do so from `config/funnel.php`. Before you do so, you need to publish your config files by running the following command: 156 | `php artisan vendor:publish --provider="TanmayMishu\LaravelFunnel\FunnelServiceProvider"` 157 | 158 | ### Customization 159 | - If the generated `apply()` method of the filter class doesn't fit your need, you can always implement your own `apply()` method but it should match the signature of the parent class. 160 | 161 | ## Contributing 162 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 163 | 164 | Please make sure to update tests as appropriate. 165 | 166 | ## License 167 | [MIT](https://choosealicense.com/licenses/mit/) 168 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tanmaymishu/laravel-funnel", 3 | "description": "Bind http query string parameters to Eloquent model attributes.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Tanmay Mishu", 9 | "email": "tanmaymishu@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.2" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "TanmayMishu\\LaravelFunnel\\": "src/" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "TanmayMishu\\Tests\\": "tests" 23 | } 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^8.5", 27 | "orchestra/testbench": "^4.0" 28 | }, 29 | "extra": { 30 | "laravel": { 31 | "providers": [ 32 | "TanmayMishu\\LaravelFunnel\\FunnelServiceProvider" 33 | ] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/funnel.php: -------------------------------------------------------------------------------- 1 | env('FUNNEL_EAGER_KEY', 'with'), 5 | ]; 6 | -------------------------------------------------------------------------------- /src/Attribute.php: -------------------------------------------------------------------------------- 1 | setName($name); 20 | } 21 | 22 | /** 23 | * Get the relation name from the relation string. 24 | * 25 | * @return string 26 | */ 27 | public function extractRelation(): string 28 | { 29 | if (! $this->hasRelation()) { 30 | throw new \RuntimeException('Trying to extract relation from non-relation string.'); 31 | } 32 | 33 | $exploded = explode('.', $this->name); 34 | 35 | return implode('.', array_slice( 36 | $exploded, 0, count($exploded) - 1, true 37 | )); 38 | } 39 | 40 | /** 41 | * Check whether the attribute contains a relation. 42 | * 43 | * @return bool 44 | */ 45 | public function hasRelation(): bool 46 | { 47 | return $this->relationCount() > 0; 48 | } 49 | 50 | /** 51 | * Get the number of relations (dot separated). 52 | * 53 | * @return int 54 | */ 55 | public function relationCount(): int 56 | { 57 | return count(explode('.', $this->name)) - 1; 58 | } 59 | 60 | /** 61 | * Get the attribute name. 62 | * 63 | * @return string 64 | */ 65 | public function getName() 66 | { 67 | return $this->hasRelation() ? $this->extractAttribute() : $this->name; 68 | } 69 | 70 | /** 71 | * Set the attribute name. 72 | * 73 | * @param string $name 74 | * @return Attribute 75 | */ 76 | public function setName(string $name): self 77 | { 78 | $this->name = $name; 79 | 80 | return $this; 81 | } 82 | 83 | /** 84 | * Isolate and extract the attribute from relation string. 85 | * 86 | * @return string 87 | */ 88 | public function extractAttribute() 89 | { 90 | if (! $this->hasRelation()) { 91 | throw new \RuntimeException('Trying to extract attribute from non-relation string.'); 92 | } 93 | 94 | $exploded = explode('.', $this->name); 95 | 96 | return $exploded[count($exploded) - 1]; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Console/FilterCommand.php: -------------------------------------------------------------------------------- 1 | option('clause') == 'orderBy') { 49 | return __DIR__.'/Stubs/filter-order-by.stub'; 50 | } elseif ($this->option('clause') == 'groupBy') { 51 | return __DIR__.'/Stubs/filter-group-by.stub'; 52 | } else { 53 | return __DIR__.'/Stubs/filter-where.stub'; 54 | } 55 | } 56 | 57 | public function handle() 58 | { 59 | $this->reservedKeywords[] = config()->has('funnel') 60 | ? config('funnel.eager_key') 61 | : 'with'; 62 | 63 | if (in_array(strtolower($this->argument('name')), $this->reservedKeywords)) { 64 | $this->error('Reserved name. Please provide a different name.'); 65 | 66 | return; 67 | } 68 | 69 | if (in_array($this->option('parameter'), $this->reservedKeywords)) { 70 | $this->error('Reserved parameter. Please provide a different parameter.'); 71 | 72 | return; 73 | } 74 | 75 | return parent::handle(); 76 | } 77 | 78 | /** 79 | * Get the default namespace for the class. 80 | * 81 | * @param string $rootNamespace 82 | * @return string 83 | */ 84 | protected function getDefaultNamespace($rootNamespace) 85 | { 86 | return $rootNamespace.'\Filters'; 87 | } 88 | 89 | /** 90 | * Build the class with the given name. 91 | * 92 | * @param string $name 93 | * @return string 94 | * 95 | * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException 96 | */ 97 | protected function buildClass($name) 98 | { 99 | $stub = $this->files->get($this->getStub()); 100 | 101 | $stub = $this->replaceOptions($stub); 102 | 103 | return $this->replaceNamespace($stub, $name)->replaceClass($stub, $name); 104 | } 105 | 106 | /** 107 | * Replace the class name for the given stub. 108 | * 109 | * @param string $stub 110 | * @param string $name 111 | * @return string 112 | */ 113 | protected function replaceClass($stub, $name) 114 | { 115 | $class = str_replace($this->getNamespace($name).'\\', '', $name); 116 | 117 | return str_replace('DummyFilter', $class, $stub); 118 | } 119 | 120 | private function replaceOptions($stub) 121 | { 122 | $stub = $this->option('attribute') 123 | ? str_replace('DummyAttribute', $this->option('attribute'), $stub) 124 | : $this->getDefaultAttribute($stub); 125 | 126 | $stub = $this->option('parameter') 127 | ? str_replace('DummyParameter', $this->option('parameter'), $stub) 128 | : $this->getDefaultParameter($stub); 129 | 130 | $stub = $this->option('operator') 131 | ? str_replace('DummyOperator', $this->option('operator'), $stub) 132 | : $this->getDefaultOperator($stub); 133 | 134 | return $stub; 135 | } 136 | 137 | private function getDefaultAttribute($stub) 138 | { 139 | return str_replace('DummyAttribute', Str::snake($this->argument('name')), $stub); 140 | } 141 | 142 | private function getDefaultParameter($stub) 143 | { 144 | return str_replace('DummyParameter', Str::snake($this->argument('name')), $stub); 145 | } 146 | 147 | private function getDefaultOperator($stub) 148 | { 149 | return str_replace('DummyOperator', '=', $stub); 150 | } 151 | 152 | /** 153 | * Get the console command arguments. 154 | * 155 | * @return array 156 | */ 157 | protected function getArguments() 158 | { 159 | return [ 160 | ['name', InputArgument::REQUIRED, 'The name of the filter class.'], 161 | ]; 162 | } 163 | 164 | /** 165 | * Get the console command options. 166 | * 167 | * @return array 168 | */ 169 | protected function getOptions() 170 | { 171 | return [ 172 | ['attribute', 'a', InputOption::VALUE_OPTIONAL, 'The attribute name of the model (e.g. is_active). Default: Snake cased filter_class'], 173 | ['parameter', 'p', InputOption::VALUE_OPTIONAL, 'The name of the request query parameter (e.g. active). Default: Snake cased filter_class'], 174 | ['operator', 'o', InputOption::VALUE_OPTIONAL, 'The operator for the WHERE clause (e.g. >, like, =, <). Default: ='], 175 | ['clause', 'c', InputOption::VALUE_OPTIONAL, 'The clause for the query (e.g. where, orderBy, groupBy). Default: where'], 176 | ]; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Console/Stubs/filter-group-by.stub: -------------------------------------------------------------------------------- 1 | groupBy(request($this->parameter)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Console/Stubs/filter-order-by.stub: -------------------------------------------------------------------------------- 1 | orderBy('DummyAttribute', request($this->parameter)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Console/Stubs/filter-where.stub: -------------------------------------------------------------------------------- 1 | getDefaultWhereBuilder($builder); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Filter.php: -------------------------------------------------------------------------------- 1 | filled($this->parameter)) { 49 | return $next($passable); 50 | } 51 | 52 | $this->checkParamCollision(); 53 | 54 | $this->builder = $next($passable); 55 | 56 | return $this->apply($this->builder); 57 | } 58 | 59 | /** 60 | * Prepare the builder. Customize this according to your need. 61 | * 62 | * @param Builder $builder 63 | * @return Builder 64 | */ 65 | abstract protected function apply(Builder $builder): Builder; 66 | 67 | public function checkParamCollision(): void 68 | { 69 | $eagerKey = config()->has('funnel') 70 | ? config('funnel.eager_key') 71 | : 'with'; 72 | 73 | if ($this->parameter == $eagerKey) { 74 | throw new \RuntimeException('Reserved parameter. Please provide a different parameter.'); 75 | } 76 | } 77 | 78 | /** 79 | * Get the attribute. 80 | * 81 | * @return Attribute 82 | */ 83 | protected function getAttribute(): Attribute 84 | { 85 | return $this->mAttribute; 86 | } 87 | 88 | /** 89 | * Set the attribute. 90 | * 91 | * @param Attribute $mAttribute 92 | * @return $this 93 | */ 94 | protected function setAttribute(Attribute $mAttribute) 95 | { 96 | $this->mAttribute = $mAttribute; 97 | 98 | return $this; 99 | } 100 | 101 | /** 102 | * Get current builder. 103 | * 104 | * @return Builder 105 | */ 106 | protected function getBuilder(): Builder 107 | { 108 | return $this->builder; 109 | } 110 | 111 | /** 112 | * Set the builder. 113 | * 114 | * @param Builder $builder 115 | * @return Filter 116 | */ 117 | protected function setBuilder(Builder $builder): self 118 | { 119 | $this->builder = $builder; 120 | 121 | return $this; 122 | } 123 | 124 | /** 125 | * Get the parameter. 126 | * 127 | * @return Parameter 128 | */ 129 | protected function getParameter(): Parameter 130 | { 131 | return $this->mParameter; 132 | } 133 | 134 | /** 135 | * Set the request parameter. 136 | * 137 | * @param Parameter $mParameter 138 | * @return Filter 139 | */ 140 | protected function setParameter(Parameter $mParameter): self 141 | { 142 | $this->mParameter = $mParameter; 143 | 144 | return $this; 145 | } 146 | 147 | /** 148 | * Get the default builder for a typical where clause. For relational 149 | * attr, query will run inside a `whereHas()` method. 150 | * 151 | * @param Builder $builder 152 | * @return Builder 153 | */ 154 | protected function getDefaultWhereBuilder(Builder $builder): Builder 155 | { 156 | if ($this->hasRelation()) { 157 | return $builder->whereHas($this->extractRelation(), function ($builder) { 158 | $this->prepareWhereBuilder($builder); 159 | }); 160 | } 161 | 162 | return $this->prepareWhereBuilder($builder); 163 | } 164 | 165 | /** 166 | * Check whether the attribute contains a relation. 167 | * 168 | * @return bool 169 | */ 170 | protected function hasRelation(): bool 171 | { 172 | if (! $this->mAttribute) { 173 | $this->setAttribute(new Attribute($this->attribute)); 174 | } 175 | 176 | return $this->mAttribute->hasRelation(); 177 | } 178 | 179 | /** 180 | * Proxy for Attribute::extractRelation. 181 | * 182 | * @return string 183 | */ 184 | protected function extractRelation(): string 185 | { 186 | return $this->mAttribute->extractRelation(); 187 | } 188 | 189 | /** 190 | * Prepare the where builder for a typical where clause. For multi-value 191 | * params, param values will be passed through `OR` sub-queries. 192 | * 193 | * @param Builder $builder 194 | * @return Builder 195 | */ 196 | protected function prepareWhereBuilder(Builder $builder): Builder 197 | { 198 | if (is_array($this->getParamValue())) { 199 | return $builder->where(function ($subBuilder) { 200 | $this->setBuilder($subBuilder)->buildForMultiValue(); 201 | }); 202 | } 203 | 204 | return $this->setBuilder($builder)->buildForSingleValue(); 205 | } 206 | 207 | /** 208 | * Get the parameter's value. 209 | * 210 | * @return array|string 211 | */ 212 | protected function getParamValue() 213 | { 214 | if (! $this->mParameter) { 215 | $this->setParameter(new Parameter($this->parameter, $this->isSearchable())); 216 | } 217 | 218 | return $this->mParameter->getValue(); 219 | } 220 | 221 | /** 222 | * Check whether the operator is a LIKE operator. 223 | * 224 | * @return bool 225 | */ 226 | protected function isSearchable(): bool 227 | { 228 | return strtolower($this->operator) == 'like'; 229 | } 230 | 231 | /** 232 | * Iterate over the parameter value and append the 233 | * WHERE clauses. 234 | * 235 | * @return void 236 | */ 237 | protected function buildForMultiValue(): void 238 | { 239 | collect($this->getParamValue())->each(function ($value, $index) { 240 | $index == 0 241 | ? $this->buildMultiValueClause($value) 242 | : $this->buildMultiValueClause($value, true); 243 | }); 244 | } 245 | 246 | /** 247 | * Build query when parameter value is an array. 248 | * 249 | * @param $value 250 | * @param bool $forOr 251 | * @return Builder 252 | */ 253 | protected function buildMultiValueClause($value, bool $forOr = false): Builder 254 | { 255 | return $forOr ? $this->buildOrWhereClause($value) : $this->buildWhereClause($value); 256 | } 257 | 258 | /** 259 | * Build orWhere query. If no $value is passed, normalized 260 | * value is used as default. 261 | * 262 | * @param string|null $value 263 | * @return Builder 264 | */ 265 | protected function buildOrWhereClause(string $value = null): Builder 266 | { 267 | return $this->builder->orWhere($this->getAttrName(), $this->operator, $value ?: $this->getParamValue()); 268 | } 269 | 270 | /** 271 | * Get the attribute's name. 272 | * 273 | * @return string 274 | */ 275 | protected function getAttrName() 276 | { 277 | if (! $this->mAttribute) { 278 | $this->setAttribute(new Attribute($this->attribute)); 279 | } 280 | 281 | return $this->mAttribute->getName(); 282 | } 283 | 284 | /** 285 | * Build where query. If no $value is passed, normalized 286 | * value is used as default. 287 | * 288 | * @param string|null $value 289 | * @return Builder 290 | */ 291 | protected function buildWhereClause(string $value = null): Builder 292 | { 293 | return $this->builder->where($this->getAttrName(), $this->operator, $value ?: $this->getParamValue()); 294 | } 295 | 296 | /** 297 | * Build where query when paramValue is non-array value. 298 | * 299 | * @return Builder 300 | */ 301 | protected function buildForSingleValue(): Builder 302 | { 303 | return $this->buildWhereClause(); 304 | } 305 | 306 | /** 307 | * Proxy for Attribute::relationCount(). 308 | * 309 | * @return int 310 | */ 311 | protected function relationCount(): int 312 | { 313 | return $this->mAttribute->relationCount(); 314 | } 315 | 316 | /** 317 | * Proxy for Attribute::extractAttribute(). 318 | * 319 | * @return string 320 | */ 321 | protected function extractAttribute(): string 322 | { 323 | return $this->mAttribute->extractAttribute(); 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /src/FunnelServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 16 | $this->commands([ 17 | FilterCommand::class, 18 | ]); 19 | } 20 | 21 | $this->publishes([ 22 | __DIR__.'/../config/funnel.php' => base_path('config/funnel.php'), 23 | ]); 24 | 25 | /** 26 | * This macro enables Funnel to catch the RelationNotFoundException 27 | * when a missing relationship gets passed via the query string. 28 | * Use the good old get(), if you want to handle it manually. 29 | */ 30 | Builder::macro('getEagerSafe', function () { 31 | try { 32 | return $this->get(); 33 | } catch (RelationNotFoundException $exception) { 34 | throw ValidationException::withMessages([ 35 | 'message' => $exception->getMessage(), 36 | ]); 37 | } 38 | }); 39 | } 40 | 41 | public function register() 42 | { 43 | $this->mergeConfigFrom(__DIR__.'/../config/funnel.php', 'funnel'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/HasFilters.php: -------------------------------------------------------------------------------- 1 | has('funnel') 19 | ? config('funnel.eager_key') 20 | : 'with'; 21 | 22 | if (request()->has($eagerKey)) { 23 | $query->with(collect(explode(',', request($eagerKey)))->filter(function ($eager) { 24 | if (str_contains($eager, '.')) { 25 | return method_exists(static::class, explode('.', $eager)[0]); 26 | } 27 | 28 | return method_exists(static::class, $eager); 29 | })->toArray()); 30 | } 31 | 32 | return app(Pipeline::class) 33 | ->send($query)->through((new static)->filters) 34 | ->thenReturn(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Parameter.php: -------------------------------------------------------------------------------- 1 | setName($name) 37 | ->setSearchable($searchable) 38 | ->setRawValue($rawValue ?? request($name)) 39 | ->setValue($this->formatValue()); 40 | } 41 | 42 | /** 43 | * Format the parameter's value. If the the operator is like/LIKE 44 | * then surround the value with `%` and if the parameter is an 45 | * array or comma-delimited list, then convert it to array. 46 | * 47 | * @return string|array 48 | */ 49 | public function formatValue() 50 | { 51 | if ($this->isSearchable() && $this->isArray()) { 52 | return $this->toLikeFriendlyArray(); 53 | } 54 | 55 | if ($this->isSearchable()) { 56 | return $this->toLikeFriendly(); 57 | } 58 | 59 | if ($this->isMultiValue()) { 60 | return $this->toArray(); 61 | } 62 | 63 | return $this->rawValue; 64 | } 65 | 66 | /** 67 | * Get the searchable. 68 | * 69 | * @return bool 70 | */ 71 | public function isSearchable(): bool 72 | { 73 | return $this->searchable; 74 | } 75 | 76 | /** 77 | * Set the searchable. 78 | * 79 | * @param bool $searchable 80 | * @return Parameter 81 | */ 82 | public function setSearchable(bool $searchable): self 83 | { 84 | $this->searchable = $searchable; 85 | 86 | return $this; 87 | } 88 | 89 | /** 90 | * Checks if the value is an array. 91 | * 92 | * @return bool 93 | */ 94 | public function isArray(): bool 95 | { 96 | return is_array($this->rawValue); 97 | } 98 | 99 | /** 100 | * Map the array to like-friendly array. 101 | * 102 | * @return array 103 | */ 104 | public function toLikeFriendlyArray(): array 105 | { 106 | if (! $this->isArray()) { 107 | throw new \RuntimeException('Could not map non-array element to array.'); 108 | } 109 | 110 | return array_map(function ($value) { 111 | return '%'.$value.'%'; 112 | }, $this->rawValue); 113 | } 114 | 115 | /** 116 | * Make the single value string param like-friendly. 117 | * 118 | * @return string 119 | */ 120 | public function toLikeFriendly(): string 121 | { 122 | if (! is_string($this->rawValue)) { 123 | throw new \RuntimeException('Could not convert to like-friendly. Param is not a string.'); 124 | } 125 | 126 | return '%'.$this->rawValue.'%'; 127 | } 128 | 129 | /** 130 | * Check if the value is an array or a comma-delimited list. 131 | * 132 | * @return bool 133 | */ 134 | public function isMultiValue(): bool 135 | { 136 | return $this->isArray() || $this->isCommaDelimited(); 137 | } 138 | 139 | /** 140 | * Check if the value is a comma-delimited list. 141 | * 142 | * @return bool 143 | */ 144 | public function isCommaDelimited(): bool 145 | { 146 | if (! is_string($this->rawValue)) { 147 | return false; 148 | } 149 | 150 | return count(explode(',', $this->rawValue)) > 1; 151 | } 152 | 153 | /** 154 | * Convert the value to an array. 155 | * 156 | * @return array 157 | */ 158 | public function toArray(): array 159 | { 160 | if ($this->isCommaDelimited()) { 161 | return explode(',', $this->rawValue); 162 | } 163 | 164 | if (! $this->isArray()) { 165 | throw new \RuntimeException('Could not convert to array. Param is neither an array, nor comma-delimited.'); 166 | } 167 | 168 | return $this->rawValue; 169 | } 170 | 171 | /** 172 | * Get the "normalized" value. 173 | * 174 | * @return array|string 175 | */ 176 | public function getValue() 177 | { 178 | return $this->value; 179 | } 180 | 181 | /** 182 | * Set the value. 183 | * 184 | * @param $value 185 | * @return $this 186 | */ 187 | public function setValue($value) 188 | { 189 | $this->value = $value; 190 | 191 | return $this; 192 | } 193 | 194 | /** 195 | * Get the "raw" parameter value. 196 | * 197 | * @return array|string 198 | */ 199 | public function getRawValue() 200 | { 201 | return $this->rawValue; 202 | } 203 | 204 | /** 205 | * Set the "raw" parameter value. 206 | * @param array|string $rawValue 207 | * @return Parameter 208 | */ 209 | public function setRawValue($rawValue) 210 | { 211 | $this->rawValue = $rawValue; 212 | 213 | return $this; 214 | } 215 | 216 | /** 217 | * Get the parameter name. 218 | * 219 | * @return string 220 | */ 221 | public function getName(): string 222 | { 223 | return $this->name; 224 | } 225 | 226 | /** 227 | * Set the parameter name. 228 | * 229 | * @param string $name 230 | * @return Parameter 231 | */ 232 | public function setName(string $name): self 233 | { 234 | $this->name = $name; 235 | 236 | return $this; 237 | } 238 | } 239 | --------------------------------------------------------------------------------