diff --git a/composer.json b/composer.json index ae4e27e..93f0cda 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "ext-mbstring": "*" }, "require-dev": { - "phpunit/phpunit": "^4.8" + "phpunit/phpunit": "^4.8", + "react/promise": "^2.4" }, "config": { "bin-dir": "bin" @@ -30,5 +31,8 @@ "GraphQL\\Benchmarks\\": "benchmarks/", "GraphQL\\Examples\\Blog\\": "examples/01-blog/Blog/" } + }, + "suggest": { + "react/promise": "To use ReactPhp promise adapter" } } diff --git a/src/Executor/Executor.php b/src/Executor/Executor.php index e0f9a2d..467caff 100644 --- a/src/Executor/Executor.php +++ b/src/Executor/Executor.php @@ -3,13 +3,15 @@ namespace GraphQL\Executor; use GraphQL\Error\Error; use GraphQL\Error\InvariantViolation; +use GraphQL\Executor\Promise\Promise; use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\FieldNode; use GraphQL\Language\AST\FragmentDefinitionNode; -use GraphQL\Language\AST\Node; use GraphQL\Language\AST\NodeKind; use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Language\AST\SelectionSetNode; +use GraphQL\Executor\Promise\Adapter\GenericPromiseAdapter; +use GraphQL\Executor\Promise\PromiseAdapter; use GraphQL\Schema; use GraphQL\Type\Definition\AbstractType; use GraphQL\Type\Definition\Directive; @@ -48,6 +50,19 @@ class Executor private static $defaultFieldResolver = [__CLASS__, 'defaultFieldResolver']; + /** + * @var PromiseAdapter + */ + private static $promiseAdapter; + + /** + * @param PromiseAdapter|null $promiseAdapter + */ + public static function setPromiseAdapter(PromiseAdapter $promiseAdapter = null) + { + self::$promiseAdapter = $promiseAdapter; + } + /** * Custom default resolve function * @@ -66,7 +81,7 @@ class Executor * @param $contextValue * @param array|\ArrayAccess $variableValues * @param null $operationName - * @return ExecutionResult + * @return ExecutionResult|Promise */ public static function execute(Schema $schema, DocumentNode $ast, $rootValue = null, $contextValue = null, $variableValues = null, $operationName = null) { @@ -88,9 +103,34 @@ class Executor } $exeContext = self::buildExecutionContext($schema, $ast, $rootValue, $contextValue, $variableValues, $operationName); + if (null === self::$promiseAdapter) { + static::setPromiseAdapter(new GenericPromiseAdapter()); + } try { - $data = self::executeOperation($exeContext, $exeContext->operation, $rootValue); + $data = self::$promiseAdapter + ->createPromise(function (callable $resolve) use ($exeContext, $rootValue) { + return $resolve(self::executeOperation($exeContext, $exeContext->operation, $rootValue)); + }); + + if (self::$promiseAdapter->isPromise($data)) { + // Return a Promise that will eventually resolve to the data described by + // The "Response" section of the GraphQL specification. + // + // If errors are encountered while executing a GraphQL field, only that + // field and its descendants will be omitted, and sibling fields will still + // be executed. An execution which encounters errors will still result in a + // resolved Promise. + return $data->then(null, function ($error) use ($exeContext) { + // Errors from sub-fields of a NonNull type may propagate to the top level, + // at which point we still log the error and null the parent field, which + // in this case is the entire response. + $exeContext->addError($error); + return null; + })->then(function ($data) use ($exeContext) { + return new ExecutionResult((array) $data, $exeContext->errors); + }); + } } catch (Error $e) { $exeContext->addError($e); $data = null; @@ -102,6 +142,15 @@ class Executor /** * Constructs a ExecutionContext object from the arguments passed to * execute, which we will pass throughout the other execution methods. + * + * @param Schema $schema + * @param DocumentNode $documentNode + * @param $rootValue + * @param $contextValue + * @param $rawVariableValues + * @param string $operationName + * @return ExecutionContext + * @throws Error */ private static function buildExecutionContext( Schema $schema, @@ -160,6 +209,11 @@ class Executor /** * Implements the "Evaluating operations" section of the spec. + * + * @param ExecutionContext $exeContext + * @param OperationDefinitionNode $operation + * @param $rootValue + * @return Promise|\stdClass|array */ private static function executeOperation(ExecutionContext $exeContext, OperationDefinitionNode $operation, $rootValue) { @@ -217,38 +271,111 @@ class Executor /** * Implements the "Evaluating selection sets" section of the spec * for "write" mode. + * + * @param ExecutionContext $exeContext + * @param ObjectType $parentType + * @param $sourceValue + * @param $path + * @param $fields + * @return Promise|\stdClass|array */ private static function executeFieldsSerially(ExecutionContext $exeContext, ObjectType $parentType, $sourceValue, $path, $fields) { - $results = []; - foreach ($fields as $responseName => $fieldNodes) { + $results = self::$promiseAdapter->createResolvedPromise([]); + + $process = function ($results, $responseName, $path, $exeContext, $parentType, $sourceValue, $fieldNodes) { $fieldPath = $path; $fieldPath[] = $responseName; $result = self::resolveField($exeContext, $parentType, $sourceValue, $fieldNodes, $fieldPath); + if ($result === self::$UNDEFINED) { + return $results; + } + if (self::$promiseAdapter->isPromise($result)) { + return $result->then(function ($resolvedResult) use ($responseName, $results) { + $results[$responseName] = $resolvedResult; + return $results; + }); + } + $results[$responseName] = $result; + return $results; + }; - if ($result !== self::$UNDEFINED) { - // Undefined means that field is not defined in schema - $results[$responseName] = $result; + foreach ($fields as $responseName => $fieldNodes) { + if (self::$promiseAdapter->isPromise($results)) { + $results = $results->then(function ($resolvedResults) use ($process, $responseName, $path, $exeContext, $parentType, $sourceValue, $fieldNodes) { + return $process($resolvedResults, $responseName, $path, $exeContext, $parentType, $sourceValue, $fieldNodes); + }); + } else { + $results = $process($results, $responseName, $path, $exeContext, $parentType, $sourceValue, $fieldNodes); } } - // see #59 - if ([] === $results) { - $results = new \stdClass(); + + if (self::$promiseAdapter->isPromise($results)) { + return $results->then(function ($resolvedResults) { + return self::fixResultsIfEmptyArray($resolvedResults); + }); } - return $results; + + return self::fixResultsIfEmptyArray($results); } /** * Implements the "Evaluating selection sets" section of the spec * for "read" mode. + * + * @param ExecutionContext $exeContext + * @param ObjectType $parentType + * @param $source + * @param $path + * @param $fields + * @return Promise|\stdClass|array */ private static function executeFields(ExecutionContext $exeContext, ObjectType $parentType, $source, $path, $fields) { - // Native PHP doesn't support promises. - // Custom executor should be built for platforms like ReactPHP - return self::executeFieldsSerially($exeContext, $parentType, $source, $path, $fields); + $containsPromise = false; + $finalResults = []; + + foreach ($fields as $responseName => $fieldNodes) { + $fieldPath = $path; + $fieldPath[] = $responseName; + $result = self::resolveField($exeContext, $parentType, $source, $fieldNodes, $fieldPath); + if ($result === self::$UNDEFINED) { + continue; + } + if (!$containsPromise && self::$promiseAdapter->isPromise($result)) { + $containsPromise = true; + } + $finalResults[$responseName] = $result; + } + + // If there are no promises, we can just return the object + if (!$containsPromise) { + return self::fixResultsIfEmptyArray($finalResults); + } + + // Otherwise, results is a map from field name to the result + // of resolving that field, which is possibly a promise. Return + // a promise that will return this same map, but with any + // promises replaced with the values they resolved to. + return self::$promiseAdapter->createPromiseAll($finalResults)->then(function ($resolvedResults) { + return self::fixResultsIfEmptyArray($resolvedResults); + }); } + /** + * @see https://github.com/webonyx/graphql-php/issues/59 + * + * @param $results + * @return \stdClass|array + */ + private static function fixResultsIfEmptyArray($results) + { + if ([] === $results) { + $results = new \stdClass(); + } + + return $results; + } /** * Given a selectionSet, adds all of the fields in that selection to @@ -258,6 +385,12 @@ class Executor * returns and Interface or Union type, the "runtime type" will be the actual * Object type returned by that field. * + * @param ExecutionContext $exeContext + * @param ObjectType $runtimeType + * @param SelectionSetNode $selectionSet + * @param $fields + * @param $visitedFragmentNames + * * @return \ArrayObject */ private static function collectFields( @@ -322,6 +455,10 @@ class Executor /** * Determines if a field should be included based on the @include and @skip * directives, where @skip has higher precedence than @include. + * + * @param ExecutionContext $exeContext + * @param $directives + * @return bool */ private static function shouldIncludeNode(ExecutionContext $exeContext, $directives) { @@ -361,6 +498,11 @@ class Executor /** * Determines if a fragment is applicable to the given type. + * + * @param ExecutionContext $exeContext + * @param $fragment + * @param ObjectType $type + * @return bool */ private static function doesFragmentConditionMatch(ExecutionContext $exeContext,/* FragmentDefinitionNode | InlineFragmentNode*/ $fragment, ObjectType $type) { @@ -382,6 +524,9 @@ class Executor /** * Implements the logic to compute the key of a given fields entry + * + * @param FieldNode $node + * @return string */ private static function getFieldEntryKey(FieldNode $node) { @@ -393,6 +538,14 @@ class Executor * 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. + * + * @param ExecutionContext $exeContext + * @param ObjectType $parentType + * @param $source + * @param $fieldNodes + * @param $path + * + * @return array|\Exception|mixed|null */ private static function resolveField(ExecutionContext $exeContext, ObjectType $parentType, $source, $fieldNodes, $path) { @@ -501,7 +654,7 @@ class Executor * @param ResolveInfo $info * @param $path * @param $result - * @return array|null + * @return array|null|Promise */ private static function completeValueCatchingError( ExecutionContext $exeContext, @@ -528,7 +681,7 @@ class Executor // Otherwise, error protection is applied, logging the error and resolving // a null value for this field if one is encountered. try { - return self::completeValueWithLocatedError( + $completed = self::completeValueWithLocatedError( $exeContext, $returnType, $fieldNodes, @@ -536,6 +689,13 @@ class Executor $path, $result ); + if (self::$promiseAdapter->isPromise($completed)) { + return $completed->then(null, function ($error) use ($exeContext) { + $exeContext->addError($error); + return self::$promiseAdapter->createResolvedPromise(null); + }); + } + return $completed; } catch (Error $err) { // If `completeValueWithLocatedError` returned abruptly (threw an error), log the error // and return null. @@ -555,10 +715,10 @@ class Executor * @param ResolveInfo $info * @param $path * @param $result - * @return array|null + * @return array|null|Promise * @throws Error */ - static function completeValueWithLocatedError( + public static function completeValueWithLocatedError( ExecutionContext $exeContext, Type $returnType, $fieldNodes, @@ -568,7 +728,7 @@ class Executor ) { try { - return self::completeValue( + $completed = self::completeValue( $exeContext, $returnType, $fieldNodes, @@ -576,6 +736,12 @@ class Executor $path, $result ); + if (self::$promiseAdapter->isPromise($completed)) { + return $completed->then(null, function ($error) use ($fieldNodes, $path) { + return self::$promiseAdapter->createRejectedPromise(Error::createLocatedError($error, $fieldNodes, $path)); + }); + } + return $completed; } catch (\Exception $error) { throw Error::createLocatedError($error, $fieldNodes, $path); } @@ -608,7 +774,7 @@ class Executor * @param ResolveInfo $info * @param array $path * @param $result - * @return array|null + * @return array|null|Promise * @throws Error * @throws \Exception */ @@ -621,6 +787,13 @@ class Executor &$result ) { + // If result is a Promise, apply-lift over completeValue. + if (self::$promiseAdapter->isPromise($result)) { + return $result->then(function (&$resolved) use ($exeContext, $returnType, $fieldNodes, $info, $path) { + return self::completeValue($exeContext, $returnType, $fieldNodes, $info, $path, $resolved); + }); + } + if ($result instanceof \Exception) { throw $result; } @@ -677,6 +850,13 @@ class Executor * which takes the property of the source object of the same name as the field * and returns it as the result, or if it's a function, returns the result * of calling that function while passing along args and context. + * + * @param $source + * @param $args + * @param $context + * @param ResolveInfo $info + * + * @return mixed|null */ public static function defaultFieldResolver($source, $args, $context, ResolveInfo $info) { @@ -705,6 +885,10 @@ class Executor * added to the query type, but that would require mutating type * definitions, which would cause issues. * + * @param Schema $schema + * @param ObjectType $parentType + * @param $fieldName + * * @return FieldDefinition */ private static function getFieldDef(Schema $schema, ObjectType $parentType, $fieldName) @@ -781,7 +965,7 @@ class Executor * @param ResolveInfo $info * @param array $path * @param $result - * @return array + * @return array|Promise * @throws \Exception */ private static function completeListValue(ExecutionContext $exeContext, ListOfType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) @@ -791,15 +975,20 @@ class Executor is_array($result) || $result instanceof \Traversable, 'User Error: expected iterable, but did not find one for field ' . $info->parentType . '.' . $info->fieldName . '.' ); + $containsPromise = false; $i = 0; - $tmp = []; + $completedItems = []; foreach ($result as $item) { $fieldPath = $path; $fieldPath[] = $i++; - $tmp[] = self::completeValueCatchingError($exeContext, $itemType, $fieldNodes, $info, $fieldPath, $item); + $completedItem = self::completeValueCatchingError($exeContext, $itemType, $fieldNodes, $info, $fieldPath, $item); + if (!$containsPromise && self::$promiseAdapter->isPromise($completedItem)) { + $containsPromise = true; + } + $completedItems[] = $completedItem; } - return $tmp; + return $containsPromise ? self::$promiseAdapter->createPromiseAll($completedItems) : $completedItems; } /** @@ -832,7 +1021,7 @@ class Executor * @param ResolveInfo $info * @param array $path * @param $result - * @return array + * @return array|Promise|\stdClass * @throws Error */ private static function completeObjectValue(ExecutionContext $exeContext, ObjectType $returnType, $fieldNodes, ResolveInfo $info, $path, &$result) @@ -888,7 +1077,13 @@ class Executor } /** - * @deprecated as of 8.0 + * @deprecated as of v0.8.0 should use self::defaultFieldResolver method + * + * @param $source + * @param $args + * @param $context + * @param ResolveInfo $info + * @return mixed|null */ public static function defaultResolveFn($source, $args, $context, ResolveInfo $info) { @@ -897,7 +1092,9 @@ class Executor } /** - * @deprecated as of 8.0 + * @deprecated as of v0.8.0 should use self::setDefaultFieldResolver method + * + * @param callable $fn */ public static function setDefaultResolveFn($fn) { diff --git a/src/Executor/Promise/Adapter/GenericPromiseAdapter.php b/src/Executor/Promise/Adapter/GenericPromiseAdapter.php new file mode 100644 index 0000000..a717984 --- /dev/null +++ b/src/Executor/Promise/Adapter/GenericPromiseAdapter.php @@ -0,0 +1,33 @@ +|null $variableValues * @param string|null $operationName - * @return array + * @return Promise|array */ public static function execute(Schema $schema, $requestString, $rootValue = null, $contextValue = null, $variableValues = null, $operationName = null) { - return self::executeAndReturnResult($schema, $requestString, $rootValue, $contextValue, $variableValues, $operationName)->toArray(); + $result = self::executeAndReturnResult($schema, $requestString, $rootValue, $contextValue, $variableValues, $operationName); + + return $result instanceof ExecutionResult ? $result->toArray() : $result->then(function(ExecutionResult $executionResult) { return $executionResult->toArray(); }); } /** @@ -32,7 +36,7 @@ class GraphQL * @param null $rootValue * @param null $variableValues * @param null $operationName - * @return array|ExecutionResult + * @return ExecutionResult|Promise */ public static function executeAndReturnResult(Schema $schema, $requestString, $rootValue = null, $contextValue = null, $variableValues = null, $operationName = null) { @@ -67,4 +71,12 @@ class GraphQL { return array_values(Directive::getInternalDirectives()); } + + /** + * @param PromiseAdapter|null $promiseAdapter + */ + public static function setPromiseAdapter(PromiseAdapter $promiseAdapter = null) + { + Executor::setPromiseAdapter($promiseAdapter); + } } diff --git a/tests/Executor/ExecutorTest.php b/tests/Executor/ExecutorTest.php index 30f7e6a..335e6ab 100644 --- a/tests/Executor/ExecutorTest.php +++ b/tests/Executor/ExecutorTest.php @@ -4,8 +4,10 @@ namespace GraphQL\Tests\Executor; require_once __DIR__ . '/TestClasses.php'; use GraphQL\Error\Error; +use GraphQL\Executor\ExecutionResult; use GraphQL\Executor\Executor; use GraphQL\Error\FormattedError; +use GraphQL\Executor\Promise\Adapter\ReactPromiseAdapter; use GraphQL\Language\Parser; use GraphQL\Language\SourceLocation; use GraphQL\Schema; @@ -16,9 +18,15 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use GraphQL\Type\Definition\UnionType; use GraphQL\Utils; +use React\Promise\Promise; class ExecutorTest extends \PHPUnit_Framework_TestCase { + public function tearDown() + { + Executor::setPromiseAdapter(null); + } + // Execute: Handles basic execution tasks /** @@ -26,7 +34,16 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase */ public function testExecutesArbitraryCode() { + Executor::setPromiseAdapter(new ReactPromiseAdapter()); $deepData = null; + $data = null; + + $promiseData = function () use (&$data) { + return new Promise(function (callable $resolve) use (&$data) { + return $resolve($data); + }); + }; + $data = [ 'a' => function () { return 'Apple';}, 'b' => function () {return 'Banana';}, @@ -37,8 +54,8 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase 'pic' => function ($size = 50) { return 'Pic of size: ' . $size; }, - 'promise' => function() use (&$data) { - return $data; + 'promise' => function() use ($promiseData) { + return $promiseData(); }, 'deep' => function () use (&$deepData) { return $deepData; @@ -51,7 +68,7 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase 'c' => function () { return ['Contrived', null, 'Confusing']; }, - 'deeper' => function () use ($data) { + 'deeper' => function () use (&$data) { return [$data, null, $data]; } ]; @@ -148,7 +165,7 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase ]); $schema = new Schema(['query' => $dataType]); - $this->assertEquals($expected, Executor::execute($schema, $ast, $data, null, ['size' => 100], 'Example')->toArray()); + $this->assertEquals($expected, self::awaitPromise(Executor::execute($schema, $ast, $data, null, ['size' => 100], 'Example'))); } /** @@ -342,12 +359,18 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase public function testNullsOutErrorSubtrees() { $doc = '{ - sync, - syncError, - syncRawError, - async, - asyncReject, + sync + syncError + syncRawError + syncReturnError + syncReturnErrorList + async + asyncReject + asyncRawReject + asyncEmptyReject asyncError + asyncRawError + asyncReturnError }'; $data = [ @@ -360,17 +383,42 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase 'syncRawError' => function() { throw new \Exception('Error getting syncRawError'); }, - // Following are inherited from JS reference implementation, but make no sense in this PHP impl - // leaving them just to simplify migrations from newer js versions + // inherited from JS reference implementation, but make no sense in this PHP impl + // leaving it just to simplify migrations from newer js versions + 'syncReturnError' => function() { + return new \Exception('Error getting syncReturnError'); + }, + 'syncReturnErrorList' => function () { + return [ + 'sync0', + new \Exception('Error getting syncReturnErrorList1'), + 'sync2', + new \Exception('Error getting syncReturnErrorList3') + ]; + }, 'async' => function() { - return 'async'; + return new Promise(function(callable $resolve) { return $resolve('async'); }); }, 'asyncReject' => function() { - throw new \Exception('Error getting asyncReject'); + return new Promise(function($_, callable $reject) { return $reject('Error getting asyncReject'); }); + }, + 'asyncRawReject' => function () { + return \React\Promise\reject('Error getting asyncRawReject'); + }, + 'asyncEmptyReject' => function () { + return \React\Promise\reject(); }, 'asyncError' => function() { - throw new \Exception('Error getting asyncError'); - } + return new Promise(function() { throw new \Exception('Error getting asyncError'); }); + }, + // inherited from JS reference implementation, but make no sense in this PHP impl + // leaving it just to simplify migrations from newer js versions + 'asyncRawError' => function() { + return new Promise(function() { throw new \Exception('Error getting asyncRawError'); }); + }, + 'asyncReturnError' => function() { + return \React\Promise\resolve(new \Exception('Error getting asyncReturnError')); + }, ]; $docAst = Parser::parse($doc); @@ -380,10 +428,16 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase 'fields' => [ 'sync' => ['type' => Type::string()], 'syncError' => ['type' => Type::string()], - 'syncRawError' => [ 'type' => Type::string() ], + 'syncRawError' => ['type' => Type::string()], + 'syncReturnError' => ['type' => Type::string()], + 'syncReturnErrorList' => ['type' => Type::listOf(Type::string())], 'async' => ['type' => Type::string()], 'asyncReject' => ['type' => Type::string() ], + 'asyncRawReject' => ['type' => Type::string() ], + 'asyncEmptyReject' => ['type' => Type::string() ], 'asyncError' => ['type' => Type::string()], + 'asyncRawError' => ['type' => Type::string()], + 'asyncReturnError' => ['type' => Type::string()], ] ]) ]); @@ -393,21 +447,79 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase 'sync' => 'sync', 'syncError' => null, 'syncRawError' => null, + 'syncReturnError' => null, + 'syncReturnErrorList' => ['sync0', null, 'sync2', null], 'async' => 'async', 'asyncReject' => null, + 'asyncRawReject' => null, + 'asyncEmptyReject' => null, 'asyncError' => null, + 'asyncRawError' => null, + 'asyncReturnError' => null, ], 'errors' => [ - FormattedError::create('Error getting syncError', [new SourceLocation(3, 7)]), - FormattedError::create('Error getting syncRawError', [new SourceLocation(4, 7)]), - FormattedError::create('Error getting asyncReject', [new SourceLocation(6, 7)]), - FormattedError::create('Error getting asyncError', [new SourceLocation(7, 7)]) + [ + 'message' => 'Error getting syncError', + 'locations' => [['line' => 3, 'column' => 7]], + 'path' => ['syncError'] + ], + [ + 'message' => 'Error getting syncRawError', + 'locations' => [ [ 'line' => 4, 'column' => 7 ] ], + 'path'=> [ 'syncRawError' ] + ], + [ + 'message' => 'Error getting syncReturnError', + 'locations' => [['line' => 5, 'column' => 7]], + 'path' => ['syncReturnError'] + ], + [ + 'message' => 'Error getting syncReturnErrorList1', + 'locations' => [['line' => 6, 'column' => 7]], + 'path' => ['syncReturnErrorList', 1] + ], + [ + 'message' => 'Error getting syncReturnErrorList3', + 'locations' => [['line' => 6, 'column' => 7]], + 'path' => ['syncReturnErrorList', 3] + ], + [ + 'message' => 'Error getting asyncReject', + 'locations' => [['line' => 8, 'column' => 7]], + 'path' => ['asyncReject'] + ], + [ + 'message' => 'Error getting asyncRawReject', + 'locations' => [['line' => 9, 'column' => 7]], + 'path' => ['asyncRawReject'] + ], + [ + 'message' => 'An unknown error occurred.', + 'locations' => [['line' => 10, 'column' => 7]], + 'path' => ['asyncEmptyReject'] + ], + [ + 'message' => 'Error getting asyncError', + 'locations' => [['line' => 11, 'column' => 7]], + 'path' => ['asyncError'] + ], + [ + 'message' => 'Error getting asyncRawError', + 'locations' => [ [ 'line' => 12, 'column' => 7 ] ], + 'path' => [ 'asyncRawError' ] + ], + [ + 'message' => 'Error getting asyncReturnError', + 'locations' => [['line' => 13, 'column' => 7]], + 'path' => ['asyncReturnError'] + ], ] ]; + Executor::setPromiseAdapter(new ReactPromiseAdapter()); $result = Executor::execute($schema, $docAst, $data); - $this->assertArraySubset($expected, $result->toArray()); + $this->assertEquals($expected, self::awaitPromise($result)); } /** @@ -629,6 +741,62 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase $this->assertEquals(['data' => ['a' => 'b']], $subscriptionResult->toArray()); } + public function testCorrectFieldOrderingDespiteExecutionOrder() + { + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + + $doc = '{ + a, + b, + c, + d, + e + }'; + $data = [ + 'a' => function () { + return 'a'; + }, + 'b' => function () { + return new Promise(function (callable $resolve) { return $resolve('b'); }); + }, + 'c' => function () { + return 'c'; + }, + 'd' => function () { + return new Promise(function (callable $resolve) { return $resolve('d'); }); + }, + 'e' => function () { + return 'e'; + }, + ]; + + $ast = Parser::parse($doc); + + $queryType = new ObjectType([ + 'name' => 'DeepDataType', + 'fields' => [ + 'a' => [ 'type' => Type::string() ], + 'b' => [ 'type' => Type::string() ], + 'c' => [ 'type' => Type::string() ], + 'd' => [ 'type' => Type::string() ], + 'e' => [ 'type' => Type::string() ], + ] + ]); + $schema = new Schema(['query' => $queryType]); + + $expected = [ + 'data' => [ + 'a' => 'a', + 'b' => 'b', + 'c' => 'c', + 'd' => 'd', + 'e' => 'e', + ] + ]; + + $this->assertEquals($expected, self::awaitPromise(Executor::execute($schema, $ast, $data))); + } + /** * @it Avoids recursion */ @@ -923,4 +1091,17 @@ class ExecutorTest extends \PHPUnit_Framework_TestCase ] ], $result->toArray()); } + + /** + * @param \GraphQL\Executor\Promise\Promise $promise + * @return array + */ + private static function awaitPromise($promise) + { + $results = null; + $promise->then(function (ExecutionResult $executionResult) use (&$results) { + $results = $executionResult->toArray(); + }); + return $results; + } } diff --git a/tests/Executor/ListsTest.php b/tests/Executor/ListsTest.php index 1cad0e8..dbcffa5 100644 --- a/tests/Executor/ListsTest.php +++ b/tests/Executor/ListsTest.php @@ -1,9 +1,11 @@ checkHandlesNullableLists( + [ 1, 2 ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesNullableLists( + [ 1, null, 2 ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, null, 2 ] ] ] ] + ); + + // Returns null + $this->checkHandlesNullableLists( + null, + [ 'data' => [ 'nest' => [ 'test' => null ] ] ] + ); + } + + /** + * @describe [T] + */ + public function testHandlesNullableListsWithPromiseArray() + { + // Contains values + $this->checkHandlesNullableLists( + \React\Promise\resolve([ 1, 2 ]), + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesNullableLists( + \React\Promise\resolve([ 1, null, 2 ]), + [ 'data' => [ 'nest' => [ 'test' => [ 1, null, 2 ] ] ] ] + ); + + // Returns null + $this->checkHandlesNullableLists( + \React\Promise\resolve(null), + [ 'data' => [ 'nest' => [ 'test' => null ] ] ] + ); + + // Rejected + $this->checkHandlesNullableLists( + function () { + return \React\Promise\reject(new \Exception('bad')); + }, + [ + 'data' => ['nest' => ['test' => null]], + 'errors' => [ + [ + 'message' => 'bad', + 'locations' => [['line' => 1, 'column' => 10]], + 'path' => ['nest', 'test'] + ] + ] + ] + ); + } + + /** + * @describe [T] + */ + public function testHandlesNullableListsWithArrayPromise() + { + // Contains values + $this->checkHandlesNullableLists( + [ \React\Promise\resolve(1), \React\Promise\resolve(2) ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesNullableLists( + [ \React\Promise\resolve(1), \React\Promise\resolve(null), \React\Promise\resolve(2) ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, null, 2 ] ] ] ] + ); + + // Returns null + $this->checkHandlesNullableLists( + \React\Promise\resolve(null), + [ 'data' => [ 'nest' => [ 'test' => null ] ] ] + ); + + // Contains reject + $this->checkHandlesNullableLists( + function () { + return [ \React\Promise\resolve(1), \React\Promise\reject(new \Exception('bad')), \React\Promise\resolve(2) ]; + }, + [ + 'data' => ['nest' => ['test' => [1, null, 2]]], + 'errors' => [ + [ + 'message' => 'bad', + 'locations' => [['line' => 1, 'column' => 10]], + 'path' => ['nest', 'test', 1] + ] + ] + ] + ); + } + + /** + * @describe [T]! + */ + public function testHandlesNonNullableListsWithArray() + { + // Contains values + $this->checkHandlesNonNullableLists( + [ 1, 2 ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesNonNullableLists( + [ 1, null, 2 ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, null, 2 ] ] ] ] + ); + + // Returns null + $this->checkHandlesNonNullableLists( + null, + [ + 'data' => [ 'nest' => null ], + 'errors' => [ + FormattedError::create( + 'Cannot return null for non-nullable field DataType.test.', + [ new SourceLocation(1, 10) ] + ) + ] + ] + ); + } + + /** + * @describe [T]! + */ + public function testHandlesNonNullableListsWithPromiseArray() + { + // Contains values + $this->checkHandlesNonNullableLists( + \React\Promise\resolve([ 1, 2 ]), + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesNonNullableLists( + \React\Promise\resolve([ 1, null, 2 ]), + [ 'data' => [ 'nest' => [ 'test' => [ 1, null, 2 ] ] ] ] + ); + + // Returns null + $this->checkHandlesNonNullableLists( + null, + [ + 'data' => [ 'nest' => null ], + 'errors' => [ + FormattedError::create( + 'Cannot return null for non-nullable field DataType.test.', + [ new SourceLocation(1, 10) ] + ) + ] + ] + ); + + // Rejected + $this->checkHandlesNonNullableLists( + function () { + return \React\Promise\reject(new \Exception('bad')); + }, + [ + 'data' => ['nest' => null], + 'errors' => [ + [ + 'message' => 'bad', + 'locations' => [['line' => 1, 'column' => 10]], + 'path' => ['nest', 'test'] + ] + ] + ] + ); + } + + /** + * @describe [T]! + */ + public function testHandlesNonNullableListsWithArrayPromise() + { + // Contains values + $this->checkHandlesNonNullableLists( + [ \React\Promise\resolve(1), \React\Promise\resolve(2) ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesNonNullableLists( + [ \React\Promise\resolve(1), \React\Promise\resolve(null), \React\Promise\resolve(2) ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, null, 2 ] ] ] ] + ); + + // Contains reject + $this->checkHandlesNonNullableLists( + function () { + return [ \React\Promise\resolve(1), \React\Promise\reject(new \Exception('bad')), \React\Promise\resolve(2) ]; + }, + [ + 'data' => ['nest' => ['test' => [1, null, 2]]], + 'errors' => [ + [ + 'message' => 'bad', + 'locations' => [['line' => 1, 'column' => 10]], + 'path' => ['nest', 'test', 1] + ] + ] + ] + ); + } + + /** + * @describe [T!] + */ + public function testHandlesListOfNonNullsWithArray() + { + // Contains values + $this->checkHandlesListOfNonNulls( + [ 1, 2 ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesListOfNonNulls( + [ 1, null, 2 ], + [ + 'data' => [ 'nest' => [ 'test' => null ] ], + 'errors' => [ + FormattedError::create( + 'Cannot return null for non-nullable field DataType.test.', + [ new SourceLocation(1, 10) ] + ) + ] + ] + ); + + // Returns null + $this->checkHandlesListOfNonNulls( + null, + [ 'data' => [ 'nest' => [ 'test' => null ] ] ] + ); + } + + /** + * @describe [T!] + */ + public function testHandlesListOfNonNullsWithPromiseArray() + { + // Contains values + $this->checkHandlesListOfNonNulls( + \React\Promise\resolve([ 1, 2 ]), + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesListOfNonNulls( + \React\Promise\resolve([ 1, null, 2 ]), + [ + 'data' => [ 'nest' => [ 'test' => null ] ], + 'errors' => [ + FormattedError::create( + 'Cannot return null for non-nullable field DataType.test.', + [ new SourceLocation(1, 10) ] + ) + ] + ] + ); + + // Returns null + $this->checkHandlesListOfNonNulls( + \React\Promise\resolve(null), + [ 'data' => [ 'nest' => [ 'test' => null ] ] ] + ); + + // Rejected + $this->checkHandlesListOfNonNulls( + function () { + return \React\Promise\reject(new \Exception('bad')); + }, + [ + 'data' => ['nest' => ['test' => null]], + 'errors' => [ + [ + 'message' => 'bad', + 'locations' => [['line' => 1, 'column' => 10]], + 'path' => ['nest', 'test'] + ] + ] + ] + ); + } + + /** + * @describe [T]! + */ + public function testHandlesListOfNonNullsWithArrayPromise() + { + // Contains values + $this->checkHandlesListOfNonNulls( + [ \React\Promise\resolve(1), \React\Promise\resolve(2) ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesListOfNonNulls( + [ \React\Promise\resolve(1), \React\Promise\resolve(null), \React\Promise\resolve(2) ], + [ 'data' => [ 'nest' => [ 'test' => null ] ] ] + ); + + // Contains reject + $this->checkHandlesListOfNonNulls( + function () { + return [ \React\Promise\resolve(1), \React\Promise\reject(new \Exception('bad')), \React\Promise\resolve(2) ]; + }, + [ + 'data' => ['nest' => ['test' => null]], + 'errors' => [ + [ + 'message' => 'bad', + 'locations' => [['line' => 1, 'column' => 10]], + 'path' => ['nest', 'test', 1] + ] + ] + ] + ); + } + + /** + * @describe [T!]! + */ + public function testHandlesNonNullListOfNonNullsWithArray() + { + // Contains values + $this->checkHandlesNonNullListOfNonNulls( + [ 1, 2 ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + + // Contains null + $this->checkHandlesNonNullListOfNonNulls( + [ 1, null, 2 ], + [ + 'data' => [ 'nest' => null ], + 'errors' => [ + FormattedError::create( + 'Cannot return null for non-nullable field DataType.test.', + [ new SourceLocation(1, 10) ] + ) + ] + ] + ); + + // Returns null + $this->checkHandlesNonNullListOfNonNulls( + null, + [ + 'data' => [ 'nest' => null ], + 'errors' => [ + FormattedError::create( + 'Cannot return null for non-nullable field DataType.test.', + [ new SourceLocation(1, 10) ] + ) + ] + ] + ); + } + + /** + * @describe [T!]! + */ + public function testHandlesNonNullListOfNonNullsWithPromiseArray() + { + // Contains values + $this->checkHandlesNonNullListOfNonNulls( + \React\Promise\resolve([ 1, 2 ]), + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesNonNullListOfNonNulls( + \React\Promise\resolve([ 1, null, 2 ]), + [ + 'data' => [ 'nest' => null ], + 'errors' => [ + FormattedError::create( + 'Cannot return null for non-nullable field DataType.test.', + [ new SourceLocation(1, 10) ] + ) + ] + ] + ); + + // Returns null + $this->checkHandlesNonNullListOfNonNulls( + \React\Promise\resolve(null), + [ + 'data' => [ 'nest' => null ], + 'errors' => [ + FormattedError::create( + 'Cannot return null for non-nullable field DataType.test.', + [ new SourceLocation(1, 10) ] + ) + ] + ] + ); + + // Rejected + $this->checkHandlesNonNullListOfNonNulls( + function () { + return \React\Promise\reject(new \Exception('bad')); + }, + [ + 'data' => ['nest' => null ], + 'errors' => [ + [ + 'message' => 'bad', + 'locations' => [['line' => 1, 'column' => 10]], + 'path' => ['nest', 'test'] + ] + ] + ] + ); + } + + /** + * @describe [T!]! + */ + public function testHandlesNonNullListOfNonNullsWithArrayPromise() + { + // Contains values + $this->checkHandlesNonNullListOfNonNulls( + [ \React\Promise\resolve(1), \React\Promise\resolve(2) ], + [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] + ); + + // Contains null + $this->checkHandlesNonNullListOfNonNulls( + [ \React\Promise\resolve(1), \React\Promise\resolve(null), \React\Promise\resolve(2) ], + [ + 'data' => [ 'nest' => null ], + 'errors' => [ + FormattedError::create( + 'Cannot return null for non-nullable field DataType.test.', + [ new SourceLocation(1, 10) ] + ) + ] + ] + ); + + // Contains reject + $this->checkHandlesNonNullListOfNonNulls( + function () { + return [ \React\Promise\resolve(1), \React\Promise\reject(new \Exception('bad')), \React\Promise\resolve(2) ]; + }, + [ + 'data' => ['nest' => null ], + 'errors' => [ + [ + 'message' => 'bad', + 'locations' => [['line' => 1, 'column' => 10]], + 'path' => ['nest', 'test'] + ] + ] + ] + ); + } + + private function checkHandlesNullableLists($testData, $expected) + { + $testType = Type::listOf(Type::int());; + $this->check($testType, $testData, $expected); + } + + private function checkHandlesNonNullableLists($testData, $expected) + { + $testType = Type::nonNull(Type::listOf(Type::int())); + $this->check($testType, $testData, $expected); + } + + private function checkHandlesListOfNonNulls($testData, $expected) + { + $testType = Type::listOf(Type::nonNull(Type::int())); + $this->check($testType, $testData, $expected); + } + + public function checkHandlesNonNullListOfNonNulls($testData, $expected) + { + $testType = Type::nonNull(Type::listOf(Type::nonNull(Type::int()))); + $this->check($testType, $testData, $expected); + } + private function check($testType, $testData, $expected) { $data = ['test' => $testData]; @@ -41,157 +558,19 @@ class ListsTest extends \PHPUnit_Framework_TestCase $ast = Parser::parse('{ nest { test } }'); $result = Executor::execute($schema, $ast, $data); - $this->assertArraySubset($expected, $result->toArray()); - } - - // Describe: Execute: Handles list nullability - - /** - * @describe [T] - */ - public function testHandlesNullableLists() - { - $type = Type::listOf(Type::int()); - - // Contains values - $this->check( - $type, - [ 1, 2 ], - [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] - ); - - // Contains null - $this->check( - $type, - [ 1, null, 2 ], - [ 'data' => [ 'nest' => [ 'test' => [ 1, null, 2 ] ] ] ] - ); - - // Returns null - $this->check( - $type, - null, - [ 'data' => [ 'nest' => [ 'test' => null ] ] ] - ); + $this->assertArraySubset($expected, self::awaitPromise($result)); } /** - * @describe [T]! + * @param \GraphQL\Executor\Promise\Promise $promise + * @return array */ - public function testHandlesNonNullableLists() + private static function awaitPromise($promise) { - $type = Type::nonNull(Type::listOf(Type::int())); - - // Contains values - $this->check( - $type, - [ 1, 2 ], - [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] - ); - - // Contains null - $this->check( - $type, - [ 1, null, 2 ], - [ 'data' => [ 'nest' => [ 'test' => [ 1, null, 2 ] ] ] ] - ); - - // Returns null - $this->check( - $type, - null, - [ - 'data' => [ 'nest' => null ], - 'errors' => [ - FormattedError::create( - 'Cannot return null for non-nullable field DataType.test.', - [ new SourceLocation(1, 10) ] - ) - ] - ] - ); - } - - /** - * @describe [T!] - */ - public function testHandlesListOfNonNulls() - { - $type = Type::listOf(Type::nonNull(Type::int())); - - // Contains values - $this->check( - $type, - [ 1, 2 ], - [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] - ); - - // Contains null - $this->check( - $type, - [ 1, null, 2 ], - [ - 'data' => [ 'nest' => [ 'test' => null ] ], - 'errors' => [ - FormattedError::create( - 'Cannot return null for non-nullable field DataType.test.', - [ new SourceLocation(1, 10) ] - ) - ] - ] - ); - - // Returns null - $this->check( - $type, - null, - [ 'data' => [ 'nest' => [ 'test' => null ] ] ] - ); - } - - /** - * @describe [T!]! - */ - public function testHandlesNonNullListOfNonNulls() - { - $type = Type::nonNull(Type::listOf(Type::nonNull(Type::int()))); - - // Contains values - $this->check( - $type, - [ 1, 2 ], - [ 'data' => [ 'nest' => [ 'test' => [ 1, 2 ] ] ] ] - ); - - - // Contains null - $this->check( - $type, - [ 1, null, 2 ], - [ - 'data' => [ 'nest' => null ], - 'errors' => [ - FormattedError::create( - 'Cannot return null for non-nullable field DataType.test.', - [ new SourceLocation(1, 10) ] - ) - ] - ] - ); - - // Returns null - $this->check( - $type, - null, - [ - 'data' => [ 'nest' => null ], - 'errors' => [ - FormattedError::create( - 'Cannot return null for non-nullable field DataType.test.', - [ new SourceLocation(1, 10) ] - ) - ] - ] - ); + $results = null; + $promise->then(function (ExecutionResult $executionResult) use (&$results) { + $results = $executionResult->toArray(); + }); + return $results; } } diff --git a/tests/Executor/MutationsTest.php b/tests/Executor/MutationsTest.php index 2dadc7b..3a0fc71 100644 --- a/tests/Executor/MutationsTest.php +++ b/tests/Executor/MutationsTest.php @@ -1,16 +1,29 @@ assertEquals($expected, $mutationResult->toArray()); + $this->assertEquals($expected, self::awaitPromise($mutationResult)); } /** @@ -114,7 +127,20 @@ class MutationsTest extends \PHPUnit_Framework_TestCase ) ] ]; - $this->assertArraySubset($expected, $mutationResult->toArray()); + $this->assertArraySubset($expected, self::awaitPromise($mutationResult)); + } + + /** + * @param \GraphQL\Executor\Promise\Promise $promise + * @return array + */ + private static function awaitPromise($promise) + { + $results = null; + $promise->then(function (ExecutionResult $executionResult) use (&$results) { + $results = $executionResult->toArray(); + }); + return $results; } private function schema() @@ -200,12 +226,14 @@ class Root { /** * @param $newNumber - * @return NumberHolder + * + * @return Promise */ public function promiseToChangeTheNumber($newNumber) { - // No promises - return $this->immediatelyChangeTheNumber($newNumber); + return new Promise(function (callable $resolve) use ($newNumber) { + return $resolve($this->immediatelyChangeTheNumber($newNumber)); + }); } /** @@ -217,11 +245,12 @@ class Root { } /** - * @throws \Exception + * @return Promise */ public function promiseAndFailToChangeTheNumber() { - // No promises - throw new \Exception("Cannot change the number"); + return new Promise(function (callable $resolve, callable $reject) { + return $reject(new \Exception("Cannot change the number")); + }); } } diff --git a/tests/Executor/NonNullTest.php b/tests/Executor/NonNullTest.php index 6aa147f..976cf80 100644 --- a/tests/Executor/NonNullTest.php +++ b/tests/Executor/NonNullTest.php @@ -1,14 +1,17 @@ syncError = new \Exception('sync'); $this->nonNullSyncError = new \Exception('nonNullSync'); + $this->promiseError = new \Exception('promise'); + $this->nonNullPromiseError = new \Exception('nonNullPromise'); $this->throwingData = [ 'sync' => function () { @@ -33,12 +45,32 @@ class NonNullTest extends \PHPUnit_Framework_TestCase 'nonNullSync' => function () { throw $this->nonNullSyncError; }, + 'promise' => function () { + return new Promise(function () { + throw $this->promiseError; + }); + }, + 'nonNullPromise' => function () { + return new Promise(function () { + throw $this->nonNullPromiseError; + }); + }, 'nest' => function () { return $this->throwingData; }, 'nonNullNest' => function () { return $this->throwingData; }, + 'promiseNest' => function () { + return new Promise(function (callable $resolve) { + $resolve($this->throwingData); + }); + }, + 'nonNullPromiseNest' => function () { + return new Promise(function (callable $resolve) { + $resolve($this->throwingData); + }); + }, ]; $this->nullingData = [ @@ -48,12 +80,32 @@ class NonNullTest extends \PHPUnit_Framework_TestCase 'nonNullSync' => function () { return null; }, + 'promise' => function () { + return new Promise(function (callable $resolve) { + return $resolve(null); + }); + }, + 'nonNullPromise' => function () { + return new Promise(function (callable $resolve) { + return $resolve(null); + }); + }, 'nest' => function () { return $this->nullingData; }, 'nonNullNest' => function () { return $this->nullingData; }, + 'promiseNest' => function () { + return new Promise(function (callable $resolve) { + $resolve($this->nullingData); + }); + }, + 'nonNullPromiseNest' => function () { + return new Promise(function (callable $resolve) { + $resolve($this->nullingData); + }); + }, ]; $dataType = new ObjectType([ @@ -62,8 +114,12 @@ class NonNullTest extends \PHPUnit_Framework_TestCase return [ 'sync' => ['type' => Type::string()], 'nonNullSync' => ['type' => Type::nonNull(Type::string())], + 'promise' => Type::string(), + 'nonNullPromise' => Type::nonNull(Type::string()), 'nest' => $dataType, - 'nonNullNest' => Type::nonNull($dataType) + 'nonNullNest' => Type::nonNull($dataType), + 'promiseNest' => $dataType, + 'nonNullPromiseNest' => Type::nonNull($dataType), ]; } ]); @@ -71,6 +127,11 @@ class NonNullTest extends \PHPUnit_Framework_TestCase $this->schema = new Schema(['query' => $dataType]); } + public function tearDown() + { + Executor::setPromiseAdapter(null); + } + // Execute: handles non-nullable types /** @@ -100,6 +161,32 @@ class NonNullTest extends \PHPUnit_Framework_TestCase $this->assertArraySubset($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')->toArray()); } + public function testNullsANullableFieldThatThrowsInAPromise() + { + $doc = ' + query Q { + promise + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'promise' => null, + ], + 'errors' => [ + FormattedError::create( + $this->promiseError->getMessage(), + [new SourceLocation(3, 9)] + ) + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')); + } + public function testNullsASynchronouslyReturnedObjectThatContainsANonNullableFieldThatThrowsSynchronously() { // nulls a synchronously returned object that contains a non-nullable field that throws synchronously @@ -124,18 +211,111 @@ class NonNullTest extends \PHPUnit_Framework_TestCase $this->assertArraySubset($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')->toArray()); } + public function testNullsAsynchronouslyReturnedObjectThatContainsANonNullableFieldThatThrowsInAPromise() + { + $doc = ' + query Q { + nest { + nonNullPromise, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => null + ], + 'errors' => [ + FormattedError::create($this->nonNullPromiseError->getMessage(), [new SourceLocation(4, 11)]) + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')); + } + + public function testNullsAnObjectReturnedInAPromiseThatContainsANonNullableFieldThatThrowsSynchronously() + { + $doc = ' + query Q { + promiseNest { + nonNullSync, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'promiseNest' => null + ], + 'errors' => [ + FormattedError::create($this->nonNullSyncError->getMessage(), [new SourceLocation(4, 11)]) + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')); + } + + public function testNullsAnObjectReturnedInAPromiseThatContainsANonNullableFieldThatThrowsInAPromise() + { + $doc = ' + query Q { + promiseNest { + nonNullPromise, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'promiseNest' => null + ], + 'errors' => [ + FormattedError::create($this->nonNullPromiseError->getMessage(), [new SourceLocation(4, 11)]) + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')); + } + public function testNullsAComplexTreeOfNullableFieldsThatThrow() { $doc = ' query Q { nest { sync + promise nest { sync + promise + } + promiseNest { + sync + promise + } + } + promiseNest { + sync + promise + nest { + sync + promise + } + promiseNest { + sync + promise } } } - '; + '; $ast = Parser::parse($doc); @@ -143,17 +323,119 @@ class NonNullTest extends \PHPUnit_Framework_TestCase 'data' => [ 'nest' => [ 'sync' => null, + 'promise' => null, 'nest' => [ 'sync' => null, - ] - ] + 'promise' => null, + ], + 'promiseNest' => [ + 'sync' => null, + 'promise' => null, + ], + ], + 'promiseNest' => [ + 'sync' => null, + 'promise' => null, + 'nest' => [ + 'sync' => null, + 'promise' => null, + ], + 'promiseNest' => [ + 'sync' => null, + 'promise' => null, + ], + ], ], 'errors' => [ FormattedError::create($this->syncError->getMessage(), [new SourceLocation(4, 11)]), - FormattedError::create($this->syncError->getMessage(), [new SourceLocation(6, 13)]), + FormattedError::create($this->promiseError->getMessage(), [new SourceLocation(5, 11)]), + FormattedError::create($this->syncError->getMessage(), [new SourceLocation(7, 13)]), + FormattedError::create($this->promiseError->getMessage(), [new SourceLocation(8, 13)]), + FormattedError::create($this->syncError->getMessage(), [new SourceLocation(11, 13)]), + FormattedError::create($this->promiseError->getMessage(), [new SourceLocation(12, 13)]), + FormattedError::create($this->syncError->getMessage(), [new SourceLocation(16, 11)]), + FormattedError::create($this->promiseError->getMessage(), [new SourceLocation(17, 11)]), + FormattedError::create($this->syncError->getMessage(), [new SourceLocation(19, 13)]), + FormattedError::create($this->promiseError->getMessage(), [new SourceLocation(20, 13)]), + FormattedError::create($this->syncError->getMessage(), [new SourceLocation(23, 13)]), + FormattedError::create($this->promiseError->getMessage(), [new SourceLocation(24, 13)]), ] ]; - $this->assertArraySubset($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')->toArray()); + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')); + } + + public function testNullsTheFirstNullableObjectAfterAFieldThrowsInALongChainOfFieldsThatAreNonNull() + { + $doc = ' + query Q { + nest { + nonNullNest { + nonNullPromiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullSync + } + } + } + } + } + promiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullSync + } + } + } + } + } + anotherNest: nest { + nonNullNest { + nonNullPromiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullPromise + } + } + } + } + } + anotherPromiseNest: promiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullPromise + } + } + } + } + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => null, + 'promiseNest' => null, + 'anotherNest' => null, + 'anotherPromiseNest' => null, + ], + 'errors' => [ + FormattedError::create($this->nonNullSyncError->getMessage(), [new SourceLocation(8, 19)]), + FormattedError::create($this->nonNullSyncError->getMessage(), [new SourceLocation(19, 19)]), + FormattedError::create($this->nonNullPromiseError->getMessage(), [new SourceLocation(30, 19)]), + FormattedError::create($this->nonNullPromiseError->getMessage(), [new SourceLocation(41, 19)]), + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')); } public function testNullsANullableFieldThatSynchronouslyReturnsNull() @@ -174,9 +456,28 @@ class NonNullTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')->toArray()); } - public function test4() + public function testNullsANullableFieldThatReturnsNullInAPromise() + { + $doc = ' + query Q { + promise + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'promise' => null, + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')); + } + + public function testNullsASynchronouslyReturnedObjectThatContainsANonNullableFieldThatReturnsNullSynchronously() { - // nulls a synchronously returned object that contains a non-nullable field that returns null synchronously $doc = ' query Q { nest { @@ -198,19 +499,107 @@ class NonNullTest extends \PHPUnit_Framework_TestCase $this->assertArraySubset($expected, Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')->toArray()); } - public function test5() + public function testNullsASynchronouslyReturnedObjectThatContainsANonNullableFieldThatReturnsNullInAPromise() { - // nulls a complex tree of nullable fields that return null + $doc = ' + query Q { + nest { + nonNullPromise, + } + } + '; + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => null, + ], + 'errors' => [ + FormattedError::create('Cannot return null for non-nullable field DataType.nonNullPromise.', [new SourceLocation(4, 11)]), + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')); + } + + public function testNullsAnObjectReturnedInAPromiseThatContainsANonNullableFieldThatReturnsNullSynchronously() + { + $doc = ' + query Q { + promiseNest { + nonNullSync, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'promiseNest' => null, + ], + 'errors' => [ + FormattedError::create('Cannot return null for non-nullable field DataType.nonNullSync.', [new SourceLocation(4, 11)]), + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')); + } + + public function testNullsAnObjectReturnedInAPromiseThatContainsANonNullableFieldThatReturnsNullInaAPromise() + { + $doc = ' + query Q { + promiseNest { + nonNullPromise, + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'promiseNest' => null, + ], + 'errors' => [ + FormattedError::create('Cannot return null for non-nullable field DataType.nonNullPromise.', [new SourceLocation(4, 11)]), + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')); + } + + public function testNullsAComplexTreeOfNullableFieldsThatReturnNull() + { $doc = ' query Q { nest { sync + promise nest { sync - nest { - sync - } + promise + } + promiseNest { + sync + promise + } + } + promiseNest { + sync + promise + nest { + sync + promise + } + promiseNest { + sync + promise } } } @@ -222,16 +611,107 @@ class NonNullTest extends \PHPUnit_Framework_TestCase 'data' => [ 'nest' => [ 'sync' => null, + 'promise' => null, 'nest' => [ 'sync' => null, - 'nest' => [ - 'sync' => null - ] + 'promise' => null, ], + 'promiseNest' => [ + 'sync' => null, + 'promise' => null, + ] ], + 'promiseNest' => [ + 'sync' => null, + 'promise' => null, + 'nest' => [ + 'sync' => null, + 'promise' => null, + ], + 'promiseNest' => [ + 'sync' => null, + 'promise' => null, + ] + ] ] ]; - $this->assertEquals($expected, Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')->toArray()); + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')->then(function ($actual) use ($expected) { + $this->assertEquals($expected, $actual); + }); + } + + public function testNullsTheFirstNullableObjectAfterAFieldReturnsNullInALongChainOfFieldsThatAreNonNull() + { + $doc = ' + query Q { + nest { + nonNullNest { + nonNullPromiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullSync + } + } + } + } + } + promiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullSync + } + } + } + } + } + anotherNest: nest { + nonNullNest { + nonNullPromiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullPromise + } + } + } + } + } + anotherPromiseNest: promiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullNest { + nonNullPromiseNest { + nonNullPromise + } + } + } + } + } + } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => [ + 'nest' => null, + 'promiseNest' => null, + 'anotherNest' => null, + 'anotherPromiseNest' => null, + ], + 'errors' => [ + FormattedError::create('Cannot return null for non-nullable field DataType.nonNullSync.', [new SourceLocation(8, 19)]), + FormattedError::create('Cannot return null for non-nullable field DataType.nonNullSync.', [new SourceLocation(19, 19)]), + FormattedError::create('Cannot return null for non-nullable field DataType.nonNullPromise.', [new SourceLocation(30, 19)]), + FormattedError::create('Cannot return null for non-nullable field DataType.nonNullPromise.', [new SourceLocation(41, 19)]), + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')); } public function testNullsTheTopLevelIfSyncNonNullableFieldThrows() @@ -241,11 +721,32 @@ class NonNullTest extends \PHPUnit_Framework_TestCase '; $expected = [ + 'data' => null, 'errors' => [ FormattedError::create($this->nonNullSyncError->getMessage(), [new SourceLocation(2, 17)]) ] ]; - $this->assertArraySubset($expected, Executor::execute($this->schema, Parser::parse($doc), $this->throwingData)->toArray()); + $actual = Executor::execute($this->schema, Parser::parse($doc), $this->throwingData)->toArray(); + $this->assertArraySubset($expected, $actual); + } + + public function testNullsTheTopLevelIfAsyncNonNullableFieldErrors() + { + $doc = ' + query Q { nonNullPromise } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => null, + 'errors' => [ + FormattedError::create($this->nonNullPromiseError->getMessage(), [new SourceLocation(2, 17)]), + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->throwingData, null, [], 'Q')); } public function testNullsTheTopLevelIfSyncNonNullableFieldReturnsNull() @@ -262,4 +763,33 @@ class NonNullTest extends \PHPUnit_Framework_TestCase ]; $this->assertArraySubset($expected, Executor::execute($this->schema, Parser::parse($doc), $this->nullingData)->toArray()); } + + public function testNullsTheTopLevelIfAsyncNonNullableFieldResolvesNull() + { + $doc = ' + query Q { nonNullPromise } + '; + + $ast = Parser::parse($doc); + + $expected = [ + 'data' => null, + 'errors' => [ + FormattedError::create('Cannot return null for non-nullable field DataType.nonNullPromise.', [new SourceLocation(2, 17)]), + ] + ]; + + Executor::setPromiseAdapter(new ReactPromiseAdapter()); + $this->assertArraySubsetPromise($expected, Executor::execute($this->schema, $ast, $this->nullingData, null, [], 'Q')); + } + + private function assertArraySubsetPromise($subset, PromiseInterface $promise) + { + $array = null; + $promise->then(function ($value) use (&$array) { + $array = $value->toArray(); + }); + + $this->assertArraySubset($subset, $array); + } }