Completed
Push — master ( 8bf216...478559 )
by
unknown
01:16
created

QueryBuilder::addFiltersToQuery()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 8
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
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 $appends;
36
37
    /** @var \Illuminate\Http\Request */
38
    protected $request;
39
40
    public function __construct(Builder $builder, ? Request $request = null)
41
    {
42
        parent::__construct(clone $builder->getQuery());
43
44
        $this->initializeFromBuilder($builder);
45
46
        $this->request = $request ?? request();
47
48
        $this->parseFields();
49
    }
50
51
    /**
52
     * Create a new QueryBuilder for a request and model.
53
     *
54
     * @param string|\Illuminate\Database\Query\Builder $baseQuery Model class or base query builder
55
     * @param \Illuminate\Http\Request                  $request
56
     *
57
     * @return \Spatie\QueryBuilder\QueryBuilder
58
     */
59
    public static function for($baseQuery, ? Request $request = null): self
60
    {
61
        if (is_string($baseQuery)) {
62
            $baseQuery = ($baseQuery)::query();
63
        }
64
65
        return new static($baseQuery, $request ?? request());
66
    }
67
68
    public function allowedFilters($filters): self
69
    {
70
        $filters = is_array($filters) ? $filters : func_get_args();
71
        $this->allowedFilters = collect($filters)->map(function ($filter) {
72
            if ($filter instanceof Filter) {
73
                return $filter;
74
            }
75
76
            return Filter::partial($filter);
77
        });
78
79
        $this->guardAgainstUnknownFilters();
80
81
        $this->addFiltersToQuery($this->request->filters());
82
83
        return $this;
84
    }
85
86
    public function allowedFields($fields): self
87
    {
88
        $fields = is_array($fields) ? $fields : func_get_args();
89
90
        $this->allowedFields = collect($fields)
91
            ->map(function (string $fieldName) {
92
                if (! str_contains($fieldName, '.')) {
93
                    $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...
94
95
                    return "{$modelTableName}.{$fieldName}";
96
                }
97
98
                return $fieldName;
99
            });
100
101
        if (! $this->allowedFields->contains('*')) {
102
            $this->guardAgainstUnknownFields();
103
        }
104
105
        return $this;
106
    }
107
108
    public function parseFields()
109
    {
110
        $this->addFieldsToQuery($this->request->fields());
111
    }
112
113
    public function allowedIncludes($includes): self
114
    {
115
        $includes = is_array($includes) ? $includes : func_get_args();
116
117
        $this->allowedIncludes = collect($includes)
118
            ->flatMap(function ($include) {
119
                return collect(explode('.', $include))
120
                    ->reduce(function ($collection, $include) {
121
                        if ($collection->isEmpty()) {
122
                            return $collection->push($include);
123
                        }
124
125
                        return $collection->push("{$collection->last()}.{$include}");
126
                    }, collect());
127
            });
128
129
        $this->guardAgainstUnknownIncludes();
130
131
        $this->addIncludesToQuery($this->request->includes());
132
133
        return $this;
134
    }
135
136
    public function allowedAppends($appends): self
137
    {
138
        $appends = is_array($appends) ? $appends : func_get_args();
139
140
        $this->allowedAppends = collect($appends);
141
142
        $this->guardAgainstUnknownAppends();
143
144
        $this->appends = $this->request->appends();
145
146
        return $this;
147
    }
148
149
    public function allowedSorts($sorts): self
150
    {
151
        $sorts = is_array($sorts) ? $sorts : func_get_args();
152
        if (! $this->request->sorts()) {
153
            return $this;
154
        }
155
156
        $this->allowedSorts = collect($sorts)->map(function ($sort) {
157
            if ($sort instanceof Sort) {
158
                return $sort;
159
            }
160
161
            return Sort::field(ltrim($sort, '-'));
162
        });
163
164
        $this->guardAgainstUnknownSorts();
165
166
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
167
168
        return $this;
169
    }
170
171
    public function defaultSort($sort): self
172
    {
173
        $this->defaultSort = $sort;
174
175
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
176
177
        return $this;
178
    }
179
180
    public function getQuery()
181
    {
182
        if ($this->request->sorts() && ! $this->allowedSorts instanceof Collection) {
183
            $this->addDefaultSorts();
184
        }
185
186
        return parent::getQuery();
187
    }
188
189
    /**
190
     * {@inheritdoc}
191
     */
192
    public function get($columns = ['*'])
193
    {
194
        if ($this->request->sorts() && ! $this->allowedSorts instanceof Collection) {
195
            $this->addDefaultSorts();
196
        }
197
198
        $results = parent::get($columns);
199
200
        if (optional($this->appends)->isNotEmpty()) {
201
            $results = $this->addAppendsToResults($results);
0 ignored issues
show
Bug introduced by
It seems like $results can also be of type array<integer,object<Ill...base\Eloquent\Builder>>; however, Spatie\QueryBuilder\Quer...::addAppendsToResults() does only seem to accept object<Illuminate\Support\Collection>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
202
        }
203
204
        return $results;
205
    }
206
207
    public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null)
208
    {
209
        if ($this->request->sorts() && ! $this->allowedSorts instanceof Collection) {
210
            $this->addDefaultSorts();
211
        }
212
213
        return parent::paginate($perPage, $columns, $pageName, $page);
214
    }
215
216
    public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null)
217
    {
218
        if ($this->request->sorts() && ! $this->allowedSorts instanceof Collection) {
219
            $this->addDefaultSorts();
220
        }
221
222
        return parent::simplePaginate($perPage, $columns, $pageName, $page);
223
    }
224
225
    /**
226
     * Add the model, scopes, eager loaded relationships, local macro's and onDelete callback
227
     * from the $builder to this query builder.
228
     *
229
     * @param \Illuminate\Database\Eloquent\Builder $builder
230
     */
231
    protected function initializeFromBuilder(Builder $builder)
232
    {
233
        $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...
234
            ->setEagerLoads($builder->getEagerLoads());
235
236
        $builder->macro('getProtected', function (Builder $builder, string $property) {
237
            return $builder->{$property};
238
        });
239
240
        $this->scopes = $builder->getProtected('scopes');
241
242
        $this->localMacros = $builder->getProtected('localMacros');
243
244
        $this->onDelete = $builder->getProtected('onDelete');
245
    }
246
247
    protected function addFieldsToQuery(Collection $fields)
248
    {
249
        $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...
250
251
        if($modelFields = $fields->get($modelTableName)) {
252
            $this->select($this->prependFieldsWithTableName($modelFields, $modelTableName));
0 ignored issues
show
Bug introduced by
The method select() does not exist on Spatie\QueryBuilder\QueryBuilder. Did you maybe mean createSelectWithConstraint()?

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...
253
        }
254
    }
255
256
    protected function prependFieldsWithTableName(array $fields, string $tableName): array
257
    {
258
        return array_map(function ($field) use ($tableName) {
259
            return "{$tableName}.{$field}";
260
        }, $fields);
261
    }
262
263
    protected function getFieldsForIncludedTable(string $relation): array
264
    {
265
        if ($this->request->fields()->isEmpty()) {
266
            return ['*'];
267
        }
268
269
        return $this->request->fields()->get($relation, []);
270
    }
271
272
    protected function addFiltersToQuery(Collection $filters)
273
    {
274
        $filters->each(function ($value, $property) {
275
            $filter = $this->findFilter($property);
276
277
            $filter->filter($this, $value);
278
        });
279
    }
280
281
    protected function findFilter(string $property): ?Filter
282
    {
283
        return $this->allowedFilters
284
            ->first(function (Filter $filter) use ($property) {
285
                return $filter->isForProperty($property);
286
            });
287
    }
288
289
    protected function addSortsToQuery(Collection $sorts)
290
    {
291
        $this->filterDuplicates($sorts)
292
            ->each(function (string $property) {
293
                $descending = $property[0] === '-';
294
295
                $key = ltrim($property, '-');
296
297
                $sort = $this->findSort($key);
298
299
                $sort->sort($this, $descending);
300
            });
301
    }
302
303
    protected function filterDuplicates(Collection $sorts): Collection
304
    {
305
        if (! is_array($orders = $this->getQuery()->orders)) {
306
            return $sorts;
307
        }
308
309
        return $sorts->reject(function (string $sort) use ($orders) {
310
            $toSort = [
311
                'column' => ltrim($sort, '-'),
312
                'direction' => ($sort[0] === '-') ? 'desc' : 'asc',
313
            ];
314
            foreach ($orders as $order) {
315
                if ($order === $toSort) {
316
                    return true;
317
                }
318
            }
319
        });
320
    }
321
322
    protected function findSort(string $property): ?Sort
323
    {
324
        return $this->allowedSorts
325
            ->first(function (Sort $sort) use ($property) {
326
                return $sort->isForProperty($property);
327
            });
328
    }
329
330
    protected function addDefaultSorts()
331
    {
332
        $this->allowedSorts = collect($this->request->sorts($this->defaultSort))->map(function ($sort) {
333
            if ($sort instanceof Sort) {
334
                return $sort;
335
            }
336
337
            return Sort::field(ltrim($sort, '-'));
338
        });
339
340
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
341
    }
342
343
    protected function addAppendsToResults(Collection $results)
344
    {
345
        return $results->each->append($this->appends->toArray());
346
    }
347
348
    protected function addIncludesToQuery(Collection $includes)
349
    {
350
        $includes
351
            ->map('camel_case')
352
            ->map(function (string $include) {
353
                return collect(explode('.', $include));
354
            })
355
            ->flatMap(function (Collection $relatedTables) {
356
                return $relatedTables
357
                    ->mapWithKeys(function ($table, $key) use ($relatedTables) {
358
                        $fields = $this->getFieldsForIncludedTable(snake_case($table));
359
360
                        $fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');
361
362
                        if (empty($fields)) {
363
                            return [$fullRelationName];
364
                        }
365
366
                        return [$fullRelationName => function ($query) use ($fields) {
367
                            $query->select($this->prependFieldsWithTableName($fields, $query->getModel()->getTable()));
368
                        }];
369
                    });
370
            })
371
            ->pipe(function (Collection $withs) {
372
                $this->with($withs->all());
373
            });
374
    }
375
376
    protected function guardAgainstUnknownFilters()
377
    {
378
        $filterNames = $this->request->filters()->keys();
379
380
        $allowedFilterNames = $this->allowedFilters->map->getProperty();
381
382
        $diff = $filterNames->diff($allowedFilterNames);
383
384
        if ($diff->count()) {
385
            throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames);
386
        }
387
    }
388
389
    protected function guardAgainstUnknownFields()
390
    {
391
        $fields = $this->request->fields()
392
            ->map(function ($fields, $model) {
393
                $tableName = snake_case(preg_replace('/-/', '_', $model));
394
395
                $fields = array_map('snake_case', $fields);
396
397
                return $this->prependFieldsWithTableName($fields, $tableName);
398
            })
399
            ->flatten()
400
            ->unique();
401
402
        $diff = $fields->diff($this->allowedFields);
403
404
        if ($diff->count()) {
405
            throw InvalidFieldQuery::fieldsNotAllowed($diff, $this->allowedFields);
406
        }
407
    }
408
409
    protected function guardAgainstUnknownSorts()
410
    {
411
        $sortNames = $this->request->sorts()->map(function ($sort) {
412
            return ltrim($sort, '-');
413
        });
414
415
        $allowedSortNames = $this->allowedSorts->map->getProperty();
416
417
        $diff = $sortNames->diff($allowedSortNames);
418
419
        if ($diff->count()) {
420
            throw InvalidSortQuery::sortsNotAllowed($diff, $allowedSortNames);
421
        }
422
    }
423
424
    protected function guardAgainstUnknownIncludes()
425
    {
426
        $includes = $this->request->includes();
427
428
        $diff = $includes->diff($this->allowedIncludes);
429
430
        if ($diff->count()) {
431
            throw InvalidIncludeQuery::includesNotAllowed($diff, $this->allowedIncludes);
432
        }
433
    }
434
435
    protected function guardAgainstUnknownAppends()
436
    {
437
        $appends = $this->request->appends();
438
439
        $diff = $appends->diff($this->allowedAppends);
440
441
        if ($diff->count()) {
442
            throw InvalidAppendQuery::appendsNotAllowed($diff, $this->allowedAppends);
443
        }
444
    }
445
}
446