This commit is contained in:
Vladimir Razuvaev 2019-03-09 22:35:12 +07:00
commit f107cc2076
4 changed files with 848 additions and 18 deletions

View File

@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace GraphQL\Type\Definition;
use GraphQL\Error\Error;
use GraphQL\Executor\Values;
use GraphQL\Language\AST\FieldNode;
use GraphQL\Language\AST\FragmentDefinitionNode;
use GraphQL\Language\AST\FragmentSpreadNode;
use GraphQL\Language\AST\InlineFragmentNode;
use GraphQL\Language\AST\SelectionSetNode;
use GraphQL\Type\Schema;
use function array_filter;
use function array_key_exists;
use function array_keys;
use function array_merge;
use function array_merge_recursive;
use function array_unique;
use function array_values;
use function count;
use function in_array;
use function is_array;
use function is_numeric;
class QueryPlan
{
/** @var string[][] */
private $types = [];
/** @var Schema */
private $schema;
/** @var mixed[] */
private $queryPlan = [];
/** @var mixed[] */
private $variableValues;
/** @var FragmentDefinitionNode[] */
private $fragments;
/**
* @param FieldNode[] $fieldNodes
* @param mixed[] $variableValues
* @param FragmentDefinitionNode[] $fragments
*/
public function __construct(ObjectType $parentType, Schema $schema, iterable $fieldNodes, array $variableValues, array $fragments)
{
$this->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;
}
}

View File

@ -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[]
*/

View File

@ -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]);

View File

@ -0,0 +1,588 @@
<?php
declare(strict_types=1);
namespace GraphQL\Tests\Type;
use GraphQL\GraphQL;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\QueryPlan;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use PHPUnit\Framework\TestCase;
final class QueryPlanTest extends TestCase
{
public function testQueryPlan() : 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
}
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'));
}
}