├── docs ├── .nojekyll ├── assets │ └── laravel-queryable-banner.png ├── index.html └── README.md ├── tests ├── .gitkeep ├── Models │ ├── User.php │ └── Group.php ├── TestCase.php └── Unit │ └── MainTest.php ├── .gitignore ├── .travis.yml ├── src ├── QueryableServiceProvider.php └── Traits │ └── QueryParamFilterable.php ├── composer.json ├── phpunit.xml ├── LICENSE.md └── README.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | -------------------------------------------------------------------------------- /docs/assets/laravel-queryable-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephenlake/laravel-queryable/HEAD/docs/assets/laravel-queryable-banner.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | sudo: true 4 | 5 | php: 6 | - 7.2 7 | 8 | services: 9 | - sqlite 10 | 11 | before_script: 12 | - composer install 13 | - travis_retry composer self-update 14 | - travis_retry composer update --no-interaction --prefer-dist 15 | - composer show laravel/framework 16 | 17 | script: 18 | - vendor/bin/phpunit 19 | -------------------------------------------------------------------------------- /src/QueryableServiceProvider.php: -------------------------------------------------------------------------------- 1 | belongsTo(\Queryable\Tests\Models\Group::class); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Models/Group.php: -------------------------------------------------------------------------------- 1 | hasMany(\Queryable\Tests\Models\User::class); 17 | } 18 | 19 | public function creator() 20 | { 21 | return $this->hasOne(\Queryable\Tests\Models\User::class); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stephenlake/laravel-queryable", 3 | "description": "Laravel HTTP query parameter based model filtering and searching.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [{ 7 | "name": "Stephen Lake", 8 | "email": "stephen@closurecode.com" 9 | }], 10 | "require-dev": { 11 | "phpunit/phpunit": "~7.0", 12 | "laravel/framework": "~5.5.0|~5.6.0|~5.7.0", 13 | "orchestra/testbench": "~3.4.0|~3.5.0|~3.6.0" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "Queryable\\": "src/" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Queryable\\Tests\\": "tests/" 23 | } 24 | }, 25 | "extra": { 26 | "laravel": { 27 | "providers": [ 28 | "Queryable\\QueryableServiceProvider" 29 | ] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/Unit/ 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Laravel Queryable 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Stephen Lake 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 Queryable 2 | 3 | ![tests](https://img.shields.io/travis/stephenlake/laravel-queryable/master.svg?style=flat-square) 4 | ![styleci](https://github.styleci.io/repos/149042065/shield?branch=master&style=flat-square) 5 | ![scrutinzer](https://img.shields.io/scrutinizer/g/stephenlake/laravel-queryable.svg?style=flat-square) 6 | ![downloads](https://img.shields.io/packagist/dt/stephenlake/laravel-queryable.svg?style=flat-square) 7 | ![release](https://img.shields.io/github/release/stephenlake/laravel-queryable.svg?style=flat-square) 8 | ![license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square) 9 | 10 | **Laravel Queryable** is a light weight package containing simple injectable model traits with configurable attributes to perform powerful and flexible queries of your models dynamically from static HTTP routes. 11 | 12 | Made with ❤️ by [Stephen Lake](http://stephenlake.github.io/) 13 | 14 | ## No Longer Maintained :exclamation: 15 | This package is no longer maintained as a far more flexible package exists, it is highly recommended to use [Spatie's Laravel Query Builder](https://docs.spatie.be/laravel-query-builder/v2/introduction/) instead. If you would like to take over this package as maintainer, please get in touch with me. 16 | 17 | ## Getting Started 18 | 19 | Install the package via composer. 20 | 21 | composer require stephenlake/laravel-queryable 22 | 23 | Add the trait to your model: 24 | 25 | use \Queryable\Traits\QueryParamFilterable; 26 | 27 | Define filters on your model: 28 | 29 | YourModel::withFilters(['name', 'content', 'created_at'])->get(); 30 | 31 | Then add dynamic queryables to your HTTP routes: 32 | 33 | https://www.example.org?name=Awesome&content=*awesome*&created_at>=2018 34 | 35 | This automatically adds the following to the query builder: 36 | 37 | YourModel::where('name', 'Awesome') 38 | ->where('content', 'like', '%awesome%') 39 | ->where('created_at, '>=', '2018') 40 | 41 | #### See [documentation](https://stephenlake.github.io/laravel-queryable/) for the full list of available operators and further usage. 42 | 43 | ## License 44 | 45 | This library is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 46 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | app->setBasePath(__DIR__.'/../'); 19 | $this->faker = \Faker\Factory::create(); 20 | $this->createShitData(); 21 | } 22 | 23 | protected function getEnvironmentSetUp($app) 24 | { 25 | $app['config']->set('database.default', 'testbench'); 26 | $app['config']->set('database.connections.testbench', [ 27 | 'driver' => 'sqlite', 28 | 'database' => ':memory:', 29 | 'prefix' => '', 30 | ]); 31 | 32 | Schema::dropIfExists('users'); 33 | Schema::dropIfExists('groups'); 34 | 35 | Schema::create('groups', function ($table) { 36 | $table->increments('id'); 37 | $table->integer('creator_id')->nullable(); 38 | $table->string('name'); 39 | $table->string('description'); 40 | $table->timestamps(); 41 | }); 42 | 43 | Schema::create('users', function ($table) { 44 | $table->increments('id'); 45 | $table->integer('group_id')->nullable(); 46 | $table->string('firstname'); 47 | $table->string('lastname'); 48 | $table->timestamps(); 49 | }); 50 | } 51 | 52 | public function createShitData() 53 | { 54 | for ($i = 0; $i < 300; $i++) { 55 | User::create([ 56 | 'firstname' => $this->faker->firstname, 57 | 'lastname' => $this->faker->lastname, 58 | ]); 59 | } 60 | 61 | for ($i = 0; $i < 50; $i++) { 62 | Group::create([ 63 | 'name' => $this->faker->company, 64 | 'description' => $this->faker->bs, 65 | 'creator_id' => User::inRandomOrder()->first()->id, 66 | ]); 67 | } 68 | 69 | User::get()->each(function ($user) { 70 | $user->group_id = Group::inRandomOrder()->first()->id; 71 | $user->save(); 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/Unit/MainTest.php: -------------------------------------------------------------------------------- 1 | take(5); 14 | $group = $groups->first(); 15 | 16 | $wordFromGroupName = array_random(explode(' ', $group->first()->name)); 17 | $wordFromGroupDescription = array_random(explode(' ', $group->first()->description)); 18 | 19 | $filters = [ 20 | "name=*{$wordFromGroupName}*", 21 | "description=*{$wordFromGroupDescription}*", 22 | ]; 23 | 24 | $groups = Group::withFilters(['name', 'description'], $filters)->get(); 25 | 26 | $this->assertTrue($groups->count() > 0); 27 | 28 | $groups->each(function ($group) use ($wordFromGroupDescription, $wordFromGroupName) { 29 | $this->assertTrue(str_contains($group->name, $wordFromGroupName)); 30 | $this->assertTrue(str_contains($group->description, $wordFromGroupDescription)); 31 | }); 32 | } 33 | 34 | public function test_attributes_like_ignore_case() 35 | { 36 | $groups = Group::inRandomOrder()->take(5); 37 | $group = $groups->first(); 38 | 39 | $wordFromGroupName = strtoupper(array_random(explode(' ', $group->first()->name))); 40 | $wordFromGroupDescription = strtoupper(array_random(explode(' ', $group->first()->description))); 41 | 42 | $filters = [ 43 | "name=*{$wordFromGroupName}*", 44 | "description=*{$wordFromGroupDescription}*", 45 | ]; 46 | 47 | $groups = Group::withFilters(['name', 'description'], $filters)->get(); 48 | 49 | $this->assertTrue($groups->count() > 0); 50 | 51 | $groups->each(function ($group) use ($wordFromGroupDescription, $wordFromGroupName) { 52 | $this->assertTrue(str_contains(strtolower($group->name), strtolower($wordFromGroupName))); 53 | $this->assertTrue(str_contains(strtolower($group->description), strtolower($wordFromGroupDescription))); 54 | }); 55 | } 56 | 57 | public function test_attributes_relationship_exact_case() 58 | { 59 | $user = User::inRandomOrder()->with('group')->first(); 60 | $group = $user->group; 61 | 62 | $wordFromGroupName = strtoupper(array_random(explode(' ', $group->first()->name))); 63 | 64 | $filters = [ 65 | "group.name=*{$wordFromGroupName}*", 66 | 'group.creator_id!=0', 67 | ]; 68 | 69 | $users = User::with('group')->withFilters(['group.name', 'group.creator_id'], $filters)->get(); 70 | 71 | $this->assertTrue($users->count() > 0); 72 | 73 | $users->each(function ($user) use ($wordFromGroupName) { 74 | $this->assertTrue(str_contains(strtolower($user->group->name), strtolower($wordFromGroupName))); 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | HTTP query parameter based model searching and filtering for Laravel Models. 7 |
8 | 9 | # Getting Started 10 | 11 | ## Install via Composer 12 | 13 | Install the package via composer. 14 | 15 | composer require stephenlake/laravel-queryable 16 | 17 | ## Add QueryParamFilterable Trait 18 | 19 | Add the `Queryable\Traits\QueryParamFilterable` trait to your model(s) you wish to be filterable. 20 | 21 | ```php 22 | use Queryable\Traits\QueryParamFilterable; 23 | 24 | class Post extends Model 25 | { 26 | use QueryParamFilterable; 27 | } 28 | ``` 29 | 30 | ## Define Queryable Fields 31 | 32 | Define which fields are allowed to be filtered on query: 33 | 34 | ```php 35 | Post::withFilters('title', 'body', 'created_at')->get(); 36 | ``` 37 | 38 | # Usage 39 | 40 | ## Quick Sample 41 | 42 | Once you have passed through the [Getting Started](#getting-started) guide, [Added QueryParamFilterable](#add-queryparamfilterable-trait) to your model(s) and [Defined Queryable Fields](#define-queryable-frields), you'll need define a simple route to one of your model if you don't already have one. 43 | 44 | Sample Route: 45 | 46 | ```php 47 | Route::get('/posts', function() { 48 | return \App\Post::withFilters('title', 'body')->get(); 49 | }); 50 | ``` 51 | 52 | Now using the values you've chosen in your filters, append some query params to your URL: 53 | 54 | `http://localhost/posts?title=*Test*&!body=*sample*` 55 | 56 | This will search for all records where the title contains **Test** OR the body contains **sample**. To perform *orWhere*, append an exclamation sign to the ampersand: &! 57 | 58 | `http://localhost/posts?title!=Test&body=*foobar*&created_at>=2018` 59 | 60 | Filter where `title` not equal (`!=`) to `Test` 61 | 62 | Filter where `body` contains (`=**`) `foobar` 63 | 64 | Filter where `created_at` is greater than or equal (`>=`) to `2018` 65 | 66 | ## Filtering on Relationships 67 | 68 | Filtering through relationships is as simple as delimiting the relationship tree with arrows (`->`) and then defining the allowed relations to filter as you would normally define the filters: 69 | 70 | Define the filterables: 71 | `Post::withFilters('threads.comments.title')->get()` 72 | 73 | Perform the HTTP call: 74 | `http://localhost?threads->comments->title=*foobar*` 75 | 76 | 77 | ## Ordering Results 78 | 79 | In order to sort your results in desired order simple append the `orderBy` query paramter to your query string with a value of the column you would like to order by: 80 | 81 | `http://localhost?orderBy=title` 82 | 83 | Add a second value of `asc` or `desc` to define the direction of the ordering: 84 | 85 | `http://localhost?orderBy=title,desc` 86 | 87 | ## Available Operators 88 | 89 | | Operator | Description | Example | 90 | | -------- | :----------------------- | :------------------------------ | 91 | | `=` | Equal To | `column=value` | 92 | | `!=` | Not Equal to | `column!=value` | 93 | | `>` | Greater Than | `column>value` | 94 | | `>=` | Greater Than Or Equal To | `column>=value` | 95 | | `<` | Less Than | `columndatabaseDriver = $this->getConnection()->getDriverName(); 35 | $this->queryables = $filterable; 36 | 37 | if (count($this->queryables)) { 38 | $this->parseQueryParamFilterables($query, $filters); 39 | } 40 | 41 | return $query; 42 | } 43 | 44 | /** 45 | * Parse potential query paramters. 46 | * 47 | * @return void 48 | */ 49 | private function parseQueryParamFilterables($query, $filters = null) 50 | { 51 | $filters = $filters ?? explode('&', str_replace('->', '.', urldecode(request()->getQueryString()))); 52 | 53 | if (count($filters) > 1) { 54 | if (starts_with($filters[0], '!')) { 55 | $filters[0] = substr($filters[0], 1); 56 | } 57 | } 58 | 59 | foreach ($filters as $rawFilter) { 60 | $operator = $this->getOperatorFromRawFilter($rawFilter); 61 | 62 | if ($operator) { 63 | $params = explode($operator, $rawFilter); 64 | 65 | if (count($params) == 2) { 66 | $column = Str::snake($params[0]); 67 | $values = $params[1]; 68 | 69 | if ($isOr = starts_with($column, '!')) { 70 | $column = substr($column, 1); 71 | } 72 | 73 | if (in_array($column, $this->queryables)) { 74 | $this->parseFilter($query, $column, $operator, $values, $isOr); 75 | } 76 | } 77 | } 78 | } 79 | 80 | if (($orderBy = request()->query('orderBy'))) { 81 | $value = explode(',', $orderBy); 82 | $query->orderBy($value[0], $value[1] ?? 'asc'); 83 | } 84 | } 85 | 86 | /** 87 | * Parse filter query paramters. 88 | * 89 | * @return void 90 | */ 91 | private function parseFilter($query, $column, $operator, $value, $isOr = false) 92 | { 93 | $value = $value == 'NULL' ? null : $value; 94 | $compare = null; 95 | 96 | if (in_array($operator, ['=', '!=', '>', '<', '>=', '<='], true)) { 97 | if (ends_with($value, '*') || starts_with($value, '*')) { 98 | if (starts_with($operator, '!')) { 99 | $operator = $this->databaseDriver == 'pgsql' ? 'NOT ILIKE' : 'NOT LIKE'; 100 | } else { 101 | $operator = $this->databaseDriver == 'pgsql' ? 'ILIKE' : 'LIKE'; 102 | } 103 | $value = str_replace('*', '%', $value); 104 | } 105 | $compare = $isOr ? 'orWhere' : 'where'; 106 | } elseif ($operator == '!=~') { 107 | $value = explode(',', $value); 108 | $compare = $isOr ? 'orWhereNotIn' : 'whereNotIn'; 109 | $operator = false; 110 | } elseif ($operator == '=~') { 111 | $value = explode(',', $value); 112 | $compare = $isOr ? 'orWhereIn' : 'whereIn'; 113 | $operator = false; 114 | } 115 | 116 | $this->queryParamFilterQueryConstruct($query, $column, $value, $compare, $operator); 117 | } 118 | 119 | /** 120 | * Append queries to query builder. 121 | * 122 | * @return \Illuminate\Database\Eloquent\Builder 123 | */ 124 | private function queryParamFilterQueryConstruct($query, $column, $value, $operation, $operator = false) 125 | { 126 | if (str_contains($column, '.')) { 127 | $keys = explode('.', $column); 128 | $attribute = $keys[count($keys) - 1]; 129 | $relations = str_replace(".{$attribute}", '', implode('.', $keys)); 130 | 131 | if ($operation == 'orWhere') { 132 | $parentOperation = 'orWhereHas'; 133 | } else { 134 | $parentOperation = 'whereHas'; 135 | } 136 | 137 | $column = $attribute; 138 | 139 | $query->$parentOperation($relations, function ($subquery) use ($column, $operation, $operator, $value) { 140 | return $this->appendQuery($subquery, $operation, $column, $operator, $value); 141 | }); 142 | } else { 143 | return $this->appendQuery($query, $operation, $column, $operator, $value); 144 | } 145 | } 146 | 147 | /** 148 | * Append queries to query builder. 149 | * 150 | * @return \Illuminate\Database\Eloquent\Builder 151 | */ 152 | private function appendQuery($query, $operation, $column, $operator, $value) 153 | { 154 | $operation = "{$operation}Raw"; 155 | 156 | if (!$operator) { 157 | $operator = '='; 158 | } 159 | 160 | return $query->$operation("LOWER($column) $operator ?", [strtolower($value)]); 161 | } 162 | 163 | /** 164 | * Get operator from raw filter. 165 | * 166 | * @return string 167 | */ 168 | private function getOperatorFromRawFilter($rawFilter) 169 | { 170 | $operator = null; 171 | $operators = ['!=~', '=~', '>=', '<=', '!=', '=', '>', '<']; 172 | 173 | foreach ($operators as $op) { 174 | if (str_contains($rawFilter, $op)) { 175 | $operator = $op; 176 | break; 177 | } 178 | } 179 | 180 | return $operator; 181 | } 182 | 183 | /** 184 | * Get the models database connection. 185 | * 186 | * @return \Illuminate\Database\Connection 187 | */ 188 | abstract public function getConnection(); 189 | } 190 | --------------------------------------------------------------------------------