WhereFilter::getAssociationMetadataForProperty()   B
last analyzed

Complexity

Conditions 6
Paths 8

Size

Total Lines 34
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 0
Metric Value
dl 0
loc 34
rs 8.439
c 2
b 1
f 0
cc 6
eloc 20
nc 8
nop 3
1
<?php
2
3
/*
4
 * This file is part of the LoopBackApiBundle package.
5
 *
6
 * (c) Théo FIDRY <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Fidry\LoopBackApiBundle\Filter;
13
14
use Doctrine\Common\Persistence\ManagerRegistry;
15
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
16
use Doctrine\ORM\Query\Expr;
17
use Doctrine\ORM\QueryBuilder;
18
use Dunglas\ApiBundle\Api\IriConverterInterface;
19
use Dunglas\ApiBundle\Api\ResourceInterface;
20
use Dunglas\ApiBundle\Doctrine\Orm\Filter\FilterInterface;
21
use Fidry\LoopBackApiBundle\Http\Request\FilterQueryExtractorInterface;
22
use Symfony\Component\HttpFoundation\RequestStack;
23
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
24
25
/**
26
 * Class SearchFilter.
27
 *
28
 * @author Théo FIDRY <[email protected]>
29
 */
30
class WhereFilter implements FilterInterface
31
{
32
    const PARAMETER_OPERATOR_OR = 'or';
33
    const PARAMETER_OPERATOR_GT = 'gt';
34
    const PARAMETER_OPERATOR_GTE = 'gte';
35
    const PARAMETER_OPERATOR_LT = 'lt';
36
    const PARAMETER_OPERATOR_LTE = 'lte';
37
    const PARAMETER_OPERATOR_BETWEEN = 'between';
38
    const PARAMETER_OPERATOR_NEQ = 'neq';
39
    const PARAMETER_OPERATOR_LIKE = 'like';
40
    const PARAMETER_OPERATOR_NLIKE = 'nlike';
41
42
    const PARAMETER_ID_KEY = 'id';
43
    const PARAMETER_NULL_VALUE = 'null';
44
45
    /**
46
     * @var IriConverterInterface
47
     */
48
    private $iriConverter;
49
50
    /**
51
     * @var FilterQueryExtractorInterface
52
     */
53
    private $queryExtractor;
54
55
    /**
56
     * @var ManagerRegistry
57
     */
58
    private $managerRegistry;
59
60
    /**
61
     * @var array|null
62
     */
63
    private $properties;
64
65
    /**
66
     * @var PropertyAccessorInterface
67
     */
68
    private $propertyAccessor;
69
70
    /**
71
     * @var RequestStack
72
     */
73
    private $requestStack;
74
75
    /**
76
     * @param ManagerRegistry               $managerRegistry
77
     * @param RequestStack                  $requestStack
78
     * @param IriConverterInterface         $iriConverter
79
     * @param PropertyAccessorInterface     $propertyAccessor
80
     * @param FilterQueryExtractorInterface $queryExtractor
81
     * @param null|array                    $properties Null to allow filtering on all properties with the exact strategy or a map of property name with strategy.
82
     */
83
    public function __construct(
84
        ManagerRegistry $managerRegistry,
85
        RequestStack $requestStack,
86
        IriConverterInterface $iriConverter,
87
        PropertyAccessorInterface $propertyAccessor,
88
        FilterQueryExtractorInterface $queryExtractor,
89
        array $properties = null
90
    ) {
91
        $this->managerRegistry = $managerRegistry;
92
        $this->iriConverter = $iriConverter;
93
        $this->propertyAccessor = $propertyAccessor;
94
        $this->queryExtractor = $queryExtractor;
95
        $this->requestStack = $requestStack;
96
        $this->properties = $properties;
97
    }
98
99
    /**
100
     * {@inheritdoc}
101
     */
102
    public function apply(ResourceInterface $resource, QueryBuilder $queryBuilder)
103
    {
104
        if (null === $request = $this->requestStack->getCurrentRequest()) {
105
            return null;
106
        }
107
108
        $queryValues = $this->queryExtractor->extractProperties($request);
109
        $metadata = $this->getClassMetadata($resource);
110
        $queryExpr = [];
111
        $aliases = [];
112
        $associationsMetadata = [];
113
114
        // Retrieve all doctrine query expressions
115
        foreach ($queryValues as $key => $value) {
116
            if (self::PARAMETER_OPERATOR_OR === $key && is_array($value)) {
117
                /*
118
                 * OR operator case
119
                 *
120
                 * At this point $dataSet is expected to equal to something like this:
121
                 *
122
                 * $value = [
123
                 *    0 => [
124
                 *       0 => [
125
                 *          'property' => [
126
                 *             'operator' => 'operand'
127
                 *          ]
128
                 *       ],
129
                 *       1 => [
130
                 *          'property' => value
131
                 *       ]
132
                 *    ],
133
                 *    1 => [...],
134
                 *    ...
135
                 * ]
136
                 */
137
                foreach ($value as $index => $dataSet) {
138
                    // Expect $dataSet to be an array containing 2 parameters
139
                    if (is_array($dataSet) && 2 === count($dataSet)) {
140
                        $queries = [];
141
142
                        // Handle each "query" of $dataSet
143
                        $count = 0;
144
                        foreach ($dataSet as $dataSetElem) {
145
                            if (false === is_array($dataSetElem)) {
146
                                continue;
147
                            }
148
                            $property = key($dataSetElem);
149
150
                            // At this point the value may be either a value or an array (for operators)
151
                            $expr = $this->handleFilter(
152
                                $queryBuilder,
153
                                $metadata,
154
                                $aliases,
155
                                $associationsMetadata,
156
                                $property,
157
                                $dataSetElem[$property],
158
                                sprintf('or_%s%d%d', $property, $index, $count)
159
                            );
160
161
                            $queries = array_merge($queries, $expr);
162
                            ++$count;
163
                        }
164
165
                        if (2 === count($queries)) {
166
                            $queryExpr[] = $queryBuilder->expr()->orX($queries[0], $queries[1]);
167
                        }
168
                    }
169
                }
170
            } else {
171
                $queryExpr = array_merge(
172
                    $queryExpr,
173
                    $this->handleFilter($queryBuilder, $metadata, $aliases, $associationsMetadata, $key, $value)
174
                );
175
            }
176
        }
177
178
        foreach ($queryExpr as $expr) {
179
            $queryBuilder->andWhere($expr);
180
        }
181
    }
182
183
    /**
184
     * Handles the given filter to call the proper operator. At this point, it's unclear if the value passed is the real
185
     * value operator.
186
     *
187
     * @param QueryBuilder    $queryBuilder
188
     * @param ClassMetadata   $resourceMetadata
189
     * @param string[]        $aliases
190
     * @param ClassMetadata[] $associationsMetadata
191
     * @param string          $property
192
     * @param array|string    $value
193
     * @param string|null     $parameter If is string is used to construct the parameter to avoid parameter conflicts.
194
     *
195
     * @return array
196
     */
197
    private function handleFilter(
198
        QueryBuilder $queryBuilder,
199
        ClassMetadata $resourceMetadata,
200
        array $aliases,
201
        array $associationsMetadata,
202
        $property,
203
        $value,
204
        $parameter = null
205
    ) {
206
        $queryExpr = [];
207
208
        /*
209
         * simple (case 1):
210
         * $property = name
211
         *
212
         * relation (case 2):
213
         * $property = relatedDummy_name
214
         * $property = relatedDymmy_id
215
         * $property = relatedDummy_user_id
216
         * $property = relatedDummy_user_name
217
         */
218
        if (false !== strpos($property, '.')) {
219
            $explodedProperty = explode('.', $property);
220
        } else {
221
            $explodedProperty = explode('_', $property);
222
        }
223
        // we are in case 2
224
        $property = array_pop($explodedProperty);
225
        $alias = $this->getResourceAliasForProperty($aliases, $explodedProperty);
226
        $aliasMetadata = $this->getAssociationMetadataForProperty(
227
            $resourceMetadata,
228
            $associationsMetadata,
229
            $explodedProperty
230
        );
231
232
        if (true === $aliasMetadata->hasField($property)) {
233
            // Entity has the property
234
            if (is_array($value)) {
235
                foreach ($value as $operator => $operand) {
236
                    // Case where there is an operator
237
                    $queryExpr[] = $this->handleOperator(
238
                        $queryBuilder,
239
                        $alias,
240
                        $aliasMetadata,
241
                        $property,
242
                        $operator,
243
                        $operand,
244
                        $parameter
245
                    );
246
                }
247
            } else {
248
                // Simple where
249
                $value = $this->normalizeValue($aliasMetadata, $property, $value);
250
                if (null === $value) {
251
                    $queryExpr[] = $queryBuilder->expr()->isNull(sprintf('%s.%s', $alias, $property));
252
                } else {
253
                    if (null === $parameter) {
254
                        $parameter = $property;
255
                    }
256
                    $queryExpr[] = $queryBuilder->expr()->eq(
257
                        sprintf('%s.%s', $alias, $property),
258
                        sprintf(':%s', $parameter)
259
                    );
260
                    $queryBuilder->setParameter($parameter, $value);
261
                }
262
            }
263
        }
264
265
        return $queryExpr;
266
    }
267
268
    /**
269
     * Gets the proper query expression for the set of data given.
270
     *
271
     * @param QueryBuilder  $queryBuilder
272
     * @param string        $alias     alias of the entity to which belongs the property
273
     * @param ClassMetadata $aliasMetadata
274
     * @param string        $property
275
     * @param string        $operator
276
     * @param string|array  $value
277
     * @param string|null   $parameter If is string is used to construct the parameter to avoid parameter conflicts.
278
     *
279
     * @return Expr|null
280
     */
281
    private function handleOperator(
282
        QueryBuilder $queryBuilder,
283
        $alias,
284
        ClassMetadata $aliasMetadata,
285
        $property,
286
        $operator,
287
        $value,
288
        $parameter =
289
        null
290
    ) {
291
        $queryExpr = null;
292
        if (null === $parameter) {
293
            $parameter = $property;
294
        }
295
296
        // Only particular case: the between operator
297
        if (self::PARAMETER_OPERATOR_BETWEEN === $operator
298
            && is_array($value)
299
            && 2 === count($value)
300
        ) {
301
            $value = array_values($value);
302
            $paramBefore = sprintf(':between_before_%s', $parameter);
303
            $paramAfter = sprintf(':between_after_%s', $parameter);
304
305
            $queryExpr = $queryBuilder->expr()->between(
306
                sprintf('%s.%s', $alias, $property),
307
                $paramBefore,
308
                $paramAfter
309
            );
310
311
            $queryBuilder
312
                ->setParameter($paramBefore, $value[0])
313
                ->setParameter($paramAfter, $value[1])
314
            ;
315
316
            return $queryExpr;
317
        }
318
319
        // Expect $value to be a string
320
        if (false === is_string($value)) {
321
            return null;
322
        }
323
324
        // Normalize $value before using it
325
        $value = $this->normalizeValue($aliasMetadata, $property, $value);
326
        $parameterValue = (self::PARAMETER_OPERATOR_LIKE === $operator || self::PARAMETER_OPERATOR_NLIKE === $operator)
327
            ? sprintf('%%%s%%', $value)
328
            : $value;
329
330
        switch ($operator) {
331 View Code Duplication
            case self::PARAMETER_OPERATOR_GT:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
332
                $queryExpr = $queryBuilder->expr()->gt(sprintf('%s.%s', $alias, $property), sprintf(':%s', $parameter));
333
                break;
334
335 View Code Duplication
            case self::PARAMETER_OPERATOR_GTE:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
336
                $queryExpr = $queryBuilder->expr()->gte(sprintf('%s.%s', $alias, $property), sprintf(':%s',
337
                    $parameter));
338
                break;
339
340 View Code Duplication
            case self::PARAMETER_OPERATOR_LT:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
341
                $queryExpr = $queryBuilder->expr()->lt(sprintf('%s.%s', $alias, $property), sprintf(':%s', $parameter));
342
                break;
343
344 View Code Duplication
            case self::PARAMETER_OPERATOR_LTE:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
345
                $queryExpr = $queryBuilder->expr()->lte(sprintf('%s.%s', $alias, $property), sprintf(':%s',
346
                    $parameter));
347
                break;
348
349
            case self::PARAMETER_OPERATOR_NEQ:
350
                if (null === $value) {
351
                    // Skip the set parameter that takes place after the switch case
352
                    return $queryBuilder->expr()->isNotNull(sprintf('%s.%s', $alias, $property));
353
                } else {
354
                    $queryExpr = $queryBuilder->expr()->neq(sprintf('%s.%s', $alias, $property), sprintf(':%s', $parameter));
355
                }
356
                break;
357
358 View Code Duplication
            case self::PARAMETER_OPERATOR_LIKE:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
359
                $queryExpr = $queryBuilder->expr()->like(sprintf('%s.%s', $alias, $property), sprintf(':%s',
360
                    $parameter));
361
                break;
362
363 View Code Duplication
            case self::PARAMETER_OPERATOR_NLIKE:
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
364
                $queryExpr = $queryBuilder->expr()->notLike(sprintf('%s.%s', $alias, $property), sprintf(':%s', $parameter));
365
                break;
366
        }
367
368
        if (null === $queryBuilder->getParameter($parameter)) {
369
            $queryBuilder->setParameter($parameter, $parameterValue);
370
        }
371
372
        return $queryExpr;
373
    }
374
375
    /**
376
     * Normalizes the value. If the key is an ID, get the real ID value. If is null, set the value to null. Otherwise
377
     * return unchanged value.
378
     *
379
     * @param ClassMetadata $metadata
380
     * @param string        $property
381
     * @param string        $value
382
     *
383
     * @return null|string
384
     */
385
    private function normalizeValue(ClassMetadata $metadata, $property, $value)
386
    {
387
        if (self::PARAMETER_ID_KEY === $property) {
388
            return $this->getFilterValueFromUrl($value);
389
        }
390
391
        if (self::PARAMETER_NULL_VALUE === $value) {
392
            return null;
393
        }
394
395
        switch ($metadata->getTypeOfField($property)) {
396
            case 'boolean':
397
                return (bool) $value;
398
399
            case 'integer':
400
                return (int) $value;
401
402
            case 'float':
403
                return (float) $value;
404
405
            case 'datetime':
406
                // the input has the format `2015-04-28T02:23:50 00:00`, transform it to match the database format
407
                // `2015-04-28 02:23:50`
408
                return preg_replace('/(\d{4}(-\d{2}){2})T(\d{2}(:\d{2}){2}) \d{2}:\d{2}/', '$1 $3', $value);
409
        }
410
411
        return $value;
412
    }
413
414
    /**
415
     * {@inheritdoc}
416
     *
417
     * TODO
418
     */
419
    public function getDescription(ResourceInterface $resource)
420
    {
421
        return [];
422
    }
423
424
    /**
425
     * Gets the ID from an URI or a raw ID.
426
     *
427
     * @param string $value
428
     *
429
     * @return string
430
     */
431
    protected function getFilterValueFromUrl($value)
432
    {
433
        try {
434
            if ($item = $this->iriConverter->getItemFromIri($value)) {
435
                return $this->propertyAccessor->getValue($item, 'id');
436
            }
437
        } catch (\InvalidArgumentException $e) {
438
            // Do nothing, return the raw value
439
        }
440
441
        return $value;
442
    }
443
444
    /**
445
     * Gets the alias used for the entity to which the property belongs.
446
     *
447
     * @example
448
     *  $property was `name`
449
     *  $explodedProperty then is []
450
     *  => 'o'
451
     *
452
     *  $property was `relatedDummy_name`
453
     *  $explodedProperty then is ['relatedDummy']
454
     *  => WhereFilter_relatedDummyAlias
455
     *
456
     *  $property was `relatedDummy_anotherDummy_name`
457
     *  $explodedProperty then is ['relatedDummy', 'anotherDummy']
458
     *  => WhereFilter_relatedDummy_anotherDummyAlias
459
     *
460
     * @param string[] $aliases Array containing all the properties for each an alias is used. The key is the
461
     *                          property and the value the actual alias.
462
     * @param string[] $explodedProperty
463
     *
464
     * @return string alias
465
     */
466
    private function getResourceAliasForProperty(array &$aliases, array $explodedProperty)
467
    {
468
        if (0 === count($explodedProperty)) {
469
            return 'o';
470
        }
471
472
        foreach ($explodedProperty as $property) {
473
            if (false === isset($aliases[$property])) {
474
                $aliases[$property] = sprintf('WhereFilter_%sAlias', implode('_', $explodedProperty));
475
            }
476
        }
477
478
        return $aliases[end($explodedProperty)];
479
    }
480
481
    /**
482
     * Gets the metadata to which belongs the property.
483
     *
484
     * @example
485
     *  $property was `name`
486
     *  $explodedProperty then is []
487
     *  => $resourceMetadata
488
     *
489
     *  $property was `relatedDummy_name`
490
     *  $explodedProperty then is ['relatedDummy']
491
     *  => metadata of relatedDummy
492
     *
493
     *  $property was `relatedDummy_anotherDummy_name`
494
     *  $explodedProperty then is ['relatedDummy', 'anotherDummy']
495
     *  => metadata of anotherDummy
496
     *
497
     * @param ClassMetadata   $resourceMetadata
498
     * @param ClassMetadata[] $associationsMetadata
499
     * @param array           $explodedProperty
500
     *
501
     * @return ClassMetadata
502
     */
503
    private function getAssociationMetadataForProperty(
504
        ClassMetadata $resourceMetadata,
505
        array &$associationsMetadata,
506
        array
507
        $explodedProperty
508
    ) {
509
        if (0 === count($explodedProperty)) {
510
            return $resourceMetadata;
511
        }
512
513
        $parentResourceMetadata = $resourceMetadata;
514
        foreach ($explodedProperty as $index => $property) {
515
            if (1 <= $index) {
516
                $parentResourceMetadata = $associationsMetadata[$explodedProperty[$index - 1]];
517
            }
518
519
            if (false === $parentResourceMetadata->hasAssociation($property)) {
520
                throw new \RuntimeException(sprintf(
521
                    'Class %s::%s is not an association.',
522
                    $parentResourceMetadata->getName
523
                    (),
524
                    $property)
525
                );
526
            }
527
528
            if (false === isset($associationsMetadata[$property])) {
529
                $associationsMetadata[$property] = $this->getMetadata(
530
                    $parentResourceMetadata->getAssociationTargetClass($property)
531
                );
532
            }
533
        }
534
535
        return $associationsMetadata[end($explodedProperty)];
536
    }
537
538
    /**
539
     * Gets class metadata for the given class.
540
     *
541
     * @param string $class
542
     *
543
     * @return ClassMetadata
544
     */
545
    private function getMetadata($class)
546
    {
547
        return $this
548
            ->managerRegistry
549
            ->getManagerForClass($class)
550
            ->getClassMetadata($class)
551
        ;
552
    }
553
554
    /**
555
     * Gets class metadata for the given resource.
556
     *
557
     * @param ResourceInterface $resource
558
     *
559
     * @return ClassMetadata
560
     */
561
    private function getClassMetadata(ResourceInterface $resource)
562
    {
563
        $entityClass = $resource->getEntityClass();
564
565
        return $this
566
            ->managerRegistry
567
            ->getManagerForClass($entityClass)
568
            ->getClassMetadata($entityClass)
569
        ;
570
    }
571
}
572