mirror of
https://github.com/retailcrm/graphql-php.git
synced 2024-11-29 00:25:17 +03:00
Simple implementation of Promises A+ for our sync case (using queue)
This commit is contained in:
parent
821e96508b
commit
3a375bb78e
147
src/Executor/Promise/Adapter/SyncPromise.php
Normal file
147
src/Executor/Promise/Adapter/SyncPromise.php
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
namespace GraphQL\Executor\Promise\Adapter;
|
||||||
|
|
||||||
|
use GraphQL\Utils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class SyncPromise
|
||||||
|
*
|
||||||
|
* Simplistic (yet full-featured) implementation of Promises A+ spec for regular PHP `sync` mode
|
||||||
|
* (using queue to defer promises execution)
|
||||||
|
*
|
||||||
|
* @package GraphQL\Executor\Promise\Adapter
|
||||||
|
*/
|
||||||
|
class SyncPromise
|
||||||
|
{
|
||||||
|
const PENDING = 'pending';
|
||||||
|
const FULFILLED = 'fulfilled';
|
||||||
|
const REJECTED = 'rejected';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \SplQueue
|
||||||
|
*/
|
||||||
|
public static $queue;
|
||||||
|
|
||||||
|
public static function runQueue()
|
||||||
|
{
|
||||||
|
while (self::$queue && !self::$queue->isEmpty()) {
|
||||||
|
$task = self::$queue->dequeue();
|
||||||
|
$task();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public $state = self::PENDING;
|
||||||
|
|
||||||
|
private $result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promises created in `then` method of this promise and awaiting for resolution of this promise
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
private $waiting = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
if (!self::$queue) {
|
||||||
|
self::$queue = new \SplQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(\Exception $reason)
|
||||||
|
{
|
||||||
|
switch ($this->state) {
|
||||||
|
case self::PENDING:
|
||||||
|
$this->state = self::REJECTED;
|
||||||
|
$this->result = $reason;
|
||||||
|
$this->enqueueWaitingPromises();
|
||||||
|
break;
|
||||||
|
case self::REJECTED:
|
||||||
|
if ($reason !== $this->result) {
|
||||||
|
throw new \Exception("Cannot change rejection reason");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case self::FULFILLED:
|
||||||
|
throw new \Exception("Cannot reject fulfilled promise");
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve($value)
|
||||||
|
{
|
||||||
|
switch ($this->state) {
|
||||||
|
case self::PENDING:
|
||||||
|
if ($value === $this) {
|
||||||
|
throw new \Exception("Cannot resolve promise with self");
|
||||||
|
}
|
||||||
|
if ($value instanceof self) {
|
||||||
|
$value->then(
|
||||||
|
function($resolvedValue) {
|
||||||
|
$this->resolve($resolvedValue);
|
||||||
|
},
|
||||||
|
function($reason) {
|
||||||
|
$this->reject($reason);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->state = self::FULFILLED;
|
||||||
|
$this->result = $value;
|
||||||
|
$this->enqueueWaitingPromises();
|
||||||
|
break;
|
||||||
|
case self::FULFILLED:
|
||||||
|
if ($this->result !== $value) {
|
||||||
|
throw new \Exception("Cannot change value of fulfilled promise");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case self::REJECTED:
|
||||||
|
throw new \Exception("Cannot resolve rejected promise");
|
||||||
|
}
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function then(callable $onFulfilled = null, callable $onRejected = null)
|
||||||
|
{
|
||||||
|
if ($this->state === self::REJECTED && !$onRejected) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
if ($this->state === self::FULFILLED && !$onFulfilled) {
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
$tmp = new self();
|
||||||
|
$this->waiting[] = [$tmp, $onFulfilled, $onRejected];
|
||||||
|
|
||||||
|
if ($this->state !== self::PENDING) {
|
||||||
|
$this->enqueueWaitingPromises();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enqueueWaitingPromises()
|
||||||
|
{
|
||||||
|
Utils::invariant($this->state !== self::PENDING, 'Cannot enqueue derived promises when parent is still pending');
|
||||||
|
|
||||||
|
foreach ($this->waiting as $descriptor) {
|
||||||
|
self::$queue->enqueue(function () use ($descriptor) {
|
||||||
|
/** @var $promise self */
|
||||||
|
list($promise, $onFulfilled, $onRejected) = $descriptor;
|
||||||
|
|
||||||
|
if ($this->state === self::FULFILLED) {
|
||||||
|
try {
|
||||||
|
$promise->resolve($onFulfilled ? $onFulfilled($this->result) : $this->result);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$promise->reject($e);
|
||||||
|
}
|
||||||
|
} else if ($this->state === self::REJECTED) {
|
||||||
|
try {
|
||||||
|
$promise->resolve($onRejected ? $onRejected($this->result) : $this->result);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$promise->reject($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$this->waiting = [];
|
||||||
|
}
|
||||||
|
}
|
308
tests/Executor/Promise/SyncPromiseTest.php
Normal file
308
tests/Executor/Promise/SyncPromiseTest.php
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
<?php
|
||||||
|
namespace GraphQL\Tests\Executor\Promise;
|
||||||
|
|
||||||
|
use GraphQL\Executor\Promise\Adapter\SyncPromise;
|
||||||
|
|
||||||
|
class SyncPromiseTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
public function getFulfilledPromiseResolveData()
|
||||||
|
{
|
||||||
|
$onFulfilledReturnsNull = function() {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
$onFulfilledReturnsSameValue = function($value) {
|
||||||
|
return $value;
|
||||||
|
};
|
||||||
|
$onFulfilledReturnsOtherValue = function($value) {
|
||||||
|
return 'other-' . $value;
|
||||||
|
};
|
||||||
|
$onFulfilledThrows = function($value) {
|
||||||
|
throw new \Exception("onFulfilled throws this!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
// $resolvedValue, $onFulfilled, $expectedNextValue, $expectedNextReason, $expectedNextState
|
||||||
|
['test-value', null, 'test-value', null, SyncPromise::FULFILLED],
|
||||||
|
[uniqid(), $onFulfilledReturnsNull, null, null, SyncPromise::FULFILLED],
|
||||||
|
['test-value', $onFulfilledReturnsSameValue, 'test-value', null, SyncPromise::FULFILLED],
|
||||||
|
['test-value-2', $onFulfilledReturnsOtherValue, 'other-test-value-2', null, SyncPromise::FULFILLED],
|
||||||
|
['test-value-3', $onFulfilledThrows, null, "onFulfilled throws this!", SyncPromise::REJECTED],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider getFulfilledPromiseResolveData
|
||||||
|
*/
|
||||||
|
public function testFulfilledPromise(
|
||||||
|
$resolvedValue,
|
||||||
|
$onFulfilled,
|
||||||
|
$expectedNextValue,
|
||||||
|
$expectedNextReason,
|
||||||
|
$expectedNextState
|
||||||
|
)
|
||||||
|
{
|
||||||
|
$promise = new SyncPromise();
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
|
||||||
|
$promise->resolve($resolvedValue);
|
||||||
|
$this->assertEquals(SyncPromise::FULFILLED, $promise->state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$promise->resolve($resolvedValue . '-other-value');
|
||||||
|
$this->fail('Expected exception not thrown');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->assertEquals('Cannot change value of fulfilled promise', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$promise->reject(new \Exception('anything'));
|
||||||
|
$this->fail('Expected exception not thrown');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->assertEquals('Cannot reject fulfilled promise', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextPromise = $promise->then(null, function() {});
|
||||||
|
$this->assertSame($promise, $nextPromise);
|
||||||
|
|
||||||
|
$onRejectedCalled = false;
|
||||||
|
$nextPromise = $promise->then($onFulfilled, function () use (&$onRejectedCalled) {
|
||||||
|
$onRejectedCalled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($onFulfilled) {
|
||||||
|
$this->assertNotSame($promise, $nextPromise);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $nextPromise->state);
|
||||||
|
} else {
|
||||||
|
$this->assertEquals(SyncPromise::FULFILLED, $nextPromise->state);
|
||||||
|
}
|
||||||
|
$this->assertEquals(false, $onRejectedCalled);
|
||||||
|
|
||||||
|
$this->assertValidPromise($nextPromise, $expectedNextReason, $expectedNextValue, $expectedNextState);
|
||||||
|
|
||||||
|
$nextPromise2 = $promise->then($onFulfilled);
|
||||||
|
$nextPromise3 = $promise->then($onFulfilled);
|
||||||
|
|
||||||
|
if ($onFulfilled) {
|
||||||
|
$this->assertNotSame($nextPromise, $nextPromise2);
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPromise::runQueue();
|
||||||
|
|
||||||
|
$this->assertValidPromise($nextPromise2, $expectedNextReason, $expectedNextValue, $expectedNextState);
|
||||||
|
$this->assertValidPromise($nextPromise3, $expectedNextReason, $expectedNextValue, $expectedNextState);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRejectedPromiseData()
|
||||||
|
{
|
||||||
|
$onRejectedReturnsNull = function() {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
$onRejectedReturnsSomeValue = function($reason) {
|
||||||
|
return 'some-value';
|
||||||
|
};
|
||||||
|
$onRejectedThrowsSameReason = function($reason) {
|
||||||
|
throw $reason;
|
||||||
|
};
|
||||||
|
$onRejectedThrowsOtherReason = function($value) {
|
||||||
|
throw new \Exception("onRejected throws other!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
// $rejectedReason, $onRejected, $expectedNextValue, $expectedNextReason, $expectedNextState
|
||||||
|
[new \Exception('test-reason'), null, null, 'test-reason', SyncPromise::REJECTED],
|
||||||
|
[new \Exception('test-reason-2'), $onRejectedReturnsNull, null, null, SyncPromise::FULFILLED],
|
||||||
|
[new \Exception('test-reason-3'), $onRejectedReturnsSomeValue, 'some-value', null, SyncPromise::FULFILLED],
|
||||||
|
[new \Exception('test-reason-4'), $onRejectedThrowsSameReason, null, 'test-reason-4', SyncPromise::REJECTED],
|
||||||
|
[new \Exception('test-reason-5'), $onRejectedThrowsOtherReason, null, 'onRejected throws other!', SyncPromise::REJECTED],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider getRejectedPromiseData
|
||||||
|
*/
|
||||||
|
public function testRejectedPromise(
|
||||||
|
$rejectedReason,
|
||||||
|
$onRejected,
|
||||||
|
$expectedNextValue,
|
||||||
|
$expectedNextReason,
|
||||||
|
$expectedNextState
|
||||||
|
)
|
||||||
|
{
|
||||||
|
$promise = new SyncPromise();
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
|
||||||
|
$promise->reject($rejectedReason);
|
||||||
|
$this->assertEquals(SyncPromise::REJECTED, $promise->state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$promise->reject(new \Exception('other-reason'));
|
||||||
|
$this->fail('Expected exception not thrown');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->assertEquals('Cannot change rejection reason', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$promise->resolve('anything');
|
||||||
|
$this->fail('Expected exception not thrown');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->assertEquals('Cannot resolve rejected promise', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextPromise = $promise->then(function() {}, null);
|
||||||
|
$this->assertSame($promise, $nextPromise);
|
||||||
|
|
||||||
|
$onFulfilledCalled = false;
|
||||||
|
$nextPromise = $promise->then(
|
||||||
|
function () use (&$onFulfilledCalled) {
|
||||||
|
$onFulfilledCalled = true;
|
||||||
|
},
|
||||||
|
$onRejected
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($onRejected) {
|
||||||
|
$this->assertNotSame($promise, $nextPromise);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $nextPromise->state);
|
||||||
|
} else {
|
||||||
|
$this->assertEquals(SyncPromise::REJECTED, $nextPromise->state);
|
||||||
|
}
|
||||||
|
$this->assertEquals(false, $onFulfilledCalled);
|
||||||
|
$this->assertValidPromise($nextPromise, $expectedNextReason, $expectedNextValue, $expectedNextState);
|
||||||
|
|
||||||
|
$nextPromise2 = $promise->then(null, $onRejected);
|
||||||
|
$nextPromise3 = $promise->then(null, $onRejected);
|
||||||
|
|
||||||
|
if ($onRejected) {
|
||||||
|
$this->assertNotSame($nextPromise, $nextPromise2);
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncPromise::runQueue();
|
||||||
|
|
||||||
|
$this->assertValidPromise($nextPromise2, $expectedNextReason, $expectedNextValue, $expectedNextState);
|
||||||
|
$this->assertValidPromise($nextPromise3, $expectedNextReason, $expectedNextValue, $expectedNextState);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPendingPromise()
|
||||||
|
{
|
||||||
|
$promise = new SyncPromise();
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$promise->resolve($promise);
|
||||||
|
$this->fail('Expected exception not thrown');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->assertEquals('Cannot resolve promise with self', $e->getMessage());
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to resolve with other promise (must resolve when other promise resolves)
|
||||||
|
$otherPromise = new SyncPromise();
|
||||||
|
$promise->resolve($otherPromise);
|
||||||
|
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $otherPromise->state);
|
||||||
|
|
||||||
|
$otherPromise->resolve('the value');
|
||||||
|
$this->assertEquals(SyncPromise::FULFILLED, $otherPromise->state);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
$this->assertValidPromise($promise, null, 'the value', SyncPromise::FULFILLED);
|
||||||
|
|
||||||
|
$promise = new SyncPromise();
|
||||||
|
$promise->resolve('resolved!');
|
||||||
|
|
||||||
|
$this->assertValidPromise($promise, null, 'resolved!', SyncPromise::FULFILLED);
|
||||||
|
|
||||||
|
// Test rejections
|
||||||
|
$promise = new SyncPromise();
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$promise->reject('a');
|
||||||
|
$this->fail('Expected exception not thrown');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
}
|
||||||
|
|
||||||
|
$promise->reject(new \Exception("Rejected Reason"));
|
||||||
|
$this->assertValidPromise($promise, "Rejected Reason", null, SyncPromise::REJECTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPendingPromiseThen()
|
||||||
|
{
|
||||||
|
$promise = new SyncPromise();
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
|
||||||
|
$nextPromise = $promise->then();
|
||||||
|
$this->assertNotSame($promise, $nextPromise);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $promise->state);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $nextPromise->state);
|
||||||
|
|
||||||
|
// Make sure that it queues derivative promises until resolution:
|
||||||
|
$onFulfilledCount = 0;
|
||||||
|
$onRejectedCount = 0;
|
||||||
|
$onFulfilled = function($value) use (&$onFulfilledCount) {
|
||||||
|
$onFulfilledCount++;
|
||||||
|
return $onFulfilledCount;
|
||||||
|
};
|
||||||
|
$onRejected = function($reason) use (&$onRejectedCount) {
|
||||||
|
$onRejectedCount++;
|
||||||
|
throw $reason;
|
||||||
|
};
|
||||||
|
|
||||||
|
$nextPromise2 = $promise->then($onFulfilled, $onRejected);
|
||||||
|
$nextPromise3 = $promise->then($onFulfilled, $onRejected);
|
||||||
|
$nextPromise4 = $promise->then($onFulfilled, $onRejected);
|
||||||
|
|
||||||
|
$this->assertEquals(SyncPromise::$queue->count(), 0);
|
||||||
|
$this->assertEquals($onFulfilledCount, 0);
|
||||||
|
$this->assertEquals($onRejectedCount, 0);
|
||||||
|
$promise->resolve(1);
|
||||||
|
|
||||||
|
$this->assertEquals(SyncPromise::$queue->count(), 4);
|
||||||
|
$this->assertEquals($onFulfilledCount, 0);
|
||||||
|
$this->assertEquals($onRejectedCount, 0);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $nextPromise->state);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $nextPromise2->state);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $nextPromise3->state);
|
||||||
|
$this->assertEquals(SyncPromise::PENDING, $nextPromise4->state);
|
||||||
|
|
||||||
|
SyncPromise::runQueue();
|
||||||
|
$this->assertEquals(SyncPromise::$queue->count(), 0);
|
||||||
|
$this->assertEquals($onFulfilledCount, 3);
|
||||||
|
$this->assertEquals($onRejectedCount, 0);
|
||||||
|
$this->assertValidPromise($nextPromise, null, 1, SyncPromise::FULFILLED);
|
||||||
|
$this->assertValidPromise($nextPromise2, null, 1, SyncPromise::FULFILLED);
|
||||||
|
$this->assertValidPromise($nextPromise3, null, 2, SyncPromise::FULFILLED);
|
||||||
|
$this->assertValidPromise($nextPromise4, null, 3, SyncPromise::FULFILLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertValidPromise(SyncPromise $promise, $expectedNextReason, $expectedNextValue, $expectedNextState)
|
||||||
|
{
|
||||||
|
$actualNextValue = null;
|
||||||
|
$actualNextReason = null;
|
||||||
|
$onFulfilledCalled = false;
|
||||||
|
$onRejectedCalled = false;
|
||||||
|
|
||||||
|
$promise->then(
|
||||||
|
function($nextValue) use (&$actualNextValue, &$onFulfilledCalled) {
|
||||||
|
$onFulfilledCalled = true;
|
||||||
|
$actualNextValue = $nextValue;
|
||||||
|
},
|
||||||
|
function(\Exception $reason) use (&$actualNextReason, &$onRejectedCalled) {
|
||||||
|
$onRejectedCalled = true;
|
||||||
|
$actualNextReason = $reason->getMessage();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals($onFulfilledCalled, false);
|
||||||
|
$this->assertEquals($onRejectedCalled, false);
|
||||||
|
|
||||||
|
SyncPromise::runQueue();
|
||||||
|
|
||||||
|
$this->assertEquals(!$expectedNextReason, $onFulfilledCalled);
|
||||||
|
$this->assertEquals(!!$expectedNextReason, $onRejectedCalled);
|
||||||
|
|
||||||
|
$this->assertEquals($expectedNextValue, $actualNextValue);
|
||||||
|
$this->assertEquals($expectedNextReason, $actualNextReason);
|
||||||
|
$this->assertEquals($expectedNextState, $promise->state);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user