Completed
Pull Request — master (#137)
by Alex
01:52
created

LaravelReadQuery::getLaravelRelationName()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
cc 2
eloc 6
nc 2
nop 1
1
<?php
2
3
namespace AlgoWeb\PODataLaravel\Query;
4
5
use AlgoWeb\PODataLaravel\Auth\NullAuthProvider;
6
use AlgoWeb\PODataLaravel\Enums\ActionVerb;
7
use AlgoWeb\PODataLaravel\Interfaces\AuthInterface;
8
use Illuminate\Database\Eloquent\Model;
9
use Illuminate\Database\Eloquent\Relations\Relation;
10
use Illuminate\Support\Facades\App;
11
use POData\Common\InvalidOperationException;
12
use POData\Common\ODataException;
13
use POData\Providers\Metadata\ResourceProperty;
14
use POData\Providers\Metadata\ResourceSet;
15
use POData\Providers\Query\QueryResult;
16
use POData\Providers\Query\QueryType;
17
use POData\UriProcessor\QueryProcessor\ExpressionParser\FilterInfo;
18
use POData\UriProcessor\QueryProcessor\OrderByParser\InternalOrderByInfo;
19
use POData\UriProcessor\QueryProcessor\SkipTokenParser\SkipTokenInfo;
20
use POData\UriProcessor\ResourcePathProcessor\SegmentParser\KeyDescriptor;
21
use Symfony\Component\Process\Exception\InvalidArgumentException;
22
23
class LaravelReadQuery
24
{
25
    const PK = 'PrimaryKey';
26
27
    protected $auth;
28
29
    public function __construct(AuthInterface $auth = null)
30
    {
31
        $this->auth = isset($auth) ? $auth : new NullAuthProvider();
32
    }
33
34
    /**
35
     * Gets collection of entities belongs to an entity set
36
     * IE: http://host/EntitySet
37
     *  http://host/EntitySet?$skip=10&$top=5&filter=Prop gt Value.
38
     *
39
     * @param QueryType                $queryType            Is this is a query for a count, entities,
40
     *                                                       or entities-with-count?
41
     * @param ResourceSet              $resourceSet          The entity set containing the entities to fetch
42
     * @param FilterInfo|null          $filterInfo           The $filter parameter of the OData query.  NULL if absent
43
     * @param null|InternalOrderByInfo $orderBy              sorted order if we want to get the data in some
44
     *                                                       specific order
45
     * @param int|null                 $top                  number of records which need to be retrieved
46
     * @param int|null                 $skip                 number of records which need to be skipped
47
     * @param SkipTokenInfo|null       $skipToken            value indicating what records to skip
48
     * @param string[]|null            $eagerLoad            array of relations to eager load
49
     * @param Model|Relation|null      $sourceEntityInstance Starting point of query
50
     *
51
     * @return QueryResult
52
     */
53
    public function getResourceSet(
54
        QueryType $queryType,
55
        ResourceSet $resourceSet,
56
        $filterInfo = null,
57
        $orderBy = null,
58
        $top = null,
59
        $skip = null,
60
        $skipToken = null,
61
        array $eagerLoad = null,
62
        $sourceEntityInstance = null
63
    ) {
64
        if (null != $filterInfo && !($filterInfo instanceof FilterInfo)) {
65
            $msg = 'Filter info must be either null or instance of FilterInfo.';
66
            throw new InvalidArgumentException($msg);
67
        }
68
        if (null != $skipToken && !($skipToken instanceof SkipTokenInfo)) {
69
            $msg = 'Skip token must be either null or instance of SkipTokenInfo.';
70
            throw new InvalidArgumentException($msg);
71
        }
72
        $rawLoad = $this->processEagerLoadList($eagerLoad);
73
        $modelLoad = [];
74
75
        $this->checkSourceInstance($sourceEntityInstance);
76
        if (null == $sourceEntityInstance) {
77
            $sourceEntityInstance = $this->getSourceEntityInstance($resourceSet);
78
        }
79
80
        $keyName = null;
81
        if ($sourceEntityInstance instanceof Model) {
82
            $modelLoad = $sourceEntityInstance->getEagerLoad();
83
            $keyName = $sourceEntityInstance->getKeyName();
84
        } elseif ($sourceEntityInstance instanceof Relation) {
85
            $modelLoad = $sourceEntityInstance->getRelated()->getEagerLoad();
86
            $keyName = $sourceEntityInstance->getRelated()->getKeyName();
87
        }
88
        assert(isset($keyName));
89
        $rawLoad = array_values(array_unique(array_merge($rawLoad, $modelLoad)));
90
91
        $checkInstance = $sourceEntityInstance instanceof Model ? $sourceEntityInstance : null;
92
        $this->checkAuth($sourceEntityInstance, $checkInstance);
93
94
        $result          = new QueryResult();
95
        $result->results = null;
96
        $result->count   = null;
97
98
        if (null != $orderBy) {
99
            foreach ($orderBy->getOrderByInfo()->getOrderByPathSegments() as $order) {
100
                foreach ($order->getSubPathSegments() as $subOrder) {
101
                    $subName = $subOrder->getName();
102
                    $subName = (self::PK == $subName) ? $keyName : $subName;
103
                    $sourceEntityInstance = $sourceEntityInstance->orderBy(
104
                        $subName,
105
                        $order->isAscending() ? 'asc' : 'desc'
106
                    );
107
                }
108
            }
109
        }
110
111
        // throttle up for trench run
112
        if (null != $skipToken) {
113
            $parameters = [];
114
            $processed = [];
115
            $segments = $skipToken->getOrderByInfo()->getOrderByPathSegments();
116
            $values = $skipToken->getOrderByKeysInToken();
117
            $numValues = count($values);
118
            assert($numValues == count($segments));
119
120
            for ($i = 0; $i < $numValues; $i++) {
121
                $relation = $segments[$i]->isAscending() ? '>' : '<';
122
                $name = $segments[$i]->getSubPathSegments()[0]->getName();
123
                $name = (self::PK == $name) ? $keyName : $name;
124
                $parameters[$name] = ['direction' => $relation, 'value' => trim($values[$i][0], '\'')];
125
            }
126
127
            foreach ($parameters as $name => $line) {
128
                $processed[$name] = ['direction' => $line['direction'], 'value' => $line['value']];
129
                $sourceEntityInstance = $sourceEntityInstance
130
                    ->orWhere(
131
                        function ($query) use ($processed) {
132
                            foreach ($processed as $key => $proc) {
133
                                $query->where($key, $proc['direction'], $proc['value']);
134
                            }
135
                        }
136
                    );
137
                // now we've handled the later-in-order segment for this key, drop it back to equality in prep
138
                // for next key - same-in-order for processed keys and later-in-order for next
139
                $processed[$name]['direction'] = '=';
140
            }
141
        }
142
143
        if (!isset($skip)) {
144
            $skip = 0;
145
        }
146
        if (!isset($top)) {
147
            $top = PHP_INT_MAX;
148
        }
149
150
        $nullFilter = true;
151
        $isvalid = null;
152
        if (isset($filterInfo)) {
153
            $method = 'return ' . $filterInfo->getExpressionAsString() . ';';
154
            $clln = '$' . $resourceSet->getResourceType()->getName();
155
            $isvalid = create_function($clln, $method);
0 ignored issues
show
Security Best Practice introduced by
The use of create_function is highly discouraged, better use a closure.

create_function can pose a great security vulnerability as it is similar to eval, and could be used for arbitrary code execution. We highly recommend to use a closure instead.

// Instead of
$function = create_function('$a, $b', 'return $a + $b');

// Better use
$function = function($a, $b) { return $a + $b; }
Loading history...
156
            $nullFilter = false;
157
        }
158
159
        $bulkSetCount = $sourceEntityInstance->count();
160
        $bigSet = 20000 < $bulkSetCount;
161
162
        if ($nullFilter) {
163
            // default no-filter case, palm processing off to database engine - is a lot faster
164
            $resultSet = $sourceEntityInstance->skip($skip)->take($top)->with($rawLoad)->get();
165
            $resultCount = $bulkSetCount;
166
        } elseif ($bigSet) {
167
            assert(isset($isvalid), 'Filter closure not set');
168
            $resultSet = collect([]);
169
            $rawCount = 0;
170
            $rawTop = null === $top ? $bulkSetCount : $top;
171
172
            // loop thru, chunk by chunk, to reduce chances of exhausting memory
173
            $sourceEntityInstance->chunk(
174
                5000,
175
                function ($results) use ($isvalid, &$skip, &$resultSet, &$rawCount, $rawTop) {
176
                    // apply filter
177
                    $results = $results->filter($isvalid);
178
                    // need to iterate through full result set to find count of items matching filter,
179
                    // so we can't bail out early
180
                    $rawCount += $results->count();
181
                    // now bolt on filtrate to accumulating result set if we haven't accumulated enough bitz
182
                    if ($rawTop > $resultSet->count()+$skip) {
183
                        $resultSet = collect(array_merge($resultSet->all(), $results->all()));
184
                        $sliceAmount = min($skip, $resultSet->count());
185
                        $resultSet = $resultSet->slice($sliceAmount);
186
                        $skip -= $sliceAmount;
187
                    }
188
                }
189
            );
190
191
            // clean up residual to-be-skipped records
192
            $resultSet = $resultSet->slice($skip);
193
            $resultCount = $rawCount;
194
        } else {
195
            if ($sourceEntityInstance instanceof Model) {
196
                $sourceEntityInstance = $sourceEntityInstance->getQuery();
197
            }
198
            $resultSet = $sourceEntityInstance->with($rawLoad)->get();
199
            $resultSet = $resultSet->filter($isvalid);
200
            $resultCount = $resultSet->count();
201
202
            if (isset($skip)) {
203
                $resultSet = $resultSet->slice($skip);
204
            }
205
        }
206
207
        if (isset($top)) {
208
            $resultSet = $resultSet->take($top);
209
        }
210
211
        if (QueryType::ENTITIES() == $queryType || QueryType::ENTITIES_WITH_COUNT() == $queryType) {
212
            $result->results = [];
213
            foreach ($resultSet as $res) {
214
                $result->results[] = $res;
215
            }
216
        }
217
        if (QueryType::COUNT() == $queryType || QueryType::ENTITIES_WITH_COUNT() == $queryType) {
218
            $result->count = $resultCount;
219
        }
220
        $hazMore = $bulkSetCount > $skip+count($resultSet);
221
        $result->hasMore = $hazMore;
222
        return $result;
223
    }
224
225
    /**
226
     * Get related resource set for a resource
227
     * IE: http://host/EntitySet(1L)/NavigationPropertyToCollection
228
     * http://host/EntitySet?$expand=NavigationPropertyToCollection.
229
     *
230
     * @param QueryType          $queryType            Is this is a query for a count, entities, or entities-with-count
231
     * @param ResourceSet        $sourceResourceSet    The entity set containing the source entity
232
     * @param Model              $sourceEntityInstance The source entity instance
233
     * @param ResourceSet        $targetResourceSet    The resource set pointed to by the navigation property
234
     * @param ResourceProperty   $targetProperty       The navigation property to retrieve
235
     * @param FilterInfo|null    $filter               The $filter parameter of the OData query.  NULL if none specified
236
     * @param mixed|null         $orderBy              sorted order if we want to get the data in some specific order
237
     * @param int|null           $top                  number of records which need to be retrieved
238
     * @param int|null           $skip                 number of records which need to be skipped
239
     * @param SkipTokenInfo|null $skipToken            value indicating what records to skip
240
     *
241
     * @return QueryResult
242
     */
243
    public function getRelatedResourceSet(
244
        QueryType $queryType,
245
        ResourceSet $sourceResourceSet,
246
        Model $sourceEntityInstance,
247
        ResourceSet $targetResourceSet,
0 ignored issues
show
Unused Code introduced by
The parameter $targetResourceSet is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
248
        ResourceProperty $targetProperty,
249
        FilterInfo $filter = null,
250
        $orderBy = null,
251
        $top = null,
252
        $skip = null,
253
        SkipTokenInfo $skipToken = null
254
    ) {
255
        $this->checkAuth($sourceEntityInstance);
256
257
        $propertyName = $targetProperty->getName();
258
        $results = $sourceEntityInstance->$propertyName();
259
260
        return $this->getResourceSet(
261
            $queryType,
262
            $sourceResourceSet,
263
            $filter,
264
            $orderBy,
265
            $top,
266
            $skip,
267
            $skipToken,
268
            null,
269
            $results
270
        );
271
    }
272
273
    /**
274
     * Gets an entity instance from an entity set identified by a key
275
     * IE: http://host/EntitySet(1L)
276
     * http://host/EntitySet(KeyA=2L,KeyB='someValue').
277
     *
278
     * @param ResourceSet        $resourceSet   The entity set containing the entity to fetch
279
     * @param KeyDescriptor|null $keyDescriptor The key identifying the entity to fetch
280
     * @param string[]|null      $eagerLoad     array of relations to eager load
281
     *
282
     * @return Model|null Returns entity instance if found else null
283
     */
284
    public function getResourceFromResourceSet(
285
        ResourceSet $resourceSet,
286
        KeyDescriptor $keyDescriptor = null,
287
        array $eagerLoad = null
288
    ) {
289
        return $this->getResource($resourceSet, $keyDescriptor, [], $eagerLoad);
290
    }
291
292
293
    /**
294
     * Common method for getResourceFromRelatedResourceSet() and getResourceFromResourceSet().
295
     *
296
     * @param ResourceSet|null    $resourceSet
297
     * @param KeyDescriptor|null  $keyDescriptor
298
     * @param Model|Relation|null $sourceEntityInstance Starting point of query
299
     *                                                  $param array               $whereCondition
300
     * @param string[]|null       $eagerLoad            array of relations to eager load
301
     *
302
     * @return Model|null
303
     */
304
    public function getResource(
305
        ResourceSet $resourceSet = null,
306
        KeyDescriptor $keyDescriptor = null,
307
        array $whereCondition = [],
308
        array $eagerLoad = null,
309
        $sourceEntityInstance = null
310
    ) {
311
        if (null == $resourceSet && null == $sourceEntityInstance) {
312
            $msg = 'Must supply at least one of a resource set and source entity.';
313
            throw new \Exception($msg);
314
        }
315
316
        $this->checkSourceInstance($sourceEntityInstance);
317
        $rawLoad = $this->processEagerLoadList($eagerLoad);
318
319
        if (null == $sourceEntityInstance) {
320
            assert(null != $resourceSet);
321
            $sourceEntityInstance = $this->getSourceEntityInstance($resourceSet);
322
        }
323
324
        $this->checkAuth($sourceEntityInstance);
325
        if ($sourceEntityInstance instanceof Model) {
326
            $modelLoad = $sourceEntityInstance->getEagerLoad();
327
        } elseif ($sourceEntityInstance instanceof Relation) {
328
            $modelLoad = $sourceEntityInstance->getRelated()->getEagerLoad();
329
        }
330
        assert(isset($modelLoad));
331
332
        $this->processKeyDescriptor($sourceEntityInstance, $keyDescriptor);
333
        foreach ($whereCondition as $fieldName => $fieldValue) {
334
            $sourceEntityInstance = $sourceEntityInstance->where($fieldName, $fieldValue);
335
        }
336
337
        $rawLoad = array_values(array_unique(array_merge($rawLoad, $modelLoad)));
0 ignored issues
show
Unused Code introduced by
$rawLoad is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
338
        $sourceEntityInstance = $sourceEntityInstance->get();
339
        $sourceCount = $sourceEntityInstance->count();
340
        if (0 == $sourceCount) {
341
            return null;
342
        }
343
        $result = $sourceEntityInstance->first();
344
        $result->PrimaryKey = $result->getKey();
345
346
        return $result;
347
    }
348
349
    /**
350
     * Get related resource for a resource
351
     * IE: http://host/EntitySet(1L)/NavigationPropertyToSingleEntity
352
     * http://host/EntitySet?$expand=NavigationPropertyToSingleEntity.
353
     *
354
     * @param ResourceSet      $sourceResourceSet    The entity set containing the source entity
355
     * @param Model            $sourceEntityInstance the source entity instance
356
     * @param ResourceSet      $targetResourceSet    The entity set containing the entity pointed to by the nav property
357
     * @param ResourceProperty $targetProperty       The navigation property to fetch
358
     *
359
     * @return object|null The related resource if found else null
0 ignored issues
show
Documentation introduced by
Consider making the return type a bit more specific; maybe use null|Model.

This check looks for the generic type array as a return type and suggests a more specific type. This type is inferred from the actual code.

Loading history...
360
     */
361
    public function getRelatedResourceReference(
362
        ResourceSet $sourceResourceSet,
0 ignored issues
show
Unused Code introduced by
The parameter $sourceResourceSet is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
363
        Model $sourceEntityInstance,
364
        ResourceSet $targetResourceSet,
365
        ResourceProperty $targetProperty
366
    ) {
367
        $this->checkAuth($sourceEntityInstance);
368
369
        $propertyName = $targetProperty->getName();
370
        $propertyName = $this->getLaravelRelationName($propertyName);
371
        $result = $sourceEntityInstance->$propertyName;
372
        if (null === $result) {
373
            return null;
374
        }
375
        assert($result instanceof Model, get_class($result));
376
        if ($targetProperty->getResourceType()->getInstanceType()->getName() != get_class($result)) {
377
            return null;
378
        }
379
        return $result;
380
    }
381
382
    /**
383
     * Gets a related entity instance from an entity set identified by a key
384
     * IE: http://host/EntitySet(1L)/NavigationPropertyToCollection(33).
385
     *
386
     * @param ResourceSet      $sourceResourceSet    The entity set containing the source entity
387
     * @param Model            $sourceEntityInstance the source entity instance
388
     * @param ResourceSet      $targetResourceSet    The entity set containing the entity to fetch
389
     * @param ResourceProperty $targetProperty       the metadata of the target property
390
     * @param KeyDescriptor    $keyDescriptor        The key identifying the entity to fetch
391
     *
392
     * @return Model|null Returns entity instance if found else null
393
     */
394
    public function getResourceFromRelatedResourceSet(
395
        ResourceSet $sourceResourceSet,
0 ignored issues
show
Unused Code introduced by
The parameter $sourceResourceSet is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
396
        Model $sourceEntityInstance,
397
        ResourceSet $targetResourceSet,
398
        ResourceProperty $targetProperty,
399
        KeyDescriptor $keyDescriptor
400
    ) {
401
        $propertyName = $targetProperty->getName();
402
        if (!method_exists($sourceEntityInstance, $propertyName)) {
403
            $msg = 'Relation method, ' . $propertyName . ', does not exist on supplied entity.';
404
            throw new InvalidArgumentException($msg);
405
        }
406
        // take key descriptor and turn it into where clause here, rather than in getResource call
407
        $sourceEntityInstance = $sourceEntityInstance->$propertyName();
408
        $this->processKeyDescriptor($sourceEntityInstance, $keyDescriptor);
409
        $result = $this->getResource(null, null, [], [], $sourceEntityInstance);
410
        assert(
411
            $result instanceof Model || null == $result,
412
            'GetResourceFromRelatedResourceSet must return an entity or null'
413
        );
414
        return $result;
415
    }
416
417
418
    /**
419
     * @param  ResourceSet $resourceSet
420
     * @return mixed
421
     */
422
    protected function getSourceEntityInstance(ResourceSet $resourceSet)
423
    {
424
        $entityClassName = $resourceSet->getResourceType()->getInstanceType()->name;
425
        return App::make($entityClassName);
426
    }
427
428
    /**
429
     * @param Model|Relation|null $source
430
     */
431
    protected function checkSourceInstance($source)
432
    {
433
        if (!(null == $source || $source instanceof Model || $source instanceof Relation)) {
434
            $msg = 'Source entity instance must be null, a model, or a relation.';
435
            throw new InvalidArgumentException($msg);
436
        }
437
    }
438
439
    protected function getAuth()
440
    {
441
        return $this->auth;
442
    }
443
444
    /**
445
     * @param $sourceEntityInstance
446
     * @param null|mixed $checkInstance
447
     *
448
     * @throws ODataException
449
     */
450
    private function checkAuth($sourceEntityInstance, $checkInstance = null)
451
    {
452
        $check = $checkInstance instanceof Model ? $checkInstance
453
            : $checkInstance instanceof Relation ? $checkInstance
454
                : $sourceEntityInstance instanceof Model ? $sourceEntityInstance
455
                    : $sourceEntityInstance instanceof Relation ? $sourceEntityInstance
456
                        : null;
457
        if (!$this->getAuth()->canAuth(ActionVerb::READ(), get_class($sourceEntityInstance), $check)) {
458
            throw new ODataException('Access denied', 403);
459
        }
460
    }
461
462
    /**
463
     * @param $sourceEntityInstance
464
     * @param  KeyDescriptor|null        $keyDescriptor
465
     * @throws InvalidOperationException
466
     */
467
    private function processKeyDescriptor(&$sourceEntityInstance, KeyDescriptor $keyDescriptor = null)
468
    {
469
        if ($keyDescriptor) {
470
            foreach ($keyDescriptor->getValidatedNamedValues() as $key => $value) {
471
                $key = (self::PK == $key) ? $sourceEntityInstance->getKeyName() : $key;
472
                $trimValue = trim($value[0], '\'');
473
                $sourceEntityInstance = $sourceEntityInstance->where($key, $trimValue);
474
            }
475
        }
476
    }
477
478
    /**
479
     * @param  string[]|null $eagerLoad
480
     * @return array
481
     */
482
    private function processEagerLoadList(array $eagerLoad = null)
483
    {
484
        $load = (null === $eagerLoad) ? [] : $eagerLoad;
485
        $rawLoad = [];
486
        foreach ($load as $line) {
487
            assert(is_string($line), 'Eager-load elements must be non-empty strings');
488
            $lineParts = explode('/', $line);
489
            $numberOfParts = count($lineParts);
490
            for ($i = 0; $i<$numberOfParts; $i++) {
491
                $lineParts[$i] = $this->getLaravelRelationName($lineParts[$i]);
492
            }
493
            $remixLine = implode('.', $lineParts);
494
            $rawLoad[] = $remixLine;
495
        }
496
        return $rawLoad;
497
    }
498
499
    /**
500
     * @param  string $odataProperty
501
     * @return string
502
     */
503
    private function getLaravelRelationName($odataProperty)
504
    {
505
        $laravelProperty = $odataProperty;
506
        $pos = strrpos($laravelProperty, '_');
507
        if ($pos !== false) {
508
            $laravelProperty = substr($laravelProperty, 0, $pos);
509
        }
510
        return $laravelProperty;
511
    }
512
}
513