Completed
Push — master ( 2ff3dd...d056c8 )
by Kévin
14s
created

SearchFilter   C

Complexity

Total Complexity 59

Size/Duplication

Total Lines 350
Duplicated Lines 12.57 %

Coupling/Cohesion

Components 2
Dependencies 9

Importance

Changes 0
Metric Value
wmc 59
lcom 2
cbo 9
dl 44
loc 350
rs 6.1904
c 0
b 0
f 0

8 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 7 2
C getDescription() 24 59 10
C getType() 0 32 16
D filterProperty() 0 95 15
C addWhereByStrategy() 20 36 7
A createWrapCase() 0 10 2
A getIdFromValue() 0 12 3
A normalizeValues() 0 10 4

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like SearchFilter often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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

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

1
<?php
2
3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <[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
declare(strict_types=1);
13
14
namespace ApiPlatform\Core\Bridge\Doctrine\Orm\Filter;
15
16
use ApiPlatform\Core\Api\IriConverterInterface;
17
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18
use ApiPlatform\Core\Exception\InvalidArgumentException;
19
use Doctrine\Common\Persistence\ManagerRegistry;
20
use Doctrine\DBAL\Types\Type;
21
use Doctrine\ORM\QueryBuilder;
22
use Psr\Log\LoggerInterface;
23
use Symfony\Component\HttpFoundation\RequestStack;
24
use Symfony\Component\PropertyAccess\PropertyAccess;
25
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
26
27
/**
28
 * Filter the collection by given properties.
29
 *
30
 * @author Kévin Dunglas <[email protected]>
31
 */
32
class SearchFilter extends AbstractFilter
33
{
34
    /**
35
     * @var string Exact matching
36
     */
37
    const STRATEGY_EXACT = 'exact';
38
39
    /**
40
     * @var string The value must be contained in the field
41
     */
42
    const STRATEGY_PARTIAL = 'partial';
43
44
    /**
45
     * @var string Finds fields that are starting with the value
46
     */
47
    const STRATEGY_START = 'start';
48
49
    /**
50
     * @var string Finds fields that are ending with the value
51
     */
52
    const STRATEGY_END = 'end';
53
54
    /**
55
     * @var string Finds fields that are starting with the word
56
     */
57
    const STRATEGY_WORD_START = 'word_start';
58
59
    protected $iriConverter;
60
    protected $propertyAccessor;
61
62
    public function __construct(ManagerRegistry $managerRegistry, RequestStack $requestStack, IriConverterInterface $iriConverter, PropertyAccessorInterface $propertyAccessor = null, LoggerInterface $logger = null, array $properties = null)
63
    {
64
        parent::__construct($managerRegistry, $requestStack, $logger, $properties);
65
66
        $this->iriConverter = $iriConverter;
67
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
68
    }
69
70
    /**
71
     * {@inheritdoc}
72
     */
73
    public function getDescription(string $resourceClass): array
74
    {
75
        $description = [];
76
77
        $properties = $this->properties;
78
        if (null === $properties) {
79
            $properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
80
        }
81
82
        foreach ($properties as $property => $strategy) {
83
            if (!$this->isPropertyMapped($property, $resourceClass, true)) {
84
                continue;
85
            }
86
87 View Code Duplication
            if ($this->isPropertyNested($property, $resourceClass)) {
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...
88
                $propertyParts = $this->splitPropertyParts($property, $resourceClass);
89
                $field = $propertyParts['field'];
90
                $metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
91
            } else {
92
                $field = $property;
93
                $metadata = $this->getClassMetadata($resourceClass);
94
            }
95
96
            if ($metadata->hasField($field)) {
97
                $typeOfField = $this->getType($metadata->getTypeOfField($field));
98
                $strategy = $this->properties[$property] ?? self::STRATEGY_EXACT;
99
                $filterParameterNames = [$property];
100
101
                if (self::STRATEGY_EXACT === $strategy) {
102
                    $filterParameterNames[] = $property.'[]';
103
                }
104
105 View Code Duplication
                foreach ($filterParameterNames as $filterParameterName) {
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...
106
                    $description[$filterParameterName] = [
107
                        'property' => $property,
108
                        'type' => $typeOfField,
109
                        'required' => false,
110
                        'strategy' => $strategy,
111
                    ];
112
                }
113
            } elseif ($metadata->hasAssociation($field)) {
114
                $filterParameterNames = [
115
                    $property,
116
                    $property.'[]',
117
                ];
118
119 View Code Duplication
                foreach ($filterParameterNames as $filterParameterName) {
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...
120
                    $description[$filterParameterName] = [
121
                        'property' => $property,
122
                        'type' => 'string',
123
                        'required' => false,
124
                        'strategy' => self::STRATEGY_EXACT,
125
                    ];
126
                }
127
            }
128
        }
129
130
        return $description;
131
    }
132
133
    /**
134
     * Converts a Doctrine type in PHP type.
135
     *
136
     * @param string $doctrineType
137
     *
138
     * @return string
139
     */
140
    private function getType(string $doctrineType): string
141
    {
142
        switch ($doctrineType) {
143
            case Type::TARRAY:
144
                return 'array';
145
            case Type::BIGINT:
146
            case Type::INTEGER:
147
            case Type::SMALLINT:
148
                return 'int';
149
            case Type::BOOLEAN:
150
                return 'bool';
151
            case Type::DATE:
152
            case Type::TIME:
153
            case Type::DATETIME:
154
            case Type::DATETIMETZ:
155
                    return \DateTimeInterface::class;
156
            case Type::FLOAT:
157
                return 'float';
158
        }
159
160
        if (defined(Type::class.'::DATE_IMMUTABLE')) {
161
            switch ($doctrineType) {
162
                case Type::DATE_IMMUTABLE:
163
                case Type::TIME_IMMUTABLE:
164
                case Type::DATETIME_IMMUTABLE:
165
                case Type::DATETIMETZ_IMMUTABLE:
166
                    return \DateTimeInterface::class;
167
            }
168
        }
169
170
        return 'string';
171
    }
172
173
    /**
174
     * {@inheritdoc}
175
     */
176
    protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
177
    {
178
        if (
179
            null === $value ||
180
            !$this->isPropertyEnabled($property, $resourceClass) ||
181
            !$this->isPropertyMapped($property, $resourceClass, true)
182
        ) {
183
            return;
184
        }
185
186
        $alias = 'o';
187
        $field = $property;
188
189
        if ($this->isPropertyNested($property, $resourceClass)) {
190
            list($alias, $field, $associations) = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass);
191
            $metadata = $this->getNestedMetadata($resourceClass, $associations);
192
        } else {
193
            $metadata = $this->getClassMetadata($resourceClass);
194
        }
195
196
        $values = $this->normalizeValues((array) $value);
197
198
        if (empty($values)) {
199
            $this->logger->notice('Invalid filter ignored', [
200
                'exception' => new InvalidArgumentException(sprintf('At least one value is required, multiple values should be in "%1$s[]=firstvalue&%1$s[]=secondvalue" format', $property)),
201
            ]);
202
203
            return;
204
        }
205
206
        $caseSensitive = true;
207
208
        if ($metadata->hasField($field)) {
209
            if ('id' === $field) {
210
                $values = array_map([$this, 'getIdFromValue'], $values);
211
            }
212
213
            $strategy = $this->properties[$property] ?? self::STRATEGY_EXACT;
214
215
            // prefixing the strategy with i makes it case insensitive
216
            if (strpos($strategy, 'i') === 0) {
217
                $strategy = substr($strategy, 1);
218
                $caseSensitive = false;
219
            }
220
221
            if (1 === count($values)) {
222
                $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values[0], $caseSensitive);
223
224
                return;
225
            }
226
227
            if (self::STRATEGY_EXACT !== $strategy) {
228
                $this->logger->notice('Invalid filter ignored', [
229
                    'exception' => new InvalidArgumentException(sprintf('"%s" strategy selected for "%s" property, but only "%s" strategy supports multiple values', $strategy, $property, self::STRATEGY_EXACT)),
230
                ]);
231
232
                return;
233
            }
234
235
            $wrapCase = $this->createWrapCase($caseSensitive);
236
            $valueParameter = $queryNameGenerator->generateParameterName($field);
237
238
            $queryBuilder
239
                ->andWhere(sprintf($wrapCase('%s.%s').' IN (:%s)', $alias, $field, $valueParameter))
240
                ->setParameter($valueParameter, $caseSensitive ? $values : array_map('strtolower', $values));
241
        }
242
243
        // metadata doesn't have the field, nor an association on the field
244
        if (!$metadata->hasAssociation($field)) {
245
            return;
246
        }
247
248
        $values = array_map([$this, 'getIdFromValue'], $values);
249
250
        $association = $field;
251
        $valueParameter = $queryNameGenerator->generateParameterName($association);
252
253
        if ($metadata->isCollectionValuedAssociation($association)) {
254
            $associationAlias = $this->addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $association);
255
            $associationField = 'id';
256
        } else {
257
            $associationAlias = $alias;
258
            $associationField = $field;
259
        }
260
261
        if (1 === count($values)) {
262
            $queryBuilder
263
                ->andWhere(sprintf('%s.%s = :%s', $associationAlias, $associationField, $valueParameter))
264
                ->setParameter($valueParameter, $values[0]);
265
        } else {
266
            $queryBuilder
267
                ->andWhere(sprintf('%s.%s IN (:%s)', $associationAlias, $associationField, $valueParameter))
268
                ->setParameter($valueParameter, $values);
269
        }
270
    }
271
272
    /**
273
     * Adds where clause according to the strategy.
274
     *
275
     * @param string                      $strategy
276
     * @param QueryBuilder                $queryBuilder
277
     * @param QueryNameGeneratorInterface $queryNameGenerator
278
     * @param string                      $alias
279
     * @param string                      $field
280
     * @param mixed                       $value
281
     * @param bool                        $caseSensitive
282
     *
283
     * @throws InvalidArgumentException If strategy does not exist
284
     */
285
    protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $value, bool $caseSensitive)
286
    {
287
        $wrapCase = $this->createWrapCase($caseSensitive);
288
        $valueParameter = $queryNameGenerator->generateParameterName($field);
289
290
        switch ($strategy) {
291
            case null:
292 View Code Duplication
            case self::STRATEGY_EXACT:
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...
293
                $queryBuilder
294
                    ->andWhere(sprintf($wrapCase('%s.%s').' = '.$wrapCase(':%s'), $alias, $field, $valueParameter))
295
                    ->setParameter($valueParameter, $value);
296
                break;
297 View Code Duplication
            case self::STRATEGY_PARTIAL:
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...
298
                $queryBuilder
299
                    ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s, \'%%\')'), $alias, $field, $valueParameter))
300
                    ->setParameter($valueParameter, $value);
301
                break;
302 View Code Duplication
            case self::STRATEGY_START:
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...
303
                $queryBuilder
304
                    ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(:%s, \'%%\')'), $alias, $field, $valueParameter))
305
                    ->setParameter($valueParameter, $value);
306
                break;
307 View Code Duplication
            case self::STRATEGY_END:
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...
308
                $queryBuilder
309
                    ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s)'), $alias, $field, $valueParameter))
310
                    ->setParameter($valueParameter, $value);
311
                break;
312
            case self::STRATEGY_WORD_START:
313
                $queryBuilder
314
                    ->andWhere(sprintf($wrapCase('%1$s.%2$s').' LIKE '.$wrapCase('CONCAT(:%3$s, \'%%\')').' OR '.$wrapCase('%1$s.%2$s').' LIKE '.$wrapCase('CONCAT(\'%% \', :%3$s, \'%%\')'), $alias, $field, $valueParameter))
315
                    ->setParameter($valueParameter, $value);
316
                break;
317
            default:
318
                throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy));
319
        }
320
    }
321
322
    /**
323
     * Creates a function that will wrap a Doctrine expression according to the
324
     * specified case sensitivity.
325
     *
326
     * For example, "o.name" will get wrapped into "LOWER(o.name)" when $caseSensitive
327
     * is false.
328
     *
329
     * @param bool $caseSensitive
330
     *
331
     * @return \Closure
332
     */
333
    protected function createWrapCase(bool $caseSensitive): \Closure
334
    {
335
        return function (string $expr) use ($caseSensitive): string {
336
            if ($caseSensitive) {
337
                return $expr;
338
            }
339
340
            return sprintf('LOWER(%s)', $expr);
341
        };
342
    }
343
344
    /**
345
     * Gets the ID from an IRI or a raw ID.
346
     *
347
     * @param string $value
348
     *
349
     * @return mixed
350
     */
351
    protected function getIdFromValue(string $value)
352
    {
353
        try {
354
            if ($item = $this->iriConverter->getItemFromIri($value, ['fetch_data' => false])) {
355
                return $this->propertyAccessor->getValue($item, 'id');
356
            }
357
        } catch (InvalidArgumentException $e) {
358
            // Do nothing, return the raw value
359
        }
360
361
        return $value;
362
    }
363
364
    /**
365
     * Normalize the values array.
366
     *
367
     * @param array $values
368
     *
369
     * @return array
370
     */
371
    protected function normalizeValues(array $values): array
372
    {
373
        foreach ($values as $key => $value) {
374
            if (!is_int($key) || !is_string($value)) {
375
                unset($values[$key]);
376
            }
377
        }
378
379
        return array_values($values);
380
    }
381
}
382