From cdd121364f9b65305adae58b04f7ba5843269f50 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 3 Jul 2026 07:20:23 +0200 Subject: [PATCH] fix(doctrine): use PHP property name in DQL for modern filters with name converter Modern parameter filters (ExactFilter, SortFilter, ComparisonFilter, PartialSearchFilter) used Parameter::getProperty() verbatim to build DQL/aggregation queries. When a name converter is configured, ParameterResourceMetadataCollectionFactory::setDefaults() normalizes that property (e.g. createdAt -> created_at) for the public/OpenAPI name, but Doctrine still knows the entity field by its PHP name, causing a 500 "has no field or association named created_at" for any multi-word property. Preserve the original PHP property name as a `query_property` extra property before normalizing, and prefer it over the converted property in both NestedPropertyHelperTrait implementations (ORM and ODM), which is the single chokepoint every modern filter routes through for flat (non-nested) properties. Nested properties already skip normalization via nested_properties_info and are unaffected. Follow-up to #7877, which fixed the equivalent issue for legacy AbstractFilter-based filters. Fixes #8380 --- .../Odm/NestedPropertyHelperTrait.php | 2 +- .../Orm/NestedPropertyHelperTrait.php | 2 +- ...meterResourceMetadataCollectionFactory.php | 5 +- .../Document/ConvertedFilterParameter.php | 57 +++++++++++++ .../Entity/ConvertedFilterParameter.php | 59 +++++++++++++ .../NameConverterModernFilterTest.php | 84 +++++++++++++++++++ 6 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php create mode 100644 tests/Functional/Parameters/NameConverterModernFilterTest.php diff --git a/src/Doctrine/Odm/NestedPropertyHelperTrait.php b/src/Doctrine/Odm/NestedPropertyHelperTrait.php index e713382bece..d3a92ecdf0b 100644 --- a/src/Doctrine/Odm/NestedPropertyHelperTrait.php +++ b/src/Doctrine/Odm/NestedPropertyHelperTrait.php @@ -39,7 +39,7 @@ protected function addNestedParameterLookups(string $property, Builder $aggregat $nestedInfo = ($extraProperties['nested_properties_info'] ?? []) ? reset($extraProperties['nested_properties_info']) : null; if (!$nestedInfo) { - return $property; + return $extraProperties['query_property'] ?? $property; } $odmSegments = $nestedInfo['odm_segments'] ?? []; diff --git a/src/Doctrine/Orm/NestedPropertyHelperTrait.php b/src/Doctrine/Orm/NestedPropertyHelperTrait.php index e93f17d46f8..f8f0e1e52e0 100644 --- a/src/Doctrine/Orm/NestedPropertyHelperTrait.php +++ b/src/Doctrine/Orm/NestedPropertyHelperTrait.php @@ -43,7 +43,7 @@ protected function addNestedParameterJoins( $nestedInfo = ($extraProperties['nested_properties_info'] ?? []) ? reset($extraProperties['nested_properties_info']) : null; if (!$nestedInfo) { - return [$alias, $property]; + return [$alias, $extraProperties['query_property'] ?? $property]; } $relationClasses = $nestedInfo['relation_classes'] ?? []; diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index f55734196e5..3972b1e8978 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -405,7 +405,10 @@ private function setDefaults(string $key, Parameter $parameter, ?object $filter, // Skip name conversion if we already have nested property info $paramExtraProps = $parameter->getExtraProperties(); if (!isset($paramExtraProps['nested_properties_info'])) { - $parameter = $parameter->withProperty($this->nameConverter->normalize($property)); + // Keep the original (non name-converted) property to build the query while the public property matches the API naming convention. + $parameter = $parameter + ->withExtraProperties([...$paramExtraProps, 'query_property' => $property]) + ->withProperty($this->nameConverter->normalize($property)); } } diff --git a/tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php b/tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.php new file mode 100644 index 00000000000..9a44bc34e0a --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/ConvertedFilterParameter.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\Document; + +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Tests that modern parameter filters (ExactFilter, SortFilter) work with + * QueryParameter on a multi-word property when a nameConverter is configured + * (issue #8380). + */ +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'nameConverted' => new QueryParameter( + filter: new ExactFilter(), + property: 'nameConverted', + ), + 'orderNameConverted' => new QueryParameter( + filter: new SortFilter(), + property: 'nameConverted', + ), + ], + ), + ], +)] +#[ODM\Document] +class ConvertedFilterParameter +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $nameConverted = ''; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php b/tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php new file mode 100644 index 00000000000..302a2c63e10 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/ConvertedFilterParameter.php @@ -0,0 +1,59 @@ + + * + * 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 ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +/** + * Tests that modern parameter filters (ExactFilter, SortFilter) work with + * QueryParameter on a multi-word property when a nameConverter is configured + * (issue #8380). + */ +#[ApiResource( + operations: [ + new GetCollection( + parameters: [ + 'nameConverted' => new QueryParameter( + filter: new ExactFilter(), + property: 'nameConverted', + ), + 'orderNameConverted' => new QueryParameter( + filter: new SortFilter(), + property: 'nameConverted', + ), + ], + ), + ], +)] +#[ORM\Entity] +class ConvertedFilterParameter +{ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + public string $nameConverted = ''; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/Parameters/NameConverterModernFilterTest.php b/tests/Functional/Parameters/NameConverterModernFilterTest.php new file mode 100644 index 00000000000..a2f3c939141 --- /dev/null +++ b/tests/Functional/Parameters/NameConverterModernFilterTest.php @@ -0,0 +1,84 @@ + + * + * 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\ConvertedFilterParameter as ConvertedFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedFilterParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Tests that modern parameter filters (ExactFilter, SortFilter) work with + * QueryParameter on a multi-word property when a nameConverter is configured. + * + * @see https://github.com/api-platform/core/issues/8380 + */ +final class NameConverterModernFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ConvertedFilterParameter::class]; + } + + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? ConvertedFilterParameterDocument::class : ConvertedFilterParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + public function testExactFilterWithNameConverter(): void + { + $response = self::createClient()->request('GET', '/converted_filter_parameters?nameConverted=bar'); + $this->assertResponseIsSuccessful(); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertSame('bar', $members[0]['name_converted']); + } + + public function testSortFilterWithNameConverter(): void + { + $response = self::createClient()->request('GET', '/converted_filter_parameters?orderNameConverted=desc'); + $this->assertResponseIsSuccessful(); + $members = $response->toArray()['hydra:member']; + $names = array_map(static fn (array $m): string => $m['name_converted'], $members); + $this->assertSame(['foo', 'baz', 'bar'], $names); + } + + /** + * @param class-string $entityClass + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + foreach (['bar', 'baz', 'foo'] as $name) { + $entity = new $entityClass(); + $entity->nameConverted = $name; + $manager->persist($entity); + } + + $manager->flush(); + } +}