Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/Doctrine/Odm/Filter/IriFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}
}
54 changes: 52 additions & 2 deletions src/Doctrine/Orm/Filter/IriFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
*/
Expand All @@ -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);
Expand All @@ -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'];
Expand All @@ -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);
}
}
55 changes: 55 additions & 0 deletions tests/Fixtures/TestBundle/Document/IriFilterScalarEnum/Game.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
}
57 changes: 57 additions & 0 deletions tests/Fixtures/TestBundle/Entity/IriFilterScalarEnum/Game.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
}
97 changes: 97 additions & 0 deletions tests/Functional/Parameters/IriFilterScalarEnumTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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();
}
}
Loading