Skip to content

Commit 2c94a5d

Browse files
committed
Add a generic promise support
1 parent 7fe5fc3 commit 2c94a5d

10 files changed

+400
-13
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,8 @@ isTypeOf | `callback($value, $context, GraphQL\Type\Definition\ResolveInfo $info
308308

309309
2. Object types are responsible for data fetching. Each of their fields may have optional `resolve` callback option. This callback takes `$value` that corresponds to instance of this type and returns `data` accepted by type of given field.
310310
If `resolve` option is not set, GraphQL will try to get `data` from `$value[$fieldName]`.
311+
`resolve` result can also be a promise implementing the interface `GraphQL\Promise\PromiseInterface`. If the result is a promise the executor will wait for
312+
promise to complete before continuing.
311313

312314
3. `resolve` callback is a place where you can use your existing data fetching logic. `$context` is defined by your application on the top level of query execution (useful for storing current user, environment details, etc)
313315

src/Executor/Executor.php

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use GraphQL\Language\AST\NodeKind;
1111
use GraphQL\Language\AST\OperationDefinitionNode;
1212
use GraphQL\Language\AST\SelectionSetNode;
13+
use GraphQL\Promise\PromiseInterface;
1314
use GraphQL\Schema;
1415
use GraphQL\Type\Definition\AbstractType;
1516
use GraphQL\Type\Definition\Directive;
@@ -169,10 +170,13 @@ private static function executeOperation(ExecutionContext $exeContext, Operation
169170

170171
$path = [];
171172
if ($operation->operation === 'mutation') {
172-
return self::executeFieldsSerially($exeContext, $type, $rootValue, $path, $fields);
173+
$results = self::executeFieldsSerially($exeContext, $type, $rootValue, $path, $fields);
174+
} else {
175+
$results = self::executeFields($exeContext, $type, $rootValue, $path, $fields);
173176
}
177+
$finalResults = self::completePromiseIfNeeded($results);
174178

175-
return self::executeFields($exeContext, $type, $rootValue, $path, $fields);
179+
return $finalResults;
176180
}
177181

178182

@@ -221,15 +225,34 @@ private static function getOperationRootType(Schema $schema, OperationDefinition
221225
*/
222226
private static function executeFieldsSerially(ExecutionContext $exeContext, ObjectType $parentType, $sourceValue, $path, $fields)
223227
{
228+
if (self::isThenable($sourceValue)) {
229+
return $sourceValue->then(function($resolvedSourceValue) use ($exeContext, $parentType, $path, $fields) {
230+
return self::executeFieldsSerially($exeContext, $parentType, $resolvedSourceValue, $path, $fields);
231+
});
232+
}
233+
224234
$results = [];
225-
foreach ($fields as $responseName => $fieldNodes) {
235+
236+
$process = function ($responseName, $fieldNodes, $results) use ($path, $exeContext, $parentType, $sourceValue) {
226237
$fieldPath = $path;
227238
$fieldPath[] = $responseName;
228239
$result = self::resolveField($exeContext, $parentType, $sourceValue, $fieldNodes, $fieldPath);
229240

230-
if ($result !== self::$UNDEFINED) {
231-
// Undefined means that field is not defined in schema
232-
$results[$responseName] = $result;
241+
// Undefined means that field is not defined in schema
242+
if ($result === self::$UNDEFINED) {
243+
return $results;
244+
}
245+
$results[$responseName] = $result;
246+
return $results;
247+
};
248+
249+
foreach ($fields as $responseName => $fieldNodes) {
250+
if (self::isThenable($results)) {
251+
$results = $results->then(function ($resolvedResults) use ($responseName, $fieldNodes, $process) {
252+
return $process($responseName, $fieldNodes, $resolvedResults);
253+
});
254+
} else {
255+
$results = $process($responseName, $fieldNodes, $results);
233256
}
234257
}
235258
// see #59
@@ -245,12 +268,9 @@ private static function executeFieldsSerially(ExecutionContext $exeContext, Obje
245268
*/
246269
private static function executeFields(ExecutionContext $exeContext, ObjectType $parentType, $source, $path, $fields)
247270
{
248-
// Native PHP doesn't support promises.
249-
// Custom executor should be built for platforms like ReactPHP
250271
return self::executeFieldsSerially($exeContext, $parentType, $source, $path, $fields);
251272
}
252273

253-
254274
/**
255275
* Given a selectionSet, adds all of the fields in that selection to
256276
* the passed in map of fields, and returns it at the end.
@@ -529,14 +549,26 @@ private static function completeValueCatchingError(
529549
// Otherwise, error protection is applied, logging the error and resolving
530550
// a null value for this field if one is encountered.
531551
try {
532-
return self::completeValueWithLocatedError(
552+
$completed = self::completeValueWithLocatedError(
533553
$exeContext,
534554
$returnType,
535555
$fieldNodes,
536556
$info,
537557
$path,
538558
$result
539559
);
560+
if (self::isThenable($completed)) {
561+
// If `completeValueWithLocatedError` returned a rejected promise, log
562+
// the rejection error and resolve to null.
563+
// Note: we don't rely on a `catch` method, but we do expect "thenable"
564+
// to take a second callback for the error case.
565+
return $completed->then(null, function ($err) use ($exeContext) {
566+
$exeContext->addError($err);
567+
return null;
568+
});
569+
}
570+
571+
return $completed;
540572
} catch (Error $err) {
541573
// If `completeValueWithLocatedError` returned abruptly (threw an error), log the error
542574
// and return null.
@@ -545,7 +577,6 @@ private static function completeValueCatchingError(
545577
}
546578
}
547579

548-
549580
/**
550581
* This is a small wrapper around completeValue which annotates errors with
551582
* location information.
@@ -559,7 +590,7 @@ private static function completeValueCatchingError(
559590
* @return array|null
560591
* @throws Error
561592
*/
562-
static function completeValueWithLocatedError(
593+
public static function completeValueWithLocatedError(
563594
ExecutionContext $exeContext,
564595
Type $returnType,
565596
$fieldNodes,
@@ -569,14 +600,20 @@ static function completeValueWithLocatedError(
569600
)
570601
{
571602
try {
572-
return self::completeValue(
603+
$completed = self::completeValue(
573604
$exeContext,
574605
$returnType,
575606
$fieldNodes,
576607
$info,
577608
$path,
578609
$result
579610
);
611+
if (self::isThenable($completed)) {
612+
return $completed->then(null, function ($error) use ($fieldNodes, $path) {
613+
throw Error::createLocatedError($error, $fieldNodes, $path);
614+
});
615+
}
616+
return $completed;
580617
} catch (\Exception $error) {
581618
throw Error::createLocatedError($error, $fieldNodes, $path);
582619
}
@@ -622,6 +659,20 @@ private static function completeValue(
622659
&$result
623660
)
624661
{
662+
// If result is a Promise, apply-lift over completeValue.
663+
if (self::isThenable($result)) {
664+
return $result->then(function ($resolved) use ($exeContext, $returnType, $fieldNodes, $info, $path) {
665+
return self::completeValue(
666+
$exeContext,
667+
$returnType,
668+
$fieldNodes,
669+
$info,
670+
$path,
671+
$resolved
672+
);
673+
});
674+
}
675+
625676
if ($result instanceof \Exception) {
626677
throw $result;
627678
}
@@ -893,6 +944,27 @@ private static function inferTypeOf($value, $context, ResolveInfo $info, Abstrac
893944
return null;
894945
}
895946

947+
private static function isThenable($value)
948+
{
949+
return ($value instanceof PromiseInterface);
950+
}
951+
952+
private static function completePromiseIfNeeded($value)
953+
{
954+
if (self::isThenable($value)) {
955+
$results = self::completePromiseIfNeeded($value->wait());
956+
return $results;
957+
} elseif (is_array($value) || $value instanceof \Traversable) {
958+
$results = [];
959+
foreach ($value as $key => $item) {
960+
$results[$key] = self::completePromiseIfNeeded($item);
961+
}
962+
return $results;
963+
}
964+
965+
return $value;
966+
}
967+
896968
/**
897969
* @deprecated as of 19.11.2016
898970
*/

src/Promise/PromiseInterface.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
namespace GraphQL\Promise;
3+
4+
interface PromiseInterface
5+
{
6+
/**
7+
* Waits until the promise completes if possible.
8+
*
9+
* @return mixed
10+
* @throws \LogicException if the promise has no wait function.
11+
*/
12+
public function wait();
13+
14+
/**
15+
* Appends fulfillment and rejection handlers to the promise, and returns
16+
* a new promise resolving to the return value of the called handler.
17+
*
18+
* @param callable $onFulfilled Invoked when the promise fulfills
19+
* @param callable $onRejected Invoked when the promise is rejected
20+
*
21+
* @return PromiseInterface
22+
*
23+
* @throws \LogicException if the promise has no then function.
24+
*/
25+
public function then(
26+
callable $onFulfilled = null,
27+
callable $onRejected = null
28+
);
29+
}

src/Promise/PromiseWrapper.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
namespace GraphQL\Promise;
4+
5+
class PromiseWrapper implements PromiseInterface
6+
{
7+
private $wrappedPromise;
8+
9+
/**
10+
* PromiseWrapper constructor.
11+
* @param $promise
12+
*/
13+
public function __construct($promise = null)
14+
{
15+
if (null !== $promise) {
16+
$this->setWrappedPromise($promise);
17+
}
18+
}
19+
/**
20+
* @param $promise
21+
* @return self
22+
*/
23+
public static function wrap($promise)
24+
{
25+
return new static($promise);
26+
}
27+
/**
28+
* Waits until the promise completes if possible.
29+
*
30+
* @return mixed
31+
* @throws \LogicException if the promise has no wait function.
32+
*/
33+
public function wait()
34+
{
35+
if (!$this->objectHasMethod($this->wrappedPromise, 'wait')) {
36+
throw new \LogicException('Promise does not implement "wait" method');
37+
}
38+
39+
return $this->wrappedPromise->wait();
40+
}
41+
/**
42+
* Appends fulfillment and rejection handlers to the promise, and returns
43+
* a new promise resolving to the return value of the called handler.
44+
*
45+
* @param callable $onFulfilled Invoked when the promise fulfills
46+
* @param callable $onRejected Invoked when the promise is rejected
47+
*
48+
* @return self
49+
*
50+
* @throws \LogicException if the promise has no then function.
51+
*/
52+
public function then(
53+
callable $onFulfilled = null,
54+
callable $onRejected = null
55+
)
56+
{
57+
if (!$this->getWrappedPromise()) {
58+
throw new \LogicException('No wrapped promise found!');
59+
}
60+
61+
if (null === $onFulfilled && null === $onRejected) {
62+
return $this;
63+
}
64+
65+
return $this->wrap($this->getWrappedPromise()->then($onFulfilled, $onRejected));
66+
}
67+
68+
public function getWrappedPromise()
69+
{
70+
return $this->wrappedPromise;
71+
}
72+
73+
public function setWrappedPromise($wrappedPromise)
74+
{
75+
if (!$this->objectHasMethod($wrappedPromise, 'then')) {
76+
throw new \LogicException('Promise does not implement "then" method');
77+
}
78+
79+
$this->wrappedPromise = $wrappedPromise;
80+
81+
return $this;
82+
}
83+
84+
protected function objectHasMethod($object, $method)
85+
{
86+
return is_object($object) && is_callable([$object, $method]);
87+
}
88+
}

tests/Promise/CliPromise.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
namespace GraphQL\Tests\Promise;
3+
4+
class CliPromise
5+
{
6+
private $pid;
7+
private $outputFile;
8+
private $onFulfilledCallbacks = [];
9+
private $onRejectedCallbacks = [];
10+
11+
public function __construct($cmd)
12+
{
13+
$outputFile = sys_get_temp_dir() . '/PromiseTest.' . time() . '.output';
14+
15+
exec(sprintf('(%s) > %s 2>&1 & echo $!', $cmd, $outputFile), $pidArr);
16+
17+
$this->pid = $pidArr[0];
18+
$this->outputFile = $outputFile;
19+
}
20+
21+
public function wait()
22+
{
23+
try {
24+
while (!$this->isTerminated()) { usleep(1000); }
25+
26+
$log = new \stdClass();
27+
$log->pid = $this->pid;
28+
$log->output = shell_exec(sprintf('cat %s', $this->outputFile));
29+
30+
foreach ($this->onFulfilledCallbacks as $onFulfilled) {
31+
$log = call_user_func($onFulfilled, $log);
32+
}
33+
return $log;
34+
} catch (\Exception $e) {
35+
foreach ($this->onRejectedCallbacks as $onRejected) {
36+
call_user_func($onRejected, $e);
37+
}
38+
}
39+
}
40+
41+
private function isTerminated()
42+
{
43+
try {
44+
$result = shell_exec(sprintf('ps %d', $this->pid));
45+
return count(preg_split("/\n/", $result)) <= 2;
46+
} catch (\Exception $e) {
47+
}
48+
49+
return false;
50+
}
51+
52+
/**
53+
* Appends fulfillment and rejection handlers to the promise, and returns
54+
* a new promise resolving to the return value of the called handler.
55+
*
56+
* @param callable $onFulfilled Invoked when the promise fulfills
57+
* @param callable $onRejected Invoked when the promise is rejected
58+
*
59+
* @return $this
60+
*/
61+
public function then(
62+
callable $onFulfilled = null,
63+
callable $onRejected = null
64+
)
65+
{
66+
if (null !== $onFulfilled) {
67+
$this->onFulfilledCallbacks[] = $onFulfilled;
68+
}
69+
70+
if (null !== $onFulfilled) {
71+
$this->onRejectedCallbacks[] = $onRejected;
72+
}
73+
74+
return $this;
75+
}
76+
}

0 commit comments

Comments
 (0)