Completed
Push — master ( 29e3b2...ea9ad6 )
by
unknown
01:45
created

QueryBuilder::addIncludesToQuery()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

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