Skip to content

Commit 404ecbb

Browse files
mremisoyuka
authored andcommitted
feat(doctrine): add new filter for filtering an entity using PHP backed enum, resolves api-platform#6506 (api-platform#6547)
1 parent 4c29bf8 commit 404ecbb

File tree

13 files changed

+529
-1
lines changed

13 files changed

+529
-1
lines changed
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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\Common\Filter;
15+
16+
use ApiPlatform\Doctrine\Common\PropertyHelperTrait;
17+
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
18+
use Psr\Log\LoggerInterface;
19+
20+
/**
21+
* Trait for filtering the collection by backed enum values.
22+
*
23+
* Filters collection on equality of backed enum properties.
24+
*
25+
* For each property passed, if the resource does not have such property or if
26+
* the value is not one of cases the property is ignored.
27+
*
28+
* @author Rémi Marseille <[email protected]>
29+
*/
30+
trait BackedEnumFilterTrait
31+
{
32+
use PropertyHelperTrait;
33+
34+
/**
35+
* @var array<string, string>
36+
*/
37+
private array $enumTypes;
38+
39+
/**
40+
* {@inheritdoc}
41+
*/
42+
public function getDescription(string $resourceClass): array
43+
{
44+
$description = [];
45+
46+
$properties = $this->getProperties();
47+
if (null === $properties) {
48+
$properties = array_fill_keys($this->getClassMetadata($resourceClass)->getFieldNames(), null);
49+
}
50+
51+
foreach ($properties as $property => $unused) {
52+
if (!$this->isPropertyMapped($property, $resourceClass) || !$this->isBackedEnumField($property, $resourceClass)) {
53+
continue;
54+
}
55+
$propertyName = $this->normalizePropertyName($property);
56+
$description[$propertyName] = [
57+
'property' => $propertyName,
58+
'type' => 'string',
59+
'required' => false,
60+
'schema' => [
61+
'type' => 'string',
62+
'enum' => array_map(fn (\BackedEnum $case) => $case->value, $this->enumTypes[$property]::cases()),
63+
],
64+
];
65+
}
66+
67+
return $description;
68+
}
69+
70+
abstract protected function getProperties(): ?array;
71+
72+
abstract protected function getLogger(): LoggerInterface;
73+
74+
abstract protected function normalizePropertyName(string $property): string;
75+
76+
/**
77+
* Determines whether the given property refers to a backed enum field.
78+
*/
79+
abstract protected function isBackedEnumField(string $property, string $resourceClass): bool;
80+
81+
private function normalizeValue($value, string $property): mixed
82+
{
83+
$values = array_map(fn (\BackedEnum $case) => $case->value, $this->enumTypes[$property]::cases());
84+
85+
if (\in_array($value, $values, true)) {
86+
return $value;
87+
}
88+
89+
$this->getLogger()->notice('Invalid filter ignored', [
90+
'exception' => new InvalidArgumentException(\sprintf('Invalid backed enum value for "%s" property, expected one of ( "%s" )',
91+
$property,
92+
implode('" | "', $values)
93+
)),
94+
]);
95+
96+
return null;
97+
}
98+
}
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+
}

0 commit comments

Comments
 (0)