Mapping Executor

This commit is contained in:
vladar 2015-09-01 01:44:03 +06:00
parent aeb56d139a
commit 3edf6248b0
7 changed files with 499 additions and 141 deletions

View File

@ -278,11 +278,12 @@ Option | Type | Notes
name | `string` | Required. Name of the field. If not set - GraphQL will look use `key` of fields array on type definition.
type | `Type` or `callback() => Type` | Required. One of internal or custom types. Alternatively - callback that returns `type`.
args | `array` | Array of possible type arguments. Each entry is expected to be an array with following keys: **name** (`string`), **type** (`Type` or `callback() => Type`), **defaultValue** (`any`)
resolve | `callback($value, $args, ResolveInfo $info) => $data` | Function that receives `$value` describing parent type and returns `$data` for this field.
resolve | `callback($value, $args, ResolveInfo $info) => $fieldValue` | Function that receives `$value` of parent type and returns value for this field. Mutually exclusive with `map`
map | `callback($listOfValues, $args, ResolveInfo $info) => $fieldValues[]` | Function that receives list of parent type values and maps them to list of field values. Mutually exclusive with `resolve`
description | `string` | Field description for clients
deprecationReason | `string` | Text describing why this field is deprecated. When not empty - field will not be returned by introspection queries (unless forced)
The `resolve` option is exactly the place where your custom fetching logic lives.
Use `map` or `resolve` for custom data fetching logic. `resolve` is easier to use, but `map` allows batching of queries to backend storage (for example you can use Redis MGET or IN(?) for SQL queries).
### Schema

View File

@ -147,10 +147,12 @@ class Executor
$fields = self::collectFields($exeContext, $type, $operation->selectionSet, new \ArrayObject(), new \ArrayObject());
if ($operation->operation === 'mutation') {
return self::executeFieldsSerially($exeContext, $type, $rootValue, $fields->getArrayCopy());
$result = self::executeFieldsSerially($exeContext, $type, [$rootValue], $fields);
} else {
$result = self::executeFields($exeContext, $type, [$rootValue], $fields);
}
return self::executeFields($exeContext, $type, $rootValue, $fields);
return null === $result || $result === [] ? [] : $result[0];
}
@ -187,30 +189,40 @@ class Executor
/**
* Implements the "Evaluating selection sets" section of the spec
* for "write" mode.
*
* @param ExecutionContext $exeContext
* @param ObjectType $parentType
* @param $sourceList
* @param $sourceIsList
* @param $fields
* @return array
* @throws Error
* @throws \Exception
*/
private static function executeFieldsSerially(ExecutionContext $exeContext, ObjectType $parentType, $sourceValue, $fields)
private static function executeFieldsSerially(ExecutionContext $exeContext, ObjectType $parentType, $sourceList, $fields)
{
$results = [];
foreach ($fields as $responseName => $fieldASTs) {
$result = self::resolveField($exeContext, $parentType, $sourceValue, $fieldASTs);
if ($result !== self::$UNDEFINED) {
// Undefined means that field is not defined in schema
$results[$responseName] = $result;
}
self::resolveField($exeContext, $parentType, $sourceList, $fieldASTs, $responseName, $results);
}
return $results;
}
/**
* Implements the "Evaluating selection sets" section of the spec
* for "read" mode.
* @param ExecutionContext $exeContext
* @param ObjectType $parentType
* @param $sourceList
* @param $fields
* @return array
*/
private static function executeFields(ExecutionContext $exeContext, ObjectType $parentType, $source, $fields)
private static function executeFields(ExecutionContext $exeContext, ObjectType $parentType, $sourceList, $fields)
{
// Native PHP doesn't support promises.
// Custom executor should be built for platforms like ReactPHP
return self::executeFieldsSerially($exeContext, $parentType, $source, $fields);
return self::executeFieldsSerially($exeContext, $parentType, $sourceList, $fields);
}
@ -345,12 +357,21 @@ class Executor
}
/**
* Resolves the field on the given source object. In particular, this
* figures out the value that the field returns by calling its resolve function,
* then calls completeValue to complete promises, serialize scalars, or execute
* the sub-selection-set for objects.
* Given list of parent type values returns corresponding list of field values
*
* In particular, this
* figures out the value that the field returns by calling its `resolve` or `map` function,
* then calls `completeValue` on each value to serialize scalars, or execute the sub-selection-set
* for objects.
*
* @param ExecutionContext $exeContext
* @param ObjectType $parentType
* @param $sourceValueList
* @param $fieldASTs
* @return array
* @throws Error
*/
private static function resolveField(ExecutionContext $exeContext, ObjectType $parentType, $source, $fieldASTs)
private static function resolveField(ExecutionContext $exeContext, ObjectType $parentType, $sourceValueList, $fieldASTs, $responseName, &$resolveResult)
{
$fieldAST = $fieldASTs[0];
$fieldName = $fieldAST->name->value;
@ -358,19 +379,11 @@ class Executor
$fieldDef = self::getFieldDef($exeContext->schema, $parentType, $fieldName);
if (!$fieldDef) {
return self::$UNDEFINED;
return ;
}
$returnType = $fieldDef->getType();
if (isset($fieldDef->resolve)) {
$resolveFn = $fieldDef->resolve;
} else if (isset($parentType->resolveField)) {
$resolveFn = $parentType->resolveField;
} else {
$resolveFn = self::$defaultResolveFn;
}
// Build hash of arguments from the field.arguments AST, using the
// variables scope to fulfill any variable references.
// TODO: find a way to memoize, in case this field is within a List type.
@ -394,30 +407,74 @@ class Executor
'variableValues' => $exeContext->variableValues,
]);
// If an error occurs while calling the field `resolve` function, ensure that
$mapFn = $fieldDef->mapFn;
// If an error occurs while calling the field `map` or `resolve` function, ensure that
// it is wrapped as a GraphQLError with locations. Log this error and return
// null if allowed, otherwise throw the error so the parent field can handle
// it.
try {
$result = call_user_func($resolveFn, $source, $args, $info);
} catch (\Exception $error) {
$reportedError = Error::createLocatedError($error, $fieldASTs);
if ($mapFn) {
try {
$mapped = call_user_func($mapFn, $sourceValueList, $args, $info);
if ($returnType instanceof NonNull) {
throw $reportedError;
Utils::invariant(
is_array($mapped) && count($mapped) === count($sourceValueList),
"Function `map` of $parentType.$fieldName is expected to return array " .
"with exact same number of items as list being mapped (first argument of `map`)"
);
} catch (\Exception $error) {
$reportedError = Error::createLocatedError($error, $fieldASTs);
if ($returnType instanceof NonNull) {
throw $reportedError;
}
$exeContext->addError($reportedError);
return null;
}
$exeContext->addError($reportedError);
return null;
}
foreach ($mapped as $index => $value) {
$resolveResult[$index][$responseName] = self::completeValueCatchingError(
$exeContext,
$returnType,
$fieldASTs,
$info,
$value
);
}
} else {
if (isset($fieldDef->resolveFn)) {
$resolveFn = $fieldDef->resolveFn;
} else if (isset($parentType->resolveFieldFn)) {
$resolveFn = $parentType->resolveFieldFn;
} else {
$resolveFn = self::$defaultResolveFn;
}
return self::completeValueCatchingError(
$exeContext,
$returnType,
$fieldASTs,
$info,
$result
);
foreach ($sourceValueList as $index => $value) {
try {
$resolved = call_user_func($resolveFn, $value, $args, $info);
} catch (\Exception $error) {
$reportedError = Error::createLocatedError($error, $fieldASTs);
if ($returnType instanceof NonNull) {
throw $reportedError;
}
$exeContext->addError($reportedError);
$resolved = null;
}
$resolveResult[$index][$responseName] = self::completeValueCatchingError(
$exeContext,
$returnType,
$fieldASTs,
$info,
$resolved
);
}
}
}
@ -489,32 +546,112 @@ class Executor
return null;
}
// If field type is List, complete each item in the list with the inner type
// If field type is Scalar or Enum, serialize to a valid value, returning
// null if serialization is not possible.
if ($returnType instanceof ScalarType ||
$returnType instanceof EnumType) {
return $returnType->serialize($result);
}
// If field type is List, and return type is Composite - complete by executing these fields with list value as parameter
if ($returnType instanceof ListOfType) {
$itemType = $returnType->getWrappedType();
Utils::invariant(
is_array($result) || $result instanceof \Traversable,
'User Error: expected iterable, but did not find one.'
);
$tmp = [];
foreach ($result as $item) {
$tmp[] = self::completeValueCatchingError($exeContext, $itemType, $fieldASTs, $info, $item);
// For Object[]:
// Allow all object fields to process list value in it's `map` callback:
if ($itemType instanceof ObjectType) {
// Filter out nulls (as `map` doesn't expect it):
$list = [];
foreach ($result as $index => $item) {
if (null !== $item) {
$list[] = $item;
}
}
$subFieldASTs = self::collectSubFields($exeContext, $itemType, $fieldASTs);
$mapped = self::executeFields($exeContext, $itemType, $list, $subFieldASTs);
$i = 0;
$completed = [];
foreach ($result as $index => $item) {
if (null === $item) {
// Complete nulls separately
$completed[] = self::completeValueCatchingError($exeContext, $itemType, $fieldASTs, $info, $item);
} else {
// Assuming same order of mapped values
$completed[] = $mapped[$i++];
}
}
return $completed;
} else if ($itemType instanceof AbstractType) {
// Values sharded by ObjectType
$listPerObjectType = [];
// Helper structures to restore ordering after resolve calls
$resultTypeMap = [];
$typeNameMap = [];
$cursors = [];
foreach ($result as $index => $item) {
if (null !== $item) {
$objectType = $itemType->getObjectType($item, $info);
if ($objectType && !$itemType->isPossibleType($objectType)) {
$exeContext->addError(new Error(
"Runtime Object type \"$objectType\" is not a possible type for \"$itemType\"."
));
$result[$index] = null;
} else {
$listPerObjectType[$objectType->name][] = $item;
$resultTypeMap[$index] = $objectType->name;
$typeNameMap[$objectType->name] = $objectType;
}
}
}
$mapped = [];
foreach ($listPerObjectType as $typeName => $list) {
$objectType = $typeNameMap[$typeName];
$subFieldASTs = self::collectSubFields($exeContext, $objectType, $fieldASTs);
$mapped[$typeName] = self::executeFields($exeContext, $objectType, $list, $subFieldASTs);
$cursors[$typeName] = 0;
}
// Restore order:
$completed = [];
foreach ($result as $index => $item) {
if (null === $item) {
// Complete nulls separately
$completed[] = self::completeValueCatchingError($exeContext, $itemType, $fieldASTs, $info, $item);
} else {
$typeName = $resultTypeMap[$index];
$completed[] = $mapped[$typeName][$cursors[$typeName]++];
}
}
return $completed;
} else {
// For simple lists:
$tmp = [];
foreach ($result as $item) {
$tmp[] = self::completeValueCatchingError($exeContext, $itemType, $fieldASTs, $info, $item);
}
return $tmp;
}
return $tmp;
}
// If field type is Scalar or Enum, serialize to a valid value, returning
// null if serialization is not possible.
if ($returnType instanceof ScalarType ||
$returnType instanceof EnumType) {
Utils::invariant(method_exists($returnType, 'serialize'), 'Missing serialize method on type');
return $returnType->serialize($result);
}
// Field type must be Object, Interface or Union and expect sub-selections.
if ($returnType instanceof ObjectType) {
$objectType = $returnType;
} else if ($returnType instanceof AbstractType) {
$objectType = $returnType->getObjectType($result, $info);
@ -542,6 +679,18 @@ class Executor
}
// Collect sub-fields to execute to complete this value.
$subFieldASTs = self::collectSubFields($exeContext, $objectType, $fieldASTs);
return self::executeFields($exeContext, $objectType, [$result], $subFieldASTs)[0];
}
/**
* @param ExecutionContext $exeContext
* @param ObjectType $objectType
* @param $fieldASTs
* @return \ArrayObject
*/
private static function collectSubFields(ExecutionContext $exeContext, ObjectType $objectType, $fieldASTs)
{
$subFieldASTs = new \ArrayObject();
$visitedFragmentNames = new \ArrayObject();
for ($i = 0; $i < count($fieldASTs); $i++) {
@ -556,11 +705,9 @@ class Executor
);
}
}
return self::executeFields($exeContext, $objectType, $result, $subFieldASTs);
return $subFieldASTs;
}
/**
* If a resolve function is not given, then a default resolve behavior is used
* which takes the property of the source object of the same name as the field

View File

@ -23,13 +23,20 @@ class FieldDefinition
public $args;
/**
* source?: any,
* args?: ?{[argName: string]: any},
* info
* Callback for resolving field value given parent value.
* Mutually exclusive with `map`
*
* @var callable
*/
public $resolve;
public $resolveFn;
/**
* Callback for mapping list of parent values to list of field values.
* Mutually exclusive with `resolve`
*
* @var callable
*/
public $mapFn;
/**
* @var string|null
@ -62,6 +69,7 @@ class FieldDefinition
'defaultValue' => Config::ANY
], Config::KEY_AS_NAME),
'resolve' => Config::CALLBACK,
'map' => Config::CALLBACK,
'description' => Config::STRING,
'deprecationReason' => Config::STRING,
]);
@ -97,7 +105,8 @@ class FieldDefinition
{
$this->name = $config['name'];
$this->type = $config['type'];
$this->resolve = isset($config['resolve']) ? $config['resolve'] : null;
$this->resolveFn = isset($config['resolve']) ? $config['resolve'] : null;
$this->mapFn = isset($config['map']) ? $config['map'] : null;
$this->args = isset($config['args']) ? FieldArgument::createMap($config['args']) : [];
$this->description = isset($config['description']) ? $config['description'] : null;

View File

@ -26,7 +26,7 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT
/**
* @var callback
*/
private $_resolveType;
private $_resolveTypeFn;
/**
* Update the interfaces to know about this implementation.
@ -52,14 +52,14 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT
FieldDefinition::getDefinition(),
Config::KEY_AS_NAME
),
'resolveType' => Config::CALLBACK,
'resolveType' => Config::CALLBACK, // function($value, ResolveInfo $info) => ObjectType
'description' => Config::STRING
]);
$this->name = $config['name'];
$this->description = isset($config['description']) ? $config['description'] : null;
$this->_fields = !empty($config['fields']) ? FieldDefinition::createMap($config['fields']) : [];
$this->_resolveType = isset($config['resolveType']) ? $config['resolveType'] : null;
$this->_resolveTypeFn = isset($config['resolveType']) ? $config['resolveType'] : null;
}
/**
@ -104,7 +104,7 @@ class InterfaceType extends Type implements AbstractType, OutputType, CompositeT
*/
public function getObjectType($value, ResolveInfo $info)
{
$resolver = $this->_resolveType;
$resolver = $this->_resolveTypeFn;
return $resolver ? call_user_func($resolver, $value, $info) : Type::getTypeOf($value, $info, $this);
}
}

View File

@ -69,7 +69,7 @@ class ObjectType extends Type implements OutputType, CompositeType
/**
* @var callable
*/
public $resolveField;
public $resolveFieldFn;
public function __construct(array $config)
{
@ -77,7 +77,7 @@ class ObjectType extends Type implements OutputType, CompositeType
$this->name = $config['name'];
$this->description = isset($config['description']) ? $config['description'] : null;
$this->resolveField = isset($config['resolveField']) ? $config['resolveField'] : null;
$this->resolveFieldFn = isset($config['resolveField']) ? $config['resolveField'] : null;
$this->_config = $config;
if (isset($config['interfaces'])) {

View File

@ -25,7 +25,7 @@ class UnionType extends Type implements AbstractType, OutputType, CompositeType
Config::validate($config, [
'name' => Config::STRING | Config::REQUIRED,
'types' => Config::arrayOf(Config::OBJECT_TYPE | Config::REQUIRED),
'resolveType' => Config::CALLBACK,
'resolveType' => Config::CALLBACK, // function($value, ResolveInfo $info) => ObjectType
'description' => Config::STRING
]);
@ -66,16 +66,19 @@ class UnionType extends Type implements AbstractType, OutputType, CompositeType
$this->_possibleTypeNames[$possibleType->name] = true;
}
}
return $this->_possibleTypeNames[$type->name] === true;
return isset($this->_possibleTypeNames[$type->name]);
}
/**
* @param ObjectType $value
* @param ResolveInfo $info
*
* @return Type
* @throws \Exception
*/
public function getObjectType($value, ResolveInfo $info)
{
$resolver = $this->_resolveType;
return $resolver ? call_user_func($resolver, $value) : Type::getTypeOf($value, $info, $this);
return $resolver ? call_user_func($resolver, $value, $info) : Type::getTypeOf($value, $info, $this);
}
}

View File

@ -6,9 +6,12 @@ use GraphQL\FormattedError;
use GraphQL\Language\Parser;
use GraphQL\Language\SourceLocation;
use GraphQL\Schema;
use GraphQL\Type\Definition\Config;
use GraphQL\Type\Definition\InterfaceType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Definition\UnionType;
use GraphQL\Utils;
class ExecutorTest extends \PHPUnit_Framework_TestCase
{
@ -164,11 +167,14 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase
'c' => ['type' => Type::string(), 'resolve' => function () {
return 'Cherry';
}],
'deep' => ['type' => function () use (&$Type) {
return $Type;
}, 'resolve' => function () {
return [];
}]
'deep' => [
'type' => function () use (&$Type) {
return $Type;
},
'resolve' => function () {
return [];
}
]
]
]);
$schema = new Schema($Type);
@ -503,72 +509,264 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase
];
$this->assertEquals($expected, $result->toArray());
/*
var query = parse('{ field(a: true, c: false, e: 0) }');
var result = await execute(schema, query);
expect(result).to.deep.equal({
data: {
field: '{"a":true,"c":false,"e":0}'
}
});
});
it('fails when an isTypeOf check is not met', async () => {
class Special {
constructor(value) {
this.value = value;
}
}
class NotSpecial {
constructor(value) {
this.value = value;
}
public function testExecutesMapCallbacksIfSet()
{
$fooData = [
['field' => '1'],
['field' => null],
null,
['field' => '4'],
];
$foo = new ObjectType([
'name' => 'Foo',
'fields' => [
'field' => [
'type' => Type::string(),
'map' => function($listOfFoo, $args, $resolveInfo) use ($fooData) {
return Utils::map($listOfFoo, function($fooData) use ($args, $resolveInfo) {
return json_encode([
'value' => $fooData['field'] === null ? null : $fooData['field'] . 'x',
'args' => $args,
'gotResolveInfo' => $resolveInfo instanceof ResolveInfo
]);
});
},
'args' => [
'a' => ['type' => Type::boolean()],
'b' => ['type' => Type::boolean()],
'c' => ['type' => Type::int()]
]
]
]
]);
$bar = new ObjectType([
'name' => 'Bar',
'fields' => [
'foo' => [
'type' => Type::listOf($foo),
'resolve' => function() use ($fooData) {
return $fooData;
}
]
]
]);
$schema = new Schema($bar);
$query = Parser::parse('{ foo { field(a: true, c: 0) } }');
$result = Executor::execute($schema, $query);
$expected = [
'data' => [
'foo' => [
['field' => '{"value":"1x","args":{"a":true,"c":0},"gotResolveInfo":true}'],
['field' => '{"value":null,"args":{"a":true,"c":0},"gotResolveInfo":true}'],
null,
['field' => '{"value":"4x","args":{"a":true,"c":0},"gotResolveInfo":true}'],
]
]
];
$this->assertEquals($expected, $result->toArray());
}
var SpecialType = new GraphQLObjectType({
name: 'SpecialType',
isTypeOf(obj) {
return obj instanceof Special;
},
fields: {
value: { type: GraphQLString }
}
});
public function testRespectsListsOfAbstractTypeWhenResolvingViaMap()
{
$type1 = null;
$type2 = null;
$type3 = null;
var schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
specials: {
type: new GraphQLList(SpecialType),
resolve: rootValue => rootValue.specials
}
}
})
});
$resolveType = function($value) use (&$type1, &$type2, &$type3) {
switch ($value['type']) {
case 'Type1':
return $type1;
case 'Type2':
return $type2;
case 'Type3':
default:
return $type3;
}
};
var query = parse('{ specials { value } }');
var value = {
specials: [ new Special('foo'), new NotSpecial('bar') ]
};
var result = await execute(schema, query, value);
$mapValues = function($typeValues, $args) {
return Utils::map($typeValues, function($value) use ($args) {
if (array_key_exists('foo', $value)) {
return json_encode([
'value' => $value,
'args' => $args,
]);
} else {
return null;
}
});
};
expect(result.data).to.deep.equal({
specials: [
{ value: 'foo' },
null
]
});
expect(result.errors).to.have.lengthOf(1);
expect(result.errors).to.containSubset([
{ message:
'Expected value of type "SpecialType" but got: [object Object].',
locations: [ { line: 1, column: 3 } ] }
]);
});
*/
$interface = new InterfaceType([
'name' => 'SomeInterface',
'fields' => [
'foo' => ['type' => Type::string()],
],
'resolveType' => $resolveType
]);
$type1 = new ObjectType([
'name' => 'Type1',
'fields' => [
'foo' => [
'type' => Type::string(),
'map' => $mapValues
]
],
'interfaces' => [$interface]
]);
$type2 = new ObjectType([
'name' => 'Type2',
'fields' => [
'foo' => [
'type' => Type::string(),
'map' => $mapValues
]
],
'interfaces' => [$interface]
]);
$type3 = new ObjectType([
'name' => 'Type3',
'fields' => [
'bar' => [
'type' => Type::listOf(Type::string()),
'map' => function($type3Values, $args) {
return Utils::map($type3Values, function($value) use ($args) {
return [
json_encode([
'value' => $value,
'args' => $args
])
];
});
}
]
]
]);
$union = new UnionType([
'name' => 'SomeUnion',
'types' => [$type1, $type3],
'resolveType' => $resolveType
]);
$complexType = new ObjectType([
'name' => 'ComplexType',
'fields' => [
'iface' => [
'type' => $interface
],
'ifaceList' => [
'type' => Type::listOf($interface)
],
'union' => [
'type' => $union
],
'unionList' => [
'type' => Type::listOf($union)
]
]
]);
$type1values = [
['type' => 'Type1', 'foo' => 'str1'],
['type' => 'Type1'],
['type' => 'Type1', 'foo' => null],
];
$type2values = [
['type' => 'Type2', 'foo' => 'str1'],
['type' => 'Type2', 'foo' => null],
['type' => 'Type2'],
];
$type3values = [
['type' => 'Type3', 'bar' => ['str1', 'str2']],
['type' => 'Type3', 'bar' => null],
];
$complexTypeValues = [
'iface' => $type1values[0],
'ifaceList' => array_merge($type1values, $type2values),
'union' => $type3values[0],
'unionList' => array_merge($type1values, $type3values)
];
$expected = [
'data' => [
'test' => [
'iface' => ['foo' => json_encode(['value' => $type1values[0], 'args' => []])],
'ifaceList' => [
['foo' => '{"value":{"type":"Type1","foo":"str1"},"args":[]}'],
['foo' => null],
['foo' => '{"value":{"type":"Type1","foo":null},"args":[]}'],
['foo' => '{"value":{"type":"Type2","foo":"str1"},"args":[]}'],
['foo' => '{"value":{"type":"Type2","foo":null},"args":[]}'],
['foo' => null],
],
'union' => [
'bar' => ['{"value":{"type":"Type3","bar":["str1","str2"]},"args":[]}']
],
'unionList' => [
['foo' => '{"value":{"type":"Type1","foo":"str1"},"args":[]}'],
['foo' => null],
['foo' => '{"value":{"type":"Type1","foo":null},"args":[]}'],
['bar' => ['{"value":{"type":"Type3","bar":["str1","str2"]},"args":[]}']],
['bar' => ['{"value":{"type":"Type3","bar":null},"args":[]}']],
]
]
]
];
$schema = new Schema(new ObjectType([
'name' => 'Query',
'fields' => [
'test' => [
'type' => $complexType,
'resolve' => function() use ($complexTypeValues) {
return $complexTypeValues;
}
]
]
]));
$query = '{
test {
iface{foo},
ifaceList{foo}
union {
... on Type1 {
foo
}
... on Type3 {
bar
}
}
unionList {
... on Type1 {
foo
}
... on Type3 {
bar
}
}
}
}';
$query = Parser::parse($query);
$result = Executor::execute($schema, $query);
$this->assertEquals($expected, $result->toArray());
}
}