Server: batched queries with shared deferreds (promises) #105

This commit is contained in:
Vladimir Razuvaev 2017-07-17 20:31:26 +07:00
parent 24ffd605f4
commit 919cf80240
2 changed files with 237 additions and 26 deletions

View File

@ -1,16 +1,20 @@
<?php
namespace GraphQL\Server;
use GraphQL\Error\Error;
use GraphQL\Error\FormattedError;
use GraphQL\Error\InvariantViolation;
use GraphQL\Error\UserError;
use GraphQL\Executor\ExecutionResult;
use GraphQL\Executor\Executor;
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
use GraphQL\Executor\Promise\Promise;
use GraphQL\GraphQL;
use GraphQL\Executor\Promise\PromiseAdapter;
use GraphQL\Language\AST\DocumentNode;
use GraphQL\Language\Parser;
use GraphQL\Utils\AST;
use GraphQL\Utils\Utils;
use GraphQL\Validator\DocumentValidator;
/**
* Class Helper
@ -21,7 +25,8 @@ use GraphQL\Utils\Utils;
class Helper
{
/**
* Executes GraphQL operation with given server configuration and returns execution result (or promise)
* Executes GraphQL operation with given server configuration and returns execution result
* (or promise when promise adapter is different from SyncPromiseAdapter)
*
* @param ServerConfig $config
* @param OperationParams $op
@ -29,9 +34,55 @@ class Helper
* @return ExecutionResult|Promise
*/
public function executeOperation(ServerConfig $config, OperationParams $op)
{
$promiseAdapter = $config->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) {
$execute = function() use ($config, $op, $promiseAdapter) {
try {
$doc = $op->queryId ? static::loadPersistedQuery($config, $op) : $op->query;
if (!$doc instanceof DocumentNode) {
@ -41,17 +92,33 @@ class Helper
throw new UserError("Cannot execute mutation in read-only context");
}
return GraphQL::executeAndReturnResult(
$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(),
static::resolveValidationRules($config, $op),
$config->getPromiseAdapter()
$config->getDefaultFieldResolver()
);
}
} catch (Error $e) {
return $promiseAdapter->createFulfilled(
new ExecutionResult(null, [$e])
);
}
};
if ($config->getDebug()) {
$execute = Utils::withErrorHandling($execute, $phpErrors);
@ -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)
{

View File

@ -1,6 +1,7 @@
<?php
namespace GraphQL\Tests\Server;
use GraphQL\Deferred;
use GraphQL\Error\Error;
use GraphQL\Error\UserError;
use GraphQL\Executor\ExecutionResult;
@ -67,6 +68,21 @@ class QueryExecutionTest extends \PHPUnit_Framework_TestCase
'resolve' => 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);