Completed
Pull Request — master (#141)
by
unknown
01:39
created

QueryBuilder::shouldExecuteAppendedCallback()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 16
rs 9.7333
c 0
b 0
f 0
cc 3
nc 3
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 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 \Illuminate\Support\Collection */
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->appends = Collection::make();
48
49
        $this->initializeFromBuilder($builder);
50
51
        $this->request = $request ?? request();
52
53
        if ($this->request->fields()->isNotEmpty()) {
54
            $this->parseSelectedFields();
55
        }
56
57
        if ($this->request->sorts()->isNotEmpty()) {
58
            $this->allowedSorts('*');
59
        }
60
    }
61
62
    /**
63
     * Add the model, scopes, eager loaded relationships, local macro's and onDelete callback
64
     * from the $builder to this query builder.
65
     *
66
     * @param \Illuminate\Database\Eloquent\Builder $builder
67
     */
68
    protected function initializeFromBuilder(Builder $builder)
69
    {
70
        $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...
71
            ->setEagerLoads($builder->getEagerLoads());
72
73
        $builder->macro('getProtected', function (Builder $builder, string $property) {
74
            return $builder->{$property};
75
        });
76
77
        $this->scopes = $builder->getProtected('scopes');
78
79
        $this->localMacros = $builder->getProtected('localMacros');
80
81
        $this->onDelete = $builder->getProtected('onDelete');
82
    }
83
84
    /**
85
     * Create a new QueryBuilder for a request and model.
86
     *
87
     * @param string|\Illuminate\Database\Query\Builder $baseQuery Model class or base query builder
88
     * @param Request $request
89
     *
90
     * @return \Spatie\QueryBuilder\QueryBuilder
91
     */
92
    public static function for($baseQuery, ? Request $request = null) : self
93
    {
94
        if (is_string($baseQuery)) {
95
            $baseQuery = ($baseQuery)::query();
96
        }
97
98
        return new static($baseQuery, $request ?? request());
99
    }
100
101
    public function allowedFilters($filters) : self
102
    {
103
        $filters = is_array($filters) ? $filters : func_get_args();
104
        $this->allowedFilters = collect($filters)->map(function ($filter) {
105
            if ($filter instanceof Filter) {
106
                return $filter;
107
            }
108
109
            return Filter::partial($filter);
110
        });
111
112
        $this->guardAgainstUnknownFilters();
113
114
        $this->addFiltersToQuery($this->request->filters());
115
116
        return $this;
117
    }
118
119
    public function allowedFields($fields) : self
120
    {
121
        $fields = is_array($fields) ? $fields : func_get_args();
122
123
        $this->allowedFields = collect($fields)
124
            ->map(function (string $fieldName) {
125
                if (! str_contains($fieldName, '.')) {
126
                    $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...
127
128
                    return "{$modelTableName}.{$fieldName}";
129
                }
130
131
                return $fieldName;
132
            });
133
134
        if (! $this->allowedFields->contains('*')) {
135
            $this->guardAgainstUnknownFields();
136
        }
137
138
        return $this;
139
    }
140
141
    public function defaultSort($sort) : self
142
    {
143
        $this->defaultSort = $sort;
144
145
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
146
147
        return $this;
148
    }
149
150
    public function allowedSorts($sorts) : self
151
    {
152
        $sorts = is_array($sorts) ? $sorts : func_get_args();
153
        if (! $this->request->sorts()) {
154
            return $this;
155
        }
156
157
        $this->allowedSorts = collect($sorts);
158
159
        if (! $this->allowedSorts->contains('*')) {
160
            $this->guardAgainstUnknownSorts();
161
        }
162
163
        $this->addSortsToQuery($this->request->sorts($this->defaultSort));
164
165
        return $this;
166
    }
167
168
    public function allowedIncludes($includes) : self
169
    {
170
        $includes = is_array($includes) ? $includes : func_get_args();
171
172
        $this->allowedIncludes = collect($includes)
173
            ->flatMap(function ($include) {
174
                return collect(explode('.', $include))
175
                    ->reduce(function ($collection, $include) {
176
                        if ($collection->isEmpty()) {
177
                            return $collection->push($include);
178
                        }
179
180
                        return $collection->push("{$collection->last()}.{$include}");
181
                    }, collect());
182
            });
183
184
        $this->guardAgainstUnknownIncludes();
185
186
        $this->addIncludesToQuery($this->request->includes());
187
188
        return $this;
189
    }
190
191
    public function allowedAppends($appends) : self
192
    {
193
        $appends = is_array($appends) ? $appends : func_get_args();
194
195
        $this->allowedAppends = collect($appends);
196
197
        $this->guardAgainstUnknownAppends();
198
199
        $this->appends = $this->request->appends();
200
201
        return $this;
202
    }
203
204
    protected function parseSelectedFields()
205
    {
206
        $this->fields = $this->request->fields();
207
208
        $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...
209
        $modelFields = $this->fields->get($modelTableName, ['*']);
210
211
        $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...
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
                $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...
256
            });
257
    }
258
259
    protected function filterDuplicates(Collection $sorts): Collection
260
    {
261
        if (! is_array($orders = $this->getQuery()->orders)) {
262
            return $sorts;
263
        }
264
265
        return $sorts->reject(function (string $sort) use ($orders) {
266
            $toSort = [
267
                'column' => ltrim($sort, '-'),
268
                'direction' => ($sort[0] === '-') ? 'desc' : 'asc',
269
            ];
270
            foreach ($orders as $order) {
271
                if ($order === $toSort) {
272
                    return true;
273
                }
274
            }
275
        });
276
    }
277
278
    protected function addIncludesToQuery(Collection $includes)
279
    {
280
        $includes
281
            ->map('camel_case')
282
            ->map(function (string $include) {
283
                return collect(explode('.', $include));
284
            })
285
            ->flatMap(function (Collection $relatedTables) {
286
                return $relatedTables
287
                    ->mapWithKeys(function ($table, $key) use ($relatedTables) {
288
                        $fields = $this->getFieldsForRelatedTable(snake_case($table));
289
290
                        $fullRelationName = $relatedTables->slice(0, $key + 1)->implode('.');
291
292
                        if (empty($fields)) {
293
                            return [$fullRelationName];
294
                        }
295
296
                        return [$fullRelationName => function ($query) use ($fields) {
297
                            $query->select($this->prependFieldsWithTableName($fields, $query->getModel()->getTable()));
298
                        }];
299
                    });
300
            })
301
            ->pipe(function (Collection $withs) {
302
                $this->with($withs->all());
303
            });
304
    }
305
306
    public function setAppendsToResult($result)
307
    {
308
        $result->map(function ($item) {
309
            $item->append($this->appends->toArray());
310
311
            return $item;
312
        });
313
314
        return $result;
315
    }
316
317
    protected function guardAgainstUnknownFilters()
318
    {
319
        $filterNames = $this->request->filters()->keys();
320
321
        $allowedFilterNames = $this->allowedFilters->map->getProperty();
322
323
        $diff = $filterNames->diff($allowedFilterNames);
324
325
        if ($diff->count()) {
326
            throw InvalidFilterQuery::filtersNotAllowed($diff, $allowedFilterNames);
327
        }
328
    }
329
330
    protected function guardAgainstUnknownFields()
331
    {
332
        $fields = $this->request->fields()
333
            ->map(function ($fields, $model) {
334
                $tableName = snake_case(preg_replace('/-/', '_', $model));
335
336
                $fields = array_map('snake_case', $fields);
337
338
                return $this->prependFieldsWithTableName($fields, $tableName);
339
            })
340
            ->flatten()
341
            ->unique();
342
343
        $diff = $fields->diff($this->allowedFields);
344
345
        if ($diff->count()) {
346
            throw InvalidFieldQuery::fieldsNotAllowed($diff, $this->allowedFields);
347
        }
348
    }
349
350
    protected function guardAgainstUnknownSorts()
351
    {
352
        $sorts = $this->request->sorts()->map(function ($sort) {
353
            return ltrim($sort, '-');
354
        });
355
356
        $diff = $sorts->diff($this->allowedSorts);
357
358
        if ($diff->count()) {
359
            throw InvalidSortQuery::sortsNotAllowed($diff, $this->allowedSorts);
360
        }
361
    }
362
363
    protected function guardAgainstUnknownIncludes()
364
    {
365
        $includes = $this->request->includes();
366
367
        $diff = $includes->diff($this->allowedIncludes);
368
369
        if ($diff->count()) {
370
            throw InvalidIncludeQuery::includesNotAllowed($diff, $this->allowedIncludes);
371
        }
372
    }
373
374
    protected function guardAgainstUnknownAppends()
375
    {
376
        $appends = $this->request->appends();
377
378
        $diff = $appends->diff($this->allowedAppends);
379
380
        if ($diff->count()) {
381
            throw InvalidAppendQuery::appendsNotAllowed($diff, $this->allowedAppends);
382
        }
383
    }
384
385
    protected function guardAgainstUnreachableCallback($requiredAppends)
386
    {
387
        $forbiddenAppends = collect($requiredAppends)->diff($this->allowedAppends);
388
389
        if ($forbiddenAppends->isNotEmpty()) {
390
            $forbidden = $forbiddenAppends->implode(', ');
391
            throw new \BadMethodCallException(
392
                "Appending of required appends [$forbidden] for callback is not allowed on builder instance!"
393
            );
394
        }
395
    }
396
397
    /**
398
     * @param string|string[] $appends
399
     * @param callable        $callback
400
     * @return $this
401
     */
402
    public function whenAppended($appends, callable $callback): self
403
    {
404
        if ($this->shouldExecuteAppendedCallback($appends)) {
405
            $callback($this);
406
        }
407
408
        return $this;
409
    }
410
411
    private function shouldExecuteAppendedCallback($appends)
412
    {
413
        $requiredAppends = array_wrap($appends);
414
415
        if ($this->appends->isEmpty()) {
416
            return false;
417
        }
418
419
        if (in_array('*', $requiredAppends)) {
420
            return true;
421
        }
422
423
        $this->guardAgainstUnreachableCallback($requiredAppends);
424
425
        return $this->appends->diff($requiredAppends)->count() === 0;
426
    }
427
428
    public function get($columns = ['*'])
429
    {
430
        $result = parent::get($columns);
431
432
        if (count($this->appends) > 0) {
433
            $result = $this->setAppendsToResult($result);
434
        }
435
436
        return $result;
437
    }
438
}
439