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)) { |
|
|
|
|
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) { |
|
|
|
|
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) { |
|
|
|
|
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: |
|
|
|
|
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: |
|
|
|
|
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: |
|
|
|
|
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: |
|
|
|
|
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
|
|
|
|
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.