diff --git a/src/Type/Definition/QueryPlan.php b/src/Type/Definition/QueryPlan.php new file mode 100644 index 0000000..7564f96 --- /dev/null +++ b/src/Type/Definition/QueryPlan.php @@ -0,0 +1,239 @@ +schema = $schema; + $this->variableValues = $variableValues; + $this->fragments = $fragments; + $this->analyzeQueryPlan($parentType, $fieldNodes); + } + + /** + * @return mixed[] + */ + public function queryPlan() : array + { + return $this->queryPlan; + } + + /** + * @return string[] + */ + public function getReferencedTypes() : array + { + return array_keys($this->types); + } + + public function hasType(string $type) : bool + { + return count(array_filter($this->getReferencedTypes(), static function (string $referencedType) use ($type) { + return $type === $referencedType; + })) > 0; + } + + /** + * @return string[] + */ + public function getReferencedFields() : array + { + return array_values(array_unique(array_merge(...array_values($this->types)))); + } + + public function hasField(string $field) : bool + { + return count(array_filter($this->getReferencedFields(), static function (string $referencedField) use ($field) { + return $field === $referencedField; + })) > 0; + } + + /** + * @return string[] + */ + public function subFields(string $typename) : array + { + if (! array_key_exists($typename, $this->types)) { + return []; + } + + return $this->types[$typename]; + } + + /** + * @param FieldNode[] $fieldNodes + */ + private function analyzeQueryPlan(ObjectType $parentType, iterable $fieldNodes) : void + { + $queryPlan = []; + /** @var FieldNode $fieldNode */ + foreach ($fieldNodes as $fieldNode) { + if (! $fieldNode->selectionSet) { + continue; + } + + $type = $parentType->getField($fieldNode->name->value)->getType(); + if ($type instanceof WrappingType) { + $type = $type->getWrappedType(); + } + + $subfields = $this->analyzeSelectionSet($fieldNode->selectionSet, $type); + + $this->types[$type->name] = array_unique(array_merge( + array_key_exists($type->name, $this->types) ? $this->types[$type->name] : [], + array_keys($subfields) + )); + + $queryPlan = array_merge_recursive( + $queryPlan, + $subfields + ); + } + + $this->queryPlan = $queryPlan; + } + + /** + * @return mixed[] + * + * @throws Error + */ + private function analyzeSelectionSet(SelectionSetNode $selectionSet, ObjectType $parentType) : array + { + $fields = []; + foreach ($selectionSet->selections as $selectionNode) { + if ($selectionNode instanceof FieldNode) { + $fieldName = $selectionNode->name->value; + $type = $parentType->getField($fieldName); + $selectionType = $type->getType(); + + $subfields = []; + if ($selectionNode->selectionSet) { + $subfields = $this->analyzeSubFields($selectionType, $selectionNode->selectionSet); + } + + $fields[$fieldName] = [ + 'type' => $selectionType, + 'fields' => $subfields ?? [], + 'args' => Values::getArgumentValues($type, $selectionNode, $this->variableValues), + ]; + } elseif ($selectionNode instanceof FragmentSpreadNode) { + $spreadName = $selectionNode->name->value; + if (isset($this->fragments[$spreadName])) { + $fragment = $this->fragments[$spreadName]; + $type = $this->schema->getType($fragment->typeCondition->name->value); + $subfields = $this->analyzeSubFields($type, $fragment->selectionSet); + + $fields = $this->arrayMergeDeep( + $subfields, + $fields + ); + } + } elseif ($selectionNode instanceof InlineFragmentNode) { + $type = $this->schema->getType($selectionNode->typeCondition->name->value); + $subfields = $this->analyzeSubFields($type, $selectionNode->selectionSet); + + $fields = $this->arrayMergeDeep( + $subfields, + $fields + ); + } + } + return $fields; + } + + /** + * @return mixed[] + */ + private function analyzeSubFields(Type $type, SelectionSetNode $selectionSet) : array + { + if ($type instanceof WrappingType) { + $type = $type->getWrappedType(); + } + + $subfields = []; + if ($type instanceof ObjectType) { + $subfields = $this->analyzeSelectionSet($selectionSet, $type); + $this->types[$type->name] = array_unique(array_merge( + array_key_exists($type->name, $this->types) ? $this->types[$type->name] : [], + array_keys($subfields) + )); + } + + return $subfields; + } + + /** + * 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 + * + * @see https://stackoverflow.com/a/25712428 + * + * @param mixed[] $array1 + * @param mixed[] $array2 + * + * @return mixed[] + */ + private function arrayMergeDeep(array $array1, array $array2) : array + { + $merged = $array1; + + foreach ($array2 as $key => & $value) { + if (is_numeric($key)) { + if (! in_array($value, $merged, true)) { + $merged[] = $value; + } + } elseif (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) { + $merged[$key] = $this->arrayMergeDeep($merged[$key], $value); + } else { + $merged[$key] = $value; + } + } + + return $merged; + } +} diff --git a/src/Type/Definition/ResolveInfo.php b/src/Type/Definition/ResolveInfo.php index 377bb49..83354c0 100644 --- a/src/Type/Definition/ResolveInfo.php +++ b/src/Type/Definition/ResolveInfo.php @@ -63,7 +63,7 @@ class ResolveInfo * Instance of a schema used for execution * * @api - * @var Schema|null + * @var Schema */ public $schema; @@ -99,6 +99,9 @@ class ResolveInfo */ public $variableValues = []; + /** @var QueryPlan */ + private $queryPlan; + /** * @param FieldNode[] $fieldNodes * @param ScalarType|ObjectType|InterfaceType|UnionType|EnumType|ListOfType|NonNull $returnType @@ -109,7 +112,7 @@ class ResolveInfo */ public function __construct( string $fieldName, - $fieldNodes, + iterable $fieldNodes, $returnType, ObjectType $parentType, array $path, @@ -179,6 +182,22 @@ class ResolveInfo return $fields; } + + public function lookAhead() : QueryPlan + { + if ($this->queryPlan === null) { + $this->queryPlan = new QueryPlan( + $this->parentType, + $this->schema, + $this->fieldNodes, + $this->variableValues, + $this->fragments + ); + } + + return $this->queryPlan; + } + /** * @return bool[] */ diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index 9a5d266..fe6768c 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -284,22 +284,6 @@ class ExecutorTest extends TestCase Executor::execute($schema, $ast, $rootValue, null, ['var' => '123']); - self::assertEquals( - [ - 'fieldName', - 'fieldNodes', - 'returnType', - 'parentType', - 'path', - 'schema', - 'fragments', - 'rootValue', - 'operation', - 'variableValues', - ], - array_keys((array) $info) - ); - self::assertEquals('test', $info->fieldName); self::assertEquals(1, count($info->fieldNodes)); self::assertSame($ast->definitions[0]->selectionSet->selections[0], $info->fieldNodes[0]); diff --git a/tests/Type/QueryPlanTest.php b/tests/Type/QueryPlanTest.php new file mode 100644 index 0000000..50ec6ae --- /dev/null +++ b/tests/Type/QueryPlanTest.php @@ -0,0 +1,588 @@ + 'Image', + 'fields' => [ + 'url' => ['type' => Type::string()], + 'width' => ['type' => Type::int()], + 'height' => ['type' => Type::int()], + ], + ]); + + $article = null; + + $author = new ObjectType([ + 'name' => 'Author', + 'fields' => static function () use ($image, &$article) { + return [ + 'id' => ['type' => Type::string()], + 'name' => ['type' => Type::string()], + 'pic' => [ + 'type' => $image, + 'args' => [ + 'width' => ['type' => Type::int()], + 'height' => ['type' => Type::int()], + ], + ], + 'recentArticle' => ['type' => $article], + ]; + }, + ]); + + $reply = new ObjectType([ + 'name' => 'Reply', + 'fields' => [ + 'author' => ['type' => $author], + 'body' => ['type' => Type::string()], + ], + ]); + + $article = new ObjectType([ + 'name' => 'Article', + 'fields' => [ + 'id' => ['type' => Type::string()], + 'isPublished' => ['type' => Type::boolean()], + 'author' => ['type' => $author], + 'title' => ['type' => Type::string()], + 'body' => ['type' => Type::string()], + 'image' => ['type' => $image], + 'replies' => ['type' => Type::listOf($reply)], + ], + ]); + + $doc = ' + query Test { + article { + author { + name + pic(width: 100, height: 200) { + url + width + } + } + image { + width + height + ...MyImage + } + replies { + body + author { + id + name + pic { + url + width + ... on Image { + height + } + } + recentArticle { + id + title + body + } + } + } + } + } + fragment MyImage on Image { + url + } +'; + $expectedQueryPlan = [ + 'author' => [ + 'type' => $author, + 'args' => [], + 'fields' => [ + 'name' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'pic' => [ + 'type' => $image, + 'args' => [ + 'width' => 100, + 'height' => 200, + ], + 'fields' => [ + 'url' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'width' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + ], + ], + ], + ], + 'image' => [ + 'type' => $image, + 'args' => [], + 'fields' => [ + 'url' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'width' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + 'height' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + ], + ], + 'replies' => [ + 'type' => Type::listOf($reply), + 'args' => [], + 'fields' => [ + 'body' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'author' => [ + 'type' => $author, + 'args' => [], + 'fields' => [ + 'id' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'name' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'pic' => [ + 'type' => $image, + 'args' => [], + 'fields' => [ + 'url' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'width' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + 'height' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + ], + ], + 'recentArticle' => [ + 'type' => $article, + 'args' => [], + 'fields' => [ + 'id' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'title' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'body' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + ], + ], + ], + ], + ], + ], + ]; + + $expectedReferencedTypes = [ + 'Image', + 'Author', + 'Article', + 'Reply', + ]; + + $expectedReferencedFields = [ + 'url', + 'width', + 'height', + 'name', + 'pic', + 'id', + 'recentArticle', + 'title', + 'body', + 'author', + 'image', + 'replies', + ]; + + $hasCalled = false; + /** @var QueryPlan $queryPlan */ + $queryPlan = null; + + $blogQuery = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'article' => [ + 'type' => $article, + 'resolve' => static function ( + $value, + $args, + $context, + ResolveInfo $info + ) use ( + &$hasCalled, + &$queryPlan + ) { + $hasCalled = true; + $queryPlan = $info->lookAhead(); + + return null; + }, + ], + ], + ]); + + $schema = new Schema(['query' => $blogQuery]); + $result = GraphQL::executeQuery($schema, $doc)->toArray(); + + self::assertTrue($hasCalled); + self::assertEquals(['data' => ['article' => null]], $result); + self::assertEquals($expectedQueryPlan, $queryPlan->queryPlan()); + self::assertEquals($expectedReferencedTypes, $queryPlan->getReferencedTypes()); + self::assertEquals($expectedReferencedFields, $queryPlan->getReferencedFields()); + self::assertEquals(['url', 'width', 'height'], $queryPlan->subFields('Image')); + + self::assertTrue($queryPlan->hasField('url')); + self::assertFalse($queryPlan->hasField('test')); + + self::assertTrue($queryPlan->hasType('Image')); + self::assertFalse($queryPlan->hasType('Test')); + } + + public function testMergedFragmentsQueryPlan() : void + { + $image = new ObjectType([ + 'name' => 'Image', + 'fields' => [ + 'url' => ['type' => Type::string()], + 'width' => ['type' => Type::int()], + 'height' => ['type' => Type::int()], + ], + ]); + + $article = null; + + $author = new ObjectType([ + 'name' => 'Author', + 'fields' => static function () use ($image, &$article) { + return [ + 'id' => ['type' => Type::string()], + 'name' => ['type' => Type::string()], + 'pic' => [ + 'type' => $image, + 'args' => [ + 'width' => ['type' => Type::int()], + 'height' => ['type' => Type::int()], + ], + ], + 'recentArticle' => ['type' => $article], + ]; + }, + ]); + + $reply = new ObjectType([ + 'name' => 'Reply', + 'fields' => [ + 'author' => ['type' => $author], + 'body' => ['type' => Type::string()], + ], + ]); + + $article = new ObjectType([ + 'name' => 'Article', + 'fields' => [ + 'id' => ['type' => Type::string()], + 'isPublished' => ['type' => Type::boolean()], + 'author' => ['type' => $author], + 'title' => ['type' => Type::string()], + 'body' => ['type' => Type::string()], + 'image' => ['type' => $image], + 'replies' => ['type' => Type::listOf($reply)], + ], + ]); + + $doc = ' + query Test { + article { + author { + name + pic(width: 100, height: 200) { + url + width + } + } + image { + width + height + ...MyImage + } + ...Replies01 + ...Replies02 + } + } + fragment MyImage on Image { + url + } + + fragment Replies01 on Article { + _replies012: replies { + body + } + } + fragment Replies02 on Article { + _replies012: replies { + author { + id + name + pic { + url + width + ... on Image { + height + } + } + recentArticle { + id + title + body + } + } + } + } +'; + + $expectedQueryPlan = [ + 'author' => [ + 'type' => $author, + 'args' => [], + 'fields' => [ + 'name' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'pic' => [ + 'type' => $image, + 'args' => [ + 'width' => 100, + 'height' => 200, + ], + 'fields' => [ + 'url' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'width' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + ], + ], + ], + ], + 'image' => [ + 'type' => $image, + 'args' => [], + 'fields' => [ + 'url' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'width' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + 'height' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + ], + ], + 'replies' => [ + 'type' => Type::listOf($reply), + 'args' => [], + 'fields' => [ + 'body' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'author' => [ + 'type' => $author, + 'args' => [], + 'fields' => [ + 'id' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'name' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'pic' => [ + 'type' => $image, + 'args' => [], + 'fields' => [ + 'url' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'width' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + 'height' => [ + 'type' => Type::int(), + 'args' => [], + 'fields' => [], + ], + ], + ], + 'recentArticle' => [ + 'type' => $article, + 'args' => [], + 'fields' => [ + 'id' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'title' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + 'body' => [ + 'type' => Type::string(), + 'args' => [], + 'fields' => [], + ], + ], + ], + ], + ], + ], + ], + ]; + + $expectedReferencedTypes = [ + 'Image', + 'Author', + 'Reply', + 'Article', + ]; + + $expectedReferencedFields = [ + 'url', + 'width', + 'height', + 'name', + 'pic', + 'id', + 'recentArticle', + 'body', + 'author', + 'replies', + 'title', + 'image', + ]; + + $hasCalled = false; + /** @var QueryPlan $queryPlan */ + $queryPlan = null; + + $blogQuery = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'article' => [ + 'type' => $article, + 'resolve' => static function ( + $value, + $args, + $context, + ResolveInfo $info + ) use ( + &$hasCalled, + &$queryPlan + ) { + $hasCalled = true; + $queryPlan = $info->lookAhead(); + + return null; + }, + ], + ], + ]); + + $schema = new Schema(['query' => $blogQuery]); + $result = GraphQL::executeQuery($schema, $doc)->toArray(); + + self::assertTrue($hasCalled); + self::assertEquals(['data' => ['article' => null]], $result); + self::assertEquals($expectedQueryPlan, $queryPlan->queryPlan()); + self::assertEquals($expectedReferencedTypes, $queryPlan->getReferencedTypes()); + self::assertEquals($expectedReferencedFields, $queryPlan->getReferencedFields()); + self::assertEquals(['url', 'width', 'height'], $queryPlan->subFields('Image')); + + self::assertTrue($queryPlan->hasField('url')); + self::assertFalse($queryPlan->hasField('test')); + + self::assertTrue($queryPlan->hasType('Image')); + self::assertFalse($queryPlan->hasType('Test')); + } +}