diff --git a/CHANGELOG.md b/CHANGELOG.md index fe50904a..e194e42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +* 2.1.x (xxxx-xx-xx) + + * Introduce new CancellablePromiseInterface implemented by all promises + * Add new .cancel() method (part of the CancellablePromiseInterface) + * 2.0.0 (2013-12-10) New major release. The goal was to streamline the API and to make it more diff --git a/README.md b/README.md index 281f450e..1bb5c32c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Table of Contents * [Deferred::progress()](#deferredprogress) * [PromiseInterface](#promiseinterface) * [PromiseInterface::then()](#promiseinterfacethen) + * [CancellablePromiseInterface](#cancellablepromiseinterface) + * [CancellablePromiseInterface::cancel()](#cancellablepromiseinterfacecancel) * [Promise](#promise-1) * [FulfilledPromise](#fulfilledpromise) * [RejectedPromise](#rejectedpromise) @@ -96,6 +98,9 @@ The `resolve` and `reject` methods control the state of the deferred. The `progress` method is for progress notification. +The constructor of the `Deferred` accepts an optional `$canceller` argument. +See [Promise](#promise-1) for more information. + #### Deferred::promise() ``` php @@ -197,6 +202,31 @@ the same call to `then()`: * [resolve()](#resolve) - Creating a resolved promise * [reject()](#reject) - Creating a rejected promise +### CancellablePromiseInterface + +A cancellable promise provides a mechanism for consumers to notify the creator +of the promise that they are not longer interested in the result of an +operation. + +#### CancellablePromiseInterface::cancel() + +``` php +$promise->cancel(); +``` + +The `cancel()` method notifies the creator of the promise that there is no +further interest in the results of the operation. + +Once a promise is settled (either fulfilled or rejected), calling `cancel()` on +a promise has no effect. + +#### Implementations + +* [Promise](#promise-1) +* [FulfilledPromise](#fulfilledpromise) +* [RejectedPromise](#rejectedpromise) +* [LazyPromise](#lazypromise) + ### Promise Creates a promise whose state is controlled by the functions passed to @@ -214,11 +244,17 @@ $resolver = function (callable $resolve, callable $reject, callable $progress) { // or $progress($progressNotification); }; -$promise = new React\Promise\Promise($resolver); +$canceller = function (callable $resolve, callable $reject, callable $progress) { + // Cancel/abort any running operations like network connections, streams etc. + + $reject(new \Exception('Promise cancelled')); +}; + +$promise = new React\Promise\Promise($resolver, $canceller); ``` -The promise constructor receives a resolver function which will be called -with 3 arguments: +The promise constructor receives a resolver function and an optional canceller +function which both will be called with 3 arguments: * `$resolve($value)` - Primary function that seals the fate of the returned promise. Accepts either a non-promise value, or another promise. @@ -228,9 +264,11 @@ with 3 arguments: * `$reject($reason)` - Function that rejects the promise. * `$progress($update)` - Function that issues progress events for the promise. -If the resolver throws an exception, the promise will be rejected with that -thrown exception as the rejection reason. +If the resolver or canceller throw an exception, the promise will be rejected +with that thrown exception as the rejection reason. +The resolver function will be called immediately, the canceller function only +once all consumers called the `cancel()` method of the promise. ### FulfilledPromise diff --git a/src/CancellablePromiseInterface.php b/src/CancellablePromiseInterface.php new file mode 100644 index 00000000..896db2d3 --- /dev/null +++ b/src/CancellablePromiseInterface.php @@ -0,0 +1,11 @@ +canceller = $canceller; + } public function promise() { @@ -16,7 +22,7 @@ public function promise() $this->resolveCallback = $resolve; $this->rejectCallback = $reject; $this->progressCallback = $progress; - }); + }, $this->canceller); } return $this->promise; diff --git a/src/FulfilledPromise.php b/src/FulfilledPromise.php index 44e1a798..bfa3f995 100644 --- a/src/FulfilledPromise.php +++ b/src/FulfilledPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class FulfilledPromise implements PromiseInterface +class FulfilledPromise implements CancellablePromiseInterface { private $value; @@ -29,4 +29,8 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, return new RejectedPromise($exception); } } + + public function cancel() + { + } } diff --git a/src/LazyPromise.php b/src/LazyPromise.php index 82acce19..e1172e54 100644 --- a/src/LazyPromise.php +++ b/src/LazyPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class LazyPromise implements PromiseInterface +class LazyPromise implements CancellablePromiseInterface { private $factory; private $promise; @@ -13,6 +13,16 @@ public function __construct(callable $factory) } public function then(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) + { + return $this->promise()->then($onFulfilled, $onRejected, $onProgress); + } + + public function cancel() + { + return $this->promise()->cancel(); + } + + private function promise() { if (null === $this->promise) { try { @@ -22,6 +32,6 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, } } - return $this->promise->then($onFulfilled, $onRejected, $onProgress); + return $this->promise; } } diff --git a/src/Promise.php b/src/Promise.php index 061d960c..09ebb32b 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -2,30 +2,21 @@ namespace React\Promise; -class Promise implements PromiseInterface +class Promise implements CancellablePromiseInterface { + private $canceller; private $result; private $handlers = []; private $progressHandlers = []; - public function __construct(callable $resolver) + private $requiredCancelRequests = 0; + private $cancelRequests = 0; + + public function __construct(callable $resolver, callable $canceller = null) { - try { - $resolver( - function ($value = null) { - $this->resolve($value); - }, - function ($reason = null) { - $this->reject($reason); - }, - function ($update = null) { - $this->progress($update); - } - ); - } catch (\Exception $e) { - $this->reject($e); - } + $this->canceller = $canceller; + $this->call($resolver); } public function then(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) @@ -34,7 +25,28 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, return $this->result->then($onFulfilled, $onRejected, $onProgress); } - return new static($this->resolver($onFulfilled, $onRejected, $onProgress)); + if (null === $this->canceller) { + return new static($this->resolver($onFulfilled, $onRejected, $onProgress)); + } + + $this->requiredCancelRequests++; + + return new static($this->resolver($onFulfilled, $onRejected, $onProgress), function ($resolve, $reject, $progress) { + if (++$this->cancelRequests < $this->requiredCancelRequests) { + return; + } + + $this->cancel(); + }); + } + + public function cancel() + { + if (null === $this->canceller || null !== $this->result) { + return; + } + + $this->call($this->canceller); } private function resolver(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) @@ -101,4 +113,23 @@ private function settle(PromiseInterface $result) $this->result = $result; } + + private function call(callable $callback) + { + try { + $callback( + function ($value = null) { + $this->resolve($value); + }, + function ($reason = null) { + $this->reject($reason); + }, + function ($update = null) { + $this->progress($update); + } + ); + } catch (\Exception $e) { + $this->reject($e); + } + } } diff --git a/src/RejectedPromise.php b/src/RejectedPromise.php index 994d7d8c..ee318cbf 100644 --- a/src/RejectedPromise.php +++ b/src/RejectedPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class RejectedPromise implements PromiseInterface +class RejectedPromise implements CancellablePromiseInterface { private $reason; @@ -27,4 +27,8 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, return new RejectedPromise($exception); } } + + public function cancel() + { + } } diff --git a/tests/DeferredTest.php b/tests/DeferredTest.php index d9520caa..754a84df 100644 --- a/tests/DeferredTest.php +++ b/tests/DeferredTest.php @@ -8,9 +8,9 @@ class DeferredTest extends TestCase { use PromiseTest\FullTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { - $d = new Deferred(); + $d = new Deferred($canceller); return new CallbackPromiseAdapter([ 'promise' => [$d, 'promise'], diff --git a/tests/FulfilledPromiseTest.php b/tests/FulfilledPromiseTest.php index 82ea0540..e72ecebc 100644 --- a/tests/FulfilledPromiseTest.php +++ b/tests/FulfilledPromiseTest.php @@ -9,7 +9,7 @@ class FulfilledPromiseTest extends TestCase use PromiseTest\PromiseSettledTestTrait, PromiseTest\PromiseFulfilledTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { $promise = null; diff --git a/tests/LazyPromiseTest.php b/tests/LazyPromiseTest.php index 2d4728f4..a6eb4463 100644 --- a/tests/LazyPromiseTest.php +++ b/tests/LazyPromiseTest.php @@ -8,9 +8,9 @@ class LazyPromiseTest extends TestCase { use PromiseTest\FullTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { - $d = new Deferred(); + $d = new Deferred($canceller); $factory = function () use ($d) { return $d->promise(); diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index ac0196be..18e30f7c 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -8,7 +8,7 @@ class PromiseTest extends TestCase { use PromiseTest\FullTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { $resolveCallback = $rejectCallback = $progressCallback = null; @@ -16,7 +16,7 @@ public function getPromiseTestAdapter() $resolveCallback = $resolve; $rejectCallback = $reject; $progressCallback = $progress; - }); + }, $canceller); return new CallbackPromiseAdapter([ 'promise' => function () use ($promise) { diff --git a/tests/PromiseTest/CancelTestTrait.php b/tests/PromiseTest/CancelTestTrait.php new file mode 100644 index 00000000..d6c09566 --- /dev/null +++ b/tests/PromiseTest/CancelTestTrait.php @@ -0,0 +1,206 @@ +createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->isType('callable'), $this->isType('callable'), $this->isType('callable')); + + $adapter = $this->getPromiseTestAdapter($mock); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldFulfillPromiseIfCancellerFulfills() + { + $adapter = $this->getPromiseTestAdapter(function ($resolve) { + $resolve(1); + }); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + $adapter->promise() + ->then($mock, $this->expectCallableNever()); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldRejectPromiseIfCancellerRejects() + { + $adapter = $this->getPromiseTestAdapter(function ($resolve, $reject) { + $reject(1); + }); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + $adapter->promise() + ->then($this->expectCallableNever(), $mock); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldRejectPromiseWithExceptionIfCancellerThrows() + { + $e = new \Exception(); + + $adapter = $this->getPromiseTestAdapter(function () use ($e) { + throw $e; + }); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo($e)); + + $adapter->promise() + ->then($this->expectCallableNever(), $mock); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldProgressPromiseIfCancellerNotifies() + { + $adapter = $this->getPromiseTestAdapter(function ($resolve, $reject, $progress) { + $progress(1); + }); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + $adapter->promise() + ->then($this->expectCallableNever(), $this->expectCallableNever(), $mock); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldCallCancellerOnlyOnceIfCancellerResolves() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->will($this->returnCallback(function ($resolve) { + $resolve(); + })); + + $adapter = $this->getPromiseTestAdapter($mock); + + $adapter->promise()->cancel(); + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldHaveNoEffectIfCancellerDoesNothing() + { + $adapter = $this->getPromiseTestAdapter(function () {}); + + $adapter->promise() + ->then($this->expectCallableNever(), $this->expectCallableNever()); + + $adapter->promise()->cancel(); + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldCallCancellerFromDeepNestedPromiseChain() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke'); + + $adapter = $this->getPromiseTestAdapter($mock); + + $promise = $adapter->promise() + ->then(function () { + return new Promise\Promise(function () {}); + }) + ->then(function () { + $d = new Promise\Deferred(); + + return $d->promise(); + }) + ->then(function () { + return new Promise\Promise(function () {}); + }); + + $promise->cancel(); + } + + /** @test */ + public function cancelCalledOnChildrenSouldOnlyCancelWhenAllChildrenCancelled() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); + + $child1 = $adapter->promise() + ->then() + ->then(); + + $adapter->promise() + ->then(); + + $child1->cancel(); + } + + /** @test */ + public function cancelShouldTriggerCancellerWhenAllChildrenCancel() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableOnce()); + + $child1 = $adapter->promise() + ->then() + ->then(); + + $child2 = $adapter->promise() + ->then(); + + $child1->cancel(); + $child2->cancel(); + } + + /** @test */ + public function cancelShouldAlwaysTriggerCancellerWhenCalledOnRootPromise() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableOnce()); + + $adapter->promise() + ->then() + ->then(); + + $adapter->promise() + ->then(); + + $adapter->promise()->cancel(); + } +} diff --git a/tests/PromiseTest/FullTestTrait.php b/tests/PromiseTest/FullTestTrait.php index 125911a4..fd8f9342 100644 --- a/tests/PromiseTest/FullTestTrait.php +++ b/tests/PromiseTest/FullTestTrait.php @@ -10,5 +10,6 @@ trait FullTestTrait PromiseRejectedTestTrait, ResolveTestTrait, RejectTestTrait, - ProgressTestTrait; + ProgressTestTrait, + CancelTestTrait; } diff --git a/tests/PromiseTest/ProgressTestTrait.php b/tests/PromiseTest/ProgressTestTrait.php index fa275508..0d160a81 100644 --- a/tests/PromiseTest/ProgressTestTrait.php +++ b/tests/PromiseTest/ProgressTestTrait.php @@ -7,7 +7,7 @@ trait ProgressTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function progressShouldProgress() diff --git a/tests/PromiseTest/PromiseFulfilledTestTrait.php b/tests/PromiseTest/PromiseFulfilledTestTrait.php index b5ad91c0..5ae29422 100644 --- a/tests/PromiseTest/PromiseFulfilledTestTrait.php +++ b/tests/PromiseTest/PromiseFulfilledTestTrait.php @@ -7,7 +7,7 @@ trait PromiseFulfilledTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function fulfilledPromiseShouldBeImmutable() @@ -175,4 +175,24 @@ public function thenShouldSwitchFromCallbacksToErrbacksWhenCallbackThrows() $mock2 ); } + + /** @test */ + public function cancelShouldReturnNullForFulfilledPromise() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->resolve(); + + $this->assertNull($adapter->promise()->cancel()); + } + + /** @test */ + public function cancelShouldHaveNoEffectForFulfilledPromise() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); + + $adapter->resolve(); + + $adapter->promise()->cancel(); + } } diff --git a/tests/PromiseTest/PromisePendingTestTrait.php b/tests/PromiseTest/PromisePendingTestTrait.php index 0341bf7b..0b188013 100644 --- a/tests/PromiseTest/PromisePendingTestTrait.php +++ b/tests/PromiseTest/PromisePendingTestTrait.php @@ -7,7 +7,7 @@ trait PromisePendingTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function thenShouldReturnAPromiseForPendingPromise() @@ -24,4 +24,12 @@ public function thenShouldReturnAllowNullForPendingPromise() $this->assertInstanceOf('React\\Promise\\PromiseInterface', $adapter->promise()->then(null, null, null)); } + + /** @test */ + public function cancelShouldReturnNullForPendingPromise() + { + $adapter = $this->getPromiseTestAdapter(); + + $this->assertNull($adapter->promise()->cancel()); + } } diff --git a/tests/PromiseTest/PromiseRejectedTestTrait.php b/tests/PromiseTest/PromiseRejectedTestTrait.php index 51f0a05b..a9cfa68a 100644 --- a/tests/PromiseTest/PromiseRejectedTestTrait.php +++ b/tests/PromiseTest/PromiseRejectedTestTrait.php @@ -7,7 +7,7 @@ trait PromiseRejectedTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function rejectedPromiseShouldBeImmutable() @@ -180,4 +180,24 @@ function ($val) { $mock ); } + + /** @test */ + public function cancelShouldReturnNullForRejectedPromise() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->reject(); + + $this->assertNull($adapter->promise()->cancel()); + } + + /** @test */ + public function cancelShouldHaveNoEffectForRejectedPromise() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); + + $adapter->reject(); + + $adapter->promise()->cancel(); + } } diff --git a/tests/PromiseTest/PromiseSettledTestTrait.php b/tests/PromiseTest/PromiseSettledTestTrait.php index 72df3e39..c5602847 100644 --- a/tests/PromiseTest/PromiseSettledTestTrait.php +++ b/tests/PromiseTest/PromiseSettledTestTrait.php @@ -7,7 +7,7 @@ trait PromiseSettledTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function thenShouldReturnAPromiseForSettledPromise() @@ -26,4 +26,24 @@ public function thenShouldReturnAllowNullForSettledPromise() $adapter->settle(); $this->assertInstanceOf('React\\Promise\\PromiseInterface', $adapter->promise()->then(null, null, null)); } + + /** @test */ + public function cancelShouldReturnNullForSettledPromise() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->settle(); + + $this->assertNull($adapter->promise()->cancel()); + } + + /** @test */ + public function cancelShouldHaveNoEffectForSettledPromise() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); + + $adapter->settle(); + + $adapter->promise()->cancel(); + } } diff --git a/tests/PromiseTest/RejectTestTrait.php b/tests/PromiseTest/RejectTestTrait.php index cce4926b..66e5e734 100644 --- a/tests/PromiseTest/RejectTestTrait.php +++ b/tests/PromiseTest/RejectTestTrait.php @@ -9,7 +9,7 @@ trait RejectTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function rejectShouldRejectWithAnImmediateValue() diff --git a/tests/PromiseTest/ResolveTestTrait.php b/tests/PromiseTest/ResolveTestTrait.php index f37735f6..4ee20191 100644 --- a/tests/PromiseTest/ResolveTestTrait.php +++ b/tests/PromiseTest/ResolveTestTrait.php @@ -9,7 +9,7 @@ trait ResolveTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function resolveShouldResolve() diff --git a/tests/RejectedPromiseTest.php b/tests/RejectedPromiseTest.php index c8c4c685..040dd2e2 100644 --- a/tests/RejectedPromiseTest.php +++ b/tests/RejectedPromiseTest.php @@ -9,7 +9,7 @@ class RejectedPromiseTest extends TestCase use PromiseTest\PromiseSettledTestTrait, PromiseTest\PromiseRejectedTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { $promise = null;