Skip to content

Commit 6682985

Browse files
authored
fix(doctrine): support filtering scalar enum columns by IRI (#8358)
Closes #8336
1 parent 71918e4 commit 6682985

5 files changed

Lines changed: 283 additions & 2 deletions

File tree

src/Doctrine/Odm/Filter/IriFilter.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider;
2626
use Doctrine\ODM\MongoDB\Aggregation\Builder;
2727
use Doctrine\ODM\MongoDB\DocumentManager;
28+
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
2829
use Doctrine\ODM\MongoDB\Mapping\MappingException;
2930

3031
/**
@@ -68,7 +69,23 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera
6869
$leafProperty = $nestedInfo['leaf_property'] ?? $property;
6970
$classMetadata = $documentManager->getClassMetadata($leafClass);
7071

72+
// Backed enum exposed as a resource: match the scalar field against the resolved enum.
7173
if (!$classMetadata->hasReference($leafProperty)) {
74+
if (!$this->isEnumField($classMetadata, $leafProperty)) {
75+
return;
76+
}
77+
78+
$normalize = static fn (mixed $v): mixed => $v instanceof \BackedEnum ? $v->value : $v;
79+
80+
if (is_iterable($value)) {
81+
$values = \is_array($value) ? $value : iterator_to_array($value);
82+
$match->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->in(array_map($normalize, $values)));
83+
84+
return;
85+
}
86+
87+
$match->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->equals($normalize($value)));
88+
7289
return;
7390
}
7491

@@ -113,4 +130,9 @@ public static function getParameterProvider(): string
113130
{
114131
return IriConverterParameterProvider::class;
115132
}
133+
134+
private function isEnumField(ClassMetadata $classMetadata, string $field): bool
135+
{
136+
return $classMetadata->hasField($field) && null !== ($classMetadata->getFieldMapping($field)['enumType'] ?? null);
137+
}
116138
}

src/Doctrine/Orm/Filter/IriFilter.php

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
use ApiPlatform\Metadata\Operation;
2323
use ApiPlatform\Metadata\ParameterProviderFilterInterface;
2424
use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider;
25+
use Doctrine\ORM\Mapping\ClassMetadata;
26+
use Doctrine\ORM\Mapping\FieldMapping;
2527
use Doctrine\ORM\QueryBuilder;
2628

2729
/**
@@ -57,6 +59,13 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q
5759
$ormLeafMetadata = $this->resolveLeafMetadataAtRuntime($queryBuilder, $resourceClass, $parameter->getProperty(), $property);
5860
}
5961

62+
// Backed enum exposed as a resource: compare the scalar column to the resolved enum.
63+
if (null === $ormLeafMetadata) {
64+
$this->applyEnumComparison($queryBuilder, $alias, $property, $parameterName, $value, $context);
65+
66+
return;
67+
}
68+
6069
// Collection associations (OneToMany/ManyToMany) require a JOIN to compare individual elements.
6170
if ($ormLeafMetadata['is_collection_valued']) {
6271
$queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName);
@@ -102,6 +111,25 @@ public static function getParameterProvider(): string
102111
return IriConverterParameterProvider::class;
103112
}
104113

114+
private function applyEnumComparison(QueryBuilder $queryBuilder, string $alias, string $property, string $parameterName, mixed $value, array $context): void
115+
{
116+
$propertyExpr = \sprintf('%s.%s', $alias, $property);
117+
$normalize = static fn (mixed $v): mixed => $v instanceof \BackedEnum ? $v->value : $v;
118+
119+
if (is_iterable($value)) {
120+
$values = \is_array($value) ? $value : iterator_to_array($value);
121+
$queryBuilder
122+
->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $propertyExpr, $parameterName))
123+
->setParameter($parameterName, array_map($normalize, $values));
124+
125+
return;
126+
}
127+
128+
$queryBuilder
129+
->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $propertyExpr, $parameterName))
130+
->setParameter($parameterName, $normalize($value));
131+
}
132+
105133
/**
106134
* @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null
107135
*/
@@ -122,9 +150,12 @@ private function getOrmLeafMetadata(mixed $parameter): ?array
122150
* Resolves leaf metadata at runtime by walking the association chain.
123151
* Used as fallback when precomputed orm_leaf_metadata is not available.
124152
*
125-
* @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}
153+
* Returns null when the leaf is a backed enum column so the caller compares it directly;
154+
* a non-association, non-enum leaf is left to raise a mapping error.
155+
*
156+
* @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null
126157
*/
127-
private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): array
158+
private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): ?array
128159
{
129160
$em = $queryBuilder->getEntityManager();
130161
$metadata = $em->getClassMetadata($resourceClass);
@@ -135,6 +166,10 @@ private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string
135166
$metadata = $em->getClassMetadata($associationMapping['targetEntity']);
136167
}
137168

169+
if (!$metadata->hasAssociation($leafProperty) && $this->isEnumField($metadata, $leafProperty)) {
170+
return null;
171+
}
172+
138173
$isCollectionValued = $metadata->isCollectionValuedAssociation($leafProperty);
139174
$associationMapping = $metadata->getAssociationMapping($leafProperty);
140175
$targetClass = $associationMapping['targetEntity'];
@@ -152,4 +187,19 @@ private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string
152187
'identifier_type' => $identifierType,
153188
];
154189
}
190+
191+
private function isEnumField(ClassMetadata $metadata, string $field): bool
192+
{
193+
if (!isset($metadata->fieldMappings[$field])) {
194+
return false;
195+
}
196+
197+
$fieldMapping = $metadata->fieldMappings[$field];
198+
// Doctrine ORM 2.x returns an array and Doctrine ORM 3.x returns a FieldMapping object.
199+
if ($fieldMapping instanceof FieldMapping) {
200+
$fieldMapping = (array) $fieldMapping;
201+
}
202+
203+
return null !== ($fieldMapping['enumType'] ?? null);
204+
}
155205
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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\Tests\Fixtures\TestBundle\Document\IriFilterScalarEnum;
15+
16+
use ApiPlatform\Doctrine\Odm\Filter\IriFilter;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use ApiPlatform\Metadata\QueryParameter;
21+
use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode;
22+
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
23+
24+
#[ODM\Document]
25+
#[ApiResource(
26+
shortName: 'IriFilterScalarEnumGame',
27+
operations: [
28+
new GetCollection(
29+
normalizationContext: ['hydra_prefix' => false],
30+
parameters: [
31+
'playMode' => new QueryParameter(filter: new IriFilter()),
32+
],
33+
),
34+
new Get(),
35+
]
36+
)]
37+
class Game
38+
{
39+
#[ODM\Id(strategy: 'INCREMENT', type: 'int')]
40+
private ?int $id = null;
41+
42+
#[ODM\Field(type: 'string')]
43+
public string $name;
44+
45+
// Scalar field backed by an enum that is itself exposed as an API resource:
46+
// the enum is never a Doctrine reference, so IriFilter must resolve the IRI
47+
// to the enum case and match the scalar field against its backing value.
48+
#[ODM\Field(type: 'string', enumType: GamePlayMode::class)]
49+
public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER;
50+
51+
public function getId(): ?int
52+
{
53+
return $this->id;
54+
}
55+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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\Tests\Fixtures\TestBundle\Entity\IriFilterScalarEnum;
15+
16+
use ApiPlatform\Doctrine\Orm\Filter\IriFilter;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use ApiPlatform\Metadata\QueryParameter;
21+
use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode;
22+
use Doctrine\ORM\Mapping as ORM;
23+
24+
#[ORM\Entity]
25+
#[ApiResource(
26+
shortName: 'IriFilterScalarEnumGame',
27+
operations: [
28+
new GetCollection(
29+
normalizationContext: ['hydra_prefix' => false],
30+
parameters: [
31+
'playMode' => new QueryParameter(filter: new IriFilter()),
32+
],
33+
),
34+
new Get(),
35+
]
36+
)]
37+
class Game
38+
{
39+
#[ORM\Id]
40+
#[ORM\GeneratedValue]
41+
#[ORM\Column(type: 'integer')]
42+
private ?int $id = null;
43+
44+
#[ORM\Column(type: 'string', length: 255)]
45+
public string $name;
46+
47+
// Scalar column backed by an enum that is itself exposed as an API resource:
48+
// the enum can never be a Doctrine association, so IriFilter must resolve the
49+
// IRI to the enum case and compare the scalar column to its backing value.
50+
#[ORM\Column(type: 'string', enumType: GamePlayMode::class)]
51+
public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER;
52+
53+
public function getId(): ?int
54+
{
55+
return $this->id;
56+
}
57+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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\Tests\Functional\Parameters;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Document\IriFilterScalarEnum\Game as DocumentGame;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterScalarEnum\Game;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode;
20+
use ApiPlatform\Tests\RecreateSchemaTrait;
21+
use ApiPlatform\Tests\SetupClassResourcesTrait;
22+
use Doctrine\ODM\MongoDB\MongoDBException;
23+
24+
final class IriFilterScalarEnumTest extends ApiTestCase
25+
{
26+
use RecreateSchemaTrait;
27+
use SetupClassResourcesTrait;
28+
29+
protected static ?bool $alwaysBootKernel = false;
30+
31+
/**
32+
* @return class-string[]
33+
*/
34+
public static function getResources(): array
35+
{
36+
return [Game::class, GamePlayMode::class];
37+
}
38+
39+
public function testFilterScalarEnumColumnByIri(): void
40+
{
41+
$client = $this->createClient();
42+
$res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode=/game_play_modes/SINGLE_PLAYER')->toArray();
43+
44+
$this->assertCount(2, $res['member']);
45+
foreach ($res['member'] as $game) {
46+
$this->assertSame('/game_play_modes/SINGLE_PLAYER', $game['playMode']);
47+
}
48+
}
49+
50+
public function testFilterScalarEnumColumnByIriMultiple(): void
51+
{
52+
$client = $this->createClient();
53+
$res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode[]=/game_play_modes/SINGLE_PLAYER&playMode[]=/game_play_modes/CO_OP')->toArray();
54+
55+
$this->assertCount(3, $res['member']);
56+
}
57+
58+
public function testFilterScalarEnumColumnByUnknownIriYieldsNoResult(): void
59+
{
60+
$client = $this->createClient();
61+
$res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode=/game_play_modes/MULTI_PLAYER')->toArray();
62+
63+
$this->assertCount(0, $res['member']);
64+
}
65+
66+
/**
67+
* @throws \Throwable
68+
*/
69+
protected function setUp(): void
70+
{
71+
$this->recreateSchema([$this->isMongoDB() ? DocumentGame::class : Game::class]);
72+
$this->loadFixtures();
73+
}
74+
75+
/**
76+
* @throws \Throwable
77+
* @throws MongoDBException
78+
*/
79+
private function loadFixtures(): void
80+
{
81+
$manager = $this->getManager();
82+
$class = $this->isMongoDB() ? DocumentGame::class : Game::class;
83+
84+
foreach ([
85+
['Solo Quest', GamePlayMode::SINGLE_PLAYER],
86+
['Lone Wolf', GamePlayMode::SINGLE_PLAYER],
87+
['Team Up', GamePlayMode::CO_OP],
88+
] as [$name, $playMode]) {
89+
$game = new $class();
90+
$game->name = $name;
91+
$game->playMode = $playMode;
92+
$manager->persist($game);
93+
}
94+
95+
$manager->flush();
96+
}
97+
}

0 commit comments

Comments
 (0)