diff --git a/README.md b/README.md index d177e047..5db20ba3 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,11 @@ $promise->then(function ($value) { Useful functions for creating, joining, mapping and reducing collections of promises. +All functions working on promise collections (like `all()`, `race()`, `some()` +etc.) support cancellation. This means, if you call `cancel()` on the returned +promise, all promises in the collection are cancelled. If the collection itself +is a promise which resolves to an array, this promise is also cancelled. + #### resolve() ```php diff --git a/composer.json b/composer.json index b90b7c30..22dae5a2 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,11 @@ }, "files": ["src/functions_include.php"] }, + "autoload-dev": { + "psr-4": { + "React\\Promise\\": "tests/fixtures" + } + }, "extra": { "branch-alias": { "dev-master": "2.0-dev" diff --git a/src/CancellationQueue.php b/src/CancellationQueue.php new file mode 100644 index 00000000..bf327ff4 --- /dev/null +++ b/src/CancellationQueue.php @@ -0,0 +1,58 @@ +started) { + return; + } + + $this->started = true; + $this->drain(); + } + + public function enqueue($promise) + { + if (!$promise instanceof CancellablePromiseInterface) { + return; + } + + $length = array_push($this->queue, $promise); + + if ($this->started && 1 === $length) { + $this->drain(); + } + } + + private function drain() + { + for ($i = key($this->queue); isset($this->queue[$i]); $i++) { + $promise = $this->queue[$i]; + + $exception = null; + + try { + $promise->cancel(); + } catch (\Exception $exception) { + } + + unset($this->queue[$i]); + + if ($exception) { + throw $exception; + } + } + + $this->queue = []; + } +} diff --git a/src/functions.php b/src/functions.php index 9b361dd9..71faf6a1 100644 --- a/src/functions.php +++ b/src/functions.php @@ -37,19 +37,35 @@ function all($promisesOrValues) function race($promisesOrValues) { - return resolve($promisesOrValues) - ->then(function ($array) { - if (!is_array($array) || !$array) { - return resolve(); - } + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($promisesOrValues); + + return new Promise(function ($resolve, $reject, $notify) use ($promisesOrValues, $cancellationQueue) { + resolve($promisesOrValues) + ->done(function ($array) use ($cancellationQueue, $resolve, $reject, $notify) { + if (!is_array($array) || !$array) { + $resolve(); + return; + } - return new Promise(function ($resolve, $reject, $notify) use ($array) { foreach ($array as $promiseOrValue) { + $cancellationQueue->enqueue($promiseOrValue); + + $fulfiller = function ($value) use ($cancellationQueue, $resolve) { + $cancellationQueue(); + $resolve($value); + }; + + $rejecter = function ($reason) use ($cancellationQueue, $reject) { + $cancellationQueue(); + $reject($reason); + }; + resolve($promiseOrValue) - ->done($resolve, $reject, $notify); + ->done($fulfiller, $rejecter, $notify); } - }); - }); + }, $reject, $notify); + }, $cancellationQueue); } function any($promisesOrValues) @@ -62,13 +78,17 @@ function any($promisesOrValues) function some($promisesOrValues, $howMany) { - return resolve($promisesOrValues) - ->then(function ($array) use ($howMany) { - if (!is_array($array) || !$array || $howMany < 1) { - return resolve([]); - } + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($promisesOrValues); + + return new Promise(function ($resolve, $reject, $notify) use ($promisesOrValues, $howMany, $cancellationQueue) { + resolve($promisesOrValues) + ->done(function ($array) use ($howMany, $cancellationQueue, $resolve, $reject, $notify) { + if (!is_array($array) || !$array || $howMany < 1) { + $resolve([]); + return; + } - return new Promise(function ($resolve, $reject, $notify) use ($array, $howMany) { $len = count($array); $toResolve = min($howMany, $len); $toReject = ($len - $toResolve) + 1; @@ -76,7 +96,7 @@ function some($promisesOrValues, $howMany) $reasons = []; foreach ($array as $i => $promiseOrValue) { - $fulfiller = function ($val) use ($i, &$values, &$toResolve, $toReject, $resolve) { + $fulfiller = function ($val) use ($i, &$values, &$toResolve, $toReject, $resolve, $cancellationQueue) { if ($toResolve < 1 || $toReject < 1) { return; } @@ -84,6 +104,7 @@ function some($promisesOrValues, $howMany) $values[$i] = $val; if (0 === --$toResolve) { + $cancellationQueue(); $resolve($values); } }; @@ -100,26 +121,34 @@ function some($promisesOrValues, $howMany) } }; + $cancellationQueue->enqueue($promiseOrValue); + resolve($promiseOrValue) ->done($fulfiller, $rejecter, $notify); } - }); - }); + }, $reject, $notify); + }, $cancellationQueue); } function map($promisesOrValues, callable $mapFunc) { - return resolve($promisesOrValues) - ->then(function ($array) use ($mapFunc) { - if (!is_array($array) || !$array) { - return resolve([]); - } + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($promisesOrValues); + + return new Promise(function ($resolve, $reject, $notify) use ($promisesOrValues, $mapFunc, $cancellationQueue) { + resolve($promisesOrValues) + ->done(function ($array) use ($mapFunc, $cancellationQueue, $resolve, $reject, $notify) { + if (!is_array($array) || !$array) { + $resolve([]); + return; + } - return new Promise(function ($resolve, $reject, $notify) use ($array, $mapFunc) { $toResolve = count($array); $values = []; foreach ($array as $i => $promiseOrValue) { + $cancellationQueue->enqueue($promiseOrValue); + resolve($promiseOrValue) ->then($mapFunc) ->done( @@ -134,35 +163,45 @@ function ($mapped) use ($i, &$values, &$toResolve, $resolve) { $notify ); } - }); - }); + }, $reject, $notify); + }, $cancellationQueue); } function reduce($promisesOrValues, callable $reduceFunc, $initialValue = null) { - return resolve($promisesOrValues) - ->then(function ($array) use ($reduceFunc, $initialValue) { - if (!is_array($array)) { - $array = []; - } - - $total = count($array); - $i = 0; - - // Wrap the supplied $reduceFunc with one that handles promises and then - // delegates to the supplied. - $wrappedReduceFunc = function ($current, $val) use ($reduceFunc, $total, &$i) { - return resolve($current) - ->then(function ($c) use ($reduceFunc, $total, &$i, $val) { - return resolve($val) - ->then(function ($value) use ($reduceFunc, $total, &$i, $c) { - return $reduceFunc($c, $value, $i++, $total); - }); - }); - }; - - return array_reduce($array, $wrappedReduceFunc, $initialValue); - }); + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($promisesOrValues); + + return new Promise(function ($resolve, $reject, $notify) use ($promisesOrValues, $reduceFunc, $initialValue, $cancellationQueue) { + resolve($promisesOrValues) + ->done(function ($array) use ($reduceFunc, $initialValue, $cancellationQueue, $resolve, $reject, $notify) { + if (!is_array($array)) { + $array = []; + } + + $total = count($array); + $i = 0; + + // Wrap the supplied $reduceFunc with one that handles promises and then + // delegates to the supplied. + $wrappedReduceFunc = function ($current, $val) use ($reduceFunc, $cancellationQueue, $total, &$i) { + $cancellationQueue->enqueue($val); + + return $current + ->then(function ($c) use ($reduceFunc, $total, &$i, $val) { + return resolve($val) + ->then(function ($value) use ($reduceFunc, $total, &$i, $c) { + return $reduceFunc($c, $value, $i++, $total); + }); + }); + }; + + $cancellationQueue->enqueue($initialValue); + + array_reduce($array, $wrappedReduceFunc, resolve($initialValue)) + ->done($resolve, $reject, $notify); + }, $reject, $notify); + }, $cancellationQueue); } // Internal functions diff --git a/tests/CancellationQueueTest.php b/tests/CancellationQueueTest.php new file mode 100644 index 00000000..50271371 --- /dev/null +++ b/tests/CancellationQueueTest.php @@ -0,0 +1,85 @@ +enqueue($p); + + $cancellationQueue(); + + $this->assertFalse($p->cancelCalled); + } + + /** @test */ + public function callsCancelOnPromisesEnqueuedBeforeStart() + { + $d1 = $this->getCancellableDeferred(); + $d2 = $this->getCancellableDeferred(); + + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($d1->promise()); + $cancellationQueue->enqueue($d2->promise()); + + $cancellationQueue(); + } + + /** @test */ + public function callsCancelOnPromisesEnqueuedAfterStart() + { + $d1 = $this->getCancellableDeferred(); + $d2 = $this->getCancellableDeferred(); + + $cancellationQueue = new CancellationQueue(); + + $cancellationQueue(); + + $cancellationQueue->enqueue($d2->promise()); + $cancellationQueue->enqueue($d1->promise()); + } + + /** @test */ + public function doesNotCallCancelTwiceWhenStartedTwice() + { + $d = $this->getCancellableDeferred(); + + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($d->promise()); + + $cancellationQueue(); + $cancellationQueue(); + } + + /** @test */ + public function rethrowsExceptionsThrownFromCancel() + { + $this->setExpectedException('\Exception', 'test'); + + $mock = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock + ->expects($this->once()) + ->method('cancel') + ->will($this->throwException(new \Exception('test'))); + + $cancellationQueue = new CancellationQueue(); + $cancellationQueue->enqueue($mock); + + $cancellationQueue(); + } + + private function getCancellableDeferred() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke'); + + return new Deferred($mock); + } +} diff --git a/tests/FunctionAnyTest.php b/tests/FunctionAnyTest.php index bf8a0db4..401eb5eb 100644 --- a/tests/FunctionAnyTest.php +++ b/tests/FunctionAnyTest.php @@ -113,4 +113,64 @@ public function shouldNotRelyOnArryIndexesWhenUnwrappingToASingleResolutionValue $d2->resolve(2); $d1->resolve(1); } + + /** @test */ + public function shouldRejectWhenInputPromiseRejects() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(null)); + + any(reject()) + ->then($this->expectCallableNever(), $mock); + } + + /** @test */ + public function shouldCancelInputPromise() + { + $mock = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock + ->expects($this->once()) + ->method('cancel'); + + any($mock)->cancel(); + } + + /** @test */ + public function shouldCancelInputArrayPromises() + { + $mock1 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock1 + ->expects($this->once()) + ->method('cancel'); + + $mock2 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + any([$mock1, $mock2])->cancel(); + } + + /** @test */ + public function shouldCancelOtherPendingInputArrayPromisesIfOnePromiseFulfills() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + + $deferred = New Deferred($mock); + $deferred->resolve(); + + $mock2 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + some([$deferred->promise(), $mock2], 1)->cancel(); + } } diff --git a/tests/FunctionMapTest.php b/tests/FunctionMapTest.php index b8bf3a83..5e86284e 100644 --- a/tests/FunctionMapTest.php +++ b/tests/FunctionMapTest.php @@ -122,4 +122,52 @@ public function shouldRejectWhenInputContainsRejection() $this->mapper() )->then($this->expectCallableNever(), $mock); } + + /** @test */ + public function shouldRejectWhenInputPromiseRejects() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(null)); + + map( + reject(), + $this->mapper() + )->then($this->expectCallableNever(), $mock); + } + + /** @test */ + public function shouldCancelInputPromise() + { + $mock = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock + ->expects($this->once()) + ->method('cancel'); + + map( + $mock, + $this->mapper() + )->cancel(); + } + + /** @test */ + public function shouldCancelInputArrayPromises() + { + $mock1 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock1 + ->expects($this->once()) + ->method('cancel'); + + $mock2 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + map( + [$mock1, $mock2], + $this->mapper() + )->cancel(); + } } diff --git a/tests/FunctionRaceTest.php b/tests/FunctionRaceTest.php index 553220c5..6cd1e987 100644 --- a/tests/FunctionRaceTest.php +++ b/tests/FunctionRaceTest.php @@ -119,4 +119,83 @@ public function shouldResolveToNullWhenInputPromiseDoesNotResolveToArray() resolve(1) )->then($mock); } + + /** @test */ + public function shouldRejectWhenInputPromiseRejects() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(null)); + + race( + reject() + )->then($this->expectCallableNever(), $mock); + } + + /** @test */ + public function shouldCancelInputPromise() + { + $mock = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock + ->expects($this->once()) + ->method('cancel'); + + race($mock)->cancel(); + } + + /** @test */ + public function shouldCancelInputArrayPromises() + { + $mock1 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock1 + ->expects($this->once()) + ->method('cancel'); + + $mock2 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + race([$mock1, $mock2])->cancel(); + } + + /** @test */ + public function shouldCancelOtherPendingInputArrayPromisesIfOnePromiseFulfills() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + $deferred = New Deferred($mock); + $deferred->resolve(); + + $mock2 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + race([$deferred->promise(), $mock2])->cancel(); + } + + /** @test */ + public function shouldCancelOtherPendingInputArrayPromisesIfOnePromiseRejects() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + $deferred = New Deferred($mock); + $deferred->reject(); + + $mock2 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + race([$deferred->promise(), $mock2])->cancel(); + } } diff --git a/tests/FunctionReduceTest.php b/tests/FunctionReduceTest.php index 715e8477..3c17511c 100644 --- a/tests/FunctionReduceTest.php +++ b/tests/FunctionReduceTest.php @@ -287,4 +287,55 @@ public function shouldProvideCorrectBasisValue() $d1->resolve(1); $d2->resolve(2); } + + /** @test */ + public function shouldRejectWhenInputPromiseRejects() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(null)); + + reduce( + reject(), + $this->plus(), + 1 + )->then($this->expectCallableNever(), $mock); + } + + /** @test */ + public function shouldCancelInputPromise() + { + $mock = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock + ->expects($this->once()) + ->method('cancel'); + + reduce( + $mock, + $this->plus(), + 1 + )->cancel(); + } + + /** @test */ + public function shouldCancelInputArrayPromises() + { + $mock1 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock1 + ->expects($this->once()) + ->method('cancel'); + + $mock2 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + reduce( + [$mock1, $mock2], + $this->plus(), + 1 + )->cancel(); + } } diff --git a/tests/FunctionSomeTest.php b/tests/FunctionSomeTest.php index 09e53504..e1c034e5 100644 --- a/tests/FunctionSomeTest.php +++ b/tests/FunctionSomeTest.php @@ -123,4 +123,66 @@ public function shouldResolveToEmptyArrayWhenInputPromiseDoesNotResolveToArray() 1 )->then($mock); } + + /** @test */ + public function shouldRejectWhenInputPromiseRejects() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(null)); + + some( + reject(), + 1 + )->then($this->expectCallableNever(), $mock); + } + + /** @test */ + public function shouldCancelInputPromise() + { + $mock = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock + ->expects($this->once()) + ->method('cancel'); + + some($mock, 1)->cancel(); + } + + /** @test */ + public function shouldCancelInputArrayPromises() + { + $mock1 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock1 + ->expects($this->once()) + ->method('cancel'); + + $mock2 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + some([$mock1, $mock2], 1)->cancel(); + } + + /** @test */ + public function shouldCancelOtherPendingInputArrayPromisesIfEnoughPromisesFulfill() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->never()) + ->method('__invoke'); + + + $deferred = New Deferred($mock); + $deferred->resolve(); + + $mock2 = $this->getMock('React\Promise\CancellablePromiseInterface'); + $mock2 + ->expects($this->once()) + ->method('cancel'); + + some([$deferred->promise(), $mock2], 1)->cancel(); + } } diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index faba7046..dc7b733d 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -82,35 +82,3 @@ public function shouldRejectIfRejectedWithSimplePromise() $adapter->resolve(new SimpleRejectedTestPromise()); } } - -class SimpleFulfilledTestPromise implements PromiseInterface -{ - public function then(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) - { - try { - if ($onFulfilled) { - $onFulfilled('foo'); - } - - return new self('foo'); - } catch (\Exception $exception) { - return new RejectedPromise($exception); - } - } -} - -class SimpleRejectedTestPromise implements PromiseInterface -{ - public function then(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) - { - try { - if ($onRejected) { - $onRejected('foo'); - } - - return new self('foo'); - } catch (\Exception $exception) { - return new RejectedPromise($exception); - } - } -} diff --git a/tests/fixtures/SimpleFulfilledTestPromise.php b/tests/fixtures/SimpleFulfilledTestPromise.php new file mode 100644 index 00000000..af5d4e4b --- /dev/null +++ b/tests/fixtures/SimpleFulfilledTestPromise.php @@ -0,0 +1,26 @@ +cancelCalled = true; + } +} diff --git a/tests/fixtures/SimpleRejectedTestPromise.php b/tests/fixtures/SimpleRejectedTestPromise.php new file mode 100644 index 00000000..3503ab64 --- /dev/null +++ b/tests/fixtures/SimpleRejectedTestPromise.php @@ -0,0 +1,19 @@ +