diff --git a/src/Type/Definition/QueryPlan.php b/src/Type/Definition/QueryPlan.php index 9cd0aec00..500bc9cb5 100644 --- a/src/Type/Definition/QueryPlan.php +++ b/src/Type/Definition/QueryPlan.php @@ -12,7 +12,9 @@ use GraphQL\Language\AST\InlineFragmentNode; use GraphQL\Language\AST\SelectionSetNode; use GraphQL\Type\Schema; +use function array_diff_key; use function array_filter; +use function array_intersect_key; use function array_key_exists; use function array_keys; use function array_merge; @@ -41,16 +43,21 @@ class QueryPlan /** @var FragmentDefinitionNode[] */ private $fragments; + /** @var bool */ + private $groupImplementorFields; + /** * @param FieldNode[] $fieldNodes * @param mixed[] $variableValues * @param FragmentDefinitionNode[] $fragments + * @param mixed[] $options */ - public function __construct(ObjectType $parentType, Schema $schema, iterable $fieldNodes, array $variableValues, array $fragments) + public function __construct(ObjectType $parentType, Schema $schema, iterable $fieldNodes, array $variableValues, array $fragments, array $options = []) { - $this->schema = $schema; - $this->variableValues = $variableValues; - $this->fragments = $fragments; + $this->schema = $schema; + $this->variableValues = $variableValues; + $this->fragments = $fragments; + $this->groupImplementorFields = in_array('group-implementor-fields', $options, true); $this->analyzeQueryPlan($parentType, $fieldNodes); } @@ -109,7 +116,8 @@ public function subFields(string $typename) : array */ private function analyzeQueryPlan(ObjectType $parentType, iterable $fieldNodes) : void { - $queryPlan = []; + $queryPlan = []; + $implementors = []; /** @var FieldNode $fieldNode */ foreach ($fieldNodes as $fieldNode) { if (! $fieldNode->selectionSet) { @@ -121,7 +129,7 @@ private function analyzeQueryPlan(ObjectType $parentType, iterable $fieldNodes) $type = $type->getWrappedType(); } - $subfields = $this->analyzeSelectionSet($fieldNode->selectionSet, $type); + $subfields = $this->analyzeSelectionSet($fieldNode->selectionSet, $type, $implementors); $this->types[$type->name] = array_unique(array_merge( array_key_exists($type->name, $this->types) ? $this->types[$type->name] : [], @@ -134,17 +142,26 @@ private function analyzeQueryPlan(ObjectType $parentType, iterable $fieldNodes) ); } - $this->queryPlan = $queryPlan; + if ($this->groupImplementorFields) { + $this->queryPlan = ['fields' => $queryPlan]; + + if ($implementors) { + $this->queryPlan['implementors'] = $implementors; + } + } else { + $this->queryPlan = $queryPlan; + } } /** * @param InterfaceType|ObjectType $parentType + * @param mixed[] $implementors * * @return mixed[] * * @throws Error */ - private function analyzeSelectionSet(SelectionSetNode $selectionSet, Type $parentType) : array + private function analyzeSelectionSet(SelectionSetNode $selectionSet, Type $parentType, array &$implementors = []) : array { $fields = []; foreach ($selectionSet->selections as $selectionNode) { @@ -169,20 +186,12 @@ private function analyzeSelectionSet(SelectionSetNode $selectionSet, Type $paren $fragment = $this->fragments[$spreadName]; $type = $this->schema->getType($fragment->typeCondition->name->value); $subfields = $this->analyzeSubFields($type, $fragment->selectionSet); - - $fields = $this->arrayMergeDeep( - $subfields, - $fields - ); + $fields = $this->mergeFields($parentType, $type, $fields, $subfields, $implementors); } } elseif ($selectionNode instanceof InlineFragmentNode) { $type = $this->schema->getType($selectionNode->typeCondition->name->value); $subfields = $this->analyzeSubFields($type, $selectionNode->selectionSet); - - $fields = $this->arrayMergeDeep( - $subfields, - $fields - ); + $fields = $this->mergeFields($parentType, $type, $fields, $subfields, $implementors); } } @@ -210,6 +219,38 @@ private function analyzeSubFields(Type $type, SelectionSetNode $selectionSet) : return $subfields; } + /** + * @param mixed[] $fields + * @param mixed[] $subfields + * @param mixed[] $implementors + * + * @return mixed[] + */ + private function mergeFields(Type $parentType, Type $type, array $fields, array $subfields, array &$implementors) : array + { + if ($this->groupImplementorFields && $parentType instanceof AbstractType && ! $type instanceof AbstractType) { + $implementors[$type->name] = [ + 'type' => $type, + 'fields' => $this->arrayMergeDeep( + $implementors[$type->name]['fields'] ?? [], + array_diff_key($subfields, $fields) + ), + ]; + + $fields = $this->arrayMergeDeep( + $fields, + array_intersect_key($subfields, $fields) + ); + } else { + $fields = $this->arrayMergeDeep( + $subfields, + $fields + ); + } + + return $fields; + } + /** * similar to array_merge_recursive this merges nested arrays, but handles non array values differently * while array_merge_recursive tries to merge non array values, in this implementation they will be overwritten diff --git a/src/Type/Definition/ResolveInfo.php b/src/Type/Definition/ResolveInfo.php index 4ca536ad3..7c73d537a 100644 --- a/src/Type/Definition/ResolveInfo.php +++ b/src/Type/Definition/ResolveInfo.php @@ -198,7 +198,10 @@ public function getFieldSelection($depth = 0) return $fields; } - public function lookAhead() : QueryPlan + /** + * @param mixed[] $options + */ + public function lookAhead(array $options = []) : QueryPlan { if ($this->queryPlan === null) { $this->queryPlan = new QueryPlan( @@ -206,7 +209,8 @@ public function lookAhead() : QueryPlan $this->schema, $this->fieldNodes, $this->variableValues, - $this->fragments + $this->fragments, + $options ); } diff --git a/tests/Type/QueryPlanTest.php b/tests/Type/QueryPlanTest.php index a0d05fd40..0e0e5abfa 100644 --- a/tests/Type/QueryPlanTest.php +++ b/tests/Type/QueryPlanTest.php @@ -11,6 +11,7 @@ use GraphQL\Type\Definition\QueryPlan; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; +use GraphQL\Type\Definition\UnionType; use GraphQL\Type\Schema; use PHPUnit\Framework\TestCase; @@ -698,4 +699,269 @@ public function testMergedFragmentsQueryPlan() : void self::assertTrue($queryPlan->hasType('Image')); self::assertFalse($queryPlan->hasType('Test')); } + + public function testQueryPlanOnInterfaceGroupingImplementorFields() : void + { + $car = null; + + $item = new InterfaceType([ + 'name' => 'Item', + 'fields' => [ + 'id' => Type::int(), + 'owner' => Type::string(), + ], + 'resolveType' => static function () use (&$car) { + return $car; + }, + ]); + + $car = new ObjectType([ + 'name' => 'Car', + 'fields' => [ + 'id' => Type::int(), + 'owner' => Type::string(), + 'mark' => Type::string(), + 'model' => Type::string(), + ], + 'interfaces' => [$item], + ]); + + $building = new ObjectType([ + 'name' => 'Building', + 'fields' => [ + 'id' => Type::int(), + 'owner' => Type::string(), + 'city' => Type::string(), + 'address' => Type::string(), + ], + 'interfaces' => [$item], + ]); + + $query = '{ + item { + id + owner + ... on Car { + mark + model + } + ... on Building { + city + } + ...BuildingFragment + } + } + fragment BuildingFragment on Building { + address + }'; + + $expectedResult = [ + 'data' => ['item' => null], + ]; + + $expectedQueryPlan = [ + 'fields' => [ + 'id' => [ + 'type' => Type::int(), + 'fields' => [], + 'args' => [], + ], + 'owner' => [ + 'type' => Type::string(), + 'fields' => [], + 'args' => [], + ], + ], + 'implementors' => [ + 'Car' => [ + 'type' => $car, + 'fields' => [ + 'mark' => [ + 'type' => Type::string(), + 'fields' => [], + 'args' => [], + ], + 'model' => [ + 'type' => Type::string(), + 'fields' => [], + 'args' => [], + ], + ], + ], + 'Building' => [ + 'type' => $building, + 'fields' => [ + 'city' => [ + 'type' => Type::string(), + 'fields' => [], + 'args' => [], + ], + 'address' => [ + 'type' => Type::string(), + 'fields' => [], + 'args' => [], + ], + ], + ], + ], + ]; + + $expectedReferencedTypes = ['Car', 'Building', 'Item']; + + $expectedReferencedFields = ['mark', 'model', 'city', 'address', 'id', 'owner']; + + $expectedItemSubFields = ['id', 'owner']; + $expectedBuildingSubFields = ['city', 'address']; + + $hasCalled = false; + /** @var QueryPlan $queryPlan */ + $queryPlan = null; + + $root = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'item' => [ + 'type' => $item, + 'resolve' => static function ($value, $args, $context, ResolveInfo $info) use (&$hasCalled, &$queryPlan) { + $hasCalled = true; + $queryPlan = $info->lookAhead(['group-implementor-fields']); + + return null; + }, + ], + ], + ]); + + $schema = new Schema([ + 'query' => $root, + 'types' => [$car, $building], + ]); + $result = GraphQL::executeQuery($schema, $query)->toArray(); + + self::assertTrue($hasCalled); + self::assertEquals($expectedResult, $result); + self::assertEquals($expectedQueryPlan, $queryPlan->queryPlan()); + self::assertEquals($expectedReferencedTypes, $queryPlan->getReferencedTypes()); + self::assertEquals($expectedReferencedFields, $queryPlan->getReferencedFields()); + self::assertEquals($expectedItemSubFields, $queryPlan->subFields('Item')); + self::assertEquals($expectedBuildingSubFields, $queryPlan->subFields('Building')); + } + + public function testQueryPlanOnUnionGroupingImplementorFields() : void + { + $car = new ObjectType([ + 'name' => 'Car', + 'fields' => [ + 'mark' => Type::string(), + 'model' => Type::string(), + ], + ]); + + $building = new ObjectType([ + 'name' => 'Building', + 'fields' => [ + 'city' => Type::string(), + 'address' => Type::string(), + ], + ]); + + $item = new UnionType([ + 'name' => 'Item', + 'types' => [$car, $building], + 'resolveType' => static function () use ($car) { + return $car; + }, + ]); + + $query = '{ + item { + ... on Car { + mark + model + } + ... on Building { + city + } + ...BuildingFragment + } + } + fragment BuildingFragment on Building { + address + }'; + + $expectedResult = [ + 'data' => ['item' => null], + ]; + + $expectedQueryPlan = [ + 'fields' => [], + 'implementors' => [ + 'Car' => [ + 'type' => $car, + 'fields' => [ + 'mark' => [ + 'type' => Type::string(), + 'fields' => [], + 'args' => [], + ], + 'model' => [ + 'type' => Type::string(), + 'fields' => [], + 'args' => [], + ], + ], + ], + 'Building' => [ + 'type' => $building, + 'fields' => [ + 'city' => [ + 'type' => Type::string(), + 'fields' => [], + 'args' => [], + ], + 'address' => [ + 'type' => Type::string(), + 'fields' => [], + 'args' => [], + ], + ], + ], + ], + ]; + + $expectedReferencedTypes = ['Car', 'Building', 'Item']; + + $expectedReferencedFields = ['mark', 'model', 'city', 'address']; + + $expectedBuildingSubFields = ['city', 'address']; + + $hasCalled = false; + /** @var QueryPlan $queryPlan */ + $queryPlan = null; + + $root = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'item' => [ + 'type' => $item, + 'resolve' => static function ($value, $args, $context, ResolveInfo $info) use (&$hasCalled, &$queryPlan) { + $hasCalled = true; + $queryPlan = $info->lookAhead(['group-implementor-fields']); + + return null; + }, + ], + ], + ]); + + $schema = new Schema(['query' => $root]); + $result = GraphQL::executeQuery($schema, $query)->toArray(); + + self::assertTrue($hasCalled); + self::assertEquals($expectedResult, $result); + self::assertEquals($expectedQueryPlan, $queryPlan->queryPlan()); + self::assertEquals($expectedReferencedTypes, $queryPlan->getReferencedTypes()); + self::assertEquals($expectedReferencedFields, $queryPlan->getReferencedFields()); + self::assertEquals($expectedBuildingSubFields, $queryPlan->subFields('Building')); + } }