diff --git a/src/Server/Helper.php b/src/Server/Helper.php index 621a060..f071f36 100644 --- a/src/Server/Helper.php +++ b/src/Server/Helper.php @@ -1,16 +1,20 @@ getPromiseAdapter() ?: Executor::getPromiseAdapter(); + $result = $this->promiseToExecuteOperation($promiseAdapter, $config, $op); + + if ($promiseAdapter instanceof SyncPromiseAdapter) { + $result = $promiseAdapter->wait($result); + } + + return $result; + } + + /** + * Executes batched GraphQL operations with shared promise queue + * (thus, effectively batching deferreds|promises of all queries at once) + * + * @param ServerConfig $config + * @param OperationParams[] $operations + * @return ExecutionResult[]|Promise + */ + public function executeBatch(ServerConfig $config, array $operations) + { + $promiseAdapter = $config->getPromiseAdapter() ?: Executor::getPromiseAdapter(); + $result = []; + + foreach ($operations as $operation) { + $result[] = $this->promiseToExecuteOperation($promiseAdapter, $config, $operation); + } + + $result = $promiseAdapter->all($result); + + // Wait for promised results when using sync promises + if ($promiseAdapter instanceof SyncPromiseAdapter) { + $result = $promiseAdapter->wait($result); + } + return $result; + } + + /** + * @param PromiseAdapter $promiseAdapter + * @param ServerConfig $config + * @param OperationParams $op + * @return Promise + */ + private function promiseToExecuteOperation(PromiseAdapter $promiseAdapter, ServerConfig $config, OperationParams $op) { $phpErrors = []; - $execute = function() use ($config, $op) { - $doc = $op->queryId ? static::loadPersistedQuery($config, $op) : $op->query; - if (!$doc instanceof DocumentNode) { - $doc = Parser::parse($doc); - } - if ($op->isReadOnly() && AST::isMutation($op->operation, $doc)) { - throw new UserError("Cannot execute mutation in read-only context"); - } + $execute = function() use ($config, $op, $promiseAdapter) { + try { + $doc = $op->queryId ? static::loadPersistedQuery($config, $op) : $op->query; - return GraphQL::executeAndReturnResult( - $config->getSchema(), - $doc, - $config->getRootValue(), - $config->getContext(), - $op->variables, - $op->operation, - $config->getDefaultFieldResolver(), - static::resolveValidationRules($config, $op), - $config->getPromiseAdapter() - ); + if (!$doc instanceof DocumentNode) { + $doc = Parser::parse($doc); + } + if ($op->isReadOnly() && AST::isMutation($op->operation, $doc)) { + throw new UserError("Cannot execute mutation in read-only context"); + } + + $validationErrors = DocumentValidator::validate( + $config->getSchema(), + $doc, + $this->resolveValidationRules($config, $op) + ); + + if (!empty($validationErrors)) { + return $promiseAdapter->createFulfilled( + new ExecutionResult(null, $validationErrors) + ); + } else { + return Executor::promiseToExecute( + $promiseAdapter, + $config->getSchema(), + $doc, + $config->getRootValue(), + $config->getContext(), + $op->variables, + $op->operation, + $config->getDefaultFieldResolver() + ); + } + } catch (Error $e) { + return $promiseAdapter->createFulfilled( + new ExecutionResult(null, [$e]) + ); + } }; if ($config->getDebug()) { $execute = Utils::withErrorHandling($execute, $phpErrors); } $result = $execute(); - $applyErrorFormatting = function(ExecutionResult $result) use ($config, $phpErrors) { + $applyErrorFormatting = function (ExecutionResult $result) use ($config, $phpErrors) { if ($config->getDebug()) { $errorFormatter = function($e) { return FormattedError::createFromException($e, true); @@ -73,15 +140,15 @@ class Helper return $result; }; - return $result instanceof Promise ? - $result->then($applyErrorFormatting) : - $applyErrorFormatting($result); + return $result->then($applyErrorFormatting); } /** * @param ServerConfig $config * @param OperationParams $op - * @return string|DocumentNode + * @return mixed + * @throws Error + * @throws InvariantViolation */ public function loadPersistedQuery(ServerConfig $config, OperationParams $op) { diff --git a/tests/Server/QueryExecutionTest.php b/tests/Server/QueryExecutionTest.php index 876d500..f2a71aa 100644 --- a/tests/Server/QueryExecutionTest.php +++ b/tests/Server/QueryExecutionTest.php @@ -1,6 +1,7 @@ function($root, $args) { return $args['arg']; } + ], + 'dfd' => [ + 'type' => Type::string(), + 'args' => [ + 'num' => [ + 'type' => Type::nonNull(Type::int()) + ], + ], + 'resolve' => function($root, $args, $context) { + $context['buffer']($args['num']); + + return new Deferred(function() use ($args, $context) { + return $context['load']($args['num']); + }); + } ] ] ]) @@ -353,6 +369,117 @@ class QueryExecutionTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expected, $result->toArray()); } + public function testExecutesBatchedQueries() + { + $batch = [ + [ + 'query' => '{invalid}' + ], + [ + 'query' => '{f1,fieldWithException}' + ], + [ + 'query' => ' + query ($a: String!, $b: String!) { + a: fieldWithArg(arg: $a) + b: fieldWithArg(arg: $b) + } + ', + 'variables' => ['a' => 'a', 'b' => 'b'], + ] + ]; + + $result = $this->executeBatchedQuery($batch); + + $expected = [ + [ + 'errors' => [['message' => 'Cannot query field "invalid" on type "Query".']] + ], + [ + 'data' => [ + 'f1' => 'f1', + 'fieldWithException' => null + ], + 'errors' => [ + ['message' => 'This is the exception we want'] + ] + ], + [ + 'data' => [ + 'a' => 'a', + 'b' => 'b' + ] + ] + ]; + + $this->assertArraySubset($expected[0], $result[0]->toArray()); + $this->assertArraySubset($expected[1], $result[1]->toArray()); + $this->assertArraySubset($expected[2], $result[2]->toArray()); + } + + public function testDeferredsAreSharedAmongAllBatchedQueries() + { + $batch = [ + [ + 'query' => '{dfd(num: 1)}' + ], + [ + 'query' => '{dfd(num: 2)}' + ], + [ + 'query' => '{dfd(num: 3)}', + ] + ]; + + $calls = []; + + $this->config + ->setRootValue('1') + ->setContext([ + 'buffer' => function($num) use (&$calls) { + $calls[] = "buffer: $num"; + }, + 'load' => function($num) use (&$calls) { + $calls[] = "load: $num"; + return "loaded: $num"; + } + ]); + + $result = $this->executeBatchedQuery($batch); + + $expectedCalls = [ + 'buffer: 1', + 'buffer: 2', + 'buffer: 3', + 'load: 1', + 'load: 2', + 'load: 3', + ]; + $this->assertEquals($expectedCalls, $calls); + + $expected = [ + [ + 'data' => [ + 'dfd' => 'loaded: 1' + ] + ], + [ + 'data' => [ + 'dfd' => 'loaded: 2' + ] + ], + [ + 'data' => [ + 'dfd' => 'loaded: 3' + ] + ], + ]; + + $this->assertEquals($expected[0], $result[0]->toArray()); + $this->assertEquals($expected[1], $result[1]->toArray()); + $this->assertEquals($expected[2], $result[2]->toArray()); + } + private function executePersistedQuery($queryId, $variables = null) { $op = OperationParams::create(['queryId' => $queryId, 'variables' => $variables]); @@ -371,6 +498,23 @@ class QueryExecutionTest extends \PHPUnit_Framework_TestCase return $result; } + private function executeBatchedQuery(array $qs) + { + $batch = []; + foreach ($qs as $params) { + $batch[] = OperationParams::create($params, true); + } + $helper = new Helper(); + $result = $helper->executeBatch($this->config, $batch); + $this->assertInternalType('array', $result); + $this->assertCount(count($qs), $result); + + foreach ($result as $index => $entry) { + $this->assertInstanceOf(ExecutionResult::class, $entry, "Result at $index is not an instance of " . ExecutionResult::class); + } + return $result; + } + private function assertQueryResultEquals($expected, $query, $variables = null) { $result = $this->executeQuery($query, $variables);