RequestCriteriaBuilder::parse()   F
last analyzed

Complexity

Conditions 28
Paths > 20000

Size

Total Lines 117
Code Lines 63

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 28
eloc 63
nc 23042
nop 5
dl 0
loc 117
rs 0
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\Framework\DataAbstractionLayer\Search;
4
5
use Shopware\Core\Framework\Context;
6
use Shopware\Core\Framework\DataAbstractionLayer\DataAbstractionLayerException;
7
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
8
use Shopware\Core\Framework\DataAbstractionLayer\Exception\AssociationNotFoundException;
9
use Shopware\Core\Framework\DataAbstractionLayer\Exception\InvalidFilterQueryException;
10
use Shopware\Core\Framework\DataAbstractionLayer\Exception\InvalidLimitQueryException;
11
use Shopware\Core\Framework\DataAbstractionLayer\Exception\InvalidPageQueryException;
12
use Shopware\Core\Framework\DataAbstractionLayer\Exception\InvalidSortQueryException;
13
use Shopware\Core\Framework\DataAbstractionLayer\Exception\QueryLimitExceededException;
14
use Shopware\Core\Framework\DataAbstractionLayer\Exception\SearchRequestException;
15
use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField;
16
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
17
use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField;
18
use Shopware\Core\Framework\DataAbstractionLayer\InvalidCriteriaIdsException;
19
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
20
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
21
use Shopware\Core\Framework\DataAbstractionLayer\Search\Grouping\FieldGrouping;
22
use Shopware\Core\Framework\DataAbstractionLayer\Search\Parser\AggregationParser;
23
use Shopware\Core\Framework\DataAbstractionLayer\Search\Parser\QueryStringParser;
24
use Shopware\Core\Framework\DataAbstractionLayer\Search\Query\ScoreQuery;
25
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\CountSorting;
26
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
27
use Shopware\Core\Framework\Log\Package;
28
use Symfony\Component\HttpFoundation\Request;
29
30
#[Package('core')]
31
class RequestCriteriaBuilder
32
{
33
    private const TOTAL_COUNT_MODE_MAPPING = [
34
        'none' => Criteria::TOTAL_COUNT_MODE_NONE,
35
        'exact' => Criteria::TOTAL_COUNT_MODE_EXACT,
36
        'next-pages' => Criteria::TOTAL_COUNT_MODE_NEXT_PAGES,
37
    ];
38
39
    /**
40
     * @internal
41
     */
42
    public function __construct(
43
        private readonly AggregationParser $aggregationParser,
44
        private readonly ApiCriteriaValidator $validator,
45
        private readonly CriteriaArrayConverter $converter,
46
        private readonly ?int $maxLimit = null
47
    ) {
48
    }
49
50
    public function handleRequest(Request $request, Criteria $criteria, EntityDefinition $definition, Context $context): Criteria
51
    {
52
        if ($request->getMethod() === Request::METHOD_GET) {
53
            $criteria = $this->fromArray($request->query->all(), $criteria, $definition, $context);
54
        } else {
55
            $criteria = $this->fromArray($request->request->all(), $criteria, $definition, $context);
56
        }
57
58
        return $criteria;
59
    }
60
61
    /**
62
     * @return array<string, mixed>
63
     */
64
    public function toArray(Criteria $criteria): array
65
    {
66
        return $this->converter->convert($criteria);
67
    }
68
69
    /**
70
     * @param array<string, mixed> $payload
71
     */
72
    public function fromArray(array $payload, Criteria $criteria, EntityDefinition $definition, Context $context): Criteria
73
    {
74
        return $this->parse($payload, $criteria, $definition, $context, $this->maxLimit);
75
    }
76
77
    public function addTotalCountMode(string $totalCountMode, Criteria $criteria): void
78
    {
79
        if (is_numeric($totalCountMode)) {
80
            $criteria->setTotalCountMode((int) $totalCountMode);
81
82
            // total count is out of bounds
83
            if ($criteria->getTotalCountMode() > 2 || $criteria->getTotalCountMode() < 0) {
84
                $criteria->setTotalCountMode(Criteria::TOTAL_COUNT_MODE_NONE);
85
            }
86
        } else {
87
            $criteria->setTotalCountMode(self::TOTAL_COUNT_MODE_MAPPING[$totalCountMode] ?? Criteria::TOTAL_COUNT_MODE_NONE);
88
        }
89
    }
90
91
    /**
92
     * @param array<string, mixed> $payload
93
     */
94
    private function parse(array $payload, Criteria $criteria, EntityDefinition $definition, Context $context, ?int $maxLimit): Criteria
95
    {
96
        $searchException = new SearchRequestException();
97
98
        if (isset($payload['ids'])) {
99
            if (\is_string($payload['ids'])) {
100
                $ids = array_filter(explode('|', $payload['ids']));
101
            } else {
102
                $ids = $payload['ids'];
103
            }
104
105
            try {
106
                $criteria->setIds($ids);
107
            } catch (InvalidCriteriaIdsException $e) {
108
                throw DataAbstractionLayerException::invalidApiCriteriaIds($e);
109
            }
110
111
            $criteria->setLimit(null);
112
        } else {
113
            if (isset($payload['total-count-mode'])) {
114
                $this->addTotalCountMode((string) $payload['total-count-mode'], $criteria);
115
            }
116
117
            if (isset($payload['limit'])) {
118
                $this->addLimit($payload, $criteria, $searchException, $maxLimit);
119
            }
120
121
            if ($criteria->getLimit() === null && $maxLimit !== null) {
122
                $criteria->setLimit($maxLimit);
123
            }
124
125
            if (isset($payload['page'])) {
126
                $this->setPage($payload, $criteria, $searchException);
127
            }
128
        }
129
130
        if (isset($payload['includes'])) {
131
            $criteria->setIncludes($payload['includes']);
132
        }
133
134
        if (isset($payload['filter'])) {
135
            $this->addFilter($definition, $payload, $criteria, $searchException);
136
        }
137
138
        if (isset($payload['grouping'])) {
139
            foreach ($payload['grouping'] as $groupField) {
140
                $criteria->addGroupField(new FieldGrouping($groupField));
141
            }
142
        }
143
144
        if (isset($payload['post-filter'])) {
145
            $this->addPostFilter($definition, $payload, $criteria, $searchException);
146
        }
147
148
        if (isset($payload['query']) && \is_array($payload['query'])) {
149
            foreach ($payload['query'] as $query) {
150
                if (!\is_array($query)) {
151
                    continue;
152
                }
153
154
                $parsedQuery = QueryStringParser::fromArray($definition, $query['query'] ?? [], $searchException);
155
                $score = $query['score'] ?? 1;
156
                $scoreField = $query['scoreField'] ?? null;
157
158
                $criteria->addQuery(new ScoreQuery($parsedQuery, $score, $scoreField));
159
            }
160
        }
161
162
        if (isset($payload['term'])) {
163
            $term = trim((string) $payload['term']);
164
            $criteria->setTerm($term);
165
        }
166
167
        if (isset($payload['sort'])) {
168
            $this->addSorting($payload, $criteria, $definition, $searchException);
169
        }
170
171
        if (isset($payload['aggregations'])) {
172
            $this->aggregationParser->buildAggregations($definition, $payload, $criteria, $searchException);
173
        }
174
175
        if (isset($payload['associations'])) {
176
            foreach ($payload['associations'] as $propertyName => $association) {
177
                if (!\is_array($association)) {
178
                    continue;
179
                }
180
181
                $field = $definition->getFields()->get($propertyName);
182
183
                if (!$field instanceof AssociationField) {
184
                    throw new AssociationNotFoundException((string) $propertyName);
185
                }
186
187
                $ref = $field->getReferenceDefinition();
188
                if ($field instanceof ManyToManyAssociationField) {
189
                    $ref = $field->getToManyReferenceDefinition();
190
                }
191
192
                $nested = $criteria->getAssociation($propertyName);
193
194
                $this->parse($association, $nested, $ref, $context, null);
195
196
                if ($field instanceof TranslationsAssociationField) {
197
                    $nested->setLimit(null);
198
                }
199
            }
200
        }
201
202
        if (isset($payload['fields'])) {
203
            $criteria->addFields($payload['fields']);
204
        }
205
206
        $searchException->tryToThrow();
207
208
        $this->validator->validate($definition->getEntityName(), $criteria, $context);
209
210
        return $criteria;
211
    }
212
213
    /**
214
     * @param list<array{order: string, type: string, field: string}> $sorting
0 ignored issues
show
Bug introduced by
The type Shopware\Core\Framework\...actionLayer\Search\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
215
     *
216
     * @return list<FieldSorting>
217
     */
218
    private function parseSorting(EntityDefinition $definition, array $sorting): array
219
    {
220
        $sortings = [];
221
        foreach ($sorting as $sort) {
222
            $order = $sort['order'] ?? 'asc';
223
            $naturalSorting = $sort['naturalSorting'] ?? false;
224
            $type = $sort['type'] ?? '';
225
226
            if (strcasecmp((string) $order, 'desc') === 0) {
227
                $order = FieldSorting::DESCENDING;
228
            } else {
229
                $order = FieldSorting::ASCENDING;
230
            }
231
232
            $class = strcasecmp((string) $type, 'count') === 0 ? CountSorting::class : FieldSorting::class;
233
234
            $sortings[] = new $class(
235
                $this->buildFieldName($definition, $sort['field']),
236
                $order,
237
                (bool) $naturalSorting
238
            );
239
        }
240
241
        return $sortings;
242
    }
243
244
    /**
245
     * @return list<FieldSorting>
246
     */
247
    private function parseSimpleSorting(EntityDefinition $definition, string $query): array
248
    {
249
        $parts = array_filter(explode(',', $query));
250
251
        if (empty($parts)) {
252
            throw new InvalidSortQueryException();
253
        }
254
255
        $sorting = [];
256
        foreach ($parts as $part) {
257
            $first = mb_substr($part, 0, 1);
258
259
            $direction = $first === '-' ? FieldSorting::DESCENDING : FieldSorting::ASCENDING;
260
261
            if ($direction === FieldSorting::DESCENDING) {
262
                $part = mb_substr($part, 1);
263
            }
264
265
            $sorting[] = new FieldSorting($this->buildFieldName($definition, $part), $direction);
266
        }
267
268
        return $sorting;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $sorting returns the type Shopware\Core\Framework\...ng\FieldSorting[]|array which is incompatible with the documented return type Shopware\Core\Framework\...actionLayer\Search\list.
Loading history...
269
    }
270
271
    /**
272
     * @param array<string, mixed> $filters
273
     */
274
    private function parseSimpleFilter(EntityDefinition $definition, array $filters, SearchRequestException $searchRequestException): MultiFilter
275
    {
276
        $queries = [];
277
278
        $index = -1;
279
        foreach ($filters as $field => $value) {
280
            ++$index;
281
282
            if ($field === '') {
283
                $searchRequestException->add(new InvalidFilterQueryException(sprintf('The key for filter at position "%d" must not be blank.', $index)), '/filter/' . $index);
284
285
                continue;
286
            }
287
288
            if ($value === '') {
289
                $searchRequestException->add(new InvalidFilterQueryException(sprintf('The value for filter "%s" must not be blank.', $field)), '/filter/' . $field);
290
291
                continue;
292
            }
293
294
            $queries[] = new EqualsFilter($this->buildFieldName($definition, $field), $value);
295
        }
296
297
        return new MultiFilter(MultiFilter::CONNECTION_AND, $queries);
298
    }
299
300
    /**
301
     * @param array{page: int, limit?: int} $payload
302
     */
303
    private function setPage(array $payload, Criteria $criteria, SearchRequestException $searchRequestException): void
304
    {
305
        if ($payload['page'] === '') {
0 ignored issues
show
introduced by
The condition $payload['page'] === '' is always false.
Loading history...
306
            $searchRequestException->add(new InvalidPageQueryException('(empty)'), '/page');
307
308
            return;
309
        }
310
311
        if (!is_numeric($payload['page'])) {
0 ignored issues
show
introduced by
The condition is_numeric($payload['page']) is always true.
Loading history...
312
            $searchRequestException->add(new InvalidPageQueryException($payload['page']), '/page');
313
314
            return;
315
        }
316
317
        $page = (int) $payload['page'];
318
        $limit = (int) ($payload['limit'] ?? 0);
319
320
        if ($page <= 0) {
321
            $searchRequestException->add(new InvalidPageQueryException($page), '/page');
322
323
            return;
324
        }
325
326
        $offset = $limit * ($page - 1);
327
        $criteria->setOffset($offset);
328
    }
329
330
    /**
331
     * @param array{limit: int} $payload
332
     */
333
    private function addLimit(array $payload, Criteria $criteria, SearchRequestException $searchRequestException, ?int $maxLimit): void
334
    {
335
        if ($payload['limit'] === '') {
0 ignored issues
show
introduced by
The condition $payload['limit'] === '' is always false.
Loading history...
336
            $searchRequestException->add(new InvalidLimitQueryException('(empty)'), '/limit');
337
338
            return;
339
        }
340
341
        if (!is_numeric($payload['limit'])) {
0 ignored issues
show
introduced by
The condition is_numeric($payload['limit']) is always true.
Loading history...
342
            $searchRequestException->add(new InvalidLimitQueryException($payload['limit']), '/limit');
343
344
            return;
345
        }
346
347
        $limit = (int) $payload['limit'];
348
        if ($limit <= 0) {
349
            $searchRequestException->add(new InvalidLimitQueryException($limit), '/limit');
350
351
            return;
352
        }
353
354
        if ($maxLimit > 0 && $limit > $maxLimit) {
355
            $searchRequestException->add(new QueryLimitExceededException($this->maxLimit, $limit), '/limit');
356
357
            return;
358
        }
359
360
        $criteria->setLimit($limit);
361
    }
362
363
    /**
364
     * @param array{filter: array<mixed>} $payload
365
     */
366
    private function addFilter(EntityDefinition $definition, array $payload, Criteria $criteria, SearchRequestException $searchException): void
367
    {
368
        if (!\is_array($payload['filter'])) {
0 ignored issues
show
introduced by
The condition is_array($payload['filter']) is always true.
Loading history...
369
            $searchException->add(new InvalidFilterQueryException('The filter parameter has to be a list of filters.'), '/filter');
370
371
            return;
372
        }
373
374
        if ($this->hasNumericIndex($payload['filter'])) {
375
            foreach ($payload['filter'] as $index => $query) {
376
                try {
377
                    $filter = QueryStringParser::fromArray($definition, $query, $searchException, '/filter/' . $index);
378
                    $criteria->addFilter($filter);
379
                } catch (InvalidFilterQueryException $ex) {
380
                    $searchException->add($ex, $ex->getPath());
381
                }
382
            }
383
384
            return;
385
        }
386
387
        $criteria->addFilter($this->parseSimpleFilter($definition, $payload['filter'], $searchException));
388
    }
389
390
    /**
391
     * @param array{post-filter: array<mixed>} $payload
392
     */
393
    private function addPostFilter(EntityDefinition $definition, array $payload, Criteria $criteria, SearchRequestException $searchException): void
394
    {
395
        if (!\is_array($payload['post-filter'])) {
0 ignored issues
show
introduced by
The condition is_array($payload['post-filter']) is always true.
Loading history...
396
            $searchException->add(new InvalidFilterQueryException('The filter parameter has to be a list of filters.'), '/post-filter');
397
398
            return;
399
        }
400
401
        if ($this->hasNumericIndex($payload['post-filter'])) {
402
            foreach ($payload['post-filter'] as $index => $query) {
403
                try {
404
                    $filter = QueryStringParser::fromArray($definition, $query, $searchException, '/post-filter/' . $index);
405
                    $criteria->addPostFilter($filter);
406
                } catch (InvalidFilterQueryException $ex) {
407
                    $searchException->add($ex, $ex->getPath());
408
                }
409
            }
410
411
            return;
412
        }
413
414
        $criteria->addPostFilter(
415
            $this->parseSimpleFilter(
416
                $definition,
417
                $payload['post-filter'],
418
                $searchException
419
            )
420
        );
421
    }
422
423
    /**
424
     * @param array<mixed> $data
425
     */
426
    private function hasNumericIndex(array $data): bool
427
    {
428
        return array_keys($data) === range(0, \count($data) - 1);
429
    }
430
431
    /**
432
     * @param array{sort: list<array{order: string, type: string, field: string}>|string} $payload
0 ignored issues
show
Documentation Bug introduced by
The doc comment array{sort: list<array{o...field: string}>|string} at position 4 could not be parsed: Expected '}' at position 4, but found 'list'.
Loading history...
433
     */
434
    private function addSorting(array $payload, Criteria $criteria, EntityDefinition $definition, SearchRequestException $searchException): void
435
    {
436
        if (\is_array($payload['sort'])) {
437
            $sorting = $this->parseSorting($definition, $payload['sort']);
438
            $criteria->addSorting(...$sorting);
439
440
            return;
441
        }
442
443
        try {
444
            $sorting = $this->parseSimpleSorting($definition, $payload['sort']);
445
            $criteria->addSorting(...$sorting);
446
        } catch (InvalidSortQueryException $ex) {
447
            $searchException->add($ex, '/sort');
448
        }
449
    }
450
451
    private function buildFieldName(EntityDefinition $definition, string $fieldName): string
452
    {
453
        if ($fieldName === '_score') {
454
            // Do not prefix _score fields because they are not actual entity properties but a calculated field in the
455
            // SQL selection.
456
            return $fieldName;
457
        }
458
459
        $prefix = $definition->getEntityName() . '.';
460
461
        if (mb_strpos($fieldName, $prefix) === false) {
462
            return $prefix . $fieldName;
463
        }
464
465
        return $fieldName;
466
    }
467
}
468