Skip to content

Commit 919cf80

Browse files
committed
Server: batched queries with shared deferreds (promises) #105
1 parent 24ffd60 commit 919cf80

File tree

2 files changed

+237
-26
lines changed

2 files changed

+237
-26
lines changed

src/Server/Helper.php

Lines changed: 93 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
<?php
22
namespace GraphQL\Server;
33

4+
use GraphQL\Error\Error;
45
use GraphQL\Error\FormattedError;
56
use GraphQL\Error\InvariantViolation;
67
use GraphQL\Error\UserError;
78
use GraphQL\Executor\ExecutionResult;
9+
use GraphQL\Executor\Executor;
10+
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
811
use GraphQL\Executor\Promise\Promise;
9-
use GraphQL\GraphQL;
12+
use GraphQL\Executor\Promise\PromiseAdapter;
1013
use GraphQL\Language\AST\DocumentNode;
1114
use GraphQL\Language\Parser;
1215
use GraphQL\Utils\AST;
1316
use GraphQL\Utils\Utils;
17+
use GraphQL\Validator\DocumentValidator;
1418

1519
/**
1620
* Class Helper
@@ -21,44 +25,107 @@
2125
class Helper
2226
{
2327
/**
24-
* Executes GraphQL operation with given server configuration and returns execution result (or promise)
28+
* Executes GraphQL operation with given server configuration and returns execution result
29+
* (or promise when promise adapter is different from SyncPromiseAdapter)
2530
*
2631
* @param ServerConfig $config
2732
* @param OperationParams $op
2833
*
2934
* @return ExecutionResult|Promise
3035
*/
3136
public function executeOperation(ServerConfig $config, OperationParams $op)
37+
{
38+
$promiseAdapter = $config->getPromiseAdapter() ?: Executor::getPromiseAdapter();
39+
$result = $this->promiseToExecuteOperation($promiseAdapter, $config, $op);
40+
41+
if ($promiseAdapter instanceof SyncPromiseAdapter) {
42+
$result = $promiseAdapter->wait($result);
43+
}
44+
45+
return $result;
46+
}
47+
48+
/**
49+
* Executes batched GraphQL operations with shared promise queue
50+
* (thus, effectively batching deferreds|promises of all queries at once)
51+
*
52+
* @param ServerConfig $config
53+
* @param OperationParams[] $operations
54+
* @return ExecutionResult[]|Promise
55+
*/
56+
public function executeBatch(ServerConfig $config, array $operations)
57+
{
58+
$promiseAdapter = $config->getPromiseAdapter() ?: Executor::getPromiseAdapter();
59+
$result = [];
60+
61+
foreach ($operations as $operation) {
62+
$result[] = $this->promiseToExecuteOperation($promiseAdapter, $config, $operation);
63+
}
64+
65+
$result = $promiseAdapter->all($result);
66+
67+
// Wait for promised results when using sync promises
68+
if ($promiseAdapter instanceof SyncPromiseAdapter) {
69+
$result = $promiseAdapter->wait($result);
70+
}
71+
return $result;
72+
}
73+
74+
/**
75+
* @param PromiseAdapter $promiseAdapter
76+
* @param ServerConfig $config
77+
* @param OperationParams $op
78+
* @return Promise
79+
*/
80+
private function promiseToExecuteOperation(PromiseAdapter $promiseAdapter, ServerConfig $config, OperationParams $op)
3281
{
3382
$phpErrors = [];
34-
$execute = function() use ($config, $op) {
35-
$doc = $op->queryId ? static::loadPersistedQuery($config, $op) : $op->query;
3683

37-
if (!$doc instanceof DocumentNode) {
38-
$doc = Parser::parse($doc);
39-
}
40-
if ($op->isReadOnly() && AST::isMutation($op->operation, $doc)) {
41-
throw new UserError("Cannot execute mutation in read-only context");
42-
}
84+
$execute = function() use ($config, $op, $promiseAdapter) {
85+
try {
86+
$doc = $op->queryId ? static::loadPersistedQuery($config, $op) : $op->query;
87+
88+
if (!$doc instanceof DocumentNode) {
89+
$doc = Parser::parse($doc);
90+
}
91+
if ($op->isReadOnly() && AST::isMutation($op->operation, $doc)) {
92+
throw new UserError("Cannot execute mutation in read-only context");
93+
}
4394

44-
return GraphQL::executeAndReturnResult(
45-
$config->getSchema(),
46-
$doc,
47-
$config->getRootValue(),
48-
$config->getContext(),
49-
$op->variables,
50-
$op->operation,
51-
$config->getDefaultFieldResolver(),
52-
static::resolveValidationRules($config, $op),
53-
$config->getPromiseAdapter()
54-
);
95+
$validationErrors = DocumentValidator::validate(
96+
$config->getSchema(),
97+
$doc,
98+
$this->resolveValidationRules($config, $op)
99+
);
100+
101+
if (!empty($validationErrors)) {
102+
return $promiseAdapter->createFulfilled(
103+
new ExecutionResult(null, $validationErrors)
104+
);
105+
} else {
106+
return Executor::promiseToExecute(
107+
$promiseAdapter,
108+
$config->getSchema(),
109+
$doc,
110+
$config->getRootValue(),
111+
$config->getContext(),
112+
$op->variables,
113+
$op->operation,
114+
$config->getDefaultFieldResolver()
115+
);
116+
}
117+
} catch (Error $e) {
118+
return $promiseAdapter->createFulfilled(
119+
new ExecutionResult(null, [$e])
120+
);
121+
}
55122
};
56123
if ($config->getDebug()) {
57124
$execute = Utils::withErrorHandling($execute, $phpErrors);
58125
}
59126
$result = $execute();
60127

61-
$applyErrorFormatting = function(ExecutionResult $result) use ($config, $phpErrors) {
128+
$applyErrorFormatting = function (ExecutionResult $result) use ($config, $phpErrors) {
62129
if ($config->getDebug()) {
63130
$errorFormatter = function($e) {
64131
return FormattedError::createFromException($e, true);
@@ -73,15 +140,15 @@ public function executeOperation(ServerConfig $config, OperationParams $op)
73140
return $result;
74141
};
75142

76-
return $result instanceof Promise ?
77-
$result->then($applyErrorFormatting) :
78-
$applyErrorFormatting($result);
143+
return $result->then($applyErrorFormatting);
79144
}
80145

81146
/**
82147
* @param ServerConfig $config
83148
* @param OperationParams $op
84-
* @return string|DocumentNode
149+
* @return mixed
150+
* @throws Error
151+
* @throws InvariantViolation
85152
*/
86153
public function loadPersistedQuery(ServerConfig $config, OperationParams $op)
87154
{

tests/Server/QueryExecutionTest.php

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22
namespace GraphQL\Tests\Server;
33

4+
use GraphQL\Deferred;
45
use GraphQL\Error\Error;
56
use GraphQL\Error\UserError;
67
use GraphQL\Executor\ExecutionResult;
@@ -67,6 +68,21 @@ public function setUp()
6768
'resolve' => function($root, $args) {
6869
return $args['arg'];
6970
}
71+
],
72+
'dfd' => [
73+
'type' => Type::string(),
74+
'args' => [
75+
'num' => [
76+
'type' => Type::nonNull(Type::int())
77+
],
78+
],
79+
'resolve' => function($root, $args, $context) {
80+
$context['buffer']($args['num']);
81+
82+
return new Deferred(function() use ($args, $context) {
83+
return $context['load']($args['num']);
84+
});
85+
}
7086
]
7187
]
7288
])
@@ -353,6 +369,117 @@ public function testAllowSkippingValidationForPersistedQueries()
353369
$this->assertEquals($expected, $result->toArray());
354370
}
355371

372+
public function testExecutesBatchedQueries()
373+
{
374+
$batch = [
375+
[
376+
'query' => '{invalid}'
377+
],
378+
[
379+
'query' => '{f1,fieldWithException}'
380+
],
381+
[
382+
'query' => '
383+
query ($a: String!, $b: String!) {
384+
a: fieldWithArg(arg: $a)
385+
b: fieldWithArg(arg: $b)
386+
}
387+
',
388+
'variables' => ['a' => 'a', 'b' => 'b'],
389+
]
390+
];
391+
392+
$result = $this->executeBatchedQuery($batch);
393+
394+
$expected = [
395+
[
396+
'errors' => [['message' => 'Cannot query field "invalid" on type "Query".']]
397+
],
398+
[
399+
'data' => [
400+
'f1' => 'f1',
401+
'fieldWithException' => null
402+
],
403+
'errors' => [
404+
['message' => 'This is the exception we want']
405+
]
406+
],
407+
[
408+
'data' => [
409+
'a' => 'a',
410+
'b' => 'b'
411+
]
412+
]
413+
];
414+
415+
$this->assertArraySubset($expected[0], $result[0]->toArray());
416+
$this->assertArraySubset($expected[1], $result[1]->toArray());
417+
$this->assertArraySubset($expected[2], $result[2]->toArray());
418+
}
419+
420+
public function testDeferredsAreSharedAmongAllBatchedQueries()
421+
{
422+
$batch = [
423+
[
424+
'query' => '{dfd(num: 1)}'
425+
],
426+
[
427+
'query' => '{dfd(num: 2)}'
428+
],
429+
[
430+
'query' => '{dfd(num: 3)}',
431+
]
432+
];
433+
434+
$calls = [];
435+
436+
$this->config
437+
->setRootValue('1')
438+
->setContext([
439+
'buffer' => function($num) use (&$calls) {
440+
$calls[] = "buffer: $num";
441+
},
442+
'load' => function($num) use (&$calls) {
443+
$calls[] = "load: $num";
444+
return "loaded: $num";
445+
}
446+
]);
447+
448+
$result = $this->executeBatchedQuery($batch);
449+
450+
$expectedCalls = [
451+
'buffer: 1',
452+
'buffer: 2',
453+
'buffer: 3',
454+
'load: 1',
455+
'load: 2',
456+
'load: 3',
457+
];
458+
$this->assertEquals($expectedCalls, $calls);
459+
460+
$expected = [
461+
[
462+
'data' => [
463+
'dfd' => 'loaded: 1'
464+
]
465+
],
466+
[
467+
'data' => [
468+
'dfd' => 'loaded: 2'
469+
]
470+
],
471+
[
472+
'data' => [
473+
'dfd' => 'loaded: 3'
474+
]
475+
],
476+
];
477+
478+
$this->assertEquals($expected[0], $result[0]->toArray());
479+
$this->assertEquals($expected[1], $result[1]->toArray());
480+
$this->assertEquals($expected[2], $result[2]->toArray());
481+
}
482+
356483
private function executePersistedQuery($queryId, $variables = null)
357484
{
358485
$op = OperationParams::create(['queryId' => $queryId, 'variables' => $variables]);
@@ -371,6 +498,23 @@ private function executeQuery($query, $variables = null)
371498
return $result;
372499
}
373500

501+
private function executeBatchedQuery(array $qs)
502+
{
503+
$batch = [];
504+
foreach ($qs as $params) {
505+
$batch[] = OperationParams::create($params, true);
506+
}
507+
$helper = new Helper();
508+
$result = $helper->executeBatch($this->config, $batch);
509+
$this->assertInternalType('array', $result);
510+
$this->assertCount(count($qs), $result);
511+
512+
foreach ($result as $index => $entry) {
513+
$this->assertInstanceOf(ExecutionResult::class, $entry, "Result at $index is not an instance of " . ExecutionResult::class);
514+
}
515+
return $result;
516+
}
517+
374518
private function assertQueryResultEquals($expected, $query, $variables = null)
375519
{
376520
$result = $this->executeQuery($query, $variables);

0 commit comments

Comments
 (0)