From 9a4c0f44aae8ebc311120f0daa55558a837c982d Mon Sep 17 00:00:00 2001 From: Simon Podlipsky Date: Thu, 27 Aug 2026 12:57:58 +0200 Subject: [PATCH] feat: add directive-aware field selection --- CHANGELOG.md | 4 + docs/class-reference.md | 20 ++++ docs/data-fetching.md | 5 +- src/Type/Definition/ResolveInfo.php | 140 +++++++++++++++++++++++++++- tests/Type/ResolveInfoTest.php | 129 +++++++++++++++++++++++++ 5 files changed, 295 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 597124776..cf97c74a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Add `ResolveInfo::getFieldSelectionRespectingDirectives()` to omit selections disabled through `@skip` or `@include` + ## v15.37.2 ### Changed diff --git a/docs/class-reference.md b/docs/class-reference.md index 81cd48e81..d3c6f1eee 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -576,6 +576,26 @@ public $variableValues; function getFieldSelection(int $depth = 0): array ``` +```php +/** + * Returns names of all fields selected in query for `$this->fieldName` up to `$depth` levels, + * excluding selections disabled through `@skip` or `@include`. + * + * This method does not consider conditional typed fragments. + * Use it with care for fields of interface and union types. + * + * @param int $depth How many levels to include in the output beyond the first + * + * @throws \Exception + * @throws Error + * + * @return array + * + * @api + */ +function getFieldSelectionRespectingDirectives(int $depth = 0): array +``` + ```php /** * Returns names and args of all fields selected in query for `$this->fieldName` up to `$depth` levels, including aliases. diff --git a/docs/data-fetching.md b/docs/data-fetching.md index 13fad0136..f97ecd117 100644 --- a/docs/data-fetching.md +++ b/docs/data-fetching.md @@ -173,7 +173,7 @@ $queryType = new ObjectType([ 'resolve' => function ($root, array $args, $context, ResolveInfo $resolveInfo): Story { // Fictitious API, use whatever database access your application/framework provides $builder = Story::builder(); - foreach ($resolveInfo->getFieldSelection() as $field => $_) { + foreach ($resolveInfo->getFieldSelectionRespectingDirectives() as $field => $_) { $builder->addSelect($field); } @@ -184,6 +184,9 @@ $queryType = new ObjectType([ ]); ``` +Use `getFieldSelectionRespectingDirectives()` when the query can conditionally omit fields through `@skip` or `@include`. +Use `getFieldSelection()` when the resolver must inspect all selections regardless of those directives. + ## Solving N+1 Problem Since: 0.9.0 diff --git a/src/Type/Definition/ResolveInfo.php b/src/Type/Definition/ResolveInfo.php index 5204cd547..cb3837566 100644 --- a/src/Type/Definition/ResolveInfo.php +++ b/src/Type/Definition/ResolveInfo.php @@ -10,6 +10,7 @@ use GraphQL\Language\AST\FragmentSpreadNode; use GraphQL\Language\AST\InlineFragmentNode; use GraphQL\Language\AST\OperationDefinitionNode; +use GraphQL\Language\AST\SelectionNode; use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Type\Introspection; use GraphQL\Type\Schema; @@ -214,6 +215,39 @@ public function getFieldSelection(int $depth = 0): array return $fields; } + /** + * Returns names of all fields selected in query for `$this->fieldName` up to `$depth` levels, + * excluding selections disabled through `@skip` or `@include`. + * + * This method does not consider conditional typed fragments. + * Use it with care for fields of interface and union types. + * + * @param int $depth How many levels to include in the output beyond the first + * + * @throws \Exception + * @throws Error + * + * @return array + * + * @api + */ + public function getFieldSelectionRespectingDirectives(int $depth = 0): array + { + $fields = []; + + foreach ($this->fieldNodes as $fieldNode) { + $selectionSet = $fieldNode->selectionSet; + if ($selectionSet !== null) { + $fields = $this->mergeSelectionsRespectingDirectives( + $fields, + $this->foldSelectionSetRespectingDirectives($selectionSet, $depth) + ); + } + } + + return $fields; + } + /** * Returns names and args of all fields selected in query for `$this->fieldName` up to `$depth` levels, including aliases. * @@ -379,10 +413,9 @@ public function lookAhead(array $options = []): QueryPlan ); } - /** @return array */ + /** @return array */ private function foldSelectionSet(SelectionSetNode $selectionSet, int $descend): array { - /** @var array $fields */ $fields = []; foreach ($selectionSet->selections as $selection) { @@ -415,6 +448,109 @@ private function foldSelectionSet(SelectionSetNode $selectionSet, int $descend): return $fields; } + /** + * @throws \Exception + * @throws Error + * + * @return array + */ + protected function foldSelectionSetRespectingDirectives(SelectionSetNode $selectionSet, int $descend): array + { + $fields = []; + + foreach ($selectionSet->selections as $selection) { + /** @var FragmentSpreadNode|FieldNode|InlineFragmentNode $selection */ + if (! $this->shouldIncludeSelectionNodeRespectingDirectives($selection)) { + continue; + } + + if ($selection instanceof FieldNode) { + if ($descend > 0 && $selection->selectionSet !== null) { + $existingSelection = $fields[$selection->name->value] ?? []; + assert(is_array($existingSelection)); + $fields[$selection->name->value] = $this->mergeSelectionsRespectingDirectives( + $existingSelection, + $this->foldSelectionSetRespectingDirectives($selection->selectionSet, $descend - 1) + ); + } elseif (! isset($fields[$selection->name->value])) { + $fields[$selection->name->value] = true; + } + + continue; + } + + if ($selection instanceof FragmentSpreadNode) { + $spreadName = $selection->name->value; + $fragment = $this->fragments[$spreadName] ?? null; + if ($fragment === null) { + continue; + } + + $fields = $this->mergeSelectionsRespectingDirectives( + $fields, + $this->foldSelectionSetRespectingDirectives($fragment->selectionSet, $descend) + ); + + continue; + } + + $fields = $this->mergeSelectionsRespectingDirectives( + $fields, + $this->foldSelectionSetRespectingDirectives($selection->selectionSet, $descend) + ); + } + + return $fields; + } + + /** + * @param array $left + * @param array $right + * + * @return array + */ + protected function mergeSelectionsRespectingDirectives(array $left, array $right): array + { + foreach ($right as $field => $selection) { + $existingSelection = $left[$field] ?? null; + if (is_array($existingSelection) && is_array($selection)) { + $left[$field] = $this->mergeSelectionsRespectingDirectives($existingSelection, $selection); + } elseif ($existingSelection === null || is_array($selection)) { + $left[$field] = $selection; + } + } + + return $left; + } + + /** + * @param FragmentSpreadNode|FieldNode|InlineFragmentNode $node + * + * @throws \Exception + * @throws Error + */ + protected function shouldIncludeSelectionNodeRespectingDirectives(SelectionNode $node): bool + { + $skip = Values::getDirectiveValues( + Directive::skipDirective(), + $node, + $this->variableValues, + $this->schema + ); + if (isset($skip['if']) && $skip['if'] === true) { + return false; + } + + $include = Values::getDirectiveValues( + Directive::includeDirective(), + $node, + $this->variableValues, + $this->schema + ); + + return ! isset($include['if']) || $include['if'] !== false; + } + /** * @throws \Exception * @throws Error diff --git a/tests/Type/ResolveInfoTest.php b/tests/Type/ResolveInfoTest.php index 4940a2570..530a2223b 100644 --- a/tests/Type/ResolveInfoTest.php +++ b/tests/Type/ResolveInfoTest.php @@ -3,6 +3,7 @@ namespace GraphQL\Tests\Type; use GraphQL\Error\Error; +use GraphQL\Error\InvariantViolation; use GraphQL\GraphQL; use GraphQL\Tests\Type\TestClasses\CustomWithObject; use GraphQL\Tests\Type\TestClasses\MyCustomType; @@ -184,6 +185,42 @@ public function testGetFieldSelection(): void self::assertEquals($expectedDeepSelection, $actualDeepSelection); } + /** + * @throws \Exception + * @throws InvariantViolation + */ + public function testGetFieldSelectionRespectingDirectives(): void + { + [$unfilteredSelection, $filteredSelection] = $this->fieldSelections(false, true); + + self::assertEquals([ + 'always' => true, + 'conditional' => true, + 'fragment' => [true, true], + 'inlineFragment' => true, + 'nested' => [ + 'alwaysNested' => true, + 'conditionalNested' => true, + ], + 'precedence' => true, + 'skipped' => true, + ], $unfilteredSelection); + self::assertEquals([ + 'always' => true, + 'nested' => ['alwaysNested' => true], + ], $filteredSelection); + + [, $includedSelection] = $this->fieldSelections(true, false); + + self::assertEquals([ + 'always' => true, + 'conditional' => true, + 'fragment' => true, + 'inlineFragment' => true, + 'nested' => ['alwaysNested' => true], + ], $includedSelection); + } + public function testGetFieldSelectionOnScalarTypes(): void { $query = ' @@ -1249,4 +1286,96 @@ public function testPathAndUnaliasedPathForList(): void ], ], $result); } + + /** + * @throws \Exception + * @throws InvariantViolation + * + * @return array{array, array} + */ + private function fieldSelections(bool $includeConditional, bool $skipInlineFragment): array + { + $nested = new ObjectType([ + 'name' => 'Nested', + 'fields' => [ + 'alwaysNested' => Type::string(), + 'conditionalNested' => Type::string(), + ], + ]); + $item = new ObjectType([ + 'name' => 'Item', + 'fields' => [ + 'always' => Type::string(), + 'conditional' => Type::string(), + 'fragment' => Type::string(), + 'inlineFragment' => Type::string(), + 'nested' => $nested, + 'precedence' => Type::string(), + 'skipped' => Type::string(), + ], + ]); + $unfilteredSelection = null; + $filteredSelection = null; + $query = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'item' => [ + 'type' => $item, + 'resolve' => static function ( + $value, + array $args, + $context, + ResolveInfo $resolveInfo + ) use ( + &$unfilteredSelection, + &$filteredSelection + ) { + $unfilteredSelection = $resolveInfo->getFieldSelection(1); + $filteredSelection = $resolveInfo->getFieldSelectionRespectingDirectives(1); + + return null; + }, + ], + ], + ]); + $schema = new Schema(['query' => $query]); + $result = GraphQL::executeQuery( + $schema, + <<<'GRAPHQL' + query Selection($includeConditional: Boolean!, $skipInlineFragment: Boolean!) { + item { + always + conditional @include(if: $includeConditional) + skipped @skip(if: true) + precedence @skip(if: true) @include(if: true) + ...ConditionalFields @include(if: $includeConditional) + ...ConditionalFields @include(if: $includeConditional) + ... on Item @skip(if: $skipInlineFragment) { + inlineFragment + } + nested { + alwaysNested + conditionalNested @include(if: false) + } + } + } + + fragment ConditionalFields on Item { + fragment + } + GRAPHQL, + null, + null, + [ + 'includeConditional' => $includeConditional, + 'skipInlineFragment' => $skipInlineFragment, + ] + )->toArray(); + + self::assertSame(['data' => ['item' => null]], $result); + self::assertIsArray($unfilteredSelection); + self::assertIsArray($filteredSelection); + + return [$unfilteredSelection, $filteredSelection]; + } }