From b42fbb522e565c8a6d51d4090010ba198dc48caa Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 4 Jun 2026 11:58:11 +0200 Subject: [PATCH 1/2] fix(graphql): nested resources without graphqloperations propagate fields When a nested resource declares an empty `graphQlOperations` array, the framework auto-adds a `nested: true` Query/QueryCollection without a provider. Two issues prevented the parent provider's data from being returned through such relations: - `ResolverFactory` wrapped the body in `ArrayPaginator` for both Query (item) and QueryCollection operations, which broke single-item nested resources whose body is already a normalized associative array. - `SerializerContextBuilder` only unwrapped the `edges/node`/`collection` pagination layer at the root attribute selection, so the Symfony serializer recursed into nested relations with an attribute tree whose leaves were still wrapped, normalizing each item to an empty object. The wrap is now restricted to `CollectionOperationInterface` and the pagination unwrap is applied recursively in `replaceIdKeys`, letting the Symfony serializer descend with a tree that mirrors the actual resource shape. Fixes #8076 --- .../Resolver/Factory/ResolverFactory.php | 21 +++-- .../Serializer/SerializerContextBuilder.php | 8 +- .../ApiResource/Issue8076/Facility.php | 30 +++++++ .../ApiResource/Issue8076/Product.php | 36 +++++++++ .../ApiResource/Issue8076/ProductProvider.php | 43 ++++++++++ .../ApiResource/Issue8076/Variant.php | 26 ++++++ tests/Fixtures/app/config/config_common.yml | 4 + tests/Functional/GraphQl/Issue8076Test.php | 80 +++++++++++++++++++ 8 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue8076/Facility.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue8076/ProductProvider.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue8076/Variant.php create mode 100644 tests/Functional/GraphQl/Issue8076Test.php diff --git a/src/GraphQl/Resolver/Factory/ResolverFactory.php b/src/GraphQl/Resolver/Factory/ResolverFactory.php index 074728a53a9..302bdea66eb 100644 --- a/src/GraphQl/Resolver/Factory/ResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ResolverFactory.php @@ -14,6 +14,7 @@ namespace ApiPlatform\GraphQl\Resolver\Factory; use ApiPlatform\GraphQl\State\Provider\NoopProvider; +use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\GraphQl\Mutation; @@ -49,14 +50,18 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul // special treatment for nested resources without a resolver/provider if ($operation instanceof Query && $operation->getNested() && !$operation->getResolver() && (!$operation->getProvider() || NoopProvider::class === $operation->getProvider())) { - return \is_array($body) ? $this->resolve( - $source, - $args, - $info, - $rootClass, - $operation, - new ArrayPaginator($body, 0, \count($body)) - ) : $body; + if ($operation instanceof CollectionOperationInterface && \is_array($body)) { + return $this->resolve( + $source, + $args, + $info, + $rootClass, + $operation, + new ArrayPaginator($body, 0, \count($body)) + ); + } + + return $body; } $propertyMetadata = $rootClass ? $propertyMetadataFactory?->create($rootClass, $info->fieldName) : null; diff --git a/src/GraphQl/Serializer/SerializerContextBuilder.php b/src/GraphQl/Serializer/SerializerContextBuilder.php index 571f3dae9b8..9f332c5954a 100644 --- a/src/GraphQl/Serializer/SerializerContextBuilder.php +++ b/src/GraphQl/Serializer/SerializerContextBuilder.php @@ -99,7 +99,13 @@ private function replaceIdKeys(array $fields, ?string $resourceClass, array $con continue; } - $denormalizedFields[$this->denormalizePropertyName((string) $key, $resourceClass, $context)] = \is_array($value) ? $this->replaceIdKeys($value, $resourceClass, $context) : $value; + if (\is_array($value)) { + // Unwrap nested pagination structures so the attribute tree mirrors the resource shape. + $value = $value['edges']['node'] ?? $value['collection'] ?? $value; + $value = \is_array($value) ? $this->replaceIdKeys($value, $resourceClass, $context) : $value; + } + + $denormalizedFields[$this->denormalizePropertyName((string) $key, $resourceClass, $context)] = $value; } return $denormalizedFields; diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8076/Facility.php b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Facility.php new file mode 100644 index 00000000000..6482b835c2d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Facility.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\ApiResource\Issue8076; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource(operations: [], graphQlOperations: [])] +final class Facility +{ + /** + * @param Variant[] $variants + */ + public function __construct( + public string $id, + public string $name, + public array $variants, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php new file mode 100644 index 00000000000..a2e72883b2f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php @@ -0,0 +1,36 @@ + + * + * 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\Issue8076; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; + +#[ApiResource( + operations: [], + provider: ProductProvider::class, + graphQlOperations: [ + new Query(), + new QueryCollection(paginationEnabled: false), + ], +)] +final class Product +{ + public function __construct( + public string $id, + public string $name, + public Facility $facility, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8076/ProductProvider.php b/tests/Fixtures/TestBundle/ApiResource/Issue8076/ProductProvider.php new file mode 100644 index 00000000000..adc1809a155 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8076/ProductProvider.php @@ -0,0 +1,43 @@ + + * + * 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\Issue8076; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; + +final class ProductProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $product = new Product( + id: '1', + name: 'a product', + facility: new Facility( + id: 'f1', + name: 'a facility', + variants: [ + new Variant(sku: 'sku-1', on: true), + new Variant(sku: 'sku-2', on: false), + ], + ), + ); + + if ($operation instanceof CollectionOperationInterface) { + return [$product]; + } + + return $product; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8076/Variant.php b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Variant.php new file mode 100644 index 00000000000..11b38dbfe43 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Variant.php @@ -0,0 +1,26 @@ + + * + * 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\Issue8076; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource(operations: [], graphQlOperations: [])] +final class Variant +{ + public function __construct( + public string $sku, + public bool $on, + ) { + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 77b2d7cbc1a..d2a2a4657cb 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -319,6 +319,10 @@ services: tags: - name: 'api_platform.graphql.resolver' + ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8076\ProductProvider: + tags: + - name: 'api_platform.state_provider' + app.graphql.query_resolver.dummy_custom_item: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\DummyCustomQueryItemResolver' tags: diff --git a/tests/Functional/GraphQl/Issue8076Test.php b/tests/Functional/GraphQl/Issue8076Test.php new file mode 100644 index 00000000000..87fa04cc9d5 --- /dev/null +++ b/tests/Functional/GraphQl/Issue8076Test.php @@ -0,0 +1,80 @@ + + * + * 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\Issue8076\Facility; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8076\Product; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8076\Variant; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * A root resource with a custom provider returns nested resources whose own + * {@see \ApiPlatform\Metadata\ApiResource::$graphQlOperations} is an empty array. + * The framework auto-adds `nested: true` Query/QueryCollection operations on those + * nested resources, with no provider. The resolver must reuse the data already + * loaded by the root provider through {@see $source} instead of trying to call the + * (non-existent) nested provider, which would raise + * `Provider not found on operation "collection_query"`. + * + * @see https://github.com/api-platform/core/issues/8076 + */ +final class Issue8076Test extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Product::class, Facility::class, Variant::class]; + } + + public function testNestedCollectionWithoutGraphQlOperationsUsesParentProviderData(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +{ + product(id: "/products/1") { + id + name + facility { + name + variants { + edges { + node { + sku + } + } + } + } + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertSame('a product', $json['data']['product']['name']); + $this->assertSame('a facility', $json['data']['product']['facility']['name']); + $edges = $json['data']['product']['facility']['variants']['edges']; + $this->assertCount(2, $edges); + $this->assertSame('sku-1', $edges[0]['node']['sku']); + $this->assertSame('sku-2', $edges[1]['node']['sku']); + } +} From 3a0d5878e73e24aafe5e47b6b5e38e28bd90fe35 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 4 Jun 2026 14:45:24 +0200 Subject: [PATCH 2/2] fix(graphql): inline product provider as static method Address review feedback on PR #8236. - Drop the dedicated ProductProvider service. Inline the fixture provider as a static method on Product and reference it via `provider: [self::class, 'provide']`. The matching service tag in tests/Fixtures/app/config/config_common.yml is removed too. - Tighten the return type: the provider only ever returns a Product or an array of Product, so the `|null` union (flagged by PHPStan as unreachable on the previous ProductProvider::provide()) is gone. - Keep `is_array($body)` in ResolverFactory rather than swapping for `is_iterable`. The body fed into this branch comes from GraphQl ItemNormalizer::normalizeCollectionOfRelations, which spreads its input into a real array ([...$attributeValue]); no Traversable reaches this point. Switching to is_iterable would require an extra iterator_to_array conversion to keep count($body) safe, with no observed call site producing the wider type. --- .../ApiResource/Issue8076/Product.php | 26 ++++++++++- .../ApiResource/Issue8076/ProductProvider.php | 43 ------------------- tests/Fixtures/app/config/config_common.yml | 4 -- 3 files changed, 25 insertions(+), 48 deletions(-) delete mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue8076/ProductProvider.php diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php index a2e72883b2f..5091b4f629f 100644 --- a/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php @@ -14,12 +14,14 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8076; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\Operation; #[ApiResource( operations: [], - provider: ProductProvider::class, + provider: [self::class, 'provide'], graphQlOperations: [ new Query(), new QueryCollection(paginationEnabled: false), @@ -33,4 +35,26 @@ public function __construct( public Facility $facility, ) { } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self|array + { + $product = new self( + id: '1', + name: 'a product', + facility: new Facility( + id: 'f1', + name: 'a facility', + variants: [ + new Variant(sku: 'sku-1', on: true), + new Variant(sku: 'sku-2', on: false), + ], + ), + ); + + if ($operation instanceof CollectionOperationInterface) { + return [$product]; + } + + return $product; + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8076/ProductProvider.php b/tests/Fixtures/TestBundle/ApiResource/Issue8076/ProductProvider.php deleted file mode 100644 index adc1809a155..00000000000 --- a/tests/Fixtures/TestBundle/ApiResource/Issue8076/ProductProvider.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * 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\Issue8076; - -use ApiPlatform\Metadata\CollectionOperationInterface; -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProviderInterface; - -final class ProductProvider implements ProviderInterface -{ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null - { - $product = new Product( - id: '1', - name: 'a product', - facility: new Facility( - id: 'f1', - name: 'a facility', - variants: [ - new Variant(sku: 'sku-1', on: true), - new Variant(sku: 'sku-2', on: false), - ], - ), - ); - - if ($operation instanceof CollectionOperationInterface) { - return [$product]; - } - - return $product; - } -} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index d2a2a4657cb..77b2d7cbc1a 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -319,10 +319,6 @@ services: tags: - name: 'api_platform.graphql.resolver' - ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8076\ProductProvider: - tags: - - name: 'api_platform.state_provider' - app.graphql.query_resolver.dummy_custom_item: class: 'ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Resolver\DummyCustomQueryItemResolver' tags: