Skip to content

Commit 858b5e9

Browse files
soyukamremi
andauthored
feat(doctrine): add new filter for filtering an entity using PHP backed enum, resolves #6506 (#6547) (#6560)
Co-authored-by: Rémi Marseille <[email protected]>
1 parent c48b76e commit 858b5e9

File tree

10 files changed

+416
-1
lines changed

10 files changed

+416
-1
lines changed

Filter/BackedEnumFilter.php

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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\Doctrine\Orm\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\Filter\BackedEnumFilterTrait;
17+
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
18+
use ApiPlatform\Metadata\Operation;
19+
use Doctrine\ORM\Mapping\ClassMetadata;
20+
use Doctrine\ORM\Mapping\FieldMapping;
21+
use Doctrine\ORM\Query\Expr\Join;
22+
use Doctrine\ORM\QueryBuilder;
23+
24+
/**
25+
* The backed enum filter allows you to search on backed enum fields and values.
26+
*
27+
* Note: it is possible to filter on properties and relations too.
28+
*
29+
* Syntax: `?property=foo`.
30+
*
31+
* <div data-code-selector>
32+
*
33+
* ```php
34+
* <?php
35+
* // api/src/Entity/Book.php
36+
* use ApiPlatform\Metadata\ApiFilter;
37+
* use ApiPlatform\Metadata\ApiResource;
38+
* use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter;
39+
*
40+
* #[ApiResource]
41+
* #[ApiFilter(BackedEnumFilter::class, properties: ['status'])]
42+
* class Book
43+
* {
44+
* // ...
45+
* }
46+
* ```
47+
*
48+
* ```yaml
49+
* # config/services.yaml
50+
* services:
51+
* book.backed_enum_filter:
52+
* parent: 'api_platform.doctrine.orm.backed_enum_filter'
53+
* arguments: [ { status: ~ } ]
54+
* tags: [ 'api_platform.filter' ]
55+
* # The following are mandatory only if a _defaults section is defined with inverted values.
56+
* # You may want to isolate filters in a dedicated file to avoid adding the following lines (by adding them in the defaults section)
57+
* autowire: false
58+
* autoconfigure: false
59+
* public: false
60+
*
61+
* # api/config/api_platform/resources.yaml
62+
* resources:
63+
* App\Entity\Book:
64+
* - operations:
65+
* ApiPlatform\Metadata\GetCollection:
66+
* filters: ['book.backed_enum_filter']
67+
* ```
68+
*
69+
* ```xml
70+
* <?xml version="1.0" encoding="UTF-8" ?>
71+
* <!-- api/config/services.xml -->
72+
* <?xml version="1.0" encoding="UTF-8" ?>
73+
* <container
74+
* xmlns="http://symfony.com/schema/dic/services"
75+
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
76+
* xsi:schemaLocation="http://symfony.com/schema/dic/services
77+
* https://symfony.com/schema/dic/services/services-1.0.xsd">
78+
* <services>
79+
* <service id="book.backed_enum_filter" parent="api_platform.doctrine.orm.backed_enum_filter">
80+
* <argument type="collection">
81+
* <argument key="status"/>
82+
* </argument>
83+
* <tag name="api_platform.filter"/>
84+
* </service>
85+
* </services>
86+
* </container>
87+
* <!-- api/config/api_platform/resources.xml -->
88+
* <resources
89+
* xmlns="https://api-platform.com/schema/metadata/resources-3.0"
90+
* xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
91+
* xsi:schemaLocation="https://api-platform.com/schema/metadata/resources-3.0
92+
* https://api-platform.com/schema/metadata/resources-3.0.xsd">
93+
* <resource class="App\Entity\Book">
94+
* <operations>
95+
* <operation class="ApiPlatform\Metadata\GetCollection">
96+
* <filters>
97+
* <filter>book.backed_enum_filter</filter>
98+
* </filters>
99+
* </operation>
100+
* </operations>
101+
* </resource>
102+
* </resources>
103+
* ```
104+
*
105+
* </div>
106+
*
107+
* Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?status=published`.
108+
*
109+
* @author Rémi Marseille <[email protected]>
110+
*/
111+
final class BackedEnumFilter extends AbstractFilter
112+
{
113+
use BackedEnumFilterTrait;
114+
115+
/**
116+
* {@inheritdoc}
117+
*/
118+
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
119+
{
120+
if (
121+
!$this->isPropertyEnabled($property, $resourceClass)
122+
|| !$this->isPropertyMapped($property, $resourceClass)
123+
|| !$this->isBackedEnumField($property, $resourceClass)
124+
) {
125+
return;
126+
}
127+
128+
$value = $this->normalizeValue($value, $property);
129+
if (null === $value) {
130+
return;
131+
}
132+
133+
$alias = $queryBuilder->getRootAliases()[0];
134+
$field = $property;
135+
136+
if ($this->isPropertyNested($property, $resourceClass)) {
137+
[$alias, $field] = $this->addJoinsForNestedProperty($property, $alias, $queryBuilder, $queryNameGenerator, $resourceClass, Join::INNER_JOIN);
138+
}
139+
140+
$valueParameter = $queryNameGenerator->generateParameterName($field);
141+
142+
$queryBuilder
143+
->andWhere(\sprintf('%s.%s = :%s', $alias, $field, $valueParameter))
144+
->setParameter($valueParameter, $value);
145+
}
146+
147+
/**
148+
* {@inheritdoc}
149+
*/
150+
protected function isBackedEnumField(string $property, string $resourceClass): bool
151+
{
152+
$propertyParts = $this->splitPropertyParts($property, $resourceClass);
153+
$metadata = $this->getNestedMetadata($resourceClass, $propertyParts['associations']);
154+
155+
if (!$metadata instanceof ClassMetadata) {
156+
return false;
157+
}
158+
159+
$fieldMapping = $metadata->fieldMappings[$propertyParts['field']];
160+
161+
// Doctrine ORM 2.x returns an array and Doctrine ORM 3.x returns a FieldMapping object
162+
if ($fieldMapping instanceof FieldMapping) {
163+
$fieldMapping = (array) $fieldMapping;
164+
}
165+
166+
if (!$enumType = $fieldMapping['enumType']) {
167+
return false;
168+
}
169+
170+
if (!($enumType::cases()[0] ?? null) instanceof \BackedEnum) {
171+
return false;
172+
}
173+
174+
$this->enumTypes[$property] = $enumType;
175+
176+
return true;
177+
}
178+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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\Doctrine\Orm\Tests\Filter;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter;
17+
use ApiPlatform\Doctrine\Orm\Tests\DoctrineOrmFilterTestCase;
18+
use ApiPlatform\Doctrine\Orm\Tests\Fixtures\Entity\Dummy;
19+
20+
/**
21+
* @author Rémi Marseille <[email protected]>
22+
*/
23+
final class BackedEnumFilterTest extends DoctrineOrmFilterTestCase
24+
{
25+
use BackedEnumFilterTestTrait;
26+
27+
protected string $filterClass = BackedEnumFilter::class;
28+
29+
public static function provideApplyTestData(): array
30+
{
31+
return array_merge_recursive(
32+
self::provideApplyTestArguments(),
33+
[
34+
'valid case' => [
35+
\sprintf('SELECT o FROM %s o WHERE o.dummyBackedEnum = :dummyBackedEnum_p1', Dummy::class),
36+
],
37+
'invalid case' => [
38+
\sprintf('SELECT o FROM %s o', Dummy::class),
39+
],
40+
'valid case for nested property' => [
41+
\sprintf('SELECT o FROM %s o INNER JOIN o.relatedDummy relatedDummy_a1 WHERE relatedDummy_a1.dummyBackedEnum = :dummyBackedEnum_p1', Dummy::class),
42+
],
43+
'invalid case for nested property' => [
44+
\sprintf('SELECT o FROM %s o', Dummy::class),
45+
],
46+
]
47+
);
48+
}
49+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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\Doctrine\Orm\Tests\Filter;
15+
16+
/**
17+
* @author Rémi Marseille <[email protected]>
18+
*/
19+
trait BackedEnumFilterTestTrait
20+
{
21+
public function testGetDescription(): void
22+
{
23+
$filter = $this->buildFilter([
24+
'id' => null,
25+
'name' => null,
26+
'foo' => null,
27+
'dummyBackedEnum' => null,
28+
]);
29+
30+
$this->assertEquals([
31+
'dummyBackedEnum' => [
32+
'property' => 'dummyBackedEnum',
33+
'type' => 'string',
34+
'required' => false,
35+
'schema' => [
36+
'type' => 'string',
37+
'enum' => ['one', 'two'],
38+
],
39+
],
40+
], $filter->getDescription($this->resourceClass));
41+
}
42+
43+
public function testGetDescriptionDefaultFields(): void
44+
{
45+
$filter = $this->buildFilter();
46+
47+
$this->assertEquals([
48+
'dummyBackedEnum' => [
49+
'property' => 'dummyBackedEnum',
50+
'type' => 'string',
51+
'required' => false,
52+
'schema' => [
53+
'type' => 'string',
54+
'enum' => ['one', 'two'],
55+
],
56+
],
57+
], $filter->getDescription($this->resourceClass));
58+
}
59+
60+
private static function provideApplyTestArguments(): array
61+
{
62+
return [
63+
'valid case' => [
64+
[
65+
'id' => null,
66+
'name' => null,
67+
'dummyBackedEnum' => null,
68+
],
69+
[
70+
'dummyBackedEnum' => 'one',
71+
],
72+
],
73+
'invalid case' => [
74+
[
75+
'id' => null,
76+
'name' => null,
77+
'dummyBackedEnum' => null,
78+
],
79+
[
80+
'dummyBackedEnum' => 'zero',
81+
],
82+
],
83+
'valid case for nested property' => [
84+
[
85+
'id' => null,
86+
'name' => null,
87+
'relatedDummy.dummyBackedEnum' => null,
88+
],
89+
[
90+
'relatedDummy.dummyBackedEnum' => 'two',
91+
],
92+
],
93+
'invalid case for nested property' => [
94+
[
95+
'id' => null,
96+
'name' => null,
97+
'relatedDummy.dummyBackedEnum' => null,
98+
],
99+
[
100+
'relatedDummy.dummyBackedEnum' => 'foo',
101+
],
102+
],
103+
];
104+
}
105+
}

Tests/Filter/ExistsFilterTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ public function testGetDescriptionDefaultFields(): void
8888
'type' => 'bool',
8989
'required' => false,
9090
],
91+
'exists[dummyBackedEnum]' => [
92+
'property' => 'dummyBackedEnum',
93+
'type' => 'bool',
94+
'required' => false,
95+
],
9196
], $filter->getDescription($this->resourceClass));
9297
}
9398

Tests/Filter/OrderFilterTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,19 @@ public function testGetDescriptionDefaultFields(): void
191191
],
192192
],
193193
],
194+
'order[dummyBackedEnum]' => [
195+
'property' => 'dummyBackedEnum',
196+
'type' => 'string',
197+
'required' => false,
198+
'schema' => [
199+
'default' => 'asc',
200+
'type' => 'string',
201+
'enum' => [
202+
'asc',
203+
'desc',
204+
],
205+
],
206+
],
194207
], $filter->getDescription($this->resourceClass));
195208

196209
$this->assertEquals([

Tests/Filter/RangeFilterTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,31 @@ public function testGetDescriptionDefaultFields(): void
331331
'type' => 'string',
332332
'required' => false,
333333
],
334+
'dummyBackedEnum[between]' => [
335+
'property' => 'dummyBackedEnum',
336+
'type' => 'string',
337+
'required' => false,
338+
],
339+
'dummyBackedEnum[gt]' => [
340+
'property' => 'dummyBackedEnum',
341+
'type' => 'string',
342+
'required' => false,
343+
],
344+
'dummyBackedEnum[gte]' => [
345+
'property' => 'dummyBackedEnum',
346+
'type' => 'string',
347+
'required' => false,
348+
],
349+
'dummyBackedEnum[lt]' => [
350+
'property' => 'dummyBackedEnum',
351+
'type' => 'string',
352+
'required' => false,
353+
],
354+
'dummyBackedEnum[lte]' => [
355+
'property' => 'dummyBackedEnum',
356+
'type' => 'string',
357+
'required' => false,
358+
],
334359
], $filter->getDescription($this->resourceClass));
335360
}
336361

0 commit comments

Comments
 (0)