From 7964c22b114a39a76e5ac730af5105a32b3bfcdb Mon Sep 17 00:00:00 2001 From: Alexis Lefebvre Date: Fri, 6 Feb 2026 16:27:41 +0100 Subject: [PATCH] feat(metadata): use entity class from stateOptions for filter property resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | main | Tickets | Closes #7610 | License | MIT | Doc PR | ∅ * Add filterClass property to Parameter to store entity class from stateOptions * Modify ParameterResourceMetadataCollectionFactory to resolve filter properties using entity class instead of resource class * Adjust Doctrine metadata factory decoration priorities to ensure correct execution order --- ...DbOdmResourceCollectionMetadataFactory.php | 27 ++++++++++-- ...neOrmResourceCollectionMetadataFactory.php | 24 ++++++++++- src/Metadata/Parameter.php | 15 +++++++ ...meterResourceMetadataCollectionFactory.php | 18 +++++--- .../Resources/config/doctrine_mongodb_odm.php | 2 +- .../Bundle/Resources/config/doctrine_orm.php | 2 +- .../FilterWithStateOptionsAndNoApiFilter.php | 39 +++++++++++++++++ ...erWithStateOptionsAndNoApiFilterEntity.php | 30 +++++++++++++ tests/Functional/Parameters/DoctrineTest.php | 42 ++++++++++++++++++- 9 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptionsAndNoApiFilter.php create mode 100644 tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsAndNoApiFilterEntity.php diff --git a/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php b/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php index 796f0089fd8..c5ceb77c329 100644 --- a/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php +++ b/src/Doctrine/Odm/Metadata/Resource/DoctrineMongoDbOdmResourceCollectionMetadataFactory.php @@ -50,7 +50,9 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $operations->add($operationName, $this->addDefaults($operation)); + $operation = $this->addDefaults($operation); + $operation = $this->setParametersFilterClass($operation, $documentClass); + $operations->add($operationName, $operation); } $resourceMetadata = $resourceMetadata->withOperations($operations); @@ -60,11 +62,14 @@ public function create(string $resourceClass): ResourceMetadataCollection if ($graphQlOperations) { foreach ($graphQlOperations as $operationName => $graphQlOperation) { - if (!$this->managerRegistry->getManagerForClass($graphQlOperation->getClass()) instanceof DocumentManager) { + $documentClass = $this->getStateOptionsClass($graphQlOperation, $graphQlOperation->getClass(), Options::class); + if (!$this->managerRegistry->getManagerForClass($documentClass) instanceof DocumentManager) { continue; } - $graphQlOperations[$operationName] = $this->addDefaults($graphQlOperation); + $graphQlOperation = $this->addDefaults($graphQlOperation); + $graphQlOperation = $this->setParametersFilterClass($graphQlOperation, $documentClass); + $graphQlOperations[$operationName] = $graphQlOperation; } $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); @@ -112,4 +117,20 @@ private function getProcessor(Operation $operation): string return 'api_platform.doctrine_mongodb.odm.state.persist_processor'; } + + private function setParametersFilterClass(Operation $operation, string $documentClass): Operation + { + $parameters = $operation->getParameters(); + if (!$parameters) { + return $operation; + } + + foreach ($parameters as $key => $parameter) { + if (null === $parameter->getFilterClass()) { + $parameters->add($key, $parameter->withFilterClass($documentClass)); + } + } + + return $operation->withParameters($parameters); + } } diff --git a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php index 77155723a89..10c733827ee 100644 --- a/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php +++ b/src/Doctrine/Orm/Metadata/Resource/DoctrineOrmResourceCollectionMetadataFactory.php @@ -51,7 +51,9 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $operations->add($operationName, $this->addDefaults($operation)); + $operation = $this->addDefaults($operation); + $operation = $this->setParametersFilterClass($operation, $entityClass); + $operations->add($operationName, $operation); } $resourceMetadata = $resourceMetadata->withOperations($operations); @@ -67,7 +69,9 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $graphQlOperations[$operationName] = $this->addDefaults($graphQlOperation); + $graphQlOperation = $this->addDefaults($graphQlOperation); + $graphQlOperation = $this->setParametersFilterClass($graphQlOperation, $entityClass); + $graphQlOperations[$operationName] = $graphQlOperation; } $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); @@ -115,4 +119,20 @@ private function getProcessor(Operation $operation): string return 'api_platform.doctrine.orm.state.persist_processor'; } + + private function setParametersFilterClass(Operation $operation, string $entityClass): Operation + { + $parameters = $operation->getParameters(); + if (!$parameters) { + return $operation; + } + + foreach ($parameters as $key => $parameter) { + if (null === $parameter->getFilterClass()) { + $parameters->add($key, $parameter->withFilterClass($entityClass)); + } + } + + return $operation->withParameters($parameters); + } } diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 784e886949e..2eb43412f72 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -30,6 +30,7 @@ abstract class Parameter * @param Type $nativeType the PHP native type, we cast values to an array if its a CollectionType, if not and it's an array with a single value we use it (eg: HTTP Header) * @param ?bool $castToNativeType whether API Platform should cast your parameter to the nativeType declared * @param ?callable(mixed): mixed $castFn the closure used to cast your parameter, this gets called only when $castToNativeType is set + * @param ?string $filterClass the class to use when resolving filter properties (from stateOptions) * * @phpstan-param array|null $schema * @@ -56,6 +57,7 @@ public function __construct( protected ?bool $castToArray = null, protected ?bool $castToNativeType = null, protected mixed $castFn = null, + protected ?string $filterClass = null, ) { } @@ -370,4 +372,17 @@ public function withCastFn(mixed $castFn): self return $self; } + + public function getFilterClass(): ?string + { + return $this->filterClass; + } + + public function withFilterClass(?string $filterClass): self + { + $self = clone $this; + $self->filterClass = $filterClass; + + return $self; + } } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 656d82ca215..da1fbd5c52a 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -94,17 +94,23 @@ public function create(string $resourceClass): ResourceMetadataCollection /** * @return array{propertyNames: string[], properties: array} */ - private function getProperties(string $resourceClass, ?Parameter $parameter = null): array + private function getProperties(string $resourceClass, ?Parameter $parameter = null, ?Operation $operation = null): array { - $k = $resourceClass.($parameter?->getProperties() ? ($parameter->getKey() ?? '') : '').(\is_string($parameter->getFilter()) ? $parameter->getFilter() : ''); + $filterClass = $parameter?->getFilterClass(); + if (null === $filterClass && null !== $operation) { + $filterClass = $this->getStateOptionsClass($operation, $resourceClass); + } + $filterClass ??= $resourceClass; + + $k = $resourceClass.($parameter?->getProperties() ? ($parameter->getKey() ?? '') : '').(\is_string($parameter->getFilter()) ? $parameter->getFilter() : '').$filterClass; if (isset($this->localPropertyCache[$k])) { return $this->localPropertyCache[$k]; } $propertyNames = []; $properties = []; - foreach ($parameter?->getProperties() ?? $this->propertyNameCollectionFactory->create($resourceClass) as $property) { - $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); + foreach ($parameter?->getProperties() ?? $this->propertyNameCollectionFactory->create($filterClass) as $property) { + $propertyMetadata = $this->propertyMetadataFactory->create($filterClass, $property); if ($propertyMetadata->isReadable()) { $propertyNames[] = $property; $properties[$property] = $propertyMetadata; @@ -147,7 +153,7 @@ private function getDefaultParameters(Operation $operation, string $resourceClas $parameter = $parameter->withKey($key); } - ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass, $parameter); + ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass, $parameter, $operation); $parameter = $parameter->withProperties($propertyNames); foreach ($propertyNames as $property) { @@ -176,7 +182,7 @@ private function getDefaultParameters(Operation $operation, string $resourceClas $key = $parameter->getKey(); - ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass, $parameter); + ['propertyNames' => $propertyNames, 'properties' => $properties] = $this->getProperties($resourceClass, $parameter, $operation); if ($filter instanceof PropertiesAwareInterface) { $parameter = $parameter->withProperties($propertyNames); diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php index db36c8b63c0..f32fe5229e5 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.php @@ -217,7 +217,7 @@ $services->alias('api_platform.state.item_provider', 'ApiPlatform\Doctrine\Odm\State\ItemProvider'); $services->set('api_platform.doctrine.odm.metadata.resource.metadata_collection_factory', DoctrineMongoDbOdmResourceCollectionMetadataFactory::class) - ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 40) + ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, -50) ->args([ service('doctrine_mongodb'), service('api_platform.doctrine.odm.metadata.resource.metadata_collection_factory.inner'), diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.php b/src/Symfony/Bundle/Resources/config/doctrine_orm.php index 894295926fb..f893cda9d16 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.php +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.php @@ -247,7 +247,7 @@ ->args([[]]); $services->set('api_platform.doctrine.orm.metadata.resource.metadata_collection_factory', DoctrineOrmResourceCollectionMetadataFactory::class) - ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 40) + ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, -50) ->args([ service('doctrine'), service('api_platform.doctrine.orm.metadata.resource.metadata_collection_factory.inner'), diff --git a/tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptionsAndNoApiFilter.php b/tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptionsAndNoApiFilter.php new file mode 100644 index 00000000000..b5b6c661ebc --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/FilterWithStateOptionsAndNoApiFilter.php @@ -0,0 +1,39 @@ + + * + * 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\ApiResource; + +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsAndNoApiFilterEntity; + +#[ApiResource( + stateOptions: new Options(entityClass: FilterWithStateOptionsAndNoApiFilterEntity::class), + operations: [ + new GetCollection( + uriTemplate: '/filter_with_state_options_and_no_api_filters_api_resource', + parameters: [ + 'search[:property]' => new QueryParameter( + properties: ['name'], + filter: new PartialSearchFilter(), + ), + ], + ), + ] +)] +final class FilterWithStateOptionsAndNoApiFilter +{ +} diff --git a/tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsAndNoApiFilterEntity.php b/tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsAndNoApiFilterEntity.php new file mode 100644 index 00000000000..f4354f87144 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/FilterWithStateOptionsAndNoApiFilterEntity.php @@ -0,0 +1,30 @@ + + * + * 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; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class FilterWithStateOptionsAndNoApiFilterEntity +{ + public function __construct( + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + #[ORM\Column(type: 'string', nullable: true)] + public ?string $name = null, + ) { + } +} diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index 088153e6c11..56b4bdbb1e0 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -15,7 +15,9 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FilterWithStateOptions; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FilterWithStateOptionsAndNoApiFilter; use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameter as SearchFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsAndNoApiFilterEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ProductWithQueryParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; @@ -35,7 +37,12 @@ final class DoctrineTest extends ApiTestCase */ public static function getResources(): array { - return [SearchFilterParameter::class, FilterWithStateOptions::class, ProductWithQueryParameter::class]; + return [ + SearchFilterParameter::class, + FilterWithStateOptions::class, + FilterWithStateOptionsAndNoApiFilter::class, + ProductWithQueryParameter::class, + ]; } public function testDoctrineEntitySearchFilter(): void @@ -147,6 +154,39 @@ public function testStateOptions(): void $this->assertEquals('after', $a['hydra:member'][0]['name']); } + public function testStateOptionsAndNoApiFilter(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + static::bootKernel(); + $container = static::$kernel->getContainer(); + $this->recreateSchema([FilterWithStateOptionsAndNoApiFilterEntity::class]); + + $manager = $container->get('doctrine')->getManager(); + $manager->persist(new FilterWithStateOptionsAndNoApiFilterEntity(name: 'current')); + $manager->persist(new FilterWithStateOptionsAndNoApiFilterEntity(name: 'null')); + $manager->persist(new FilterWithStateOptionsAndNoApiFilterEntity(name: 'after')); + $manager->flush(); + + $uri = '/filter_with_state_options_and_no_api_filters_api_resource'; + + $response = self::createClient()->request('GET', $uri); + $this->assertResponseIsSuccessful(); + $a = $response->toArray(); + $this->assertSame('hydra:Collection', $a['@type']); + $this->assertSame(3, $a['hydra:totalItems']); + $this->assertCount(3, $a['hydra:member']); + + $response = self::createClient()->request('GET', $uri.'?search[name]=aft'); + $this->assertResponseIsSuccessful(); + $a = $response->toArray(); + $this->assertSame('hydra:Collection', $a['@type']); + $this->assertSame(1, $a['hydra:totalItems']); + $this->assertCount(1, $a['hydra:member']); + } + #[DataProvider('partialFilterParameterProviderForSearchFilterParameter')] public function testPartialSearchFilterWithSearchFilterParameter(string $url, int $expectedCount, array $expectedFoos): void {