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

QueryBuilder::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 10
rs 9.9332
c 0
b 0
f 0
cc 1
nc 1
nop 2
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 \Illuminate\Support\Collection */
24
    protected $defaultSorts;
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 array|string|\Spatie\QueryBuilder\Sort $sorts
171
     *
172
     * @return \Spatie\QueryBuilder\QueryBuilder
173
     */
174
    public function defaultSort($sorts): self
0 ignored issues
show
Unused Code introduced by
The parameter $sorts is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
175
    {
176
        return $this->defaultSorts(func_get_args());
177
    }
178
179
    /**
180
     * @param array|string|\Spatie\QueryBuilder\Sort $sorts
181
     *
182
     * @return \Spatie\QueryBuilder\QueryBuilder
183
     */
184
    public function defaultSorts($sorts): self
185
    {
186
        $sorts = is_array($sorts) ? $sorts : func_get_args();
187
188
        $this->defaultSorts = collect($sorts)->map(function ($sort) {
189
            if (is_string($sort)) {
190
                return Sort::field($sort);
191
            }
192
193
            return $sort;
194
        });
195
196
        return $this;
197
    }
198
199
    public function getQuery()
200
    {
201
        $this->parseSorts();
202
203
        return parent::getQuery();
204
    }
205
206
    /**
207
     * {@inheritdoc}
208
     */
209
    public function get($columns = ['*'])
210
    {
211
        $this->parseSorts();
212
213
        $results = parent::get($columns);
214
215
        if ($this->request->appends()->isNotEmpty()) {
216
            $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...
217
        }
218
219
        return $results;
220
    }
221
222
    public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null)
223
    {
224
        $this->parseSorts();
225
226
        return parent::paginate($perPage, $columns, $pageName, $page);
227
    }
228
229
    public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null)
230
    {
231
        $this->parseSorts();
232
233
        return parent::simplePaginate($perPage, $columns, $pageName, $page);
234
    }
235
236
    /**
237
     * Add the model, scopes, eager loaded relationships, local macro's and onDelete callback
238
     * from the $builder to this query builder.
239
     *
240
     * @param \Illuminate\Database\Eloquent\Builder $builder
241
     */
242
    protected function initializeFromBuilder(Builder $builder)
243
    {
244
        $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...
245
            ->setEagerLoads($builder->getEagerLoads());
246
247
        $builder->macro('getProtected', function (Builder $builder, string $property) {
248
            return $builder->{$property};
249
        });
250
251
        $this->scopes = $builder->getProtected('scopes');
252
253
        $this->localMacros = $builder->getProtected('localMacros');
254
255
        $this->onDelete = $builder->getProtected('onDelete');
256
    }
257
258
    protected function addFieldsToQuery(Collection $fields)
259
    {
260
        $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...
261
262
        if ($modelFields = $fields->get($modelTableName)) {
263
            $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...
264
        }
265
    }
266
267
    protected function prependFieldsWithTableName(array $fields, string $tableName): array
268
    {
269
        return array_map(function ($field) use ($tableName) {
270
            return "{$tableName}.{$field}";
271
        }, $fields);
272
    }
273
274
    protected function getFieldsForIncludedTable(string $relation): array
275
    {
276
        return $this->request->fields()->get($relation, []);
277
    }
278
279
    protected function addFiltersToQuery(Collection $filters)
280
    {
281
        $filters->each(function ($value, $property) {
282
            $filter = $this->findFilter($property);
283
284
            $filter->filter($this, $value);
285
        });
286
    }
287
288
    protected function findFilter(string $property): ?Filter
289
    {
290
        return $this->allowedFilters
291
            ->first(function (Filter $filter) use ($property) {
292
                return $filter->isForProperty($property);
293
            });
294
    }
295
296
    protected function parseSorts()
297
    {
298
        // Avoid repeated calls when used by e.g. 'paginate'
299
        if ($this->sortsWereParsed) {
300
            return;
301
        }
302
303
        $sorts = $this->request->sorts();
304
305
        if ($sorts && ! $this->allowedSorts instanceof Collection) {
306
            $this->addDefaultSorts();
307
        }
308
309
        if ($sorts->isEmpty()) {
310
            optional($this->defaultSorts)->each(function (Sort $sort) {
311
                $sort->sort($this);
312
            });
313
        }
314
315
        $sorts
316
            ->each(function (string $property) {
317
                $descending = $property[0] === '-';
318
319
                $key = ltrim($property, '-');
320
321
                $sort = $this->findSort($key);
322
323
                $sort->sort($this, $descending);
324
            });
325
326
        $this->sortsWereParsed = true;
327
    }
328
329
    protected function findSort(string $property): ?Sort
330
    {
331
        return $this->allowedSorts
332
            ->merge($this->defaultSorts)
333
            ->first(function (Sort $sort) use ($property) {
334
                return $sort->isForProperty($property);
335
            });
336
    }
337
338
    protected function addDefaultSorts()
339
    {
340
        $this->allowedSorts = collect($this->request->sorts($this->defaultSorts))
341
            ->map(function ($sort) {
342
                if ($sort instanceof Sort) {
343
                    return $sort;
344
                }
345
346
                return Sort::field(ltrim($sort, '-'));
347
            });
348
    }
349
350
    protected function addAppendsToResults(Collection $results)
351
    {
352
        $appends = $this->request->appends();
353
354
        return $results->each->append($appends->toArray());
355
    }
356
357
    protected function addIncludesToQuery(Collection $includes)
358
    {
359
        $includes
360
            ->map([Str::class, 'camel'])
361
            ->map(function (string $include) {
362
                return collect(explode('.', $include));
363
            })
364
            ->flatMap(function (Collection $relatedTables) {
365
                return $relatedTables
366
                    ->mapWithKeys(function ($table, $key) use ($relatedTables) {
367
                        $fields = $this->getFieldsForIncludedTable(Str::snake($table));
368
369
                        $fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');
370
371
                        if (empty($fields)) {
372
                            return [$fullRelationName];
373
                        }
374
375
                        return [$fullRelationName => function ($query) use ($fields) {
376
                            $query->select($fields);
377
                        }];
378
                    });
379
            })
380
            ->pipe(function (Collection $withs) {
381
                $this->with($withs->all());
382
            });
383
    }
384
385
    protected function guardAgainstUnknownFilters()
386
    {
387
        $filterNames = $this->request->filters()->keys();
388
389
        $allowedFilterNames = $this->allowedFilters->map->getProperty();
390
391
        $diff = $filterNames->diff($allowedFilterNames);
392
393
        if ($diff->count()) {
394
            throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames);
395
        }
396
    }
397
398
    protected function guardAgainstUnknownFields()
399
    {
400
        $fields = $this->request->fields()
401
            ->map(function ($fields, $model) {
402
                $tableName = Str::snake(preg_replace('/-/', '_', $model));
403
404
                $fields = array_map([Str::class, 'snake'], $fields);
405
406
                return $this->prependFieldsWithTableName($fields, $tableName);
407
            })
408
            ->flatten()
409
            ->unique();
410
411
        $diff = $fields->diff($this->allowedFields);
412
413
        if ($diff->count()) {
414
            throw InvalidFieldQuery::fieldsNotAllowed($diff, $this->allowedFields);
415
        }
416
    }
417
418
    protected function guardAgainstUnknownSorts()
419
    {
420
        $sortNames = $this->request->sorts()->map(function ($sort) {
421
            return ltrim($sort, '-');
422
        });
423
424
        $allowedSortNames = $this->allowedSorts->map->getProperty();
425
426
        $diff = $sortNames->diff($allowedSortNames);
427
428
        if ($diff->count()) {
429
            throw InvalidSortQuery::sortsNotAllowed($diff, $allowedSortNames);
430
        }
431
    }
432
433
    protected function guardAgainstUnknownIncludes()
434
    {
435
        $includes = $this->request->includes();
436
437
        $diff = $includes->diff($this->allowedIncludes);
438
439
        if ($diff->count()) {
440
            throw InvalidIncludeQuery::includesNotAllowed($diff, $this->allowedIncludes);
441
        }
442
    }
443
444
    protected function guardAgainstUnknownAppends()
445
    {
446
        $appends = $this->request->appends();
447
448
        $diff = $appends->diff($this->allowedAppends);
449
450
        if ($diff->count()) {
451
            throw InvalidAppendQuery::appendsNotAllowed($diff, $this->allowedAppends);
452
        }
453
    }
454
}
455