Completed
Push — master ( f994a3...fb5694 )
by
unknown
01:37
created

QueryBuilder::guardAgainstUnknownAppends()   A

Complexity

Conditions 2
Paths 2

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 2
nc 2
nop 0
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 $fields;
36
37
    /** @var array */
38
    protected $appends = [];
39
40
    /** @var \Illuminate\Http\Request */
41
    protected $request;
42
43
    public function __construct(Builder $builder, ? Request $request = null)
44
    {
45
        parent::__construct(clone $builder->getQuery());
46
47
        $this->initializeFromBuilder($builder);
48
49
        $this->request = $request ?? request();
50
51
        $this->parseSelectedFields();
52
    }
53
54
    /**
55
     * Add the model, scopes, eager loaded relationships, local macro's and onDelete callback
56
     * from the $builder to this query builder.
57
     *
58
     * @param \Illuminate\Database\Eloquent\Builder $builder
59
     */
60
    protected function initializeFromBuilder(Builder $builder)
61
    {
62
        $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...
63
            ->setEagerLoads($builder->getEagerLoads());
64
65
        $builder->macro('getProtected', function (Builder $builder, string $property) {
66
            return $builder->{$property};
67
        });
68
69
        $this->scopes = $builder->getProtected('scopes');
70
71
        $this->localMacros = $builder->getProtected('localMacros');
72
73
        $this->onDelete = $builder->getProtected('onDelete');
74
    }
75
76
    /**
77
     * Create a new QueryBuilder for a request and model.
78
     *
79
     * @param string|\Illuminate\Database\Query\Builder $baseQuery Model class or base query builder
80
     * @param Request $request
81
     *
82
     * @return \Spatie\QueryBuilder\QueryBuilder
83
     */
84
    public static function for($baseQuery, ? Request $request = null) : self
85
    {
86
        if (is_string($baseQuery)) {
87
            $baseQuery = ($baseQuery)::query();
88
        }
89
90
        return new static($baseQuery, $request ?? request());
91
    }
92
93
    public function allowedFilters($filters) : self
94
    {
95
        $filters = is_array($filters) ? $filters : func_get_args();
96
        $this->allowedFilters = collect($filters)->map(function ($filter) {
97
            if ($filter instanceof Filter) {
98
                return $filter;
99
            }
100
101
            return Filter::partial($filter);
102
        });
103
104
        $this->guardAgainstUnknownFilters();
105
106
        $this->addFiltersToQuery($this->request->filters());
107
108
        return $this;
109
    }
110
111
    public function allowedFields($fields) : self
112
    {
113
        $fields = is_array($fields) ? $fields : func_get_args();
114
115
        $this->allowedFields = collect($fields)
116
            ->map(function (string $fieldName) {
117
                if (! str_contains($fieldName, '.')) {
118
                    $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...
119
120
                    return "{$modelTableName}.{$fieldName}";
121
                }
122
123
                return $fieldName;
124
            });
125
126
        if (! $this->allowedFields->contains('*')) {
127
            $this->guardAgainstUnknownFields();
128
        }
129
130
        return $this;
131
    }
132
133
    public function defaultSort($sort) : self
134
    {
135
        $this->defaultSort = $sort;
136
137
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
138
139
        return $this;
140
    }
141
142
    public function allowedSorts($sorts) : self
143
    {
144
        $sorts = is_array($sorts) ? $sorts : func_get_args();
145
        if (! $this->request->sorts()) {
146
            return $this;
147
        }
148
149
        $this->allowedSorts = collect($sorts)->map(function ($sort) {
150
            if ($sort instanceof Sort) {
151
                return $sort;
152
            }
153
154
            return Sort::field(ltrim($sort, '-'));
155
        });
156
157
        $this->guardAgainstUnknownSorts();
158
159
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
160
161
        return $this;
162
    }
163
164
    public function allowedIncludes($includes) : self
165
    {
166
        $includes = is_array($includes) ? $includes : func_get_args();
167
168
        $this->allowedIncludes = collect($includes)
169
            ->flatMap(function ($include) {
170
                return collect(explode('.', $include))
171
                    ->reduce(function ($collection, $include) {
172
                        if ($collection->isEmpty()) {
173
                            return $collection->push($include);
174
                        }
175
176
                        return $collection->push("{$collection->last()}.{$include}");
177
                    }, collect());
178
            });
179
180
        $this->guardAgainstUnknownIncludes();
181
182
        $this->addIncludesToQuery($this->request->includes());
183
184
        return $this;
185
    }
186
187
    public function allowedAppends($appends) : self
188
    {
189
        $appends = is_array($appends) ? $appends : func_get_args();
190
191
        $this->allowedAppends = collect($appends);
192
193
        $this->guardAgainstUnknownAppends();
194
195
        $this->appends = $this->request->appends();
196
197
        return $this;
198
    }
199
200
    protected function parseSelectedFields()
201
    {
202
        $this->fields = $this->request->fields();
203
204
        $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...
205
        $modelFields = $this->fields->get($modelTableName, ['*']);
206
207
        $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 parseSelectedFields()?

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...
208
    }
209
210
    protected function prependFieldsWithTableName(array $fields, string $tableName): array
211
    {
212
        return array_map(function ($field) use ($tableName) {
213
            return "{$tableName}.{$field}";
214
        }, $fields);
215
    }
216
217
    protected function getFieldsForRelatedTable(string $relation): array
218
    {
219
        if (! $this->fields) {
220
            return ['*'];
221
        }
222
223
        return $this->fields->get($relation, []);
224
    }
225
226
    protected function addFiltersToQuery(Collection $filters)
227
    {
228
        $filters->each(function ($value, $property) {
229
            $filter = $this->findFilter($property);
230
231
            $filter->filter($this, $value);
232
        });
233
    }
234
235
    protected function findFilter(string $property) : ? Filter
236
    {
237
        return $this->allowedFilters
238
            ->first(function (Filter $filter) use ($property) {
239
                return $filter->isForProperty($property);
240
            });
241
    }
242
243
    protected function addSortsToQuery(Collection $sorts)
244
    {
245
        $this->filterDuplicates($sorts)
246
            ->each(function (string $property) {
247
                $descending = $property[0] === '-';
248
249
                $key = ltrim($property, '-');
250
251
                $sort = $this->findSort($key);
252
253
                $sort->sort($this, $descending);
254
            });
255
    }
256
257
    protected function filterDuplicates(Collection $sorts): Collection
258
    {
259
        if (! is_array($orders = $this->getQuery()->orders)) {
260
            return $sorts;
261
        }
262
263
        return $sorts->reject(function (string $sort) use ($orders) {
264
            $toSort = [
265
                'column' => ltrim($sort, '-'),
266
                'direction' => ($sort[0] === '-') ? 'desc' : 'asc',
267
            ];
268
            foreach ($orders as $order) {
269
                if ($order === $toSort) {
270
                    return true;
271
                }
272
            }
273
        });
274
    }
275
276
    protected function findSort(string $property) : ? Sort
277
    {
278
        return $this->allowedSorts
279
            ->first(function (Sort $sort) use ($property) {
280
                return $sort->isForProperty($property);
281
            });
282
    }
283
284
    protected function addDefaultSorts()
285
    {
286
        $this->allowedSorts = collect($this->request->sorts($this->defaultSort))->map(function ($sort) {
287
            if ($sort instanceof Sort) {
288
                return $sort;
289
            }
290
291
            return Sort::field(ltrim($sort, '-'));
292
        });
293
294
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
295
    }
296
297
    protected function addIncludesToQuery(Collection $includes)
298
    {
299
        $includes
300
            ->map('camel_case')
301
            ->map(function (string $include) {
302
                return collect(explode('.', $include));
303
            })
304
            ->flatMap(function (Collection $relatedTables) {
305
                return $relatedTables
306
                    ->mapWithKeys(function ($table, $key) use ($relatedTables) {
307
                        $fields = $this->getFieldsForRelatedTable(snake_case($table));
308
309
                        $fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');
310
311
                        if (empty($fields)) {
312
                            return [$fullRelationName];
313
                        }
314
315
                        return [$fullRelationName => function ($query) use ($fields) {
316
                            $query->select($this->prependFieldsWithTableName($fields, $query->getModel()->getTable()));
317
                        }];
318
                    });
319
            })
320
            ->pipe(function (Collection $withs) {
321
                $this->with($withs->all());
322
            });
323
    }
324
325
    public function setAppendsToResult($result)
326
    {
327
        $result->map(function ($item) {
328
            $item->append($this->appends->toArray());
0 ignored issues
show
Bug introduced by
The method toArray cannot be called on $this->appends (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
329
330
            return $item;
331
        });
332
333
        return $result;
334
    }
335
336
    protected function guardAgainstUnknownFilters()
337
    {
338
        $filterNames = $this->request->filters()->keys();
339
340
        $allowedFilterNames = $this->allowedFilters->map->getProperty();
341
342
        $diff = $filterNames->diff($allowedFilterNames);
343
344
        if ($diff->count()) {
345
            throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames);
346
        }
347
    }
348
349
    protected function guardAgainstUnknownFields()
350
    {
351
        $fields = $this->request->fields()
352
            ->map(function ($fields, $model) {
353
                $tableName = snake_case(preg_replace('/-/', '_', $model));
354
355
                $fields = array_map('snake_case', $fields);
356
357
                return $this->prependFieldsWithTableName($fields, $tableName);
358
            })
359
            ->flatten()
360
            ->unique();
361
362
        $diff = $fields->diff($this->allowedFields);
363
364
        if ($diff->count()) {
365
            throw InvalidFieldQuery::fieldsNotAllowed($diff, $this->allowedFields);
366
        }
367
    }
368
369
    protected function guardAgainstUnknownSorts()
370
    {
371
        $sortNames = $this->request->sorts()->map(function ($sort) {
372
            return ltrim($sort, '-');
373
        });
374
375
        $allowedSortNames = $this->allowedSorts->map->getProperty();
376
377
        $diff = $sortNames->diff($allowedSortNames);
378
379
        if ($diff->count()) {
380
            throw InvalidSortQuery::sortsNotAllowed($diff, $allowedSortNames);
381
        }
382
    }
383
384
    protected function guardAgainstUnknownIncludes()
385
    {
386
        $includes = $this->request->includes();
387
388
        $diff = $includes->diff($this->allowedIncludes);
389
390
        if ($diff->count()) {
391
            throw InvalidIncludeQuery::includesNotAllowed($diff, $this->allowedIncludes);
392
        }
393
    }
394
395
    protected function guardAgainstUnknownAppends()
396
    {
397
        $appends = $this->request->appends();
398
399
        $diff = $appends->diff($this->allowedAppends);
400
401
        if ($diff->count()) {
402
            throw InvalidAppendQuery::appendsNotAllowed($diff, $this->allowedAppends);
403
        }
404
    }
405
406
    public function getQuery()
407
    {
408
        if ($this->request->sorts() && ! $this->allowedSorts instanceof Collection) {
409
            $this->addDefaultSorts();
410
        }
411
412
        return parent::getQuery();
413
    }
414
415
    public function get($columns = ['*'])
416
    {
417
        if ($this->request->sorts() && ! $this->allowedSorts instanceof Collection) {
418
            $this->addDefaultSorts();
419
        }
420
421
        $result = parent::get($columns);
422
423
        if (count($this->appends) > 0) {
424
            $result = $this->setAppendsToResult($result);
425
        }
426
427
        return $result;
428
    }
429
430
    public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null)
431
    {
432
        if ($this->request->sorts() && ! $this->allowedSorts instanceof Collection) {
433
            $this->addDefaultSorts();
434
        }
435
436
        return parent::paginate($perPage, $columns, $pageName, $page);
437
    }
438
439
    public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null)
440
    {
441
        if ($this->request->sorts() && ! $this->allowedSorts instanceof Collection) {
442
            $this->addDefaultSorts();
443
        }
444
445
        return parent::simplePaginate($perPage, $columns, $pageName, $page);
446
    }
447
}
448