├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json └── src ├── Helpers └── ColumnNameSanitizer.php ├── QueryBuilder.php ├── QueryBuilderModelTrait.php ├── QueryBuilderServiceProvider.php └── config └── querybuilder.php /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw[mnpcod] 2 | *.log 3 | *.tmp 4 | *.tmp.* 5 | log.txt 6 | *.sublime-project 7 | *.sublime-workspace 8 | .vscode/ 9 | 10 | .idea/ 11 | .sourcemaps/ 12 | .sass-cache/ 13 | .tmp/ 14 | 15 | .DS_Store 16 | Thumbs.db 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Jesper Bjerke 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel ApiQueryBuilder 2 | 3 | A library to enable clients to query models in a very dynamic way, mimicking the Eloquent ORM. 4 | 5 | ## Installation 6 | 7 | ```shell script 8 | composer require bjerke/api-query-builder 9 | ``` 10 | 11 | ### Configuration 12 | 13 | There is not much to configure, but one thing that can be configured is what collation to use when querying with localized order clauses. 14 | It is preconfigured to use `utf8mb4_swedish_ci` for the `sv` and `sv-SE` locales. If you don't need any special collations for your other locales, there's no need to publish this configuration. 15 | If you do want to add other collations for specific locales however, you need to publish the configuration file from this library so you can change it in your own application. 16 | To do this run the following artisan command: 17 | ```sh 18 | php artisan vendor:publish --provider="Bjerke\ApiQueryBuilder\QueryBuilderServiceProvider" 19 | ``` 20 | 21 | You will now have a `querybuilder.php` config file in `/config` where you can add additional locales => collation combinations 22 | 23 | ## Usage 24 | 25 | To use the query builder, you will use the 2 main components of this library. The trait `QueryBuilderModelTrait` and the builder itself `QueryBuilder`. 26 | 27 | The trait is there to help the builder validate requested fields, relations, appendable attributes and counts. As well as some helper methods. Read more on how to use [relations](#with), [appendable attributes](#appends) and [counts](#counts) in their own descriptions below. 28 | This trait needs to be included in the models you want to use the query builder on. 29 | 30 | In your controller method, you can then use the query builder to compile an Eloquent builder class based on the request like: 31 | ```php 32 | public function index(Request $request) 33 | { 34 | // Setup the builder 35 | $queryBuilder = new QueryBuilder(new MyModel, $request); 36 | 37 | // Parse the request and return an Eloquent Builder instance 38 | $query = $queryBuilder->build(); 39 | 40 | // The instance can be extended/modified freely just as any other Eloquent Builder instance 41 | // For example, maybe we want to enable the option to turn pagination on/off? 42 | if (($pagination = $request->input('paginate')) !== null && 43 | ($pagination === false || $pagination === 'false' || $pagination === '0') 44 | ) { 45 | return $query->get(); 46 | } 47 | 48 | $perPage = $request->input('per_page'); 49 | 50 | return $query->paginate($perPage)->appends($request->except('page')); 51 | } 52 | ``` 53 | 54 | ### Available query methods 55 | 56 | Most methods include an `or` counterpart, that will allow you to create OR statements in your queries. Just like Eloquent. 57 | For example `where` and `orWhere`. 58 | 59 | - [where](#where--orwhere) 60 | - [whereIn](#wherein--orwherein) 61 | - [whereNotIn](#wherenotin--orwherenotin) 62 | - [whereBetween](#wherebetween--orwherebetween) 63 | - [whereNotBetween](#wherenotbetween--orwherenotbetween) 64 | - [whereNull](#wherenull--orwherenull) 65 | - [whereNotNull](#wherenotnull--orwherenotnull) 66 | - [whereHas](#wherehas--orwherehas) 67 | - [whereDoesntHave](#wheredoesnthave--orwheredoesnthave) 68 | - [whereDate](#wheredate) (whereDate / whereMonth / whereDay / whereYear / whereTime), 69 | - [search](#search) 70 | - [select](#select) 71 | - [orderBy](#orderby) 72 | - [groupBy](#groupby) 73 | - [limit](#limit) 74 | - [with](#with) 75 | - [appends](#appends) 76 | - [counts](#counts) 77 | - [pagination](#pagination--per_page) 78 | 79 | --- 80 | 81 | ### where / orWhere 82 | 83 | Executes a where statement. It can be defined in a couple of ways. 84 | The following will do an exact match where `first_name` equals `test` 85 | ``` 86 | ?where[first_name]=test 87 | ``` 88 | You can also do more advanced matching by defining an operator (`=`, `!=`, `like`, `>`, `<`). When defining an operator you also need to define a `value` parameter. 89 | The following will perform a `like` query matching on `%test%` 90 | ``` 91 | ?where[first_name][value]=%25test%25&where[first_name][operator]=like 92 | ``` 93 | __These methods are recursive. Meaning you can wrap multiple statements in a parent "where" to match all statements in it.__ 94 | 95 | --- 96 | 97 | ### whereIn / orWhereIn 98 | 99 | Similar to [where / orWhere](#where--orwhere), but matches a list of values. Values can be defined as a comma-separated string or as an actual array. 100 | ``` 101 | ?whereIn[id]=1,2,3 102 | ``` 103 | or 104 | ``` 105 | ?whereIn[id][]=1&whereIn[id][]=2&whereIn[id][]=3 106 | ``` 107 | 108 | --- 109 | 110 | ### whereNotIn / orWhereNotIn 111 | 112 | Same as [whereNotIn / orWhereNotIn](#wherenotin--orwherenotin), but matches the absence of provided values. 113 | 114 | --- 115 | 116 | ### whereBetween / orWhereBetween 117 | 118 | Matches column value is between the 2 provided values. Values can be defined as a comma-separated string or as an actual array. 119 | ``` 120 | ?whereBetween[date]=2017-01-01,2018-01-01 121 | ``` 122 | or 123 | ``` 124 | ?whereBetween[date][]=2017-01-01&whereBetween[date][]2018-01-01 125 | ``` 126 | 127 | --- 128 | 129 | ### whereNotBetween / orWhereNotBetween 130 | 131 | Same as [whereBetween / orWhereBetween](#wherenotbetween--orwherenotbetween), but matches the value should be outside of provided range. 132 | 133 | --- 134 | 135 | ### whereNull / orWhereNull 136 | ``` 137 | ?whereNull[]=updated_at 138 | ``` 139 | --- 140 | 141 | ### whereNotNull / orWhereNotNull 142 | 143 | Same as [whereNull / orWhereNull](#wherenull--orwherenull), but matches the value should not be null. 144 | 145 | --- 146 | 147 | ### whereHas / orWhereHas 148 | 149 | Queries existance of a relation. This requires your relation to be added to the `allowedApiRelations` array on your model. Otherwise it will just ignore this query. 150 | 151 | Simple existence check, will only return results that has any bookings related to it: 152 | ``` 153 | ?whereHas[]=bookings 154 | ``` 155 | Filter the existance check by a column value. Will only return results that has a booking with id 1 related to it: 156 | ``` 157 | ?whereHas[][bookings][id]=1 158 | ``` 159 | Advanced querying. Will accept most query methods: 160 | ``` 161 | ?whereHas[][bookings][whereIn][id]=1,2,3 162 | ``` 163 | 164 | --- 165 | 166 | ### whereDoesntHave / orWhereDoesntHave 167 | 168 | Same as [whereHas / orWhereHas](#wheredoesnthave--orwheredoesnthave), but matches the absence of a relation. 169 | 170 | --- 171 | 172 | ### whereDate 173 | 174 | Query by date. All abbreviations of this method are: whereDate / whereMonth / whereDay / whereYear / whereTime. 175 | ``` 176 | ?whereDate[created_at]=2016-12-31 177 | ``` 178 | You can also do more advanced matching by defining an operator (`=`, `!=`, `>`, `<`). When defining an operator you also need to define a `value` parameter. 179 | ``` 180 | ?whereDate[created_at][value]=2016-12-31&where[created_at][operator]=< 181 | ``` 182 | 183 | --- 184 | 185 | ### search 186 | 187 | This is a method to make it a bit easier to do search queries on multiple columns, instead of doing advanced `where`-queries. 188 | ``` 189 | ?search[value]=Jesper&search[columns]=first_name,last_name,phone&search[split]=true 190 | ``` 191 | Parameters: 192 | ``` 193 | - value: Search query 194 | - columns: Comma separated string or array of column names to search in 195 | - split: Boolean. Defaults to false 196 | Optionally set to true to treat spaces as delimiters for keywords, 197 | i.e "Jesper Bjerke" will result a query for all "Jesper" and all "Bjerke" 198 | Without split, it will treat it as a single keyword and match on full "Jesper Bjerke" 199 | - json: Boolean. Defaults to false. 200 | If the search column is json and you want the search to be case insensitive, set this to true. 201 | ``` 202 | 203 | --- 204 | 205 | ### select 206 | 207 | Limit the data-set to only pull specific colummns. 208 | ``` 209 | ?select=id,first_name,last_name 210 | ``` 211 | or 212 | ``` 213 | ?select[]=id&select[]=first_name&select[]=last_name 214 | ``` 215 | You can also select relation properties, if you've loaded this with `with`. 216 | ``` 217 | ?select[]=user.first_name 218 | ``` 219 | 220 | --- 221 | 222 | ### orderBy 223 | 224 | Order the result based on one or more columns. 225 | ``` 226 | ?orderBy=first_name,desc 227 | ``` 228 | or multiple columns 229 | ``` 230 | ?orderBy[first_name]=desc&orderBy[created_at]=desc 231 | ``` 232 | Define the order with `desc` or `asc`. There is also a specialized order called `localizedDesc` and `localizedDesc` that will run the ordering with a preconfigured collation based on current locale. Read more about [configuration](#configuration). 233 | 234 | You can also order based on a relation property, if you've loaded this with `with`. 235 | ``` 236 | ?orderBy=user.first_name,desc 237 | ``` 238 | 239 | --- 240 | 241 | ### groupBy 242 | 243 | Group the result by a column. 244 | ``` 245 | ?groupBy=first_name 246 | ``` 247 | 248 | --- 249 | 250 | ### limit 251 | 252 | Limit the total possible returned result by a number. 253 | ``` 254 | ?limit=2 255 | ``` 256 | Will only ever return 2 results at most. 257 | 258 | --- 259 | 260 | ### with 261 | 262 | Eager load relations. This requires your relation to be added to the `allowedApiRelations` array on your model ([read more](#defining-allowed-relations-appendable-attributes-and-counts)). Otherwise it will just ignore this query. 263 | 264 | Be cautions of performance of loading a lot of relations. Only do this where you know you will only get a limited result-set. 265 | 266 | ``` 267 | ?with=user,booking 268 | ``` 269 | or 270 | ``` 271 | ?with[]=user&with[]=booking 272 | ``` 273 | 274 | --- 275 | 276 | ### appends 277 | 278 | Append attributes. This requires your attribute to be added to the `allowedApiAppends` array on your model ([read more](#defining-allowed-relations-appendable-attributes-and-counts)). Otherwise it will just ignore this query. 279 | 280 | Be cautions of performance of loading a lot of appendable attributes. These are processed after the query result on each model. Only do this where you know you will only get a limited result-set and the appended attribute is not hammering the database etc. 281 | 282 | ``` 283 | ?appends=full_name,generated_name 284 | ``` 285 | or 286 | ``` 287 | ?appends[]=full_name&appends[]=generated_name 288 | ``` 289 | 290 | --- 291 | 292 | ### counts 293 | 294 | Relation-counts. This will return a property with an integer indicating the number of results of a relation this model has. 295 | 296 | This requires your attribute to be added to the `allowedApiCounts` array on your model ([read more](#defining-allowed-relations-appendable-attributes-and-counts)). Otherwise it will just ignore this query. 297 | 298 | Be cautions of performance of counting a lot of relations. They will produce extra database hits. 299 | 300 | ``` 301 | ?counts=users,bookings 302 | ``` 303 | or 304 | ``` 305 | ?counts[]=users&counts[]=bookings 306 | ``` 307 | 308 | --- 309 | 310 | ### pagination / per_page 311 | 312 | Pagination is not specifically handled by the query builder, but here's an example of how you can do this: 313 | ```php 314 | public function index(Request $request) 315 | { 316 | // Setup the builder 317 | $queryBuilder = new QueryBuilder(new MyModel, $request); 318 | 319 | // Parse the request and return an Eloquent Builder instance 320 | $query = $queryBuilder->build(); 321 | 322 | // The instance can be extended/modified freely just as any other Eloquent Builder instance 323 | // For example, maybe we want to enable the option to turn pagination on/off? 324 | if (($pagination = $request->input('paginate')) !== null && 325 | ($pagination === false || $pagination === 'false' || $pagination === '0') 326 | ) { 327 | return $query->get(); 328 | } 329 | 330 | $perPage = $request->input('per_page'); 331 | 332 | return $query->paginate($perPage)->appends($request->except('page')); 333 | } 334 | ``` 335 | Pagination is now true by default, and then to control the pagination for the query, you can now pass extra URL parameters. 336 | 337 | To turn pagination off completely: 338 | ``` 339 | ?pagination=false 340 | ``` 341 | 342 | To adjust the number of results per page 343 | ``` 344 | ?per_page=25 345 | ``` 346 | 347 | --- 348 | 349 | ## Defining allowed fields, relations, appendable attributes and counts 350 | 351 | To avoid exposing everything on your models, you will have to define each relation, appended attribute or count that you want to be queryable. 352 | The validation basically works the same on all of them. The only exception is `allowedApiFields`, where there is a default to allow all standard fields. After including the `QueryBuilderModelTrait` in your model, you can add the following methods to it: 353 | 354 | ```php 355 | // ... 356 | public function allowedApiFields(): array 357 | { 358 | // Default is ['*'] 359 | return [ 360 | 'firstname', 361 | 'lastname' 362 | ]; 363 | } 364 | // ... 365 | ```` 366 | 367 | ```php 368 | // ... 369 | public function allowedApiRelations(): array 370 | { 371 | return [ 372 | 'user' // Must be a relation on your model 373 | ]; 374 | } 375 | // ... 376 | ```` 377 | 378 | ```php 379 | // ... 380 | public function allowedApiCounts(): array 381 | { 382 | return [ 383 | 'user' // Must be a relation on your model 384 | ]; 385 | } 386 | // ... 387 | ``` 388 | 389 | ```php 390 | // ... 391 | public function allowedApiAppends(): array 392 | { 393 | return [ 394 | 'full_name' // Must be an appendable attribute on your model 395 | ]; 396 | } 397 | // ... 398 | ``` 399 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bjerke/api-query-builder", 3 | "description": "A query builder for Laravel that parses the request and uses Eloquent ORM to query database", 4 | "keywords": [ 5 | "jesperbjerke", 6 | "querybuilder", 7 | "api", 8 | "eloquent", 9 | "query", 10 | "builder", 11 | "orm", 12 | "rest", 13 | "laravel" 14 | ], 15 | "license": "MIT", 16 | "type": "library", 17 | "authors": [ 18 | { 19 | "name": "Jesper Bjerke", 20 | "email": "jesper.bjerke@gmail.com" 21 | } 22 | ], 23 | "require": { 24 | "php": "^7.3 || ^8.0", 25 | "illuminate/support": "^8.0 | ^9.0 | ^10.0", 26 | "illuminate/http": "^8.0 | ^9.0 | ^10.0", 27 | "illuminate/database": "^8.0 | ^9.0 | ^10.0" 28 | }, 29 | "autoload": { 30 | "psr-4": { 31 | "Bjerke\\ApiQueryBuilder\\": "src" 32 | } 33 | }, 34 | "extra": { 35 | "laravel": { 36 | "providers": [ 37 | "Bjerke\\ApiQueryBuilder\\QueryBuilderServiceProvider" 38 | ] 39 | } 40 | }, 41 | "minimum-stability": "dev", 42 | "prefer-stable": true 43 | } 44 | -------------------------------------------------------------------------------- /src/Helpers/ColumnNameSanitizer.php: -------------------------------------------------------------------------------- 1 | property` 27 | $subParts = explode('->', $columnPart); 28 | foreach ($subParts as $subColumn) { 29 | self::validateColumn($subColumn); 30 | } 31 | } 32 | 33 | return $column; 34 | } 35 | 36 | public static function sanitizeArray(array $columns): array 37 | { 38 | return array_map([self::class, 'sanitize'], $columns); 39 | } 40 | 41 | private static function validateColumn($column) 42 | { 43 | if (strlen($column) > self::MAX_COLUMN_NAME_LENGTH) { 44 | $maxLength = self::MAX_COLUMN_NAME_LENGTH; 45 | throw new HttpException( 46 | 400, 47 | "Given column name `{$column}` exceeds the maximum column name length of {$maxLength} characters." 48 | ); 49 | } 50 | if (!preg_match(self::VALID_COLUMN_NAME_REGEX, $column)) { 51 | throw new HttpException( 52 | 400, 53 | "Given column name `{$column}` may contain only alphanumerics or underscores, and may not begin with a digit." 54 | ); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | request = $request; 60 | $this->model = $model; 61 | } 62 | 63 | /** 64 | * Compiles and returns database query 65 | * 66 | * @return Builder 67 | * @throws \Exception 68 | */ 69 | public function build() 70 | { 71 | $query = $this->model->newQuery(); 72 | 73 | $params = $this->request->all(); 74 | $query = $this->queryRecursive($query, $params); 75 | 76 | if (($with = $this->request->get('with')) !== null) { 77 | $this->setWith($query, $with, $this->request->get('select')); 78 | } 79 | 80 | if (($appends = $this->request->get('appends')) !== null) { 81 | if (is_string($appends)) { 82 | $appends = explode(',', $appends); 83 | } 84 | 85 | $this->model::mergeAppends($this->model->validatedApiAppends($appends)); 86 | } 87 | 88 | if (($select = $this->request->get('select')) !== null) { 89 | $this->setSelect($query, $select); 90 | } 91 | 92 | if (($counts = $this->request->get('counts')) !== null) { 93 | $this->setCounts($query, $counts); 94 | } 95 | 96 | if (($orderBy = ($this->request->get('orderBy') ?? $this->request->get('order_by'))) !== null) { 97 | $this->setOrderBy($query, $orderBy); 98 | } 99 | 100 | if (($groupBy = ($this->request->get('groupBy') ?? $this->request->get('group_by'))) !== null) { 101 | $this->setGroupBy($query, $groupBy); 102 | } 103 | 104 | if (($limit = $this->request->get('limit')) !== null && 105 | is_numeric($limit) && 106 | $limit > 0 107 | ) { 108 | $query->limit($limit); 109 | } 110 | 111 | return $query; 112 | } 113 | 114 | /** 115 | * @param Builder $query 116 | * @param array|string $with 117 | * @param null|array|string $select 118 | * 119 | * @return Builder 120 | */ 121 | private function setWith(Builder $query, $with, $select = null) 122 | { 123 | if (is_string($with)) { 124 | $with = explode(',', $with); 125 | } 126 | 127 | $formattedRelationSelect = []; 128 | if ($select !== null) { 129 | if (is_string($select)) { 130 | $select = explode(',', $select); 131 | } 132 | 133 | $select = array_filter( 134 | $select, 135 | static function ($column) { 136 | return (strpos($column, '.') !== false); 137 | } 138 | ); 139 | 140 | foreach ($select as $nestedSelect) { 141 | $stack = explode('.', $nestedSelect); 142 | $column = array_pop($stack); 143 | $relation = Str::camel(join('.', $stack)); 144 | 145 | if (!array_key_exists($relation, $formattedRelationSelect)) { 146 | $formattedRelationSelect[$relation] = []; 147 | } 148 | 149 | $formattedRelationSelect[$relation][] = ColumnNameSanitizer::sanitize($column); 150 | } 151 | } 152 | 153 | $queriedRelations = $this->model->validatedApiRelations(array_map(static function ($relation) { 154 | return Str::camel($relation); 155 | }, $with)); 156 | 157 | if (empty($formattedRelationSelect)) { 158 | return $query->with($queriedRelations); 159 | } 160 | 161 | $relationQueries = []; 162 | foreach ($queriedRelations as $queriedRelation) { 163 | $validatedQueries = $this->validateRelationSelect($this->model, $queriedRelation, $formattedRelationSelect); 164 | foreach ($validatedQueries as $relationQuery => $relationSelect) { 165 | if ($relationSelect && !empty($relationSelect)) { 166 | $relationQueries[$relationQuery] = static function ($query) use ($relationSelect) { 167 | $query->select($relationSelect); 168 | }; 169 | } else { 170 | $relationQueries[] = $relationQuery; 171 | } 172 | } 173 | } 174 | 175 | if (!empty($relationQueries)) { 176 | $query->with($relationQueries); 177 | } 178 | } 179 | 180 | /** 181 | * @param Model $model 182 | * @param string $relations 183 | * @param array $select 184 | * @param null|string $currentStack 185 | * 186 | * @return array 187 | */ 188 | private function validateRelationSelect($model, $relations, $select = [], $currentStack = null) 189 | { 190 | $validatedRelations = []; 191 | 192 | $stack = explode('.', $currentStack ?? $relations); 193 | $thisRelation = array_shift($stack); 194 | $relationModel = $model->{$thisRelation}()->getRelated(); 195 | $nextStack = implode('.', $stack); 196 | $queryStack = rtrim(Str::replaceLast($nextStack, '', $relations), '.'); 197 | 198 | if (array_key_exists($queryStack, $select)) { 199 | $validatedRelations[$queryStack] = $relationModel->validatedApiFields($select[$queryStack]); 200 | } else { 201 | $validatedRelations[$queryStack] = null; 202 | } 203 | 204 | if (!empty($stack)) { 205 | $validatedRelations = array_merge( 206 | $validatedRelations, 207 | $this->validateRelationSelect( 208 | $relationModel, 209 | $relations, 210 | $select, 211 | implode('.', $stack) 212 | ) 213 | ); 214 | } 215 | 216 | return $validatedRelations; 217 | } 218 | 219 | /** 220 | * @param Builder $query 221 | * @param array|string $select 222 | * 223 | * @return Builder 224 | */ 225 | private function setSelect(Builder $query, $select) 226 | { 227 | if (is_string($select)) { 228 | $select = explode(',', $select); 229 | } 230 | 231 | // Filter out nested selects (handled in "with" query) 232 | $compiledSelects = array_filter( 233 | $this->model->validatedApiFields(ColumnNameSanitizer::sanitizeArray($select)), 234 | static function ($column) { 235 | return (strpos($column, '.') === false); 236 | } 237 | ); 238 | 239 | if (empty($compiledSelects)) { 240 | return $query; 241 | } 242 | 243 | $table = $this->model->getTable(); 244 | $query->select(array_map(static function ($column) use ($table) { 245 | return $table . '.' . $column; 246 | }, $compiledSelects)); 247 | 248 | return $query; 249 | } 250 | 251 | /** 252 | * @param Builder $query 253 | * @param array|string $counts 254 | * 255 | * @return Builder 256 | */ 257 | private function setCounts(Builder $query, $counts) 258 | { 259 | if (is_string($counts)) { 260 | $counts = explode(',', $counts); 261 | $compiledCounts = $this->model->validatedApiCounts(array_map(static function ($relation) { 262 | return Str::camel($relation); 263 | }, $counts)); 264 | } else { 265 | $compiledCounts = []; 266 | $allowedApiCounts = $this->model->allowedApiCounts(); 267 | foreach ($counts as $relation => $countQuery) { 268 | $countRelation = Str::camel((is_string($relation)) ? $relation : $countQuery); 269 | 270 | if (!in_array($countRelation, $allowedApiCounts, true)) { 271 | continue; 272 | } 273 | 274 | if (is_array($countQuery)) { 275 | $compiledCounts[$countRelation] = function (Builder $query) use ($countQuery) { 276 | $this->queryRecursive($query, $countQuery); 277 | }; 278 | } else { 279 | $compiledCounts[] = $countRelation; 280 | } 281 | } 282 | } 283 | 284 | $query->withCount($compiledCounts); 285 | 286 | return $query; 287 | } 288 | 289 | /** 290 | * @param Builder $query 291 | * @param array|string $orderBy 292 | * 293 | * @return Builder 294 | * @throws \Exception 295 | */ 296 | private function setOrderBy(Builder $query, $orderBy) 297 | { 298 | if (is_string($orderBy)) { 299 | $orderBy = explode(',', $orderBy); 300 | $this->setOrder($query, $orderBy[0], $orderBy[1]); 301 | } else { 302 | foreach ($orderBy as $column => $order) { 303 | $this->setOrder($query, $column, $order); 304 | } 305 | } 306 | 307 | return $query; 308 | } 309 | 310 | /** 311 | * @param Builder $query 312 | * @param string $column 313 | * @param string $order 314 | * 315 | * @return Builder 316 | * @throws \Exception 317 | */ 318 | private function setOrder(Builder $query, $column, $order) 319 | { 320 | $order = Str::lower($order); 321 | if ($order === 'asc' || 322 | $order === 'desc' || 323 | $order === 'localizedasc' || 324 | $order === 'localizeddesc' 325 | ) { 326 | if (strpos($column, '.') !== false) { 327 | $stack = explode('.', $column); 328 | $relationName = $this->getRelationName($stack[0]); 329 | if (in_array($relationName, $this->model->allowedApiRelations(), true)) { 330 | $sanitizedColumn = '(' . $this->model->{$relationName}()->getRelationExistenceQuery( 331 | $this->model->{$relationName}() 332 | ->getRelated() 333 | ->newQueryWithoutRelationships(), 334 | $query, 335 | [ColumnNameSanitizer::sanitize($stack[1])] 336 | )->toSql() . ' limit 1)'; 337 | } else { 338 | return $query; 339 | } 340 | } else { 341 | $sanitizedColumn = $query->getGrammar()->wrap(ColumnNameSanitizer::sanitize($column)); 342 | } 343 | 344 | if (Str::startsWith($order, 'localized')) { 345 | $query->orderByRaw(implode('', [ 346 | $sanitizedColumn, 347 | ' COLLATE ', 348 | config('querybuilder.collations.locale.' . App::getLocale(), 'utf8mb4_unicode_ci'), 349 | ' ', 350 | Str::replaceFirst('localized', '', $order) 351 | ])); 352 | } else { 353 | $query->orderByRaw($sanitizedColumn . ' ' . $order); 354 | } 355 | } else { 356 | throw new HttpException(400, 'Sort order must be asc or desc'); 357 | } 358 | 359 | return $query; 360 | } 361 | 362 | /** 363 | * @param Builder $query 364 | * @param array|string $groupBy 365 | * 366 | * @return Builder 367 | */ 368 | private function setGroupBy(Builder $query, $groupBy) 369 | { 370 | if (is_string($groupBy)) { 371 | $groupBy = explode(',', $groupBy); 372 | $query->groupBy(ColumnNameSanitizer::sanitizeArray($groupBy)); 373 | } else { 374 | $query->groupBy(ColumnNameSanitizer::sanitizeArray($groupBy)); 375 | } 376 | 377 | return $query; 378 | } 379 | 380 | /** 381 | * Returns formatted relation method name 382 | * 383 | * @param string $rawRelation 384 | * 385 | * @return string 386 | * @throws \Exception 387 | */ 388 | private function getRelationName($rawRelation) 389 | { 390 | $rawRelationName = Str::camel($rawRelation); 391 | 392 | if (method_exists($this->model, $rawRelationName)) { 393 | return $rawRelationName; 394 | } 395 | 396 | $pluralRelationName = Str::camel(Str::plural($rawRelation)); 397 | if (method_exists($this->model, $pluralRelationName)) { 398 | return $pluralRelationName; 399 | } 400 | 401 | $singularRelationName = Str::camel(Str::singular($rawRelation)); 402 | if (method_exists($this->model, $singularRelationName)) { 403 | return $singularRelationName; 404 | } 405 | 406 | throw new HttpException(400, "Relation {$rawRelation} not found"); 407 | } 408 | 409 | /** 410 | * Converts string representations of certain types to actual values 411 | * 412 | * @param mixed $value 413 | * 414 | * @return mixed 415 | */ 416 | private function formatValue($value) 417 | { 418 | if (is_string($value)) { 419 | switch ($value) { 420 | case 'false': 421 | $value = false; 422 | break; 423 | case 'true': 424 | $value = true; 425 | break; 426 | case 'null': 427 | $value = null; 428 | break; 429 | } 430 | } 431 | 432 | return $value; 433 | } 434 | 435 | /** 436 | * Initiates recursive query, wraps all queries in its own 437 | * where-statement to isolate external queries others added later on 438 | * 439 | * @param Builder $query 440 | * @param array $params 441 | * @param null|string $subMethod 442 | * 443 | * @return Builder 444 | * @throws \Exception 445 | */ 446 | private function queryRecursive(Builder $query, $params, $subMethod = null) 447 | { 448 | $hasQuery = !empty(array_intersect($this->queryMethods, array_keys($params))); 449 | if (!$hasQuery) { 450 | return $query; 451 | } 452 | 453 | $query->where(function ($query) use ($params, $subMethod) { 454 | $this->loopNestedQuery($query, $params, $subMethod); 455 | }); 456 | 457 | return $query; 458 | } 459 | 460 | /** 461 | * Loops queries, wraps in nested statements 462 | * 463 | * @param Builder $query 464 | * @param array $params 465 | * @param null|string $subMethod 466 | * 467 | * @return Builder 468 | * @throws \Exception 469 | */ 470 | private function loopNestedQuery(Builder $query, $params, $subMethod = null) 471 | { 472 | if ($subMethod !== null) { 473 | if (in_array($subMethod, $this->queryMethods, true)) { 474 | $query->{$subMethod}(function ($query) use ($params, $subMethod) { 475 | foreach ($params as $method => $columns) { 476 | if (in_array($method, $this->queryMethods, true)) { 477 | $this->performNestedQuery($query, $method, $columns); 478 | } else { 479 | $this->performQuery($query, $subMethod, $method, $columns); 480 | } 481 | } 482 | }); 483 | } 484 | } else { 485 | foreach ($params as $method => $columns) { 486 | if (in_array($method, $this->queryMethods, true)) { 487 | $this->performNestedQuery($query, $method, $columns); 488 | } 489 | } 490 | } 491 | } 492 | 493 | /** 494 | * Performs all queries, runs recursively on where/orWhere 495 | * 496 | * @param Builder $query 497 | * @param array $params 498 | * @param null|string $subMethod 499 | * 500 | * @return Builder 501 | * @throws \Exception 502 | */ 503 | private function performNestedQuery(Builder $query, $method, $columns) 504 | { 505 | switch ($method) { 506 | case 'where': 507 | case 'orWhere': 508 | $this->loopNestedQuery($query, $columns, $method); 509 | break; 510 | case 'search': 511 | $query = $this->performQuery($query, $method, $columns, null); 512 | break; 513 | default: 514 | foreach ($columns as $column => $value) { 515 | $query = $this->performQuery($query, $method, $column, $value); 516 | } 517 | } 518 | } 519 | 520 | /** 521 | * Runs query method on provided query. 522 | * Formats provided value 523 | * 524 | * Examples: 525 | * where[first_name]=test 526 | * where[first_name][value]=%25test%25&where[first_name][operator]=like 527 | * 528 | * whereIn[id]=1,2,3 529 | * whereBetween[date]=2017-01-01,2018-01-01 530 | * 531 | * whereHas[]=bookings 532 | * whereHas[][bookings][id]=1 533 | * whereHas[][bookings][whereIn][id]=1,2,3 534 | * 535 | * search[value]=Jesper&search[columns]=first_name,last_name,phone 536 | * 537 | * @param Builder $query 538 | * @param string $method 539 | * @param string|array $column 540 | * @param mixed $value 541 | * 542 | * @return Builder 543 | * @throws \Exception 544 | */ 545 | private function performQuery(Builder $query, $method, $column, $value) 546 | { 547 | switch ($method) { 548 | case 'where': 549 | case 'orWhere': 550 | case 'whereDate': 551 | case 'orWhereDate': 552 | case 'whereDay': 553 | case 'orWhereDay': 554 | case 'whereMonth': 555 | case 'orWhereMonth': 556 | case 'whereYear': 557 | case 'orWhereYear': 558 | case 'whereTime': 559 | case 'orWhereTime': 560 | $column = ColumnNameSanitizer::sanitize($column); 561 | if (!in_array($column, $this->model->validatedApiFields([$column]), true)) { 562 | return $query; 563 | } 564 | 565 | if (is_array($value)) { 566 | if (isset($value['operator'], $value['value'])) { 567 | $query->{$method}($column, $value['operator'], $this->formatValue($value['value'])); 568 | } 569 | } else { 570 | $query->{$method}($column, $this->formatValue($value)); 571 | } 572 | break; 573 | case 'whereNull': 574 | case 'orWhereNull': 575 | case 'whereNotNull': 576 | case 'orWhereNotNull': 577 | $column = ColumnNameSanitizer::sanitize($value); 578 | if (!in_array($column, $this->model->validatedApiFields([$column]), true)) { 579 | return $query; 580 | } 581 | $query->{$method}($column); 582 | break; 583 | case 'whereHas': 584 | case 'orWhereHas': 585 | case 'whereDoesntHave': 586 | case 'orWhereDoesntHave': 587 | $this->performExistanceQuery($query, $method, $column, $value); 588 | break; 589 | case 'whereIn': 590 | case 'orWhereIn': 591 | case 'whereNotIn': 592 | case 'orWhereNotIn': 593 | case 'whereBetween': 594 | case 'orWhereBetween': 595 | case 'whereNotBetween': 596 | case 'orWhereNotBetween': 597 | $column = ColumnNameSanitizer::sanitize($column); 598 | if (!in_array($column, $this->model->validatedApiFields([$column]), true)) { 599 | return $query; 600 | } 601 | 602 | if (is_string($value)) { 603 | $value = explode(',', $value); 604 | } 605 | $query->{$method}($column, $value); 606 | break; 607 | case 'search': 608 | if (!in_array($column, $this->model->validatedApiFields([$column]), true)) { 609 | return $query; 610 | } 611 | $this->performSearchQuery($query, $column); 612 | break; 613 | } 614 | 615 | return $query; 616 | } 617 | 618 | /** 619 | * Runs variations of whereHas queries 620 | * 621 | * Examples: 622 | * whereHas[]=bookings 623 | * whereHas[][bookings][id]=1 624 | * whereHas[][bookings][whereIn][id]=1,2,3 625 | * whereHas[bookings][where][bookable_type]=test 626 | * 627 | * @param Builder $query 628 | * @param string $method 629 | * @param string|int $relation Array index or relation name 630 | * @param string|array $params Array or string of relations or query params for provided relation in $relation 631 | * 632 | * @return Builder 633 | * @throws \Exception 634 | */ 635 | private function performExistanceQuery($query, $method, $relation, $params) 636 | { 637 | // Complex query (eg. whereHas[bookings][where][bookable_type]=test) 638 | if (is_string($relation)) { 639 | $relationName = $this->getRelationName($relation); 640 | if (in_array($relationName, $this->model->allowedApiRelations(), true)) { 641 | $query->{$method}($relationName, function ($query) use ($relationName, $params) { 642 | foreach ($params as $column => $value) { 643 | if (in_array($column, $this->queryMethods, true)) { 644 | foreach ($value as $subColumn => $subValue) { 645 | if (strpos($subColumn, '.') === false) { 646 | $subColumn = $this->model->{$relationName}() 647 | ->getRelated() 648 | ->getTable() . '.' . $subColumn; 649 | } 650 | $this->performQuery($query, $column, $subColumn, $subValue); 651 | } 652 | } else { 653 | if (strpos($column, '.') === false) { 654 | $column = $this->model->{$relationName}()->getRelated()->getTable() . '.' . $column; 655 | } 656 | $this->performQuery($query, 'where', $column, $value); 657 | } 658 | } 659 | }); 660 | } 661 | 662 | return $query; 663 | } 664 | 665 | // Simple query (eg. whereHas[]=bookings 666 | if (is_string($params)) { 667 | if (in_array($this->getRelationName($params), $this->model->allowedApiRelations(), true)) { 668 | $query->{$method}($this->getRelationName($params)); 669 | } 670 | 671 | return $query; 672 | } 673 | 674 | // Multiquery (eg. whereHas[][bookings][id]=1&whereHas[][bookings][id]=2 675 | if (is_array($params)) { 676 | foreach ($params as $relationName => $relationColumns) { 677 | $relationName = $this->getRelationName($relationName); 678 | 679 | if (!in_array($this->getRelationName($relationName), $this->model->allowedApiRelations(), true)) { 680 | continue; 681 | } 682 | 683 | $query->{$method}($relationName, function ($query) use ($relationName, $relationColumns) { 684 | foreach ($relationColumns as $column => $value) { 685 | if (in_array($column, $this->queryMethods, true)) { 686 | foreach ($value as $subColumn => $subValue) { 687 | if (strpos($subColumn, '.') === false) { 688 | $subColumn = $this->model->{$relationName}() 689 | ->getRelated() 690 | ->getTable() . '.' . $subColumn; 691 | } 692 | $this->performQuery($query, $column, $subColumn, $subValue); 693 | } 694 | } else { 695 | if (strpos($column, '.') === false) { 696 | $column = $this->model->{$relationName}()->getRelated()->getTable() . '.' . $column; 697 | } 698 | $this->performQuery($query, 'where', $column, $value); 699 | } 700 | } 701 | }); 702 | } 703 | 704 | return $query; 705 | } 706 | 707 | return $query; 708 | } 709 | 710 | /** 711 | * Runs loose search on multiple columns 712 | * Optionally set "split" to true, to treat spaces as delimiters for keywords, 713 | * i.e "Jesper Bjerke" will result a query for all "Jesper" and all "Bjerke" 714 | * Without split, it will treat is as a single keyword and match on full "Jesper Bjerke" 715 | * 716 | * Examples: 717 | * search[value]=Jesper&search[columns]=first_name,last_name,phone&search[split]=true 718 | * 719 | * @param Builder $query 720 | * @param array $options 721 | * 722 | * @return Builder 723 | * @throws \Exception 724 | */ 725 | private function performSearchQuery($query, $options) 726 | { 727 | $value = (isset($options['value']) && !empty($options['value'])) ? $options['value'] : ''; 728 | 729 | if (!$value) { 730 | throw new HttpException(400, 'Search value missing'); 731 | } 732 | 733 | $split = (isset($options['split']) && !empty($options['split'])) ? $options['split'] : false; 734 | 735 | if ($split === true || $split === 'true') { 736 | $value = explode(' ', $value); 737 | } 738 | 739 | $columns = (isset($options['columns']) && !empty($options['columns'])) ? $options['columns'] : ''; 740 | 741 | if (is_string($columns)) { 742 | $columns = explode(',', $columns); 743 | } 744 | 745 | if (empty($columns)) { 746 | throw new HttpException(400, 'Search columns missing'); 747 | } 748 | 749 | $caseInsensitiveJSON = (isset($options['json']) && !empty($options['json'])) ? $options['json'] : false; 750 | if ($caseInsensitiveJSON === true || $caseInsensitiveJSON === 'true') { 751 | $this->caseInsensitiveJsonSearch($query, $columns, $value); 752 | } else { 753 | $this->likeSearch($query, $columns, $value); 754 | } 755 | 756 | return $query; 757 | } 758 | 759 | private function caseInsensitiveJsonSearch($query, $columns, $value) 760 | { 761 | $query->where(static function (Builder $query) use ($columns, $value) { 762 | foreach ($columns as $column) { 763 | $column = ColumnNameSanitizer::sanitize($column); 764 | 765 | if (is_string($value)) { 766 | $query->orWhereRaw('LOWER(' . $query->getGrammar()->wrap($column) . ') like ?', ['%' . Str::lower($value) . '%']); 767 | } else { 768 | foreach ($value as $val) { 769 | $query->orWhereRaw('LOWER(' . $query->getGrammar()->wrap($column) . ') like ?', ['%' . Str::lower($val) . '%']); 770 | } 771 | } 772 | } 773 | }); 774 | return $query; 775 | } 776 | 777 | private function likeSearch($query, $columns, $value) { 778 | $query->where(static function (Builder $query) use ($columns, $value) { 779 | foreach ($columns as $column) { 780 | $column = ColumnNameSanitizer::sanitize($column); 781 | 782 | if (is_string($value)) { 783 | $query->orWhere($column, 'like', '%' . $value . '%'); 784 | } else { 785 | foreach ($value as $val) { 786 | $query->orWhere($column, 'like', '%' . $val . '%'); 787 | } 788 | } 789 | } 790 | }); 791 | 792 | return $query; 793 | } 794 | } 795 | -------------------------------------------------------------------------------- /src/QueryBuilderModelTrait.php: -------------------------------------------------------------------------------- 1 | allowedApiFields(); 83 | 84 | if (in_array('*', $allowedApiFields, true)) { 85 | return $requestedFields; 86 | } 87 | 88 | if (!empty($allowedApiFields)) { 89 | $validatedApiFields = array_intersect($allowedApiFields, $requestedFields); 90 | } 91 | 92 | return $validatedApiFields; 93 | } 94 | 95 | /** 96 | * Returns array of relations that are allowed to be loaded/returned 97 | * 98 | * @return array 99 | */ 100 | public function allowedApiRelations(): array 101 | { 102 | return []; 103 | } 104 | 105 | /** 106 | * Returns the allowed relations from requested ones 107 | * 108 | * @param array $requestedRelations Array of requested relations to load 109 | * 110 | * @return array 111 | */ 112 | public function validatedApiRelations($requestedRelations = []): array 113 | { 114 | $validatedApiRelations = []; 115 | $allowedApiRelations = $this->allowedApiRelations(); 116 | 117 | if (!empty($allowedApiRelations)) { 118 | $validatedApiRelations = array_intersect($allowedApiRelations, $requestedRelations); 119 | } 120 | 121 | return $validatedApiRelations; 122 | } 123 | 124 | /** 125 | * Returns array of appended attributes that are allowed to be loaded/returned 126 | * 127 | * @return array 128 | */ 129 | public function allowedApiAppends(): array 130 | { 131 | return []; 132 | } 133 | 134 | /** 135 | * Returns the allowed appended attributes from requested ones 136 | * 137 | * @param array $requestedAppends Array of requested appends to load 138 | * 139 | * @return array 140 | */ 141 | public function validatedApiAppends($requestedAppends = []): array 142 | { 143 | $validatedApiAppends = []; 144 | $allowedApiAppends = $this->allowedApiAppends(); 145 | 146 | if (!empty($allowedApiAppends)) { 147 | $validatedApiAppends = array_intersect($allowedApiAppends, $requestedAppends); 148 | } 149 | 150 | return $validatedApiAppends; 151 | } 152 | 153 | /** 154 | * Returns array of relations that are allowed to return counts on 155 | * 156 | * @return array 157 | */ 158 | public function allowedApiCounts(): array 159 | { 160 | return []; 161 | } 162 | 163 | /** 164 | * Returns the allowed counts from requested ones 165 | * 166 | * @param array $requestedCounts Array of requested counts to load 167 | * 168 | * @return array 169 | */ 170 | public function validatedApiCounts($requestedCounts = []): array 171 | { 172 | $validatedApiCounts = []; 173 | $allowedApiCounts = $this->allowedApiCounts(); 174 | 175 | if (!empty($allowedApiCounts)) { 176 | $validatedApiCounts = array_intersect($allowedApiCounts, $requestedCounts); 177 | } 178 | 179 | return $validatedApiCounts; 180 | } 181 | 182 | } 183 | -------------------------------------------------------------------------------- /src/QueryBuilderServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 17 | __DIR__ . '/config/querybuilder.php' => config_path('querybuilder.php'), 18 | ]); 19 | } 20 | 21 | /** 22 | * Register bindings in the container. 23 | * 24 | * @return void 25 | */ 26 | public function register() 27 | { 28 | $this->mergeConfigFrom(__DIR__ . '/config/querybuilder.php', 'querybuilder'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/config/querybuilder.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'locale' => [ 15 | 'sv' => 'utf8mb4_swedish_ci', 16 | 'sv-SE' => 'utf8mb4_swedish_ci' 17 | ] 18 | ], 19 | 20 | ]; 21 | --------------------------------------------------------------------------------