• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

api-platform / core / 15133993414

20 May 2025 09:30AM UTC coverage: 26.313% (-1.2%) from 27.493%
15133993414

Pull #7161

github

web-flow
Merge e2c03d45f into 5459ba375
Pull Request #7161: fix(metadata): infer parameter string type from schema

0 of 2 new or added lines in 1 file covered. (0.0%)

11019 existing lines in 363 files now uncovered.

12898 of 49018 relevant lines covered (26.31%)

34.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

45.24
/src/Doctrine/Orm/Filter/SearchFilter.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
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\Doctrine\Orm\Filter;
15

16
use ApiPlatform\Doctrine\Common\Filter\SearchFilterInterface;
17
use ApiPlatform\Doctrine\Common\Filter\SearchFilterTrait;
18
use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper;
19
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
20
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
21
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
22
use ApiPlatform\Metadata\IriConverterInterface;
23
use ApiPlatform\Metadata\Operation;
24
use Doctrine\DBAL\Types\Types;
25
use Doctrine\ORM\Query\Expr\Join;
26
use Doctrine\ORM\QueryBuilder;
27
use Doctrine\Persistence\ManagerRegistry;
28
use Psr\Log\LoggerInterface;
29
use Symfony\Component\PropertyAccess\PropertyAccess;
30
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
31
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
32

33
/**
34
 * The search filter allows to filter a collection by given properties.
35
 *
36
 * The search filter supports `exact`, `partial`, `start`, `end`, and `word_start` matching strategies:
37
 * - `exact` strategy searches for fields that exactly match the value
38
 * - `partial` strategy uses `LIKE %value%` to search for fields that contain the value
39
 * - `start` strategy uses `LIKE value%` to search for fields that start with the value
40
 * - `end` strategy uses `LIKE %value` to search for fields that end with the value
41
 * - `word_start` strategy uses `LIKE value% OR LIKE % value%` to search for fields that contain words starting with the value
42
 *
43
 * Note: it is possible to filter on properties and relations too.
44
 *
45
 * Prepend the letter `i` to the filter if you want it to be case-insensitive. For example `ipartial` or `iexact`.
46
 * Note that this will use the `LOWER` function and *will* impact performance if there is no proper index.
47
 *
48
 * Case insensitivity may already be enforced at the database level depending on the [collation](https://en.wikipedia.org/wiki/Collation) used.
49
 * If you are using MySQL, note that the commonly used `utf8_unicode_ci` collation (and its sibling `utf8mb4_unicode_ci`)
50
 * are already case-insensitive, as indicated by the `_ci` part in their names.
51
 *
52
 * Note: Search filters with the `exact` strategy can have multiple values for the same property (in this case the
53
 * condition will be similar to a SQL IN clause).
54
 *
55
 * Syntax: `?property[]=foo&property[]=bar`.
56
 *
57
 * <div data-code-selector>
58
 *
59
 * ```php
60
 * <?php
61
 * // api/src/Entity/Book.php
62
 * use ApiPlatform\Metadata\ApiFilter;
63
 * use ApiPlatform\Metadata\ApiResource;
64
 * use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
65
 *
66
 * #[ApiResource]
67
 * #[ApiFilter(SearchFilter::class, properties: ['isbn' => 'exact', 'description' => 'partial'])]
68
 * class Book
69
 * {
70
 *     // ...
71
 * }
72
 * ```
73
 *
74
 * ```yaml
75
 * # config/services.yaml
76
 * services:
77
 *     book.search_filter:
78
 *         parent: 'api_platform.doctrine.orm.search_filter'
79
 *         arguments: [ { isbn: 'exact', description: 'partial' } ]
80
 *         tags:  [ 'api_platform.filter' ]
81
 *         # The following are mandatory only if a _defaults section is defined with inverted values.
82
 *         # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
83
 *         autowire: false
84
 *         autoconfigure: false
85
 *         public: false
86
 *
87
 * # api/config/api_platform/resources.yaml
88
 * resources:
89
 *     App\Entity\Book:
90
 *         - operations:
91
 *               ApiPlatform\Metadata\GetCollection:
92
 *                   filters: ['book.search_filter']
93
 * ```
94
 *
95
 * ```xml
96
 * <?xml version="1.0" encoding="UTF-8" ?>
97
 * <!-- api/config/services.xml -->
98
 * <?xml version="1.0" encoding="UTF-8" ?>
99
 * <container
100
 *         xmlns="http://symfony.com/schema/dic/services"
101
 *         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
102
 *         xsi:schemaLocation="http://symfony.com/schema/dic/services
103
 *         https://symfony.com/schema/dic/services/services-1.0.xsd">
104
 *     <services>
105
 *         <service id="book.search_filter" parent="api_platform.doctrine.orm.search_filter">
106
 *             <argument type="collection">
107
 *                 <argument key="isbn">exact</argument>
108
 *                 <argument key="description">partial</argument>
109
 *             </argument>
110
 *             <tag name="api_platform.filter"/>
111
 *         </service>
112
 *     </services>
113
 * </container>
114
 * <!-- api/config/api_platform/resources.xml -->
115
 * <resources
116
 *         xmlns="https://api-platform.com/schema/metadata/resources-3.0"
117
 *         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
118
 *         xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
119
 *         https://api-platform.com/schema/metadata/resources-3.0.xsd">
120
 *     <resource class="App\Entity\Book">
121
 *         <operations>
122
 *             <operation class="ApiPlatform\Metadata\GetCollection">
123
 *                 <filters>
124
 *                     <filter>book.search_filter</filter>
125
 *                 </filters>
126
 *             </operation>
127
 *         </operations>
128
 *     </resource>
129
 * </resources>
130
 * ```
131
 *
132
 * </div>
133
 *
134
 * @author Kévin Dunglas <dunglas@gmail.com>
135
 */
136
final class SearchFilter extends AbstractFilter implements SearchFilterInterface
137
{
138
    use SearchFilterTrait;
139

140
    public const DOCTRINE_INTEGER_TYPE = Types::INTEGER;
141

142
    public function __construct(ManagerRegistry $managerRegistry, IriConverterInterface $iriConverter, ?PropertyAccessorInterface $propertyAccessor = null, ?LoggerInterface $logger = null, ?array $properties = null, ?IdentifiersExtractorInterface $identifiersExtractor = null, ?NameConverterInterface $nameConverter = null)
143
    {
UNCOV
144
        parent::__construct($managerRegistry, $logger, $properties, $nameConverter);
157✔
145

UNCOV
146
        $this->iriConverter = $iriConverter;
157✔
UNCOV
147
        $this->identifiersExtractor = $identifiersExtractor;
157✔
UNCOV
148
        $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor();
157✔
149
    }
150

151
    protected function getIriConverter(): IriConverterInterface
152
    {
153
        return $this->iriConverter;
×
154
    }
155

156
    protected function getPropertyAccessor(): PropertyAccessorInterface
157
    {
158
        return $this->propertyAccessor;
×
159
    }
160

161
    /**
162
     * {@inheritdoc}
163
     */
164
    protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
165
    {
166
        if (
UNCOV
167
            null === $value
2✔
UNCOV
168
            || !$this->isPropertyEnabled($property, $resourceClass)
2✔
UNCOV
169
            || !$this->isPropertyMapped($property, $resourceClass, true)
2✔
170
        ) {
UNCOV
171
            return;
2✔
172
        }
173

UNCOV
174
        $alias = $queryBuilder->getRootAliases()[0];
2✔
UNCOV
175
        $field = $property;
2✔
176

UNCOV
177
        $values = $this->normalizeValues((array) $value, $property);
2✔
UNCOV
178
        if (null === $values) {
2✔
179
            return;
×
180
        }
181

UNCOV
182
        $associations = [];
2✔
UNCOV
183
        if ($this->isPropertyNested($property, $resourceClass)) {
2✔
184
            [$alias, $field, $associations] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN);
×
185
        }
186

UNCOV
187
        $caseSensitive = true;
2✔
UNCOV
188
        $strategy = $this->properties[$property] ?? self::STRATEGY_EXACT;
2✔
189

190
        // prefixing the strategy with i makes it case insensitive
UNCOV
191
        if (str_starts_with($strategy, 'i')) {
2✔
192
            $strategy = substr($strategy, 1);
×
193
            $caseSensitive = false;
×
194
        }
195

UNCOV
196
        $metadata = $this->getNestedMetadata($resourceClass, $associations);
2✔
197

UNCOV
198
        if ($metadata->hasField($field)) {
2✔
UNCOV
199
            if ('id' === $field) {
2✔
200
                $values = array_map($this->getIdFromValue(...), $values);
×
201
                // todo: handle composite IDs
202
            }
203

UNCOV
204
            if (!$this->hasValidValues($values, $this->getDoctrineFieldType($property, $resourceClass))) {
2✔
205
                $this->logger->notice('Invalid filter ignored', [
×
206
                    'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)),
×
207
                ]);
×
208

209
                return;
×
210
            }
211

UNCOV
212
            $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values, $caseSensitive);
2✔
213

UNCOV
214
            return;
2✔
215
        }
216

217
        // metadata doesn't have the field, nor an association on the field
218
        if (!$metadata->hasAssociation($field)) {
×
219
            return;
×
220
        }
221

222
        // association, let's fetch the entity (or reference to it) if we can so we can make sure we get its orm id
223
        $associationResourceClass = $metadata->getAssociationTargetClass($field);
×
224
        $associationMetadata = $this->getClassMetadata($associationResourceClass);
×
225
        $associationFieldIdentifier = $associationMetadata->getIdentifierFieldNames()[0];
×
226
        $doctrineTypeField = $this->getDoctrineFieldType($associationFieldIdentifier, $associationResourceClass);
×
227

228
        $values = array_map(function ($value) use ($associationFieldIdentifier, $doctrineTypeField) {
×
229
            if (is_numeric($value)) {
×
230
                return $value;
×
231
            }
232
            try {
233
                $item = $this->getIriConverter()->getResourceFromIri($value, ['fetch_data' => false]);
×
234

235
                return $this->propertyAccessor->getValue($item, $associationFieldIdentifier);
×
236
            } catch (InvalidArgumentException) {
×
237
                /*
238
                 * Can we do better? This is not the ApiResource the call was made on,
239
                 * so we don't get any kind of api metadata for it without (a lot of?) work elsewhere...
240
                 * Let's just pretend it's always the ORM id for now.
241
                 */
242
                if (!$this->hasValidValues([$value], $doctrineTypeField)) {
×
243
                    $this->logger->notice('Invalid filter ignored', [
×
244
                        'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $associationFieldIdentifier)),
×
245
                    ]);
×
246

247
                    return null;
×
248
                }
249

250
                return $value;
×
251
            }
252
        }, $values);
×
253

254
        $expected = \count($values);
×
255
        $values = array_filter($values, static fn ($value) => null !== $value);
×
256
        if ($expected > \count($values)) {
×
257
            /*
258
             * Shouldn't this actually fail harder?
259
             */
260
            $this->logger->notice('Invalid filter ignored', [
×
261
                'exception' => new InvalidArgumentException(\sprintf('Values for field "%s" are not valid according to the doctrine type.', $field)),
×
262
            ]);
×
263

264
            return;
×
265
        }
266

267
        $associationAlias = $alias;
×
268
        $associationField = $field;
×
269
        if ($metadata->isCollectionValuedAssociation($associationField) || $metadata->isAssociationInverseSide($field)) {
×
270
            $associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $associationField);
×
271
            $associationField = $associationFieldIdentifier;
×
272
        }
273

274
        $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $associationAlias, $associationField, $values, $caseSensitive);
×
275
    }
276

277
    /**
278
     * Adds where clause according to the strategy.
279
     *
280
     * @throws InvalidArgumentException If strategy does not exist
281
     */
282
    protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, mixed $values, bool $caseSensitive): void
283
    {
UNCOV
284
        if (!\is_array($values)) {
2✔
285
            $values = [$values];
×
286
        }
287

UNCOV
288
        $wrapCase = $this->createWrapCase($caseSensitive);
2✔
UNCOV
289
        $valueParameter = ':'.$queryNameGenerator->generateParameterName($field);
2✔
UNCOV
290
        $aliasedField = \sprintf('%s.%s', $alias, $field);
2✔
291

UNCOV
292
        if (!$strategy || self::STRATEGY_EXACT === $strategy) {
2✔
UNCOV
293
            if (1 === \count($values)) {
2✔
UNCOV
294
                $queryBuilder
2✔
UNCOV
295
                    ->andWhere($queryBuilder->expr()->eq($wrapCase($aliasedField), $wrapCase($valueParameter)))
2✔
UNCOV
296
                    ->setParameter($valueParameter, $values[0]);
2✔
297

UNCOV
298
                return;
2✔
299
            }
300

301
            $queryBuilder
×
302
                ->andWhere($queryBuilder->expr()->in($wrapCase($aliasedField), $valueParameter))
×
303
                ->setParameter($valueParameter, $caseSensitive ? $values : array_map('strtolower', $values));
×
304

305
            return;
×
306
        }
307

UNCOV
308
        $ors = [];
2✔
UNCOV
309
        $parameters = [];
2✔
UNCOV
310
        foreach ($values as $key => $value) {
2✔
UNCOV
311
            $keyValueParameter = \sprintf('%s_%s', $valueParameter, $key);
2✔
UNCOV
312
            $parameters[] = [$caseSensitive ? $value : strtolower($value), $keyValueParameter];
2✔
313

UNCOV
314
            $ors[] = match ($strategy) {
2✔
UNCOV
315
                self::STRATEGY_PARTIAL => $queryBuilder->expr()->like(
2✔
UNCOV
316
                    $wrapCase($aliasedField),
2✔
UNCOV
317
                    $wrapCase((string) $queryBuilder->expr()->concat("'%'", $keyValueParameter, "'%'"))
2✔
UNCOV
318
                ),
2✔
319
                self::STRATEGY_START => $queryBuilder->expr()->like(
×
320
                    $wrapCase($aliasedField),
×
321
                    $wrapCase((string) $queryBuilder->expr()->concat($keyValueParameter, "'%'"))
×
322
                ),
×
323
                self::STRATEGY_END => $queryBuilder->expr()->like(
×
324
                    $wrapCase($aliasedField),
×
325
                    $wrapCase((string) $queryBuilder->expr()->concat("'%'", $keyValueParameter))
×
326
                ),
×
327
                self::STRATEGY_WORD_START => $queryBuilder->expr()->orX(
×
328
                    $queryBuilder->expr()->like(
×
329
                        $wrapCase($aliasedField),
×
330
                        $wrapCase((string) $queryBuilder->expr()->concat($keyValueParameter, "'%'"))
×
331
                    ),
×
332
                    $queryBuilder->expr()->like(
×
333
                        $wrapCase($aliasedField),
×
334
                        $wrapCase((string) $queryBuilder->expr()->concat("'% '", $keyValueParameter, "'%'"))
×
335
                    )
×
336
                ),
×
337
                default => throw new InvalidArgumentException(\sprintf('strategy %s does not exist.', $strategy)),
×
UNCOV
338
            };
2✔
339
        }
340

UNCOV
341
        $queryBuilder->andWhere($queryBuilder->expr()->orX(...$ors));
2✔
UNCOV
342
        foreach ($parameters as $parameter) {
2✔
UNCOV
343
            $queryBuilder->setParameter($parameter[1], $parameter[0]);
2✔
344
        }
345
    }
346

347
    /**
348
     * Creates a function that will wrap a Doctrine expression according to the
349
     * specified case sensitivity.
350
     *
351
     * For example, "o.name" will get wrapped into "LOWER(o.name)" when $caseSensitive
352
     * is false.
353
     */
354
    protected function createWrapCase(bool $caseSensitive): \Closure
355
    {
UNCOV
356
        return static function (string $expr) use ($caseSensitive): string {
2✔
UNCOV
357
            if ($caseSensitive) {
2✔
UNCOV
358
                return $expr;
2✔
359
            }
360

361
            return \sprintf('LOWER(%s)', $expr);
×
UNCOV
362
        };
2✔
363
    }
364

365
    /**
366
     * {@inheritdoc}
367
     */
368
    protected function getType(string $doctrineType): string
369
    {
370
        // Remove this test when doctrine/dbal:3 support is removed
UNCOV
371
        if (\defined(Types::class.'::ARRAY') && Types::ARRAY === $doctrineType) {
9✔
372
            return 'array';
×
373
        }
374

UNCOV
375
        return match ($doctrineType) {
UNCOV
376
            Types::BIGINT, Types::INTEGER, Types::SMALLINT => 'int',
9✔
UNCOV
377
            Types::BOOLEAN => 'bool',
9✔
UNCOV
378
            Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATE_IMMUTABLE, Types::TIME_IMMUTABLE, Types::DATETIME_IMMUTABLE, Types::DATETIMETZ_IMMUTABLE => \DateTimeInterface::class,
9✔
UNCOV
379
            Types::FLOAT => 'float',
9✔
UNCOV
380
            default => 'string',
9✔
UNCOV
381
        };
382
    }
383
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc