Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/class-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed>
*
* @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.
Expand Down
5 changes: 4 additions & 1 deletion docs/data-fetching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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
Expand Down
140 changes: 138 additions & 2 deletions src/Type/Definition/ResolveInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, mixed>
*
* @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.
*
Expand Down Expand Up @@ -379,10 +413,9 @@ public function lookAhead(array $options = []): QueryPlan
);
}

/** @return array<string, bool> */
/** @return array<string, mixed> */
private function foldSelectionSet(SelectionSetNode $selectionSet, int $descend): array
{
/** @var array<string, bool> $fields */
$fields = [];

foreach ($selectionSet->selections as $selection) {
Expand Down Expand Up @@ -415,6 +448,109 @@ private function foldSelectionSet(SelectionSetNode $selectionSet, int $descend):
return $fields;
}

/**
* @throws \Exception
* @throws Error
*
* @return array<string, mixed>
*/
protected function foldSelectionSetRespectingDirectives(SelectionSetNode $selectionSet, int $descend): array

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping these helpers protected is intentional. CONTRIBUTING.md requires protected over private for extensibility (Extensibility > protected over private). The existing private helper does not override that documented rule.

{
$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<string, mixed> $left
* @param array<string, mixed> $right
*
* @return array<string, mixed>
*/
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrower PHPDoc is intentional. SelectionNode is an empty marker interface, while this method accepts the three concrete selection node types. This also matches the PHPDoc on ReferenceExecutor::shouldIncludeNode(), so keeping the union helps static analysis and preserves consistency.

*
* @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
Expand Down
129 changes: 129 additions & 0 deletions tests/Type/ResolveInfoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = '
Expand Down Expand Up @@ -1249,4 +1286,96 @@ public function testPathAndUnaliasedPathForList(): void
],
], $result);
}

/**
* @throws \Exception
* @throws InvariantViolation
*
* @return array{array<string, mixed>, array<string, mixed>}
*/
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];
}
}
Loading