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