Passed
Pull Request — master (#199)
by Alex
06:54
created

LaravelReadQuery::checkAuth()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 7
c 4
b 0
f 0
dl 0
loc 10
rs 9.6111
cc 5
nc 4
nop 2
1
<?php
2
3
namespace AlgoWeb\PODataLaravel\Query;
4
5
use AlgoWeb\PODataLaravel\Enums\ActionVerb;
6
use AlgoWeb\PODataLaravel\Models\MetadataTrait;
7
use Illuminate\Database\Eloquent\Builder;
8
use Illuminate\Database\Eloquent\Collection;
9
use Illuminate\Database\Eloquent\Model;
10
use Illuminate\Database\Eloquent\Relations\Relation;
11
use Illuminate\Support\Facades\App;
12
use POData\Common\InvalidOperationException;
13
use POData\Common\ODataException;
14
use POData\Providers\Metadata\ResourceProperty;
15
use POData\Providers\Metadata\ResourceSet;
16
use POData\Providers\Query\QueryResult;
17
use POData\Providers\Query\QueryType;
18
use POData\UriProcessor\QueryProcessor\ExpressionParser\FilterInfo;
19
use POData\UriProcessor\QueryProcessor\OrderByParser\InternalOrderByInfo;
20
use POData\UriProcessor\QueryProcessor\SkipTokenParser\SkipTokenInfo;
21
use POData\UriProcessor\ResourcePathProcessor\SegmentParser\KeyDescriptor;
22
use Symfony\Component\Process\Exception\InvalidArgumentException;
23
24
class LaravelReadQuery extends LaravelBaseQuery
25
{
26
    /**
27
     * Gets collection of entities belongs to an entity set
28
     * IE: http://host/EntitySet
29
     *  http://host/EntitySet?$skip=10&$top=5&filter=Prop gt Value.
30
     *
31
     * @param QueryType                $queryType            Is this is a query for a count, entities,
32
     *                                                       or entities-with-count?
33
     * @param ResourceSet              $resourceSet          The entity set containing the entities to fetch
34
     * @param FilterInfo|null          $filterInfo           The $filter parameter of the OData query.  NULL if absent
35
     * @param null|InternalOrderByInfo $orderBy              sorted order if we want to get the data in some
36
     *                                                       specific order
37
     * @param int|null                 $top                  number of records which need to be retrieved
38
     * @param int|null                 $skip                 number of records which need to be skipped
39
     * @param SkipTokenInfo|null       $skipToken            value indicating what records to skip
40
     * @param string[]|null            $eagerLoad            array of relations to eager load
41
     * @param Model|Relation|null      $sourceEntityInstance Starting point of query
42
     *
43
     * @return QueryResult
44
     * @throws InvalidArgumentException
45
     * @throws InvalidOperationException
46
     * @throws \ReflectionException
47
     * @throws ODataException
48
     */
49
    public function getResourceSet(
50
        QueryType $queryType,
51
        ResourceSet $resourceSet,
52
        FilterInfo $filterInfo = null,
53
        $orderBy = null,
54
        $top = null,
55
        $skip = null,
56
        SkipTokenInfo $skipToken = null,
57
        array $eagerLoad = null,
58
        $sourceEntityInstance = null
59
    ) {
60
        $rawLoad = $this->processEagerLoadList($eagerLoad);
61
62
        $sourceEntityInstance = $this->checkSourceInstance($sourceEntityInstance, $resourceSet);
63
64
        /** @var MetadataTrait $model */
65
        $model = $sourceEntityInstance instanceof Model ? $sourceEntityInstance : $sourceEntityInstance->getRelated();
66
        $modelLoad = $model->getEagerLoad();
67
        $keyName = $model->getKeyName();
68
        $tableName = $model->getTable();
69
70
        if (null === $keyName) {
0 ignored issues
show
introduced by
The condition null === $keyName is always false.
Loading history...
71
            throw new InvalidOperationException('Key name not retrieved');
72
        }
73
        $rawLoad = array_values(array_unique(array_merge($rawLoad, $modelLoad)));
74
75
        $checkInstance = $sourceEntityInstance instanceof Model ? $sourceEntityInstance : null;
76
        $this->checkAuth($sourceEntityInstance, $checkInstance);
77
78
        $result          = new QueryResult();
79
        $result->results = null;
80
        $result->count   = null;
81
82
        if (null != $orderBy) {
83
            foreach ($orderBy->getOrderByInfo()->getOrderByPathSegments() as $order) {
84
                foreach ($order->getSubPathSegments() as $subOrder) {
85
                    $subName = $subOrder->getName();
86
                    $subName = $tableName.'.'.$subName;
87
                    $sourceEntityInstance = $sourceEntityInstance->orderBy(
88
                        $subName,
89
                        $order->isAscending() ? 'asc' : 'desc'
90
                    );
91
                }
92
            }
93
        }
94
95
        // throttle up for trench run
96
        if (null != $skipToken) {
97
            $sourceEntityInstance = $this->processSkipToken($skipToken, $sourceEntityInstance);
98
        }
99
100
        if (!isset($skip)) {
101
            $skip = 0;
102
        }
103
        if (!isset($top)) {
104
            $top = PHP_INT_MAX;
105
        }
106
107
        $nullFilter = !isset($filterInfo);
108
        $isvalid = null;
109
        if (isset($filterInfo)) {
110
            $method = 'return ' . $filterInfo->getExpressionAsString() . ';';
111
            $clln = $resourceSet->getResourceType()->getName();
112
            $isvalid = function ($inputD) use ($clln, $method) {
113
                $$clln = $inputD;
114
                return eval($method);
0 ignored issues
show
introduced by
The use of eval() is discouraged.
Loading history...
115
            };
116
        }
117
118
        list($bulkSetCount, $resultSet, $resultCount, $skip) = $this->applyFiltering(
119
            $top,
120
            $skip,
121
            $sourceEntityInstance,
122
            $nullFilter,
123
            $rawLoad,
124
            $isvalid
125
        );
126
127
        if (isset($top)) {
128
            $resultSet = $resultSet->take($top);
129
        }
130
131
        $qVal = $queryType;
132
        if (QueryType::ENTITIES() == $qVal || QueryType::ENTITIES_WITH_COUNT() == $qVal) {
133
            $result->results = [];
134
            foreach ($resultSet as $res) {
135
                $result->results[] = $res;
136
            }
137
        }
138
        if (QueryType::COUNT() == $qVal || QueryType::ENTITIES_WITH_COUNT() == $qVal) {
139
            $result->count = $resultCount;
140
        }
141
        $hazMore = $bulkSetCount > $skip + count($resultSet);
142
        $result->hasMore = $hazMore;
143
        return $result;
144
    }
145
146
    /**
147
     * Get related resource set for a resource
148
     * IE: http://host/EntitySet(1L)/NavigationPropertyToCollection
149
     * http://host/EntitySet?$expand=NavigationPropertyToCollection.
150
     *
151
     * @param QueryType          $queryType            Is this is a query for a count, entities, or entities-with-count
152
     * @param ResourceSet        $sourceResourceSet    The entity set containing the source entity
153
     * @param Model              $sourceEntityInstance The source entity instance
154
     * @param ResourceSet        $targetResourceSet    The resource set pointed to by the navigation property
155
     * @param ResourceProperty   $targetProperty       The navigation property to retrieve
156
     * @param FilterInfo|null    $filter               The $filter parameter of the OData query.  NULL if none specified
157
     * @param mixed|null         $orderBy              sorted order if we want to get the data in some specific order
158
     * @param int|null           $top                  number of records which need to be retrieved
159
     * @param int|null           $skip                 number of records which need to be skipped
160
     * @param SkipTokenInfo|null $skipToken            value indicating what records to skip
161
     *
162
     * @return QueryResult
163
     * @throws InvalidOperationException
164
     * @throws ODataException
165
     * @throws \ReflectionException
166
     */
167
    public function getRelatedResourceSet(
168
        QueryType $queryType,
169
        ResourceSet $sourceResourceSet,
170
        Model $sourceEntityInstance,
171
        /** @noinspection PhpUnusedParameterInspection */
172
        ResourceSet $targetResourceSet,
173
        ResourceProperty $targetProperty,
174
        FilterInfo $filter = null,
175
        $orderBy = null,
176
        $top = null,
177
        $skip = null,
178
        SkipTokenInfo $skipToken = null
179
    ) {
180
        $this->checkAuth($sourceEntityInstance);
181
182
        $propertyName = $targetProperty->getName();
183
        $results = $sourceEntityInstance->$propertyName();
184
185
        return $this->getResourceSet(
186
            $queryType,
187
            $sourceResourceSet,
188
            $filter,
189
            $orderBy,
190
            $top,
191
            $skip,
192
            $skipToken,
193
            null,
194
            $results
195
        );
196
    }
197
198
    /**
199
     * Gets an entity instance from an entity set identified by a key
200
     * IE: http://host/EntitySet(1L)
201
     * http://host/EntitySet(KeyA=2L,KeyB='someValue').
202
     *
203
     * @param ResourceSet        $resourceSet   The entity set containing the entity to fetch
204
     * @param KeyDescriptor|null $keyDescriptor The key identifying the entity to fetch
205
     * @param string[]|null      $eagerLoad     array of relations to eager load
206
     *
207
     * @return Model|null Returns entity instance if found else null
208
     * @throws \Exception;
209
     */
210
    public function getResourceFromResourceSet(
211
        ResourceSet $resourceSet,
212
        KeyDescriptor $keyDescriptor = null,
213
        array $eagerLoad = null
214
    ) {
215
        return $this->getResource($resourceSet, $keyDescriptor, [], $eagerLoad);
216
    }
217
218
219
    /**
220
     * Common method for getResourceFromRelatedResourceSet() and getResourceFromResourceSet().
221
     *
222
     * @param ResourceSet|null    $resourceSet
223
     * @param KeyDescriptor|null  $keyDescriptor
224
     * @param Model|Relation|null $sourceEntityInstance Starting point of query
225
     * @param array               $whereCondition
226
     * @param string[]|null       $eagerLoad            array of relations to eager load
227
     *
228
     * @return Model|null
229
     * @throws \Exception
230
     */
231
    public function getResource(
232
        ResourceSet $resourceSet = null,
233
        KeyDescriptor $keyDescriptor = null,
234
        array $whereCondition = [],
235
        array $eagerLoad = null,
236
        $sourceEntityInstance = null
237
    ) {
238
        if (null == $resourceSet && null == $sourceEntityInstance) {
239
            $msg = 'Must supply at least one of a resource set and source entity.';
240
            throw new \Exception($msg);
241
        }
242
243
        $sourceEntityInstance = $this->checkSourceInstance($sourceEntityInstance, $resourceSet);
244
245
        $this->checkAuth($sourceEntityInstance);
246
        $modelLoad = null;
247
        if ($sourceEntityInstance instanceof Model) {
248
            $modelLoad = $sourceEntityInstance->getEagerLoad();
249
        } elseif ($sourceEntityInstance instanceof Relation) {
250
            /** @var MetadataTrait $model */
251
            $model = $sourceEntityInstance->getRelated();
252
            $modelLoad = $model->getEagerLoad();
253
        }
254
        if (!(isset($modelLoad))) {
255
            throw new InvalidOperationException('');
256
        }
257
258
        $this->processKeyDescriptor(/** @scrutinizer ignore-type */$sourceEntityInstance, $keyDescriptor);
259
        foreach ($whereCondition as $fieldName => $fieldValue) {
260
            $sourceEntityInstance = $sourceEntityInstance->where($fieldName, $fieldValue);
261
        }
262
263
        $sourceEntityInstance = $sourceEntityInstance->get();
264
        $sourceCount = $sourceEntityInstance->count();
265
        if (0 == $sourceCount) {
266
            return null;
267
        }
268
        $result = $sourceEntityInstance->first();
269
270
        return $result;
271
    }
272
273
    /**
274
     * Get related resource for a resource
275
     * IE: http://host/EntitySet(1L)/NavigationPropertyToSingleEntity
276
     * http://host/EntitySet?$expand=NavigationPropertyToSingleEntity.
277
     *
278
     * @param ResourceSet      $sourceResourceSet    The entity set containing the source entity
279
     * @param Model            $sourceEntityInstance the source entity instance
280
     * @param ResourceSet      $targetResourceSet    The entity set containing the entity pointed to by the nav property
281
     * @param ResourceProperty $targetProperty       The navigation property to fetch
282
     *
283
     * @return Model|null The related resource if found else null
284
     * @throws ODataException
285
     * @throws InvalidOperationException
286
     * @throws \ReflectionException
287
     */
288
    public function getRelatedResourceReference(
289
        /** @noinspection PhpUnusedParameterInspection */
290
        ResourceSet $sourceResourceSet,
291
        Model $sourceEntityInstance,
292
        ResourceSet $targetResourceSet,
293
        ResourceProperty $targetProperty
294
    ) {
295
        $this->checkAuth($sourceEntityInstance);
296
297
        $propertyName = $targetProperty->getName();
298
        $propertyName = $this->getLaravelRelationName($propertyName);
299
        $result = $sourceEntityInstance->$propertyName()->first();
300
        if (null === $result) {
301
            return null;
302
        }
303
        if (!$result instanceof Model) {
304
            throw new InvalidOperationException('Model not retrieved from Eloquent relation');
305
        }
306
        if ($targetProperty->getResourceType()->getInstanceType()->getName() != get_class($result)) {
307
            return null;
308
        }
309
        return $result;
310
    }
311
312
    /**
313
     * Gets a related entity instance from an entity set identified by a key
314
     * IE: http://host/EntitySet(1L)/NavigationPropertyToCollection(33).
315
     *
316
     * @param ResourceSet      $sourceResourceSet    The entity set containing the source entity
317
     * @param Model            $sourceEntityInstance the source entity instance
318
     * @param ResourceSet      $targetResourceSet    The entity set containing the entity to fetch
319
     * @param ResourceProperty $targetProperty       the metadata of the target property
320
     * @param KeyDescriptor    $keyDescriptor        The key identifying the entity to fetch
321
     *
322
     * @return Model|null Returns entity instance if found else null
323
     * @throws InvalidOperationException
324
     * @throws \Exception
325
     */
326
    public function getResourceFromRelatedResourceSet(
327
        /** @noinspection PhpUnusedParameterInspection */
328
        ResourceSet $sourceResourceSet,
329
        Model $sourceEntityInstance,
330
        ResourceSet $targetResourceSet,
331
        ResourceProperty $targetProperty,
332
        KeyDescriptor $keyDescriptor
333
    ) {
334
        $propertyName = $targetProperty->getName();
335
        if (!method_exists($sourceEntityInstance, $propertyName)) {
336
            $msg = 'Relation method, ' . $propertyName . ', does not exist on supplied entity.';
337
            throw new InvalidArgumentException($msg);
338
        }
339
        // take key descriptor and turn it into where clause here, rather than in getResource call
340
        $sourceEntityInstance = $sourceEntityInstance->$propertyName();
341
        $this->processKeyDescriptor($sourceEntityInstance, $keyDescriptor);
342
        $result = $this->getResource(null, null, [], [], $sourceEntityInstance);
343
        if (!(null == $result || $result instanceof Model)) {
0 ignored issues
show
introduced by
$result is always a sub-type of Illuminate\Database\Eloquent\Model.
Loading history...
344
            $msg = 'GetResourceFromRelatedResourceSet must return an entity or null';
345
            throw new InvalidOperationException($msg);
346
        }
347
        return $result;
348
    }
349
350
    /**
351
     * @param  ResourceSet $resourceSet
352
     * @return mixed
353
     * @throws \ReflectionException
354
     */
355
    protected function getSourceEntityInstance(ResourceSet $resourceSet)
356
    {
357
        $entityClassName = $resourceSet->getResourceType()->getInstanceType()->name;
358
        return App::make($entityClassName);
359
    }
360
361
    /**
362
     * @param Model|Relation|null $source
363
     * @param ResourceSet|null $resourceSet
364
     * @return Model|Relation|mixed|null
365
     * @throws \ReflectionException
366
     */
367
    protected function checkSourceInstance($source, ResourceSet $resourceSet = null)
368
    {
369
        if (!(null == $source || $source instanceof Model || $source instanceof Relation)) {
0 ignored issues
show
introduced by
$source is always a sub-type of Illuminate\Database\Eloquent\Relations\Relation.
Loading history...
370
            $msg = 'Source entity instance must be null, a model, or a relation.';
371
            throw new InvalidArgumentException($msg);
372
        }
373
374
        if (null == $source) {
375
            $source = $this->getSourceEntityInstance(/** @scrutinizer ignore-type */$resourceSet);
376
        }
377
378
        return $source;
379
    }
380
381
    /**
382
     * @param Model|Relation|null $sourceEntityInstance
383
     * @param null|mixed $checkInstance
384
     *
385
     * @throws ODataException
386
     */
387
    private function checkAuth($sourceEntityInstance, $checkInstance = null)
388
    {
389
        $check = array_reduce([$sourceEntityInstance, $checkInstance], function ($carry, $item) {
390
            if ($item instanceof Model || $item instanceof Relation) {
391
                return $item;
392
            }
393
        }, null);
394
        $sourceName = null !== $check ? get_class($check) : null;
395
        if (!$this->getAuth()->canAuth(ActionVerb::READ(), $sourceName, $check)) {
396
            throw new ODataException('Access denied', 403);
397
        }
398
    }
399
400
    /**
401
     * @param Model|Builder $sourceEntityInstance
402
     * @param  KeyDescriptor|null        $keyDescriptor
403
     * @throws InvalidOperationException
404
     */
405
    private function processKeyDescriptor(&$sourceEntityInstance, KeyDescriptor $keyDescriptor = null)
406
    {
407
        if ($keyDescriptor) {
408
            $table = ($sourceEntityInstance instanceof Model) ? $sourceEntityInstance->getTable().'.' : '';
409
            foreach ($keyDescriptor->getValidatedNamedValues() as $key => $value) {
410
                $trimValue = trim($value[0], '\'');
411
                $sourceEntityInstance = $sourceEntityInstance->where($table.$key, $trimValue);
412
            }
413
        }
414
    }
415
416
    /**
417
     * @param  string[]|null $eagerLoad
418
     * @return array
419
     * @throws InvalidOperationException
420
     */
421
    private function processEagerLoadList(array $eagerLoad = null)
422
    {
423
        $load = (null === $eagerLoad) ? [] : $eagerLoad;
424
        $rawLoad = [];
425
        foreach ($load as $line) {
426
            if (!is_string($line)) {
427
                throw new InvalidOperationException('Eager-load elements must be non-empty strings');
428
            }
429
            $lineParts = explode('/', $line);
430
            $numberOfParts = count($lineParts);
431
            for ($i = 0; $i<$numberOfParts; $i++) {
432
                $lineParts[$i] = $this->getLaravelRelationName($lineParts[$i]);
433
            }
434
            $remixLine = implode('.', $lineParts);
435
            $rawLoad[] = $remixLine;
436
        }
437
        return $rawLoad;
438
    }
439
440
    /**
441
     * @param  string $odataProperty
442
     * @return string
443
     */
444
    private function getLaravelRelationName($odataProperty)
445
    {
446
        $laravelProperty = $odataProperty;
447
        $pos = strrpos($laravelProperty, '_');
448
        if ($pos !== false) {
449
            $laravelProperty = substr($laravelProperty, 0, $pos);
450
        }
451
        return $laravelProperty;
452
    }
453
454
    /**
455
     * @param SkipTokenInfo $skipToken
456
     * @param Model|Builder $sourceEntityInstance
457
     * @return mixed
458
     * @throws InvalidOperationException
459
     */
460
    protected function processSkipToken(SkipTokenInfo $skipToken, $sourceEntityInstance)
461
    {
462
        $parameters = [];
463
        $processed = [];
464
        $segments = $skipToken->getOrderByInfo()->getOrderByPathSegments();
465
        $values = $skipToken->getOrderByKeysInToken();
466
        $numValues = count($values);
467
        if ($numValues != count($segments)) {
468
            $msg = 'Expected '.count($segments).', got '.$numValues;
469
            throw new InvalidOperationException($msg);
470
        }
471
472
        for ($i = 0; $i < $numValues; $i++) {
473
            $relation = $segments[$i]->isAscending() ? '>' : '<';
474
            $name = $segments[$i]->getSubPathSegments()[0]->getName();
475
            $parameters[$name] = ['direction' => $relation, 'value' => trim($values[$i][0], '\'')];
0 ignored issues
show
Bug introduced by
$values[$i][0] of type POData\Providers\Metadata\Type\IType is incompatible with the type string expected by parameter $str of trim(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

475
            $parameters[$name] = ['direction' => $relation, 'value' => trim(/** @scrutinizer ignore-type */ $values[$i][0], '\'')];
Loading history...
476
        }
477
478
        foreach ($parameters as $name => $line) {
479
            $processed[$name] = ['direction' => $line['direction'], 'value' => $line['value']];
480
            $sourceEntityInstance = $sourceEntityInstance
481
                ->orWhere(
482
                    function (Builder $query) use ($processed) {
483
                        foreach ($processed as $key => $proc) {
484
                            $query->where($key, $proc['direction'], $proc['value']);
485
                        }
486
                    }
487
                );
488
            // now we've handled the later-in-order segment for this key, drop it back to equality in prep
489
            // for next key - same-in-order for processed keys and later-in-order for next
490
            $processed[$name]['direction'] = '=';
491
        }
492
        return $sourceEntityInstance;
493
    }
494
495
    /**
496
     * @param $top
497
     * @param $skip
498
     * @param Model|Builder $sourceEntityInstance
499
     * @param $nullFilter
500
     * @param $rawLoad
501
     * @param callable|null $isvalid
502
     * @return array
503
     * @throws InvalidOperationException
504
     */
505
    protected function applyFiltering(
506
        $top,
507
        $skip,
508
        $sourceEntityInstance,
509
        $nullFilter,
510
        $rawLoad,
511
        callable $isvalid = null
512
    ) {
513
        $bulkSetCount = $sourceEntityInstance->count();
514
        $bigSet = 20000 < $bulkSetCount;
515
516
        if ($nullFilter) {
517
            // default no-filter case, palm processing off to database engine - is a lot faster
518
            $resultSet = $sourceEntityInstance->skip($skip)->take($top)->with($rawLoad)
519
                ->get();
520
            $resultCount = $bulkSetCount;
521
        } elseif ($bigSet) {
522
            if (!(isset($isvalid))) {
523
                $msg = 'Filter closure not set';
524
                throw new InvalidOperationException($msg);
525
            }
526
            $resultSet = new Collection([]);
527
            $rawCount = 0;
528
            $rawTop = null === $top ? $bulkSetCount : $top;
529
530
            // loop thru, chunk by chunk, to reduce chances of exhausting memory
531
            $sourceEntityInstance->chunk(
532
                5000,
533
                function (Collection $results) use ($isvalid, &$skip, &$resultSet, &$rawCount, $rawTop) {
534
                    // apply filter
535
                    $results = $results->filter($isvalid);
536
                    // need to iterate through full result set to find count of items matching filter,
537
                    // so we can't bail out early
538
                    $rawCount += $results->count();
539
                    // now bolt on filtrate to accumulating result set if we haven't accumulated enough bitz
540
                    if ($rawTop > $resultSet->count() + $skip) {
541
                        $resultSet = collect(array_merge($resultSet->all(), $results->all()));
542
                        $sliceAmount = min($skip, $resultSet->count());
543
                        $resultSet = $resultSet->slice($sliceAmount);
544
                        $skip -= $sliceAmount;
545
                    }
546
                }
547
            );
548
549
            // clean up residual to-be-skipped records
550
            $resultSet = $resultSet->slice($skip);
551
            $resultCount = $rawCount;
552
        } else {
553
            /** @var Collection $resultSet */
554
            $resultSet = $sourceEntityInstance->with($rawLoad)->get();
555
            $resultSet = $resultSet->filter($isvalid);
556
            $resultCount = $resultSet->count();
557
558
            if (isset($skip)) {
559
                $resultSet = $resultSet->slice($skip);
560
            }
561
        }
562
        return [$bulkSetCount, $resultSet, $resultCount, $skip];
563
    }
564
}
565