From 047ec38d225f812ace9614609542e1b06ac386ec Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 11 May 2026 11:46:18 +0200 Subject: [PATCH] fix(graphql): accept FilterInterface instance in QueryParameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | refs #7966 | License | MIT | Doc PR | ∅ FieldsBuilder called filterLocator->has() with whatever QueryParameter::$filter returned, crashing on object-form filters (new SortFilter()) when the parameter key contained '['. A resolveFilter() helper now handles both string service ids and FilterInterface instances, applied at every locator site. --- src/GraphQl/Type/FieldsBuilder.php | 48 +++++++++++------ .../Issue7966/SortFilterParameterDummy.php | 42 +++++++++++++++ tests/Functional/GraphQl/Issue7966Test.php | 52 +++++++++++++++++++ 3 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue7966/SortFilterParameterDummy.php create mode 100644 tests/Functional/GraphQl/Issue7966Test.php diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 458f92bb248..4d674368723 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -512,20 +512,16 @@ private function getParameterArgs(Operation $operation, array $args = []): array $name = key($leafs); $filterLeafs = []; - if (($filterId = $parameter->getFilter()) && $this->filterLocator->has($filterId)) { - $filter = $this->filterLocator->get($filterId); - - if ($filter instanceof FilterInterface) { - $property = $parameter->getProperty() ?? $name; - $property = str_replace('.', $this->nestingSeparator, $property); - $description = $filter->getDescription($operation->getClass()); - - foreach ($description as $descKey => $descValue) { - $descKey = str_replace('.', $this->nestingSeparator, $descKey); - parse_str($descKey, $descValues); - if (isset($descValues[$property]) && \is_array($descValues[$property])) { - $filterLeafs = array_merge($filterLeafs, $descValues[$property]); - } + if ($filter = $this->resolveFilter($parameter->getFilter())) { + $property = $parameter->getProperty() ?? $name; + $property = str_replace('.', $this->nestingSeparator, $property); + $description = $filter->getDescription($operation->getClass()); + + foreach ($description as $descKey => $descValue) { + $descKey = str_replace('.', $this->nestingSeparator, $descKey); + parse_str($descKey, $descValues); + if (isset($descValues[$property]) && \is_array($descValues[$property])) { + $filterLeafs = array_merge($filterLeafs, $descValues[$property]); } } } @@ -612,12 +608,12 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root } foreach ($resourceOperation->getFilters() ?? [] as $filterId) { - if (!$this->filterLocator->has($filterId)) { + if (!($filter = $this->resolveFilter($filterId))) { continue; } $entityClass = $this->getStateOptionsClass($resourceOperation, $resourceOperation->getClass()); - foreach ($this->filterLocator->get($filterId)->getDescription($entityClass) as $key => $description) { + foreach ($filter->getDescription($entityClass) as $key => $description) { $filterType = \in_array($description['type'], TypeIdentifier::values(), true) ? Type::builtin($description['type']) : Type::object($description['type']); if (!($description['required'] ?? false)) { $filterType = Type::nullable($filterType); @@ -751,4 +747,24 @@ private function normalizePropertyName(string $property, string $resourceClass): return $this->nameConverter->normalize($property, $resourceClass); } + + /** + * Resolves a filter reference to a {@see FilterInterface} instance, supporting + * both a string service id (legacy/locator path) and an object form + * (`new QueryParameter(filter: new SortFilter())`). + */ + private function resolveFilter(mixed $filter): ?FilterInterface + { + if ($filter instanceof FilterInterface) { + return $filter; + } + + if (\is_string($filter) && $this->filterLocator->has($filter)) { + $resolved = $this->filterLocator->get($filter); + + return $resolved instanceof FilterInterface ? $resolved : null; + } + + return null; + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7966/SortFilterParameterDummy.php b/tests/Fixtures/TestBundle/ApiResource/Issue7966/SortFilterParameterDummy.php new file mode 100644 index 00000000000..423d89d951e --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7966/SortFilterParameterDummy.php @@ -0,0 +1,42 @@ + + * + * 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\Issue7966; + +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; + +#[ApiResource( + operations: [], + graphQlOperations: [ + new QueryCollection( + provider: [self::class, 'provide'], + paginationEnabled: false, + parameters: [ + 'order[:property]' => new QueryParameter(filter: new SortFilter()), + ], + ), + ], +)] +final class SortFilterParameterDummy +{ + public ?string $id = null; + public ?string $name = null; + + public static function provide(): array + { + return []; + } +} diff --git a/tests/Functional/GraphQl/Issue7966Test.php b/tests/Functional/GraphQl/Issue7966Test.php new file mode 100644 index 00000000000..e47a4643463 --- /dev/null +++ b/tests/Functional/GraphQl/Issue7966Test.php @@ -0,0 +1,52 @@ + + * + * 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\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7966\SortFilterParameterDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Object-form filter (FilterInterface instance) combined with a bracketed parameter + * key crashed the GraphQL schema build because `filterLocator->has()` was called + * with the instance rather than a string service id. + * + * @see https://github.com/api-platform/core/issues/7966 + */ +final class Issue7966Test extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [SortFilterParameterDummy::class]; + } + + public function testSchemaBuildsWithObjectFormFilterAndBracketedKey(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => '{ __type(name: "SortFilterParameterDummy") { name } }', + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertSame('SortFilterParameterDummy', $json['data']['__type']['name']); + } +}