diff --git a/README.md b/README.md index c91fc7e..39de76e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 73bf7f2..5cb5af6 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -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 diff --git a/src/Type/Definition/FieldDefinition.php b/src/Type/Definition/FieldDefinition.php index 686a4e5..23edb7a 100644 --- a/src/Type/Definition/FieldDefinition.php +++ b/src/Type/Definition/FieldDefinition.php @@ -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; diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php index f1d1650..6435e6f 100644 --- a/src/Type/Definition/InterfaceType.php +++ b/src/Type/Definition/InterfaceType.php @@ -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); } } diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php index dc9c444..de19237 100644 --- a/src/Type/Definition/ObjectType.php +++ b/src/Type/Definition/ObjectType.php @@ -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'])) { diff --git a/src/Type/Definition/UnionType.php b/src/Type/Definition/UnionType.php index c72de31..527f668 100644 --- a/src/Type/Definition/UnionType.php +++ b/src/Type/Definition/UnionType.php @@ -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); } } diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index 76d9784..c6a6782 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -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()); } }