From 6800ffda8ce8ba2eed289ee3f1a57d208e181fc0 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 26 Jun 2026 15:21:14 +0200 Subject: [PATCH] fix(doctrine): support filtering scalar enum columns by IRI IriFilter assumed the targeted leaf property was always a Doctrine association. A backed enum exposed as an API resource has an IRI but is stored in a scalar column, never an association: the ORM filter crashed with "No mapping found for field" and the ODM filter silently applied no filter at all. Resolve the leaf metadata to null when the property is not an association and fall back to a direct scalar comparison, normalizing the IRI-resolved BackedEnum to its backing value. Closes #8336 --- src/Doctrine/Odm/Filter/IriFilter.php | 22 +++++ src/Doctrine/Orm/Filter/IriFilter.php | 54 ++++++++++- .../Document/IriFilterScalarEnum/Game.php | 55 +++++++++++ .../Entity/IriFilterScalarEnum/Game.php | 57 +++++++++++ .../Parameters/IriFilterScalarEnumTest.php | 97 +++++++++++++++++++ 5 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php create mode 100644 tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php create mode 100644 tests/Functional/Parameters/IriFilterScalarEnumTest.php diff --git a/src/Doctrine/Odm/Filter/IriFilter.php b/src/Doctrine/Odm/Filter/IriFilter.php index 26290e09b56..0464e631e75 100644 --- a/src/Doctrine/Odm/Filter/IriFilter.php +++ b/src/Doctrine/Odm/Filter/IriFilter.php @@ -25,6 +25,7 @@ use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\ODM\MongoDB\Mapping\MappingException; /** @@ -68,7 +69,23 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera $leafProperty = $nestedInfo['leaf_property'] ?? $property; $classMetadata = $documentManager->getClassMetadata($leafClass); + // Backed enum exposed as a resource: match the scalar field against the resolved enum. if (!$classMetadata->hasReference($leafProperty)) { + if (!$this->isEnumField($classMetadata, $leafProperty)) { + return; + } + + $normalize = static fn (mixed $v): mixed => $v instanceof \BackedEnum ? $v->value : $v; + + if (is_iterable($value)) { + $values = \is_array($value) ? $value : iterator_to_array($value); + $match->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->in(array_map($normalize, $values))); + + return; + } + + $match->{$operator}($aggregationBuilder->matchExpr()->field($matchField)->equals($normalize($value))); + return; } @@ -113,4 +130,9 @@ public static function getParameterProvider(): string { return IriConverterParameterProvider::class; } + + private function isEnumField(ClassMetadata $classMetadata, string $field): bool + { + return $classMetadata->hasField($field) && null !== ($classMetadata->getFieldMapping($field)['enumType'] ?? null); + } } diff --git a/src/Doctrine/Orm/Filter/IriFilter.php b/src/Doctrine/Orm/Filter/IriFilter.php index 4cac01d21b6..e8de32ce793 100644 --- a/src/Doctrine/Orm/Filter/IriFilter.php +++ b/src/Doctrine/Orm/Filter/IriFilter.php @@ -22,6 +22,8 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ParameterProviderFilterInterface; use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\FieldMapping; use Doctrine\ORM\QueryBuilder; /** @@ -57,6 +59,13 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q $ormLeafMetadata = $this->resolveLeafMetadataAtRuntime($queryBuilder, $resourceClass, $parameter->getProperty(), $property); } + // Backed enum exposed as a resource: compare the scalar column to the resolved enum. + if (null === $ormLeafMetadata) { + $this->applyEnumComparison($queryBuilder, $alias, $property, $parameterName, $value, $context); + + return; + } + // Collection associations (OneToMany/ManyToMany) require a JOIN to compare individual elements. if ($ormLeafMetadata['is_collection_valued']) { $queryBuilder->join(\sprintf('%s.%s', $alias, $property), $parameterName); @@ -102,6 +111,25 @@ public static function getParameterProvider(): string return IriConverterParameterProvider::class; } + private function applyEnumComparison(QueryBuilder $queryBuilder, string $alias, string $property, string $parameterName, mixed $value, array $context): void + { + $propertyExpr = \sprintf('%s.%s', $alias, $property); + $normalize = static fn (mixed $v): mixed => $v instanceof \BackedEnum ? $v->value : $v; + + if (is_iterable($value)) { + $values = \is_array($value) ? $value : iterator_to_array($value); + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s IN (:%s)', $propertyExpr, $parameterName)) + ->setParameter($parameterName, array_map($normalize, $values)); + + return; + } + + $queryBuilder + ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s = :%s', $propertyExpr, $parameterName)) + ->setParameter($parameterName, $normalize($value)); + } + /** * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null */ @@ -122,9 +150,12 @@ private function getOrmLeafMetadata(mixed $parameter): ?array * Resolves leaf metadata at runtime by walking the association chain. * Used as fallback when precomputed orm_leaf_metadata is not available. * - * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string} + * Returns null when the leaf is a backed enum column so the caller compares it directly; + * a non-association, non-enum leaf is left to raise a mapping error. + * + * @return array{is_collection_valued: bool, association_target_class: string, identifier_type: ?string}|null */ - private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): array + private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $resourceClass, string $originalProperty, string $leafProperty): ?array { $em = $queryBuilder->getEntityManager(); $metadata = $em->getClassMetadata($resourceClass); @@ -135,6 +166,10 @@ private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string $metadata = $em->getClassMetadata($associationMapping['targetEntity']); } + if (!$metadata->hasAssociation($leafProperty) && $this->isEnumField($metadata, $leafProperty)) { + return null; + } + $isCollectionValued = $metadata->isCollectionValuedAssociation($leafProperty); $associationMapping = $metadata->getAssociationMapping($leafProperty); $targetClass = $associationMapping['targetEntity']; @@ -152,4 +187,19 @@ private function resolveLeafMetadataAtRuntime(QueryBuilder $queryBuilder, string 'identifier_type' => $identifierType, ]; } + + private function isEnumField(ClassMetadata $metadata, string $field): bool + { + if (!isset($metadata->fieldMappings[$field])) { + return false; + } + + $fieldMapping = $metadata->fieldMappings[$field]; + // Doctrine ORM 2.x returns an array and Doctrine ORM 3.x returns a FieldMapping object. + if ($fieldMapping instanceof FieldMapping) { + $fieldMapping = (array) $fieldMapping; + } + + return null !== ($fieldMapping['enumType'] ?? null); + } } diff --git a/tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php b/tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php new file mode 100644 index 00000000000..1fcc69cba1e --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document\IriFilterScalarEnum; + +use ApiPlatform\Doctrine\Odm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +#[ApiResource( + shortName: 'IriFilterScalarEnumGame', + operations: [ + new GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'playMode' => new QueryParameter(filter: new IriFilter()), + ], + ), + new Get(), + ] +)] +class Game +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $name; + + // Scalar field backed by an enum that is itself exposed as an API resource: + // the enum is never a Doctrine reference, so IriFilter must resolve the IRI + // to the enum case and match the scalar field against its backing value. + #[ODM\Field(type: 'string', enumType: GamePlayMode::class)] + public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php b/tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php new file mode 100644 index 00000000000..d582a780df4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterScalarEnum; + +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[ApiResource( + shortName: 'IriFilterScalarEnumGame', + operations: [ + new GetCollection( + normalizationContext: ['hydra_prefix' => false], + parameters: [ + 'playMode' => new QueryParameter(filter: new IriFilter()), + ], + ), + new Get(), + ] +)] +class Game +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + public string $name; + + // Scalar column backed by an enum that is itself exposed as an API resource: + // the enum can never be a Doctrine association, so IriFilter must resolve the + // IRI to the enum case and compare the scalar column to its backing value. + #[ORM\Column(type: 'string', enumType: GamePlayMode::class)] + public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/Parameters/IriFilterScalarEnumTest.php b/tests/Functional/Parameters/IriFilterScalarEnumTest.php new file mode 100644 index 00000000000..34ddcc14f08 --- /dev/null +++ b/tests/Functional/Parameters/IriFilterScalarEnumTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\IriFilterScalarEnum\Game as DocumentGame; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriFilterScalarEnum\Game; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; + +final class IriFilterScalarEnumTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Game::class, GamePlayMode::class]; + } + + public function testFilterScalarEnumColumnByIri(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode=/game_play_modes/SINGLE_PLAYER')->toArray(); + + $this->assertCount(2, $res['member']); + foreach ($res['member'] as $game) { + $this->assertSame('/game_play_modes/SINGLE_PLAYER', $game['playMode']); + } + } + + public function testFilterScalarEnumColumnByIriMultiple(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode[]=/game_play_modes/SINGLE_PLAYER&playMode[]=/game_play_modes/CO_OP')->toArray(); + + $this->assertCount(3, $res['member']); + } + + public function testFilterScalarEnumColumnByUnknownIriYieldsNoResult(): void + { + $client = $this->createClient(); + $res = $client->request('GET', '/iri_filter_scalar_enum_games?playMode=/game_play_modes/MULTI_PLAYER')->toArray(); + + $this->assertCount(0, $res['member']); + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $this->recreateSchema([$this->isMongoDB() ? DocumentGame::class : Game::class]); + $this->loadFixtures(); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DocumentGame::class : Game::class; + + foreach ([ + ['Solo Quest', GamePlayMode::SINGLE_PLAYER], + ['Lone Wolf', GamePlayMode::SINGLE_PLAYER], + ['Team Up', GamePlayMode::CO_OP], + ] as [$name, $playMode]) { + $game = new $class(); + $game->name = $name; + $game->playMode = $playMode; + $manager->persist($game); + } + + $manager->flush(); + } +}