Completed
Pull Request — master (#140)
by
unknown
01:15
created

QueryBuilder::guardAgainstUnknownFilters()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 12
rs 9.8666
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
    /** @var string */
44
    protected $modelTableName;
45
46
    public function __construct(Builder $builder, ? Request $request = null)
47
    {
48
        parent::__construct(clone $builder->getQuery());
49
50
        $this->initializeFromBuilder($builder);
51
52
        $this->request = $request ?? request();
53
54
        $this->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...
55
56
        if ($this->request->fields()->isNotEmpty()) {
57
            $this->parseSelectedFields();
58
        }
59
60
        if ($this->request->sorts()->isNotEmpty()) {
61
            $this->allowedSorts('*');
62
        }
63
    }
64
65
    /**
66
     * Add the model, scopes, eager loaded relationships, local macro's and onDelete callback
67
     * from the $builder to this query builder.
68
     *
69
     * @param \Illuminate\Database\Eloquent\Builder $builder
70
     */
71
    protected function initializeFromBuilder(Builder $builder)
72
    {
73
        $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...
74
            ->setEagerLoads($builder->getEagerLoads());
75
76
        $builder->macro('getProtected', function (Builder $builder, string $property) {
77
            return $builder->{$property};
78
        });
79
80
        $this->scopes = $builder->getProtected('scopes');
81
82
        $this->localMacros = $builder->getProtected('localMacros');
83
84
        $this->onDelete = $builder->getProtected('onDelete');
85
    }
86
87
    /**
88
     * Create a new QueryBuilder for a request and model.
89
     *
90
     * @param string|\Illuminate\Database\Query\Builder $baseQuery Model class or base query builder
91
     * @param Request $request
92
     *
93
     * @return \Spatie\QueryBuilder\QueryBuilder
94
     */
95
    public static function for($baseQuery, ? Request $request = null) : self
96
    {
97
        if (is_string($baseQuery)) {
98
            $baseQuery = ($baseQuery)::query();
99
        }
100
101
        return new static($baseQuery, $request ?? request());
102
    }
103
104
    public function allowedFilters($filters) : self
105
    {
106
        $filters = is_array($filters) ? $filters : func_get_args();
107
        $this->allowedFilters = collect($filters)->map(function ($filter) {
108
            if ($filter instanceof Filter) {
109
                return $filter;
110
            }
111
112
            return Filter::partial($filter);
113
        });
114
115
        $this->guardAgainstUnknownFilters();
116
117
        $this->addFiltersToQuery($this->request->filters());
118
119
        return $this;
120
    }
121
122
    public function allowedFields($fields) : self
123
    {
124
        $fields = is_array($fields) ? $fields : func_get_args();
125
126
        $this->allowedFields = collect($fields)
127
            ->map(function (string $fieldName) {
128
                if (! str_contains($fieldName, '.')) {
129
                    return "{$this->modelTableName}.{$fieldName}";
130
                }
131
132
                return $fieldName;
133
            });
134
135
        if (! $this->allowedFields->contains('*')) {
136
            $this->guardAgainstUnknownFields();
137
        }
138
139
        return $this;
140
    }
141
142
    public function defaultSort($sort) : self
143
    {
144
        $this->defaultSort = $sort;
145
146
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
147
148
        return $this;
149
    }
150
151
    public function allowedSorts($sorts) : self
152
    {
153
        $sorts = is_array($sorts) ? $sorts : func_get_args();
154
        if (! $this->request->sorts()) {
155
            return $this;
156
        }
157
158
        $this->allowedSorts = collect($sorts);
159
160
        if (! $this->allowedSorts->contains('*')) {
161
            $this->guardAgainstUnknownSorts();
162
        }
163
164
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
165
166
        return $this;
167
    }
168
169
    public function allowedIncludes($includes) : self
170
    {
171
        $includes = is_array($includes) ? $includes : func_get_args();
172
173
        $this->allowedIncludes = collect($includes)
174
            ->flatMap(function ($include) {
175
                return collect(explode('.', $include))
176
                    ->reduce(function ($collection, $include) {
177
                        if ($collection->isEmpty()) {
178
                            return $collection->push($include);
179
                        }
180
181
                        return $collection->push("{$collection->last()}.{$include}");
182
                    }, collect());
183
            });
184
185
        $this->guardAgainstUnknownIncludes();
186
187
        $this->addIncludesToQuery($this->request->includes());
188
189
        return $this;
190
    }
191
192
    public function allowedAppends($appends) : self
193
    {
194
        $appends = is_array($appends) ? $appends : func_get_args();
195
196
        $this->allowedAppends = collect($appends);
197
198
        $this->guardAgainstUnknownAppends();
199
200
        $this->appends = $this->request->appends();
201
202
        return $this;
203
    }
204
205
    protected function parseSelectedFields()
206
    {
207
        $this->fields = $this->request->fields();
208
209
        $modelFields = $this->fields->get($this->modelTableName, ['*']);
210
211
        $this->select($this->prependFieldsWithTableName($modelFields, $this->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...
212
    }
213
214
    protected function prependFieldsWithTableName(array $fields, string $tableName): array
215
    {
216
        return array_map(function ($field) use ($tableName) {
217
            return "{$tableName}.{$field}";
218
        }, $fields);
219
    }
220
221
    protected function getFieldsForRelatedTable(string $relation): array
222
    {
223
        if (! $this->fields) {
224
            return ['*'];
225
        }
226
227
        return $this->fields->get($relation, []);
228
    }
229
230
    protected function addFiltersToQuery(Collection $filters)
231
    {
232
        $filters->each(function ($value, $property) {
233
            $filter = $this->findFilter($property);
234
235
            $filter->filter($this, $value);
236
        });
237
    }
238
239
    protected function findFilter(string $property) : ? Filter
240
    {
241
        return $this->allowedFilters
242
            ->first(function (Filter $filter) use ($property) {
243
                return $filter->isForProperty($property);
244
            });
245
    }
246
247
    protected function addSortsToQuery(Collection $sorts)
248
    {
249
        $this->filterDuplicates($sorts)
250
            ->each(function (string $sort) {
251
                $descending = $sort[0] === '-';
252
253
                $key = ltrim($sort, '-');
254
255
                if (in_array($key, $this->getDbColumns($this->modelTableName))) {
256
                    $key = "{$this->modelTableName}.{$key}";
257
                }
258
259
                $this->orderBy($key, $descending ? 'desc' : 'asc');
0 ignored issues
show
Bug introduced by
The method orderBy() does not exist on Spatie\QueryBuilder\QueryBuilder. Did you maybe mean enforceOrderBy()?

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...
260
            });
261
    }
262
263
    protected function filterDuplicates(Collection $sorts): Collection
264
    {
265
        if (! is_array($orders = $this->getQuery()->orders)) {
266
            return $sorts;
267
        }
268
269
        return $sorts->reject(function (string $sort) use ($orders) {
270
            $sortColumn = ltrim($sort, '-');
271
272
            if (in_array($sortColumn, $this->getDbColumns($this->modelTableName))) {
273
                $sortColumn = "{$this->modelTableName}.{$sortColumn}";
274
            }
275
276
            $toSort = [
277
                'column' => $sortColumn,
278
                'direction' => ($sort[0] === '-') ? 'desc' : 'asc',
279
            ];
280
            foreach ($orders as $order) {
281
                if ($order === $toSort) {
282
                    return true;
283
                }
284
            }
285
        });
286
    }
287
288
    protected function addIncludesToQuery(Collection $includes)
289
    {
290
        $includes
291
            ->map('camel_case')
292
            ->map(function (string $include) {
293
                return collect(explode('.', $include));
294
            })
295
            ->flatMap(function (Collection $relatedTables) {
296
                return $relatedTables
297
                    ->mapWithKeys(function ($table, $key) use ($relatedTables) {
298
                        $fields = $this->getFieldsForRelatedTable(snake_case($table));
299
300
                        $fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');
301
302
                        if (empty($fields)) {
303
                            return [$fullRelationName];
304
                        }
305
306
                        return [$fullRelationName => function ($query) use ($fields) {
307
                            $query->select($this->prependFieldsWithTableName($fields, $query->getModel()->getTable()));
308
                        }];
309
                    });
310
            })
311
            ->pipe(function (Collection $withs) {
312
                $this->with($withs->all());
313
            });
314
    }
315
316
    public function setAppendsToResult($result)
317
    {
318
        $result->map(function ($item) {
319
            $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...
320
321
            return $item;
322
        });
323
324
        return $result;
325
    }
326
327
    protected function guardAgainstUnknownFilters()
328
    {
329
        $filterNames = $this->request->filters()->keys();
330
331
        $allowedFilterNames = $this->allowedFilters->map->getProperty();
332
333
        $diff = $filterNames->diff($allowedFilterNames);
334
335
        if ($diff->count()) {
336
            throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames);
337
        }
338
    }
339
340
    protected function guardAgainstUnknownFields()
341
    {
342
        $fields = $this->request->fields()
343
            ->map(function ($fields, $model) {
344
                $tableName = snake_case(preg_replace('/-/', '_', $model));
345
346
                $fields = array_map('snake_case', $fields);
347
348
                return $this->prependFieldsWithTableName($fields, $tableName);
349
            })
350
            ->flatten()
351
            ->unique();
352
353
        $diff = $fields->diff($this->allowedFields);
354
355
        if ($diff->count()) {
356
            throw InvalidFieldQuery::fieldsNotAllowed($diff, $this->allowedFields);
357
        }
358
    }
359
360
    protected function guardAgainstUnknownSorts()
361
    {
362
        $sorts = $this->request->sorts()->map(function ($sort) {
363
            return ltrim($sort, '-');
364
        });
365
366
        $diff = $sorts->diff($this->allowedSorts);
367
368
        if ($diff->count()) {
369
            throw InvalidSortQuery::sortsNotAllowed($diff, $this->allowedSorts);
370
        }
371
    }
372
373
    protected function guardAgainstUnknownIncludes()
374
    {
375
        $includes = $this->request->includes();
376
377
        $diff = $includes->diff($this->allowedIncludes);
378
379
        if ($diff->count()) {
380
            throw InvalidIncludeQuery::includesNotAllowed($diff, $this->allowedIncludes);
381
        }
382
    }
383
384
    protected function guardAgainstUnknownAppends()
385
    {
386
        $appends = $this->request->appends();
387
388
        $diff = $appends->diff($this->allowedAppends);
389
390
        if ($diff->count()) {
391
            throw InvalidAppendQuery::appendsNotAllowed($diff, $this->allowedAppends);
392
        }
393
    }
394
395
    public function get($columns = ['*'])
396
    {
397
        $result = parent::get($columns);
398
399
        if (count($this->appends) > 0) {
400
            $result = $this->setAppendsToResult($result);
401
        }
402
403
        return $result;
404
    }
405
406
    public function getDbColumns(string $tableName)
407
    {
408
        return \Schema::getColumnListing($tableName);
409
    }
410
}
411