Completed
Pull Request — master (#140)
by
unknown
01:18
created

QueryBuilder   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 441
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 9

Importance

Changes 0
Metric Value
wmc 58
lcom 2
cbo 9
dl 0
loc 441
rs 4.5599
c 0
b 0
f 0

26 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 18 3
A initializeFromBuilder() 0 15 1
A for() 0 8 2
A allowedFilters() 0 17 3
A allowedFields() 0 19 4
A defaultSort() 0 8 1
A allowedSorts() 0 17 4
A allowedIncludes() 0 22 3
A allowedAppends() 0 12 2
A parseSelectedFields() 0 8 1
A prependFieldsWithTableName() 0 6 1
A getFieldsForRelatedTable() 0 8 2
A addFiltersToQuery() 0 8 1
A findFilter() 0 7 1
A addSortsToQuery() 0 17 3
B filterDuplicates() 0 26 6
A addIncludesToQuery() 0 27 2
A setAppendsToResult() 0 10 1
A guardAgainstUnknownFilters() 0 12 2
A guardAgainstUnknownFields() 0 19 2
A guardAgainstUnknownSorts() 0 12 2
A guardAgainstUnknownIncludes() 0 10 2
A guardAgainstUnknownAppends() 0 10 2
A get() 0 10 2
A getDbColumns() 0 4 1
A getJoinColumnConflicts() 0 30 4

How to fix   Complexity   

Complex Class

Complex classes like QueryBuilder often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use QueryBuilder, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Spatie\QueryBuilder;
4
5
use Illuminate\Http\Request;
6
use Illuminate\Support\Collection;
7
use Illuminate\Database\Eloquent\Builder;
8
use Spatie\QueryBuilder\Exceptions\InvalidSortQuery;
9
use Spatie\QueryBuilder\Exceptions\InvalidFieldQuery;
10
use Spatie\QueryBuilder\Exceptions\InvalidAppendQuery;
11
use Spatie\QueryBuilder\Exceptions\InvalidFilterQuery;
12
use Spatie\QueryBuilder\Exceptions\InvalidIncludeQuery;
13
14
class QueryBuilder extends Builder
15
{
16
    /** @var \Illuminate\Support\Collection */
17
    protected $allowedFilters;
18
19
    /** @var \Illuminate\Support\Collection */
20
    protected $allowedFields;
21
22
    /** @var string|null */
23
    protected $defaultSort;
24
25
    /** @var \Illuminate\Support\Collection */
26
    protected $allowedSorts;
27
28
    /** @var \Illuminate\Support\Collection */
29
    protected $allowedIncludes;
30
31
    /** @var \Illuminate\Support\Collection */
32
    protected $allowedAppends;
33
34
    /** @var \Illuminate\Support\Collection */
35
    protected $fields;
36
37
    /** @var array */
38
    protected $appends = [];
39
40
    /** @var \Illuminate\Http\Request */
41
    protected $request;
42
43
    /** @var string */
44
    protected $modelTableName;
45
46
    /** @var array|null */
47
    private $joinColumnConflicts = null;
48
49
    public function __construct(Builder $builder, ? Request $request = null)
50
    {
51
        parent::__construct(clone $builder->getQuery());
52
53
        $this->initializeFromBuilder($builder);
54
55
        $this->request = $request ?? request();
56
57
        $this->modelTableName = $this->getModel()->getTable();
0 ignored issues
show
Bug introduced by
The method getTable does only exist in Illuminate\Database\Eloquent\Model, but not in Illuminate\Database\Eloquent\Builder.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
58
59
        if ($this->request->fields()->isNotEmpty()) {
60
            $this->parseSelectedFields();
61
        }
62
63
        if ($this->request->sorts()->isNotEmpty()) {
64
            $this->allowedSorts('*');
65
        }
66
    }
67
68
    /**
69
     * Add the model, scopes, eager loaded relationships, local macro's and onDelete callback
70
     * from the $builder to this query builder.
71
     *
72
     * @param \Illuminate\Database\Eloquent\Builder $builder
73
     */
74
    protected function initializeFromBuilder(Builder $builder)
75
    {
76
        $this->setModel($builder->getModel())
0 ignored issues
show
Bug introduced by
It seems like $builder->getModel() targeting Illuminate\Database\Eloquent\Builder::getModel() can also be of type object<Illuminate\Database\Eloquent\Builder>; however, Illuminate\Database\Eloquent\Builder::setModel() does only seem to accept object<Illuminate\Database\Eloquent\Model>, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
77
            ->setEagerLoads($builder->getEagerLoads());
78
79
        $builder->macro('getProtected', function (Builder $builder, string $property) {
80
            return $builder->{$property};
81
        });
82
83
        $this->scopes = $builder->getProtected('scopes');
84
85
        $this->localMacros = $builder->getProtected('localMacros');
86
87
        $this->onDelete = $builder->getProtected('onDelete');
88
    }
89
90
    /**
91
     * Create a new QueryBuilder for a request and model.
92
     *
93
     * @param string|\Illuminate\Database\Query\Builder $baseQuery Model class or base query builder
94
     * @param Request $request
95
     *
96
     * @return \Spatie\QueryBuilder\QueryBuilder
97
     */
98
    public static function for($baseQuery, ? Request $request = null) : self
99
    {
100
        if (is_string($baseQuery)) {
101
            $baseQuery = ($baseQuery)::query();
102
        }
103
104
        return new static($baseQuery, $request ?? request());
105
    }
106
107
    public function allowedFilters($filters) : self
108
    {
109
        $filters = is_array($filters) ? $filters : func_get_args();
110
        $this->allowedFilters = collect($filters)->map(function ($filter) {
111
            if ($filter instanceof Filter) {
112
                return $filter;
113
            }
114
115
            return Filter::partial($filter);
116
        });
117
118
        $this->guardAgainstUnknownFilters();
119
120
        $this->addFiltersToQuery($this->request->filters());
121
122
        return $this;
123
    }
124
125
    public function allowedFields($fields) : self
126
    {
127
        $fields = is_array($fields) ? $fields : func_get_args();
128
129
        $this->allowedFields = collect($fields)
130
            ->map(function (string $fieldName) {
131
                if (! str_contains($fieldName, '.')) {
132
                    return "{$this->modelTableName}.{$fieldName}";
133
                }
134
135
                return $fieldName;
136
            });
137
138
        if (! $this->allowedFields->contains('*')) {
139
            $this->guardAgainstUnknownFields();
140
        }
141
142
        return $this;
143
    }
144
145
    public function defaultSort($sort) : self
146
    {
147
        $this->defaultSort = $sort;
148
149
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
150
151
        return $this;
152
    }
153
154
    public function allowedSorts($sorts) : self
155
    {
156
        $sorts = is_array($sorts) ? $sorts : func_get_args();
157
        if (! $this->request->sorts()) {
158
            return $this;
159
        }
160
161
        $this->allowedSorts = collect($sorts);
162
163
        if (! $this->allowedSorts->contains('*')) {
164
            $this->guardAgainstUnknownSorts();
165
        }
166
167
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
168
169
        return $this;
170
    }
171
172
    public function allowedIncludes($includes) : self
173
    {
174
        $includes = is_array($includes) ? $includes : func_get_args();
175
176
        $this->allowedIncludes = collect($includes)
177
            ->flatMap(function ($include) {
178
                return collect(explode('.', $include))
179
                    ->reduce(function ($collection, $include) {
180
                        if ($collection->isEmpty()) {
181
                            return $collection->push($include);
182
                        }
183
184
                        return $collection->push("{$collection->last()}.{$include}");
185
                    }, collect());
186
            });
187
188
        $this->guardAgainstUnknownIncludes();
189
190
        $this->addIncludesToQuery($this->request->includes());
191
192
        return $this;
193
    }
194
195
    public function allowedAppends($appends) : self
196
    {
197
        $appends = is_array($appends) ? $appends : func_get_args();
198
199
        $this->allowedAppends = collect($appends);
200
201
        $this->guardAgainstUnknownAppends();
202
203
        $this->appends = $this->request->appends();
204
205
        return $this;
206
    }
207
208
    protected function parseSelectedFields()
209
    {
210
        $this->fields = $this->request->fields();
211
212
        $modelFields = $this->fields->get($this->modelTableName, ['*']);
213
214
        $this->select($this->prependFieldsWithTableName($modelFields, $this->modelTableName));
0 ignored issues
show
Bug introduced by
The method select() does not exist on Spatie\QueryBuilder\QueryBuilder. Did you maybe mean parseSelectedFields()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
215
    }
216
217
    protected function prependFieldsWithTableName(array $fields, string $tableName): array
218
    {
219
        return array_map(function ($field) use ($tableName) {
220
            return "{$tableName}.{$field}";
221
        }, $fields);
222
    }
223
224
    protected function getFieldsForRelatedTable(string $relation): array
225
    {
226
        if (! $this->fields) {
227
            return ['*'];
228
        }
229
230
        return $this->fields->get($relation, []);
231
    }
232
233
    protected function addFiltersToQuery(Collection $filters)
234
    {
235
        $filters->each(function ($value, $property) {
236
            $filter = $this->findFilter($property);
237
238
            $filter->filter($this, $value);
239
        });
240
    }
241
242
    protected function findFilter(string $property) : ? Filter
243
    {
244
        return $this->allowedFilters
245
            ->first(function (Filter $filter) use ($property) {
246
                return $filter->isForProperty($property);
247
            });
248
    }
249
250
    protected function addSortsToQuery(Collection $sorts)
251
    {
252
        $this->getJoinColumnConflicts();
253
254
        $this->filterDuplicates($sorts)
255
            ->each(function (string $sort) {
256
                $descending = $sort[0] === '-';
257
258
                $key = ltrim($sort, '-');
259
260
                if (in_array($key, $this->joinColumnConflicts)) {
261
                    $key = "{$this->modelTableName}.{$key}";
262
                }
263
264
                $this->orderBy($key, $descending ? 'desc' : 'asc');
0 ignored issues
show
Bug introduced by
The method orderBy() does not exist on Spatie\QueryBuilder\QueryBuilder. Did you maybe mean enforceOrderBy()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
265
            });
266
    }
267
268
    protected function filterDuplicates(Collection $sorts): Collection
269
    {
270
        if (! is_array($orders = $this->getQuery()->orders)) {
271
            return $sorts;
272
        }
273
274
        $this->getJoinColumnConflicts();
275
276
        return $sorts->reject(function (string $sort) use ($orders) {
277
            $sortColumn = ltrim($sort, '-');
278
279
            if (in_array($sortColumn, $this->joinColumnConflicts)) {
280
                $sortColumn = "{$this->modelTableName}.{$sortColumn}";
281
            }
282
283
            $toSort = [
284
                'column' => $sortColumn,
285
                'direction' => ($sort[0] === '-') ? 'desc' : 'asc',
286
            ];
287
            foreach ($orders as $order) {
288
                if ($order === $toSort) {
289
                    return true;
290
                }
291
            }
292
        });
293
    }
294
295
    protected function addIncludesToQuery(Collection $includes)
296
    {
297
        $includes
298
            ->map('camel_case')
299
            ->map(function (string $include) {
300
                return collect(explode('.', $include));
301
            })
302
            ->flatMap(function (Collection $relatedTables) {
303
                return $relatedTables
304
                    ->mapWithKeys(function ($table, $key) use ($relatedTables) {
305
                        $fields = $this->getFieldsForRelatedTable(snake_case($table));
306
307
                        $fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');
308
309
                        if (empty($fields)) {
310
                            return [$fullRelationName];
311
                        }
312
313
                        return [$fullRelationName => function ($query) use ($fields) {
314
                            $query->select($this->prependFieldsWithTableName($fields, $query->getModel()->getTable()));
315
                        }];
316
                    });
317
            })
318
            ->pipe(function (Collection $withs) {
319
                $this->with($withs->all());
320
            });
321
    }
322
323
    public function setAppendsToResult($result)
324
    {
325
        $result->map(function ($item) {
326
            $item->append($this->appends->toArray());
0 ignored issues
show
Bug introduced by
The method toArray cannot be called on $this->appends (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
327
328
            return $item;
329
        });
330
331
        return $result;
332
    }
333
334
    protected function guardAgainstUnknownFilters()
335
    {
336
        $filterNames = $this->request->filters()->keys();
337
338
        $allowedFilterNames = $this->allowedFilters->map->getProperty();
339
340
        $diff = $filterNames->diff($allowedFilterNames);
341
342
        if ($diff->count()) {
343
            throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames);
344
        }
345
    }
346
347
    protected function guardAgainstUnknownFields()
348
    {
349
        $fields = $this->request->fields()
350
            ->map(function ($fields, $model) {
351
                $tableName = snake_case(preg_replace('/-/', '_', $model));
352
353
                $fields = array_map('snake_case', $fields);
354
355
                return $this->prependFieldsWithTableName($fields, $tableName);
356
            })
357
            ->flatten()
358
            ->unique();
359
360
        $diff = $fields->diff($this->allowedFields);
361
362
        if ($diff->count()) {
363
            throw InvalidFieldQuery::fieldsNotAllowed($diff, $this->allowedFields);
364
        }
365
    }
366
367
    protected function guardAgainstUnknownSorts()
368
    {
369
        $sorts = $this->request->sorts()->map(function ($sort) {
370
            return ltrim($sort, '-');
371
        });
372
373
        $diff = $sorts->diff($this->allowedSorts);
374
375
        if ($diff->count()) {
376
            throw InvalidSortQuery::sortsNotAllowed($diff, $this->allowedSorts);
377
        }
378
    }
379
380
    protected function guardAgainstUnknownIncludes()
381
    {
382
        $includes = $this->request->includes();
383
384
        $diff = $includes->diff($this->allowedIncludes);
385
386
        if ($diff->count()) {
387
            throw InvalidIncludeQuery::includesNotAllowed($diff, $this->allowedIncludes);
388
        }
389
    }
390
391
    protected function guardAgainstUnknownAppends()
392
    {
393
        $appends = $this->request->appends();
394
395
        $diff = $appends->diff($this->allowedAppends);
396
397
        if ($diff->count()) {
398
            throw InvalidAppendQuery::appendsNotAllowed($diff, $this->allowedAppends);
399
        }
400
    }
401
402
    public function get($columns = ['*'])
403
    {
404
        $result = parent::get($columns);
405
406
        if (count($this->appends) > 0) {
407
            $result = $this->setAppendsToResult($result);
408
        }
409
410
        return $result;
411
    }
412
413
    public function getDbColumns(string $tableName)
414
    {
415
        return \Schema::getColumnListing($tableName);
416
    }
417
418
    /**
419
     * Get the names of the columns in the selected table that have the same
420
     * name as a column in a JOINed table
421
     *
422
     * @return array
423
     */
424
    protected function getJoinColumnConflicts()
425
    {
426
        // Check if all of the column conflicts have been gathered before. If
427
        // so, there is no need to do it again.
428
        if (is_array($this->joinColumnConflicts)) {
429
            return $this->joinColumnConflicts;
430
        }
431
432
        $joins = $this->getQuery()->joins;
433
434
        // Check if there are any JOIN statements in the query
435
        if (!is_array($joins)) {
436
            return $this->joinColumnConflicts = [];
437
        }
438
439
        $joinColumns = [];
440
441
        // Get all of the column names of the joined tables
442
        foreach ($joins as $join) {
443
            // Only get the first word in the table name just in case the
444
            // table name contains an AS (Example: "table as my_table")
445
            $joinTableName = explode(' ',trim($join->table))[0];
446
447
            $joinColumns = array_merge($joinColumns, $this->getDbColumns($joinTableName));
448
        }
449
450
        $this->joinColumnConflicts = array_intersect($joinColumns, $this->getDbColumns($this->modelTableName));
451
452
        return $this->joinColumnConflicts;
453
    }
454
}
455