Completed
Pull Request — master (#103)
by
unknown
01:30
created

QueryBuilder   B

Complexity

Total Complexity 49

Size/Duplication

Total Lines 366
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 9

Importance

Changes 0
Metric Value
wmc 49
lcom 2
cbo 9
dl 0
loc 366
rs 8.48
c 0
b 0
f 0

24 Methods

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

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\InvalidAppendQuery;
10
use Spatie\QueryBuilder\Exceptions\InvalidFieldsQuery;
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
    public function __construct(Builder $builder, ? Request $request = null)
44
    {
45
        parent::__construct(clone $builder->getQuery());
46
47
        $this->initializeFromBuilder($builder);
48
49
        $this->request = $request ?? request();
50
51
        $this->parseSelectedFields();
52
53
        if ($this->request->sorts()) {
54
            $this->allowedSorts('*');
55
        }
56
    }
57
58
    /**
59
     * Add the model, scopes, eager loaded relationships, local macro's and onDelete callback
60
     * from the $builder to this query builder.
61
     *
62
     * @param \Illuminate\Database\Eloquent\Builder $builder
63
     */
64
    protected function initializeFromBuilder(Builder $builder)
65
    {
66
        $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...
67
            ->setEagerLoads($builder->getEagerLoads());
68
69
        $builder->macro('getProtected', function (Builder $builder, string $property) {
70
            return $builder->{$property};
71
        });
72
73
        $this->scopes = $builder->getProtected('scopes');
74
75
        $this->localMacros = $builder->getProtected('localMacros');
76
77
        $this->onDelete = $builder->getProtected('onDelete');
78
    }
79
80
    /**
81
     * Create a new QueryBuilder for a request and model.
82
     *
83
     * @param string|\Illuminate\Database\Query\Builder $baseQuery Model class or base query builder
84
     * @param Request $request
85
     *
86
     * @return \Spatie\QueryBuilder\QueryBuilder
87
     */
88
    public static function for($baseQuery, ? Request $request = null) : self
89
    {
90
        if (is_string($baseQuery)) {
91
            $baseQuery = ($baseQuery)::query();
92
        }
93
94
        return new static($baseQuery, $request ?? request());
95
    }
96
97
    public function allowedFilters($filters) : self
98
    {
99
        $filters = is_array($filters) ? $filters : func_get_args();
100
        $this->allowedFilters = collect($filters)->map(function ($filter) {
101
            if ($filter instanceof Filter) {
102
                return $filter;
103
            }
104
105
            return Filter::partial($filter);
106
        });
107
108
        $this->guardAgainstUnknownFilters();
109
110
        $this->addFiltersToQuery($this->request->filters());
111
112
        return $this;
113
    }
114
115
    public function allowedFields($fields) : self
116
    {
117
        $fields = is_array($fields) ? $fields : func_get_args();
118
119
        $this->allowedFields = collect($fields);
120
121
        $this->guardAgainstUnknownFields();
122
123
        return $this;
124
    }
125
126
    public function defaultSort($sort) : self
127
    {
128
        $this->defaultSort = $sort;
129
130
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
131
132
        return $this;
133
    }
134
135
    public function allowedSorts($sorts) : self
136
    {
137
        $sorts = is_array($sorts) ? $sorts : func_get_args();
138
        if (! $this->request->sorts()) {
139
            return $this;
140
        }
141
142
        $this->allowedSorts = collect($sorts);
143
144
        if (! $this->allowedSorts->contains('*')) {
145
            $this->guardAgainstUnknownSorts();
146
        }
147
148
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
149
150
        return $this;
151
    }
152
153
    public function allowedIncludes($includes) : self
154
    {
155
        $includes = is_array($includes) ? $includes : func_get_args();
156
157
        $this->allowedIncludes = collect($includes)
158
            ->flatMap(function ($include) {
159
                return collect(explode('.', $include))
160
                    ->reduce(function ($collection, $include) {
161
                        if ($collection->isEmpty()) {
162
                            return $collection->push($include);
163
                        }
164
165
                        return $collection->push("{$collection->last()}.{$include}");
166
                    }, collect());
167
            });
168
169
        $this->guardAgainstUnknownIncludes();
170
171
        $this->addIncludesToQuery($this->request->includes());
172
173
        return $this;
174
    }
175
176
    public function allowedAppends($appends) : self
177
    {
178
        $appends = is_array($appends) ? $appends : func_get_args();
179
180
        $this->allowedAppends = collect($appends);
181
182
        $this->guardAgainstUnknownAppends();
183
184
        $this->appends = $this->request->appends();
185
186
        return $this;
187
    }
188
189
    protected function parseSelectedFields()
190
    {
191
        $this->fields = $this->request->fields();
192
193
        $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...
194
        $modelFields = $this->fields->get($modelTableName);
195
196
        if (! $modelFields) {
197
            $modelFields = '*';
198
        }
199
200
        $this->select($this->prependFieldsWithTableName(explode(',', $modelFields), $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...
201
    }
202
203
    protected function prependFieldsWithTableName(array $fields, string $tableName): array
204
    {
205
        return array_map(function ($field) use ($tableName) {
206
            return "{$tableName}.{$field}";
207
        }, $fields);
208
    }
209
210
    protected function getFieldsForRelatedTable(string $relation): array
211
    {
212
        $fields = $this->fields->get($relation);
213
214
        if (! $fields) {
215
            return [];
216
        }
217
218
        return explode(',', $fields);
219
    }
220
221
    protected function addFiltersToQuery(Collection $filters)
222
    {
223
        $filters->each(function ($value, $property) {
224
            $filter = $this->findFilter($property);
225
226
            $filter->filter($this, $value);
227
        });
228
    }
229
230
    protected function findFilter(string $property) : ? Filter
231
    {
232
        return $this->allowedFilters
233
            ->first(function (Filter $filter) use ($property) {
234
                return $filter->isForProperty($property);
235
            });
236
    }
237
238
    protected function addSortsToQuery(Collection $sorts)
239
    {
240
        $this->filterDuplicates($sorts)
241
            ->each(function (string $sort) {
242
                $descending = $sort[0] === '-';
243
244
                $key = ltrim($sort, '-');
245
246
                $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...
247
            });
248
    }
249
250
    protected function filterDuplicates(Collection $sorts): Collection
251
    {
252
        if (! is_array($orders = $this->getQuery()->orders)) {
253
            return $sorts;
254
        }
255
256
        return $sorts->reject(function (string $sort) use ($orders) {
257
            $toSort = [
258
                'column' => ltrim($sort, '-'),
259
                'direction' => ($sort[0] === '-') ? 'desc' : 'asc',
260
            ];
261
            foreach ($orders as $order) {
262
                if ($order === $toSort) {
263
                    return true;
264
                }
265
            }
266
        });
267
    }
268
269
    protected function addIncludesToQuery(Collection $includes)
270
    {
271
        $includes
272
            ->map('camel_case')
273
            ->map(function (string $include) {
274
                return collect(explode('.', $include));
275
            })
276
            ->flatMap(function (Collection $relatedTables) {
277
                return $relatedTables
278
                    ->mapWithKeys(function ($table, $key) use ($relatedTables) {
279
                        $fields = $this->getFieldsForRelatedTable(snake_case($table));
280
281
                        $fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');
282
283
                        if (empty($fields)) {
284
                            return [$fullRelationName];
285
                        }
286
287
                        return [$fullRelationName => function ($query) use ($fields) {
288
                            $query->select($fields);
289
                        }];
290
                    });
291
            })
292
            ->pipe(function (Collection $withs) {
293
                $this->with($withs->all());
294
            });
295
    }
296
297
    public function setAppendsToResult($result)
298
    {
299
        $result->map(function ($item) {
300
            $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...
301
302
            return $item;
303
        });
304
305
        return $result;
306
    }
307
308
    protected function guardAgainstUnknownFilters()
309
    {
310
        $filterNames = $this->request->filters()->keys();
311
312
        $allowedFilterNames = $this->allowedFilters->map->getProperty();
313
314
        $diff = $filterNames->diff($allowedFilterNames);
315
316
        if ($diff->count()) {
317
            throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames);
318
        }
319
    }
320
321
    protected function guardAgainstUnknownFields()
322
    {
323
        $fields = $this->request->fields()->flatMap(function ($value) {
324
            return explode(',', $value);
325
        })->unique();
326
327
        $diff = $fields->diff($this->allowedFields);
328
329
        if ($diff->count()) {
330
            throw InvalidFieldsQuery::fieldsNotAllowed($diff, $this->allowedFields);
331
        }
332
    }
333
334
    protected function guardAgainstUnknownSorts()
335
    {
336
        $sorts = $this->request->sorts()->map(function ($sort) {
337
            return ltrim($sort, '-');
338
        });
339
340
        $diff = $sorts->diff($this->allowedSorts);
341
342
        if ($diff->count()) {
343
            throw InvalidSortQuery::sortsNotAllowed($diff, $this->allowedSorts);
344
        }
345
    }
346
347
    protected function guardAgainstUnknownIncludes()
348
    {
349
        $includes = $this->request->includes();
350
351
        $diff = $includes->diff($this->allowedIncludes);
352
353
        if ($diff->count()) {
354
            throw InvalidIncludeQuery::includesNotAllowed($diff, $this->allowedIncludes);
355
        }
356
    }
357
358
    protected function guardAgainstUnknownAppends()
359
    {
360
        $appends = $this->request->appends();
361
362
        $diff = $appends->diff($this->allowedAppends);
363
364
        if ($diff->count()) {
365
            throw InvalidAppendQuery::appendsNotAllowed($diff, $this->allowedAppends);
366
        }
367
    }
368
369
    public function get($columns = ['*'])
370
    {
371
        $result = parent::get($columns);
372
373
        if (count($this->appends) > 0) {
374
            $result = $this->setAppendsToResult($result);
375
        }
376
377
        return $result;
378
    }
379
}
380