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