Completed
Pull Request — master (#215)
by
unknown
01:17
created

QueryBuilder::getQuery()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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