Passed
Pull Request — master (#182)
by Alex
05:28
created

LaravelReadQuery::getAuth()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
eloc 1
nc 1
nop 0
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 = true;
108
        $isvalid = null;
109
        if (isset($filterInfo)) {
110
            $method = 'return ' . $filterInfo->getExpressionAsString() . ';';
111
            $clln = '$' . $resourceSet->getResourceType()->getName();
112
            $isvalid = create_function($clln, $method);
0 ignored issues
show
Deprecated Code introduced by
The function create_function() has been deprecated: 7.2 ( Ignorable by Annotation )

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

112
            $isvalid = /** @scrutinizer ignore-deprecated */ create_function($clln, $method);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
113
            $nullFilter = false;
114
        }
115
116
        list($bulkSetCount, $resultSet, $resultCount, $skip) = $this->applyFiltering(
117
            $top,
118
            $skip,
119
            $sourceEntityInstance,
120
            $nullFilter,
121
            $rawLoad,
122
            $isvalid
123
        );
124
125
        if (isset($top)) {
126
            $resultSet = $resultSet->take($top);
127
        }
128
129
        $qVal = $queryType->getValue();
130
        if (QueryType::ENTITIES()->getValue() == $qVal || QueryType::ENTITIES_WITH_COUNT()->getValue() == $qVal) {
131
            $result->results = [];
132
            foreach ($resultSet as $res) {
133
                $result->results[] = $res;
134
            }
135
        }
136
        if (QueryType::COUNT()->getValue() == $qVal || QueryType::ENTITIES_WITH_COUNT()->getValue() == $qVal) {
137
            $result->count = $resultCount;
138
        }
139
        $hazMore = $bulkSetCount > $skip+count($resultSet);
140
        $result->hasMore = $hazMore;
141
        return $result;
142
    }
143
144
    /**
145
     * Get related resource set for a resource
146
     * IE: http://host/EntitySet(1L)/NavigationPropertyToCollection
147
     * http://host/EntitySet?$expand=NavigationPropertyToCollection.
148
     *
149
     * @param QueryType          $queryType            Is this is a query for a count, entities, or entities-with-count
150
     * @param ResourceSet        $sourceResourceSet    The entity set containing the source entity
151
     * @param Model              $sourceEntityInstance The source entity instance
152
     * @param ResourceSet        $targetResourceSet    The resource set pointed to by the navigation property
153
     * @param ResourceProperty   $targetProperty       The navigation property to retrieve
154
     * @param FilterInfo|null    $filter               The $filter parameter of the OData query.  NULL if none specified
155
     * @param mixed|null         $orderBy              sorted order if we want to get the data in some specific order
156
     * @param int|null           $top                  number of records which need to be retrieved
157
     * @param int|null           $skip                 number of records which need to be skipped
158
     * @param SkipTokenInfo|null $skipToken            value indicating what records to skip
159
     *
160
     * @return QueryResult
161
     * @throws InvalidOperationException
162
     * @throws ODataException
163
     * @throws \ReflectionException
164
     */
165
    public function getRelatedResourceSet(
166
        QueryType $queryType,
167
        ResourceSet $sourceResourceSet,
168
        Model $sourceEntityInstance,
169
        /** @noinspection PhpUnusedParameterInspection */
170
        ResourceSet $targetResourceSet,
171
        ResourceProperty $targetProperty,
172
        FilterInfo $filter = null,
173
        $orderBy = null,
174
        $top = null,
175
        $skip = null,
176
        SkipTokenInfo $skipToken = null
177
    ) {
178
        $this->checkAuth($sourceEntityInstance);
179
180
        $propertyName = $targetProperty->getName();
181
        $results = $sourceEntityInstance->$propertyName();
182
183
        return $this->getResourceSet(
184
            $queryType,
185
            $sourceResourceSet,
186
            $filter,
187
            $orderBy,
188
            $top,
189
            $skip,
190
            $skipToken,
191
            null,
192
            $results
193
        );
194
    }
195
196
    /**
197
     * Gets an entity instance from an entity set identified by a key
198
     * IE: http://host/EntitySet(1L)
199
     * http://host/EntitySet(KeyA=2L,KeyB='someValue').
200
     *
201
     * @param ResourceSet        $resourceSet   The entity set containing the entity to fetch
202
     * @param KeyDescriptor|null $keyDescriptor The key identifying the entity to fetch
203
     * @param string[]|null      $eagerLoad     array of relations to eager load
204
     *
205
     * @return Model|null Returns entity instance if found else null
206
     * @throws \Exception;
207
     */
208
    public function getResourceFromResourceSet(
209
        ResourceSet $resourceSet,
210
        KeyDescriptor $keyDescriptor = null,
211
        array $eagerLoad = null
212
    ) {
213
        return $this->getResource($resourceSet, $keyDescriptor, [], $eagerLoad);
214
    }
215
216
217
    /**
218
     * Common method for getResourceFromRelatedResourceSet() and getResourceFromResourceSet().
219
     *
220
     * @param ResourceSet|null    $resourceSet
221
     * @param KeyDescriptor|null  $keyDescriptor
222
     * @param Model|Relation|null $sourceEntityInstance Starting point of query
223
     * @param array               $whereCondition
224
     * @param string[]|null       $eagerLoad            array of relations to eager load
225
     *
226
     * @return Model|null
227
     * @throws \Exception
228
     */
229
    public function getResource(
230
        ResourceSet $resourceSet = null,
231
        KeyDescriptor $keyDescriptor = null,
232
        array $whereCondition = [],
233
        array $eagerLoad = null,
234
        $sourceEntityInstance = null
235
    ) {
236
        if (null == $resourceSet && null == $sourceEntityInstance) {
237
            $msg = 'Must supply at least one of a resource set and source entity.';
238
            throw new \Exception($msg);
239
        }
240
241
        $sourceEntityInstance = $this->checkSourceInstance($sourceEntityInstance, $resourceSet);
242
243
        $this->checkAuth($sourceEntityInstance);
244
        $modelLoad = null;
245
        if ($sourceEntityInstance instanceof Model) {
246
            $modelLoad = $sourceEntityInstance->getEagerLoad();
247
        } elseif ($sourceEntityInstance instanceof Relation) {
248
            /** @var MetadataTrait $model */
249
            $model = $sourceEntityInstance->getRelated();
250
            $modelLoad = $model->getEagerLoad();
251
        }
252
        if (!(isset($modelLoad))) {
253
            throw new InvalidOperationException('');
254
        }
255
256
        $this->processKeyDescriptor(/** @scrutinizer ignore-type */$sourceEntityInstance, $keyDescriptor);
257
        foreach ($whereCondition as $fieldName => $fieldValue) {
258
            $sourceEntityInstance = $sourceEntityInstance->where($fieldName, $fieldValue);
259
        }
260
261
        $sourceEntityInstance = $sourceEntityInstance->get();
262
        $sourceCount = $sourceEntityInstance->count();
263
        if (0 == $sourceCount) {
264
            return null;
265
        }
266
        $result = $sourceEntityInstance->first();
267
268
        return $result;
269
    }
270
271
    /**
272
     * Get related resource for a resource
273
     * IE: http://host/EntitySet(1L)/NavigationPropertyToSingleEntity
274
     * http://host/EntitySet?$expand=NavigationPropertyToSingleEntity.
275
     *
276
     * @param ResourceSet      $sourceResourceSet    The entity set containing the source entity
277
     * @param Model            $sourceEntityInstance the source entity instance
278
     * @param ResourceSet      $targetResourceSet    The entity set containing the entity pointed to by the nav property
279
     * @param ResourceProperty $targetProperty       The navigation property to fetch
280
     *
281
     * @return Model|null The related resource if found else null
282
     * @throws ODataException
283
     * @throws InvalidOperationException
284
     * @throws \ReflectionException
285
     */
286
    public function getRelatedResourceReference(
287
        /** @noinspection PhpUnusedParameterInspection */
288
        ResourceSet $sourceResourceSet,
289
        Model $sourceEntityInstance,
290
        ResourceSet $targetResourceSet,
291
        ResourceProperty $targetProperty
292
    ) {
293
        $this->checkAuth($sourceEntityInstance);
294
295
        $propertyName = $targetProperty->getName();
296
        $propertyName = $this->getLaravelRelationName($propertyName);
297
        $result = $sourceEntityInstance->$propertyName()->first();
298
        if (null === $result) {
299
            return null;
300
        }
301
        if (!$result instanceof Model) {
302
            throw new InvalidOperationException('Model not retrieved from Eloquent relation');
303
        }
304
        if ($targetProperty->getResourceType()->getInstanceType()->getName() != get_class($result)) {
305
            return null;
306
        }
307
        return $result;
308
    }
309
310
    /**
311
     * Gets a related entity instance from an entity set identified by a key
312
     * IE: http://host/EntitySet(1L)/NavigationPropertyToCollection(33).
313
     *
314
     * @param ResourceSet      $sourceResourceSet    The entity set containing the source entity
315
     * @param Model            $sourceEntityInstance the source entity instance
316
     * @param ResourceSet      $targetResourceSet    The entity set containing the entity to fetch
317
     * @param ResourceProperty $targetProperty       the metadata of the target property
318
     * @param KeyDescriptor    $keyDescriptor        The key identifying the entity to fetch
319
     *
320
     * @return Model|null Returns entity instance if found else null
321
     * @throws InvalidOperationException
322
     * @throws \Exception
323
     */
324
    public function getResourceFromRelatedResourceSet(
325
        /** @noinspection PhpUnusedParameterInspection */
326
        ResourceSet $sourceResourceSet,
327
        Model $sourceEntityInstance,
328
        ResourceSet $targetResourceSet,
329
        ResourceProperty $targetProperty,
330
        KeyDescriptor $keyDescriptor
331
    ) {
332
        $propertyName = $targetProperty->getName();
333
        if (!method_exists($sourceEntityInstance, $propertyName)) {
334
            $msg = 'Relation method, ' . $propertyName . ', does not exist on supplied entity.';
335
            throw new InvalidArgumentException($msg);
336
        }
337
        // take key descriptor and turn it into where clause here, rather than in getResource call
338
        $sourceEntityInstance = $sourceEntityInstance->$propertyName();
339
        $this->processKeyDescriptor($sourceEntityInstance, $keyDescriptor);
340
        $result = $this->getResource(null, null, [], [], $sourceEntityInstance);
341
        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...
342
            $msg = 'GetResourceFromRelatedResourceSet must return an entity or null';
343
            throw new InvalidOperationException($msg);
344
        }
345
        return $result;
346
    }
347
348
    /**
349
     * @param  ResourceSet $resourceSet
350
     * @return mixed
351
     * @throws \ReflectionException
352
     */
353
    protected function getSourceEntityInstance(ResourceSet $resourceSet)
354
    {
355
        $entityClassName = $resourceSet->getResourceType()->getInstanceType()->name;
356
        return App::make($entityClassName);
357
    }
358
359
    /**
360
     * @param Model|Relation|null $source
361
     * @param ResourceSet|null $resourceSet
362
     * @return Model|Relation|mixed|null
363
     * @throws \ReflectionException
364
     */
365
    protected function checkSourceInstance($source, ResourceSet $resourceSet = null)
366
    {
367
        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...
368
            $msg = 'Source entity instance must be null, a model, or a relation.';
369
            throw new InvalidArgumentException($msg);
370
        }
371
372
        if (null == $source) {
373
            $source = $this->getSourceEntityInstance(/** @scrutinizer ignore-type */$resourceSet);
374
        }
375
376
        return $source;
377
    }
378
379
    /**
380
     * @param Model|Relation|null $sourceEntityInstance
381
     * @param null|mixed $checkInstance
382
     *
383
     * @throws ODataException
384
     */
385
    private function checkAuth($sourceEntityInstance, $checkInstance = null)
386
    {
387
        $check = $checkInstance instanceof Model ? $checkInstance
388
            : $checkInstance instanceof Relation ? $checkInstance
389
                : $sourceEntityInstance instanceof Model ? $sourceEntityInstance
390
                    : $sourceEntityInstance instanceof Relation ? $sourceEntityInstance
391
                        : null;
392
        if (!$this->getAuth()->canAuth(ActionVerb::READ(), $sourceEntityInstance, $check)) {
393
            throw new ODataException('Access denied', 403);
394
        }
395
    }
396
397
    /**
398
     * @param Model|Builder $sourceEntityInstance
399
     * @param  KeyDescriptor|null        $keyDescriptor
400
     * @throws InvalidOperationException
401
     */
402
    private function processKeyDescriptor(&$sourceEntityInstance, KeyDescriptor $keyDescriptor = null)
403
    {
404
        if ($keyDescriptor) {
405
            $table = ($sourceEntityInstance instanceof Model) ? $sourceEntityInstance->getTable().'.' : '';
406
            foreach ($keyDescriptor->getValidatedNamedValues() as $key => $value) {
407
                $trimValue = trim($value[0], '\'');
408
                $sourceEntityInstance = $sourceEntityInstance->where($table.$key, $trimValue);
409
            }
410
        }
411
    }
412
413
    /**
414
     * @param  string[]|null $eagerLoad
415
     * @return array
416
     * @throws InvalidOperationException
417
     */
418
    private function processEagerLoadList(array $eagerLoad = null)
419
    {
420
        $load = (null === $eagerLoad) ? [] : $eagerLoad;
421
        $rawLoad = [];
422
        foreach ($load as $line) {
423
            if (!is_string($line)) {
424
                throw new InvalidOperationException('Eager-load elements must be non-empty strings');
425
            }
426
            $lineParts = explode('/', $line);
427
            $numberOfParts = count($lineParts);
428
            for ($i = 0; $i<$numberOfParts; $i++) {
429
                $lineParts[$i] = $this->getLaravelRelationName($lineParts[$i]);
430
            }
431
            $remixLine = implode('.', $lineParts);
432
            $rawLoad[] = $remixLine;
433
        }
434
        return $rawLoad;
435
    }
436
437
    /**
438
     * @param  string $odataProperty
439
     * @return string
440
     */
441
    private function getLaravelRelationName($odataProperty)
442
    {
443
        $laravelProperty = $odataProperty;
444
        $pos = strrpos($laravelProperty, '_');
445
        if ($pos !== false) {
446
            $laravelProperty = substr($laravelProperty, 0, $pos);
447
        }
448
        return $laravelProperty;
449
    }
450
451
    /**
452
     * @param SkipTokenInfo $skipToken
453
     * @param Model|Builder $sourceEntityInstance
454
     * @return mixed
455
     * @throws InvalidOperationException
456
     */
457
    protected function processSkipToken(SkipTokenInfo $skipToken, $sourceEntityInstance)
458
    {
459
        $parameters = [];
460
        $processed = [];
461
        $segments = $skipToken->getOrderByInfo()->getOrderByPathSegments();
462
        $values = $skipToken->getOrderByKeysInToken();
463
        $numValues = count($values);
464
        if ($numValues != count($segments)) {
465
            $msg = 'Expected '.count($segments).', got '.$numValues;
466
            throw new InvalidOperationException($msg);
467
        }
468
469
        for ($i = 0; $i < $numValues; $i++) {
470
            $relation = $segments[$i]->isAscending() ? '>' : '<';
471
            $name = $segments[$i]->getSubPathSegments()[0]->getName();
472
            $parameters[$name] = ['direction' => $relation, 'value' => trim($values[$i][0], '\'')];
473
        }
474
475
        foreach ($parameters as $name => $line) {
476
            $processed[$name] = ['direction' => $line['direction'], 'value' => $line['value']];
477
            $sourceEntityInstance = $sourceEntityInstance
478
                ->orWhere(
479
                    function (Builder $query) use ($processed) {
480
                        foreach ($processed as $key => $proc) {
481
                            $query->where($key, $proc['direction'], $proc['value']);
482
                        }
483
                    }
484
                );
485
            // now we've handled the later-in-order segment for this key, drop it back to equality in prep
486
            // for next key - same-in-order for processed keys and later-in-order for next
487
            $processed[$name]['direction'] = '=';
488
        }
489
        return $sourceEntityInstance;
490
    }
491
492
    /**
493
     * @param $top
494
     * @param $skip
495
     * @param Model|Builder $sourceEntityInstance
496
     * @param $nullFilter
497
     * @param $rawLoad
498
     * @param callable|null $isvalid
499
     * @return array
500
     * @throws InvalidOperationException
501
     */
502
    protected function applyFiltering(
503
        $top,
504
        $skip,
505
        $sourceEntityInstance,
506
        $nullFilter,
507
        $rawLoad,
508
        callable $isvalid = null
509
    ) {
510
        $bulkSetCount = $sourceEntityInstance->count();
511
        $bigSet = 20000 < $bulkSetCount;
512
513
        if ($nullFilter) {
514
            // default no-filter case, palm processing off to database engine - is a lot faster
515
            $resultSet = $sourceEntityInstance->skip($skip)->take($top)->with($rawLoad)->get();
516
            $resultCount = $bulkSetCount;
517
        } elseif ($bigSet) {
518
            if (!(isset($isvalid))) {
519
                $msg = 'Filter closure not set';
520
                throw new InvalidOperationException($msg);
521
            }
522
            $resultSet = new Collection([]);
523
            $rawCount = 0;
524
            $rawTop = null === $top ? $bulkSetCount : $top;
525
526
            // loop thru, chunk by chunk, to reduce chances of exhausting memory
527
            $sourceEntityInstance->chunk(
528
                5000,
529
                function (Collection $results) use ($isvalid, &$skip, &$resultSet, &$rawCount, $rawTop) {
530
                    // apply filter
531
                    $results = $results->filter($isvalid);
532
                    // need to iterate through full result set to find count of items matching filter,
533
                    // so we can't bail out early
534
                    $rawCount += $results->count();
535
                    // now bolt on filtrate to accumulating result set if we haven't accumulated enough bitz
536
                    if ($rawTop > $resultSet->count() + $skip) {
537
                        $resultSet = collect(array_merge($resultSet->all(), $results->all()));
538
                        $sliceAmount = min($skip, $resultSet->count());
539
                        $resultSet = $resultSet->slice($sliceAmount);
540
                        $skip -= $sliceAmount;
541
                    }
542
                }
543
            );
544
545
            // clean up residual to-be-skipped records
546
            $resultSet = $resultSet->slice($skip);
547
            $resultCount = $rawCount;
548
        } else {
549
            if ($sourceEntityInstance instanceof Model) {
550
                /** @var Builder $sourceEntityInstance */
551
                $sourceEntityInstance = $sourceEntityInstance->getQuery();
552
            }
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