├── 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 | 
4 | 
5 | 
6 | 
7 | 
8 | 
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 |
--------------------------------------------------------------------------------