QueryProcessor   F
last analyzed

Complexity

Total Complexity 65

Size/Duplication

Total Lines 561
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 199
dl 0
loc 561
rs 3.2
c 3
b 0
f 0
wmc 65

14 Methods

Rating   Name   Duplication   Size   Complexity  
A checkForEmptyQueryArguments() 0 27 5
B processSkipAndTop() 0 35 9
A processQuery() 0 8 1
B __construct() 0 28 7
A process() 0 11 2
A readSkipOrTopOption() 0 30 4
B processExpandAndSelect() 0 40 8
B processCount() 0 41 6
A isSSPagingRequired() 0 9 2
A checkExpandOrSelectApplicable() 0 5 2
A checkSetQueryApplicable() 0 5 2
A processSkipToken() 0 33 5
B processOrderBy() 0 47 8
A processFilter() 0 19 4

How to fix   Complexity   

Complex Class

Complex classes like QueryProcessor often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use QueryProcessor, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace POData\UriProcessor\QueryProcessor;
6
7
use POData\Common\InvalidOperationException;
8
use POData\Common\Messages;
9
use POData\Common\NotImplementedException;
10
use POData\Common\ODataConstants;
11
use POData\Common\ODataException;
12
use POData\IService;
13
use POData\Providers\Metadata\ResourceTypeKind;
14
use POData\Providers\Metadata\Type\Int32;
15
use POData\Providers\Query\QueryType;
16
use POData\UriProcessor\QueryProcessor\ExpandProjectionParser\ExpandProjectionParser;
17
use POData\UriProcessor\QueryProcessor\ExpressionParser\ExpressionParser2;
18
use POData\UriProcessor\QueryProcessor\OrderByParser\OrderByParser;
19
use POData\UriProcessor\QueryProcessor\SkipTokenParser\SkipTokenParser;
20
use POData\UriProcessor\RequestDescription;
21
use POData\UriProcessor\ResourcePathProcessor\SegmentParser\TargetKind;
22
use POData\UriProcessor\ResourcePathProcessor\SegmentParser\TargetSource;
23
use ReflectionException;
24
25
/**
26
 * Class QueryProcessor.
27
 */
28
class QueryProcessor
29
{
30
    /**
31
     * Holds details of the request that client has submitted.
32
     *
33
     * @var RequestDescription
34
     */
35
    private $request;
36
37
    /**
38
     * Holds reference to the underlying data service specific
39
     * instance.
40
     *
41
     * @var IService
42
     */
43
    private $service;
44
45
    /**
46
     * If $orderby, $skip, $top and $count options can be applied to the request.
47
     *
48
     * @var bool
49
     */
50
    private $setQueryApplicable;
51
52
    /**
53
     * Whether the top level request is a candidate for paging.
54
     *
55
     * @var bool
56
     */
57
    private $pagingApplicable;
58
59
    /**
60
     * Whether $expand, $select can be applied to the request.
61
     *
62
     * @var bool
63
     */
64
    private $expandSelectApplicable;
65
66
    /**
67
     * Creates new instance of QueryProcessor.
68
     *
69
     * @param RequestDescription $request Description of the request submitted by client
70
     * @param IService           $service Reference to the service implementation
71
     */
72
    private function __construct(RequestDescription $request, IService $service)
73
    {
74
        $this->request = $request;
75
        $this->service = $service;
76
77
        $isSingleResult = $request->isSingleResult();
78
79
        //$top, $skip, $order, $inlinecount & $count are only applicable if:
80
        //The query targets a resource collection
81
        $this->setQueryApplicable = ($request->getTargetKind() == TargetKind::RESOURCE() && !$isSingleResult);
82
        //Or it's a $count resource (although $inlinecount isn't applicable in this case..
83
        //but there's a check somewhere else for this
84
        $this->setQueryApplicable |= $request->queryType == QueryType::COUNT();
85
86
        //Paging is allowed if
87
        //The request targets a resource collection
88
        //and the request isn't for a $count segment
89
        $this->pagingApplicable = $this->request->getTargetKind() == TargetKind::RESOURCE()
90
            && !$isSingleResult
91
            && ($request->queryType != QueryType::COUNT());
92
93
        $targetResourceType       = $this->request->getTargetResourceType();
94
        $targetResourceSetWrapper = $this->request->getTargetResourceSetWrapper();
95
96
        $this->expandSelectApplicable = null !== $targetResourceType
97
            && null !== $targetResourceSetWrapper
98
            && $targetResourceType->getResourceTypeKind() == ResourceTypeKind::ENTITY()
99
            && !$this->request->isLinkUri();
100
    }
101
102
    /**
103
     * Process the OData query options and update RequestDescription accordingly.
104
     *
105
     * @param RequestDescription $request Description of the request submitted by client
106
     * @param IService           $service Reference to the data service
107
     *
108
     * @throws ODataException
109
     * @throws NotImplementedException
110
     * @throws InvalidOperationException
111
     * @throws ReflectionException
112
     */
113
    public static function process(RequestDescription $request, IService $service): void
114
    {
115
        $queryProcessor = new self($request, $service);
116
        if ($request->getTargetSource() == TargetSource::NONE()) {
117
            //A service directory, metadata or batch request
118
            $queryProcessor->checkForEmptyQueryArguments();
119
        } else {
120
            $queryProcessor->processQuery();
121
        }
122
123
        unset($queryProcessor);
124
    }
125
126
    /**
127
     * Checks whether client request contains any odata query options.
128
     *
129
     *
130
     * @throws ODataException Throws bad request error if client request
131
     *                        includes any odata query option
132
     */
133
    private function checkForEmptyQueryArguments(): void
134
    {
135
        $serviceHost = $this->service->getHost();
136
        $items       = [
137
            ODataConstants::HTTPQUERY_STRING_FILTER,
138
            ODataConstants::HTTPQUERY_STRING_EXPAND,
139
            ODataConstants::HTTPQUERY_STRING_INLINECOUNT,
140
            ODataConstants::HTTPQUERY_STRING_ORDERBY,
141
            ODataConstants::HTTPQUERY_STRING_SELECT,
142
            ODataConstants::HTTPQUERY_STRING_SKIP,
143
            ODataConstants::HTTPQUERY_STRING_SKIPTOKEN,
144
            ODataConstants::HTTPQUERY_STRING_TOP
145
        ];
146
147
        $allNull = true;
148
        foreach ($items as $queryItem) {
149
            $item        = $serviceHost->getQueryStringItem($queryItem);
150
            $currentNull = null === $item;
151
            $allNull     = ($currentNull && $allNull);
152
            if (false === $allNull) {
153
                break;
154
            }
155
        }
156
157
        if (false === $allNull) {
158
            throw ODataException::createBadRequestError(
159
                Messages::queryProcessorNoQueryOptionsApplicable()
160
            );
161
        }
162
    }
163
164
    /**
165
     * Processes the odata query options in the request uri and update the request description
166
     * instance with processed details.
167
     *
168
     * @throws ODataException            If any error occurred while processing the query options
169
     * @throws NotImplementedException
170
     * @throws InvalidOperationException
171
     * @throws ReflectionException
172
     */
173
    private function processQuery(): void
174
    {
175
        $this->processSkipAndTop();
176
        $this->processOrderBy();
177
        $this->processFilter();
178
        $this->processCount();
179
        $this->processSkipToken();
180
        $this->processExpandAndSelect();
181
    }
182
183
    /**
184
     * Process $skip and $top options.
185
     *
186
     *
187
     * @throws ODataException Throws syntax error if the $skip or $top option
188
     *                        is specified with non-integer value, throws
189
     *                        bad request error if the $skip or $top option
190
     *                        is not applicable for the requested resource
191
     */
192
    private function processSkipAndTop(): void
193
    {
194
        $value = null;
195
        if ($this->readSkipOrTopOption(ODataConstants::HTTPQUERY_STRING_SKIP, $value)) {
196
            $this->request->setSkipCount($value);
197
        }
198
199
        $pageSize         = 0;
200
        $isPagingRequired = $this->isSSPagingRequired();
201
        if ($isPagingRequired) {
202
            $pageSize = $this->request
203
                ->getTargetResourceSetWrapper()
204
                ->getResourceSetPageSize();
205
        }
206
207
        if ($this->readSkipOrTopOption(ODataConstants::HTTPQUERY_STRING_TOP, $value)) {
208
            $this->request->setTopOptionCount($value);
209
            if ($isPagingRequired && $pageSize < $value) {
210
                //If $top is greater than or equal to page size,
211
                //we will need a $skiptoken and thus our response
212
                //will be 2.0
213
                $this->request->raiseResponseVersion(2, 0);
214
                $this->request->setTopCount($pageSize);
215
            } else {
216
                $this->request->setTopCount($value);
217
            }
218
        } elseif ($isPagingRequired) {
219
            $this->request->raiseResponseVersion(2, 0);
220
            $this->request->setTopCount($pageSize);
221
        }
222
223
        if (null !== $this->request->getSkipCount()
224
            || null !== $this->request->getTopCount()
225
        ) {
226
            $this->checkSetQueryApplicable();
227
        }
228
    }
229
230
    /**
231
     * Read skip or top query option value which is expected to be positive
232
     * integer.
233
     *
234
     * @param string $queryItem The name of the query item to read from request
235
     *                          uri ($skip or $top)
236
     * @param int    &$value    On return, If the requested query item is
237
     *                          present with a valid integer value then this
238
     *                          argument will holds that integer value
239
     *                          otherwise holds zero
240
     *
241
     * @throws ODataException Throws syntax error if the requested argument
242
     *                        is present and it is not an integer
243
     * @return bool           True     If the requested query item with valid integer
244
     *                        value is present in the request, false query
245
     *                        item is absent in the request uri
246
     */
247
    private function readSkipOrTopOption(string $queryItem, ?int &$value): bool
248
    {
249
        $value = $this->service->getHost()->getQueryStringItem($queryItem);
250
        if (null !== $value) {
251
            $int = new Int32();
252
            if (!$int->validate($value, $outValue)) {
253
                throw ODataException::createSyntaxError(
254
                    Messages::queryProcessorIncorrectArgumentFormat(
255
                        $queryItem,
256
                        $value
257
                    )
258
                );
259
            }
260
261
            $value = intval($value);
262
            if (0 > $value) {
263
                throw ODataException::createSyntaxError(
264
                    Messages::queryProcessorIncorrectArgumentFormat(
265
                        $queryItem,
266
                        $value
267
                    )
268
                );
269
            }
270
271
            return true;
272
        }
273
274
        $value = 0;
275
276
        return false;
277
    }
278
279
    /**
280
     * Is server side paging is configured, this function return true
281
     * if the resource targeted by the resource path is applicable
282
     * for paging and paging is enabled for the targeted resource set
283
     * else false.
284
     *
285
     * @return bool
286
     */
287
    private function isSSPagingRequired(): bool
288
    {
289
        if ($this->pagingApplicable) {
290
            $targetResourceSetWrapper = $this->request->getTargetResourceSetWrapper();
291
            //assert($targetResourceSetWrapper != NULL)
292
            return 0 != $targetResourceSetWrapper->getResourceSetPageSize();
293
        }
294
295
        return false;
296
    }
297
298
    /**
299
     * To check whether the the query options $orderby, $inlinecount, $skip
300
     * or $top is applicable for the current requested resource.
301
     *
302
     *
303
     * @throws ODataException Throws bad request error if any of the query options $orderby, $inlinecount,
304
     *                        $skip or $top cannot be applied to the requested resource
305
     */
306
    private function checkSetQueryApplicable(): void
307
    {
308
        if (!$this->setQueryApplicable) {
309
            throw ODataException::createBadRequestError(
310
                Messages::queryProcessorQuerySetOptionsNotApplicable()
311
            );
312
        }
313
    }
314
315
    /**
316
     * Process $orderby option, This function requires _processSkipAndTopOption
317
     * function to be already called as this function need to know whether
318
     * client has requested for skip, top or paging is enabled for the
319
     * requested resource in these cases function generates additional orderby
320
     * expression using keys.
321
     *
322
     *
323
     * @throws ODataException            If any error occurs while parsing orderby option
324
     * @throws InvalidOperationException
325
     * @throws ReflectionException
326
     */
327
    private function processOrderBy(): void
328
    {
329
        $orderBy = $this->service->getHost()->getQueryStringItem(ODataConstants::HTTPQUERY_STRING_ORDERBY);
330
331
        if (null !== $orderBy) {
332
            $this->checkSetQueryApplicable();
333
        }
334
335
        $targetResourceType = $this->request->getTargetResourceType();
336
        assert($targetResourceType != null, 'Request target resource type must not be null');
337
        /*
338
         * We need to do sorting in the folowing cases, irrespective of
339
         * $orderby clause is present or not.
340
         * 1. If $top or $skip is specified
341
         *     skip and take will be applied on sorted list only. If $skip
342
         *     is specified then RequestDescription::getSkipCount will give
343
         *     non-null value. If $top is specified then
344
         *     RequestDescription::getTopCount will give non-null value.
345
         * 2. If server side paging is enabled for the requested resource
346
         *     If server-side paging is enabled for the requested resource then
347
         *     RequestDescription::getTopCount will give non-null value.
348
         *
349
         */
350
351
        if (null !== $this->request->getSkipCount() || null !== $this->request->getTopCount()) {
352
            $orderBy = null !== $orderBy ? $orderBy . ', ' : null;
353
            $keys    = array_keys($targetResourceType->getKeyProperties());
354
            //assert(!empty($keys))
355
            foreach ($keys as $key) {
356
                $orderBy = $orderBy . $key . ', ';
357
            }
358
359
            $orderBy = rtrim(strval($orderBy), ', ');
360
        }
361
362
        if (null !== $orderBy && '' != trim($orderBy)) {
363
            $setWrapper = $this->request->getTargetResourceSetWrapper();
364
            assert(null != $setWrapper, 'Target resource set wrapper must not be null');
365
            $internalOrderByInfo = OrderByParser::parseOrderByClause(
366
                $setWrapper,
367
                $targetResourceType,
368
                $orderBy,
369
                $this->service->getProvidersWrapper()
370
            );
371
372
            $this->request->setInternalOrderByInfo(
373
                $internalOrderByInfo
374
            );
375
        }
376
    }
377
378
    /**
379
     * Process the $filter option in the request and update request description.
380
     *
381
     *
382
     * @throws ODataException          Throws error in the following cases:
383
     *                                 (1) If $filter cannot be applied to the
384
     *                                 resource targeted by the request uri
385
     *                                 (2) If any error occurred while parsing and
386
     *                                 translating the odata $filter expression
387
     *                                 to expression tree
388
     *                                 (3) If any error occurred while generating
389
     *                                 php expression from expression tree
390
     * @throws NotImplementedException
391
     * @throws ReflectionException
392
     */
393
    private function processFilter(): void
394
    {
395
        $filter = $this->service->getHost()->getQueryStringItem(ODataConstants::HTTPQUERY_STRING_FILTER);
396
        if (null === $filter) {
397
            return;
398
        }
399
400
        $kind = $this->request->getTargetKind();
401
        if (!($kind->isNotFilterable()
402
            || $this->request->queryType == QueryType::COUNT())
403
        ) {
404
            throw ODataException::createBadRequestError(
405
                Messages::queryProcessorQueryFilterOptionNotApplicable()
406
            );
407
        }
408
        $resourceType       = $this->request->getTargetResourceType();
409
        $expressionProvider = $this->service->getProvidersWrapper()->getExpressionProvider();
410
        $filterInfo         = ExpressionParser2::parseExpression2($filter, $resourceType, $expressionProvider);
411
        $this->request->setFilterInfo($filterInfo);
412
    }
413
414
    /**
415
     * Process the $inlinecount option and update the request description.
416
     *
417
     *
418
     * @throws ODataException Throws bad request error in the following cases
419
     *                        (1) If $inlinecount is disabled by the developer
420
     *                        (2) If both $count and $inlinecount specified
421
     *                        (3) If $inlinecount value is unknown
422
     *                        (4) If capability negotiation over version fails
423
     */
424
    private function processCount(): void
425
    {
426
        $inlineCount = $this->service->getHost()->getQueryStringItem(ODataConstants::HTTPQUERY_STRING_INLINECOUNT);
427
428
        //If it's not specified, we're done
429
        if (null === $inlineCount) {
430
            return;
431
        }
432
433
        //If the service doesn't allow count requests..then throw an exception
434
        if (!$this->service->getConfiguration()->getAcceptCountRequests()) {
435
            throw ODataException::createBadRequestError(
436
                Messages::configurationCountNotAccepted()
437
            );
438
        }
439
440
        $inlineCount = trim($inlineCount);
441
442
        //if it's set to none, we don't do inline counts
443
        if ($inlineCount === ODataConstants::URI_ROWCOUNT_OFFOPTION) {
444
            return;
445
        }
446
447
        //You can't specify $count & $inlinecount together
448
        //TODO: ensure there's a test for this case see #55
449
        if ($this->request->queryType == QueryType::COUNT()) {
450
            throw ODataException::createBadRequestError(
451
                Messages::queryProcessorInlineCountWithValueCount()
452
            );
453
        }
454
455
        $this->checkSetQueryApplicable(); //TODO: why do we do this check?
456
457
        if ($inlineCount === ODataConstants::URI_ROWCOUNT_ALLOPTION) {
458
            $this->request->queryType = QueryType::ENTITIES_WITH_COUNT();
459
460
            $this->request->raiseMinVersionRequirement(2, 0);
461
            $this->request->raiseResponseVersion(2, 0);
462
        } else {
463
            throw ODataException::createBadRequestError(
464
                Messages::queryProcessorInvalidInlineCountOptionError()
465
            );
466
        }
467
    }
468
469
    /**
470
     * Process the $skiptoken option in the request and update the request
471
     * description, this function requires _processOrderBy method to be
472
     * already invoked.
473
     *
474
     *
475
     * @throws ODataException      Throws bad request error in the following cases
476
     * @throws ReflectionException
477
     *                             (1) If $skiptoken cannot be applied to the
478
     *                             resource targeted by the request uri
479
     *                             (2) If paging is not enabled for the resource
480
     *                             targeted by the request uri
481
     *                             (3) If parsing of $skiptoken fails
482
     *                             (4) If capability negotiation over version fails
483
     */
484
    private function processSkipToken(): void
485
    {
486
        $skipToken = $this->service->getHost()->getQueryStringItem(ODataConstants::HTTPQUERY_STRING_SKIPTOKEN);
487
        if (null === $skipToken) {
488
            return;
489
        }
490
491
        if (!$this->pagingApplicable) {
492
            throw ODataException::createBadRequestError(
493
                Messages::queryProcessorSkipTokenNotAllowed()
494
            );
495
        }
496
497
        if (!$this->isSSPagingRequired()) {
498
            $set     = $this->request->getTargetResourceSetWrapper();
499
            $setName = (null != $set) ? $set->getName() : 'null';
500
            $msg     = Messages::queryProcessorSkipTokenCannotBeAppliedForNonPagedResourceSet($setName);
501
            throw ODataException::createBadRequestError($msg);
502
        }
503
504
        $internalOrderByInfo = $this->request->getInternalOrderByInfo();
505
        assert($internalOrderByInfo != null, 'Internal order info must not be null');
506
        $targetResourceType = $this->request->getTargetResourceType();
507
        assert($targetResourceType != null, 'Request target resource type must not be null');
508
509
        $internalSkipTokenInfo = SkipTokenParser::parseSkipTokenClause(
510
            $targetResourceType,
511
            $internalOrderByInfo,
512
            $skipToken
513
        );
514
        $this->request->setInternalSkipTokenInfo($internalSkipTokenInfo);
515
        $this->request->raiseMinVersionRequirement(2, 0);
516
        $this->request->raiseResponseVersion(2, 0);
517
    }
518
519
    /**
520
     * Process the $expand and $select option and update the request description.
521
     *
522
     *
523
     * @throws ODataException            Throws bad request error in the following cases
524
     * @throws InvalidOperationException
525
     * @throws ReflectionException
526
     *                                   (1) If $expand or select cannot be applied to the
527
     *                                   requested resource.
528
     *                                   (2) If projection is disabled by the developer
529
     *                                   (3) If some error occurs while parsing the options
530
     */
531
    private function processExpandAndSelect(): void
532
    {
533
        $expand = $this->service->getHost()->getQueryStringItem(ODataConstants::HTTPQUERY_STRING_EXPAND);
534
535
        if (null !== $expand) {
536
            $this->checkExpandOrSelectApplicable(ODataConstants::HTTPQUERY_STRING_EXPAND);
537
        }
538
539
        $select = $this->service->getHost()->getQueryStringItem(ODataConstants::HTTPQUERY_STRING_SELECT);
540
541
        if (null !== $select) {
542
            if (!$this->service->getConfiguration()->getAcceptProjectionRequests()) {
543
                throw ODataException::createBadRequestError(Messages::configurationProjectionsNotAccepted());
544
            }
545
546
            $this->checkExpandOrSelectApplicable(ODataConstants::HTTPQUERY_STRING_SELECT);
547
        }
548
549
        // We will generate RootProjectionNode in case of $link request also, but
550
        // expand and select in this case must be null (we are ensuring this above)
551
        // 'RootProjectionNode' is required while generating next page Link
552
        if ($this->expandSelectApplicable || $this->request->isLinkUri()) {
553
            $rootProjectionNode = ExpandProjectionParser::parseExpandAndSelectClause(
554
                $this->request->getTargetResourceSetWrapper(),
555
                $this->request->getTargetResourceType(),
556
                $this->request->getInternalOrderByInfo(),
557
                $this->request->getSkipCount(),
558
                $this->request->getTopCount(),
559
                $expand,
560
                $select,
561
                $this->service->getProvidersWrapper()
562
            );
563
            if ($rootProjectionNode->isSelectionSpecified()) {
564
                $this->request->raiseMinVersionRequirement(2, 0);
565
            }
566
567
            if ($rootProjectionNode->hasPagedExpandedResult()) {
568
                $this->request->raiseResponseVersion(2, 0);
569
            }
570
            $this->request->setRootProjectionNode($rootProjectionNode);
571
        }
572
    }
573
574
    /**
575
     * To check whether the the query options $select, $expand
576
     * is applicable for the current requested resource.
577
     *
578
     * @param string $queryItem The query option to check
579
     *
580
     * @throws ODataException Throws bad request error if the query
581
     *                        options $select, $expand cannot be
582
     *                        applied to the requested resource
583
     */
584
    private function checkExpandOrSelectApplicable(string $queryItem): void
585
    {
586
        if (!$this->expandSelectApplicable) {
587
            throw ODataException::createBadRequestError(
588
                Messages::queryProcessorSelectOrExpandOptionNotApplicable($queryItem)
589
            );
590
        }
591
    }
592
}
593