diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index 0530a60..011e7cf 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -969,9 +969,59 @@ class Executor $runtimeType = $returnType->resolveType($result, $exeContext->contextValue, $info); if (null === $runtimeType) { - $runtimeType = self::inferTypeOf($result, $exeContext->contextValue, $info, $returnType); + $runtimeType = self::defaultTypeResolver($result, $exeContext->contextValue, $info, $returnType); } + if ($this->promises->isThenable($runtimeType)) { + $runtimeType = $this->promises->convertThenable($runtimeType); + Utils::invariant($runtimeType instanceof Promise); + } + + if ($runtimeType instanceof Promise) { + return $runtimeType->then(function($resolvedRuntimeType) use ($returnType, $fieldNodes, $info, $path, &$result) { + return $this->validateRuntimeTypeAndCompleteObjectValue( + $returnType, + $fieldNodes, + $info, + $path, + $resolvedRuntimeType, + $result + ); + }); + } + + return $this->validateRuntimeTypeAndCompleteObjectValue( + $returnType, + $fieldNodes, + $info, + $path, + $runtimeType, + $result + ); + } + + /** + * @param AbstractType $returnType + * @param FieldNode[] $fieldNodes + * @param ResolveInfo $info + * @param array $path + * @param mixed $returnedRuntimeType + * @param $result + * @return array|Promise|\stdClass + * @throws Error + */ + private function validateRuntimeTypeAndCompleteObjectValue( + AbstractType $returnType, + $fieldNodes, + ResolveInfo $info, + $path, + $returnedRuntimeType, + &$result + ) + { + $exeContext = $this->exeContext; + $runtimeType = $returnedRuntimeType; + // If resolveType returns a string, we assume it's a ObjectType name. if (is_string($runtimeType)) { $runtimeType = $exeContext->schema->getType($runtimeType); @@ -1064,12 +1114,61 @@ class Executor */ private function completeObjectValue(ObjectType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) { - $exeContext = $this->exeContext; + $isTypeOf = $returnType->isTypeOf($result, $this->exeContext->contextValue, $info); - // If there is an isTypeOf predicate function, call it with the - // current result. If isTypeOf returns false, then raise an error rather - // than continuing execution. - if (false === $returnType->isTypeOf($result, $exeContext->contextValue, $info)) { + if (null === $isTypeOf) { + return $this->validateResultTypeAndExecuteFields( + $returnType, + $fieldNodes, + $info, + $path, + $result, + true + ); + } + + if ($this->promises->isThenable($isTypeOf)) { + /** @var Promise $isTypeOf */ + $isTypeOf = $this->promises->convertThenable($isTypeOf); + Utils::invariant($isTypeOf instanceof Promise); + + return $isTypeOf->then(function($isTypeOfResult) use ($returnType, $fieldNodes, $info, $path, &$result) { + return $this->validateResultTypeAndExecuteFields( + $returnType, + $fieldNodes, + $info, + $path, + $result, + $isTypeOfResult + ); + }); + } + + return $this->validateResultTypeAndExecuteFields($returnType, $fieldNodes, $info, $path, $result, $isTypeOf); + } + + /** + * @param ObjectType $returnType + * @param FieldNode[] $fieldNodes + * @param ResolveInfo $info + * @param array $path + * @param array $result + * @param bool $isTypeOfResult + * @return array|Promise|\stdClass + * @throws Error + */ + private function validateResultTypeAndExecuteFields( + ObjectType $returnType, + $fieldNodes, + ResolveInfo $info, + $path, + &$result, + $isTypeOfResult + ) + { + // If isTypeOf returns false, then raise an error + // rather than continuing execution. + if (false === $isTypeOfResult) { throw new Error( "Expected value of type $returnType but got: " . Utils::getVariableType($result), $fieldNodes @@ -1095,23 +1194,47 @@ class Executor } /** - * Infer type of the value using isTypeOf of corresponding AbstractType + * If a resolveType function is not given, then a default resolve behavior is + * used which tests each possible type for the abstract type by calling + * isTypeOf for the object being coerced, returning the first type that matches. * * @param $value * @param $context * @param ResolveInfo $info * @param AbstractType $abstractType - * @return ObjectType|null + * @return ObjectType|Promise|null */ - private static function inferTypeOf($value, $context, ResolveInfo $info, AbstractType $abstractType) + private function defaultTypeResolver($value, $context, ResolveInfo $info, AbstractType $abstractType) { $possibleTypes = $info->schema->getPossibleTypes($abstractType); + $promisedIsTypeOfResults = []; + $promisedIsTypeOfResultTypes = []; foreach ($possibleTypes as $type) { - if ($type->isTypeOf($value, $context, $info)) { - return $type; + $isTypeOfResult = $type->isTypeOf($value, $context, $info); + + if (null !== $isTypeOfResult) { + if ($this->promises->isThenable($isTypeOfResult)) { + $promisedIsTypeOfResults[] = $this->promises->convertThenable($isTypeOfResult); + $promisedIsTypeOfResultTypes[] = $type; + } else if ($isTypeOfResult) { + return $type; + } } } + + if (!empty($promisedIsTypeOfResults)) { + return $this->promises->all($promisedIsTypeOfResults) + ->then(function($isTypeOfResults) use ($promisedIsTypeOfResultTypes) { + foreach ($isTypeOfResults as $index => $result) { + if ($result) { + return $promisedIsTypeOfResultTypes[$index]; + } + } + return null; + }); + } + return null; } diff --git a/tests/Executor/AbstractPromiseTest.php b/tests/Executor/AbstractPromiseTest.php new file mode 100644 index 0000000..1a6894a --- /dev/null +++ b/tests/Executor/AbstractPromiseTest.php @@ -0,0 +1,654 @@ + 'Pet', + 'fields' => [ + 'name' => [ 'type' => Type::string() ] + ] + ]); + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'interfaces' => [ $PetType ], + 'isTypeOf' => function($obj) { + return new Deferred(function() use ($obj) { + return $obj instanceof Dog; + }); + }, + 'fields' => [ + 'name' => [ 'type' => Type::string() ], + 'woofs' => [ 'type' => Type::boolean() ], + ] + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'interfaces' => [ $PetType ], + 'isTypeOf' => function($obj) { + return new Deferred(function() use ($obj) { + return $obj instanceof Cat; + }); + }, + 'fields' => [ + 'name' => [ 'type' => Type::string() ], + 'meows' => [ 'type' => Type::boolean() ], + ] + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => function() { + return [ + new Dog('Odie', true), + new Cat('Garfield', false) + ]; + } + ] + ] + ]), + 'types' => [ $CatType, $DogType ] + ]); + + $query = '{ + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + }'; + + $result = GraphQL::execute($schema, $query); + + $expected = [ + 'data' => [ + 'pets' => [ + [ 'name' => 'Odie', 'woofs' => true ], + [ 'name' => 'Garfield', 'meows' => false ] + ] + ] + ]; + + $this->assertEquals($expected, $result); + } + + /** + * @it isTypeOf can be rejected + */ + public function testIsTypeOfCanBeRejected() + { + + $PetType = new InterfaceType([ + 'name' => 'Pet', + 'fields' => [ + 'name' => ['type' => Type::string()] + ] + ]); + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'interfaces' => [$PetType], + 'isTypeOf' => function () { + return new Deferred(function () { + throw new \Exception('We are testing this error'); + }); + }, + 'fields' => [ + 'name' => ['type' => Type::string()], + 'woofs' => ['type' => Type::boolean()], + ] + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'interfaces' => [$PetType], + 'isTypeOf' => function ($obj) { + return new Deferred(function () use ($obj) { + return $obj instanceof Cat; + }); + }, + 'fields' => [ + 'name' => ['type' => Type::string()], + 'meows' => ['type' => Type::boolean()], + ] + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => function () { + return [ + new Dog('Odie', true), + new Cat('Garfield', false) + ]; + } + ] + ] + ]), + 'types' => [$CatType, $DogType] + ]); + + $query = '{ + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + }'; + + $result = GraphQL::execute($schema, $query); + + $expected = [ + 'data' => [ + 'pets' => [null, null] + ], + 'errors' => [ + [ + 'message' => 'We are testing this error', + 'locations' => [['line' => 2, 'column' => 7]], + 'path' => ['pets', 0] + ], + [ + 'message' => 'We are testing this error', + 'locations' => [['line' => 2, 'column' => 7]], + 'path' => ['pets', 1] + ] + ] + ]; + + $this->assertEquals($expected, $result); + } + + /** + * @it isTypeOf used to resolve runtime type for Union + */ + public function testIsTypeOfUsedToResolveRuntimeTypeForUnion() + { + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'isTypeOf' => function ($obj) { + return new Deferred(function () use ($obj) { + return $obj instanceof Dog; + }); + }, + 'fields' => [ + 'name' => ['type' => Type::string()], + 'woofs' => ['type' => Type::boolean()], + ] + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'isTypeOf' => function ($obj) { + return new Deferred(function () use ($obj) { + return $obj instanceof Cat; + }); + }, + 'fields' => [ + 'name' => ['type' => Type::string()], + 'meows' => ['type' => Type::boolean()], + ] + ]); + + $PetType = new UnionType([ + 'name' => 'Pet', + 'types' => [$DogType, $CatType] + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => function () { + return [new Dog('Odie', true), new Cat('Garfield', false)]; + } + ] + ] + ]) + ]); + + $query = '{ + pets { + ... on Dog { + name + woofs + } + ... on Cat { + name + meows + } + } + }'; + + $result = GraphQL::execute($schema, $query); + + $expected = [ + 'data' => [ + 'pets' => [ + ['name' => 'Odie', 'woofs' => true], + ['name' => 'Garfield', 'meows' => false] + ] + ] + ]; + + $this->assertEquals($expected, $result); + } + + /** + * @it resolveType on Interface yields useful error + */ + public function testResolveTypeOnInterfaceYieldsUsefulError() + { + $PetType = new InterfaceType([ + 'name' => 'Pet', + 'resolveType' => function ($obj) use (&$DogType, &$CatType, &$HumanType) { + return new Deferred(function () use ($obj, $DogType, $CatType, $HumanType) { + if ($obj instanceof Dog) { + return $DogType; + } + if ($obj instanceof Cat) { + return $CatType; + } + if ($obj instanceof Human) { + return $HumanType; + } + return null; + }); + }, + 'fields' => [ + 'name' => ['type' => Type::string()] + ] + ]); + + $HumanType = new ObjectType([ + 'name' => 'Human', + 'fields' => [ + 'name' => ['type' => Type::string()], + ] + ]); + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'interfaces' => [$PetType], + 'fields' => [ + 'name' => ['type' => Type::string()], + 'woofs' => ['type' => Type::boolean()], + ] + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'interfaces' => [$PetType], + 'fields' => [ + 'name' => ['type' => Type::string()], + 'meows' => ['type' => Type::boolean()], + ] + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => function () { + return new Deferred(function () { + return [ + new Dog('Odie', true), + new Cat('Garfield', false), + new Human('Jon') + ]; + }); + } + ] + ] + ]), + 'types' => [$CatType, $DogType] + ]); + + $query = '{ + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + }'; + + $result = GraphQL::execute($schema, $query); + + $expected = [ + 'data' => [ + 'pets' => [ + ['name' => 'Odie', 'woofs' => true], + ['name' => 'Garfield', 'meows' => false], + null + ] + ], + 'errors' => [ + [ + 'message' => 'Runtime Object type "Human" is not a possible type for "Pet".', + 'locations' => [['line' => 2, 'column' => 7]], + 'path' => ['pets', 2] + ], + ] + ]; + + $this->assertEquals($expected, $result); + } + + /** + * @it resolveType on Union yields useful error + */ + public function testResolveTypeOnUnionYieldsUsefulError() + { + + $HumanType = new ObjectType([ + 'name' => 'Human', + 'fields' => [ + 'name' => ['type' => Type::string()], + ] + ]); + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'fields' => [ + 'name' => ['type' => Type::string()], + 'woofs' => ['type' => Type::boolean()], + ] + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'fields' => [ + 'name' => ['type' => Type::string()], + 'meows' => ['type' => Type::boolean()], + ] + ]); + + $PetType = new UnionType([ + 'name' => 'Pet', + 'resolveType' => function ($obj) use ($DogType, $CatType, $HumanType) { + return new Deferred(function () use ($obj, $DogType, $CatType, $HumanType) { + if ($obj instanceof Dog) { + return $DogType; + } + if ($obj instanceof Cat) { + return $CatType; + } + if ($obj instanceof Human) { + return $HumanType; + } + return null; + }); + }, + 'types' => [$DogType, $CatType] + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => function () { + return [ + new Dog('Odie', true), + new Cat('Garfield', false), + new Human('Jon') + ]; + } + ] + ] + ]) + ]); + + $query = '{ + pets { + ... on Dog { + name + woofs + } + ... on Cat { + name + meows + } + } + }'; + + $result = GraphQL::execute($schema, $query); + + $expected = [ + 'data' => [ + 'pets' => [ + ['name' => 'Odie', 'woofs' => true], + ['name' => 'Garfield', 'meows' => false], + null + ] + ], + 'errors' => [ + [ + 'message' => 'Runtime Object type "Human" is not a possible type for "Pet".', + 'locations' => [['line' => 2, 'column' => 7]], + 'path' => ['pets', 2] + ] + ] + ]; + + $this->assertEquals($expected, $result); + } + + /** + * @it resolveType allows resolving with type name + */ + public function testResolveTypeAllowsResolvingWithTypeName() + { + $PetType = new InterfaceType([ + 'name' => 'Pet', + 'resolveType' => function ($obj) { + return new Deferred(function () use ($obj) { + if ($obj instanceof Dog) { + return 'Dog'; + } + if ($obj instanceof Cat) { + return 'Cat'; + } + return null; + }); + }, + 'fields' => [ + 'name' => ['type' => Type::string()] + ] + ]); + + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'interfaces' => [$PetType], + 'fields' => [ + 'name' => ['type' => Type::string()], + 'woofs' => ['type' => Type::boolean()], + ] + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'interfaces' => [$PetType], + 'fields' => [ + 'name' => ['type' => Type::string()], + 'meows' => ['type' => Type::boolean()], + ] + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => function () { + return [ + new Dog('Odie', true), + new Cat('Garfield', false) + ]; + } + ] + ] + ]), + 'types' => [$CatType, $DogType] + ]); + + $query = '{ + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + }'; + + $result = GraphQL::execute($schema, $query); + + $expected = [ + 'data' => [ + 'pets' => [ + ['name' => 'Odie', 'woofs' => true], + ['name' => 'Garfield', 'meows' => false], + ] + ] + ]; + $this->assertEquals($expected, $result); + } + + /** + * @it resolveType can be caught + */ + public function testResolveTypeCanBeCaught() + { + + $PetType = new InterfaceType([ + 'name' => 'Pet', + 'resolveType' => function () { + return new Deferred(function () { + throw new \Exception('We are testing this error'); + }); + }, + 'fields' => [ + 'name' => ['type' => Type::string()] + ] + ]); + + $DogType = new ObjectType([ + 'name' => 'Dog', + 'interfaces' => [$PetType], + 'fields' => [ + 'name' => ['type' => Type::string()], + 'woofs' => ['type' => Type::boolean()], + ] + ]); + + $CatType = new ObjectType([ + 'name' => 'Cat', + 'interfaces' => [$PetType], + 'fields' => [ + 'name' => ['type' => Type::string()], + 'meows' => ['type' => Type::boolean()], + ] + ]); + + $schema = new Schema([ + 'query' => new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'pets' => [ + 'type' => Type::listOf($PetType), + 'resolve' => function () { + return [ + new Dog('Odie', true), + new Cat('Garfield', false) + ]; + } + ] + ] + ]), + 'types' => [$CatType, $DogType] + ]); + + $query = '{ + pets { + name + ... on Dog { + woofs + } + ... on Cat { + meows + } + } + }'; + + $result = GraphQL::execute($schema, $query); + + $expected = [ + 'data' => [ + 'pets' => [null, null] + ], + 'errors' => [ + [ + 'message' => 'We are testing this error', + 'locations' => [['line' => 2, 'column' => 7]], + 'path' => ['pets', 0] + ], + [ + 'message' => 'We are testing this error', + 'locations' => [['line' => 2, 'column' => 7]], + 'path' => ['pets', 1] + ] + ] + ]; + + $this->assertEquals($expected, $result); + } +}