diff --git a/README.md b/README.md index 77b0202..92f8116 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ There are many implementations of the concept of a `Promise`. This library aims *** - + Build status @@ -18,14 +18,14 @@ There are many implementations of the concept of a `Promise`. This library aims Current version - PHP.Gt/Promise documentation + PHP.GT/Promise documentation In computer science, a `Promise` is a mechanism that provides a simple and direct relationship between procedural code and asynchronous callbacks. Functions within procedural languages, like plain old PHP, have two ways they can affect your program's flow: either by returning values or throwing exceptions. When working with functions that execute asynchronously, we can't return values because they might not be ready yet, and we can't throw exceptions because that's a procedural concept (where should we catch them?). That's where promises come in: instead of returning a value or throwing an exception, your functions can return a `Promise`, which is an object that can be _fulfilled_ with a value, or _rejected_ with an exception, but not necessarily at the point that they are returned. -With this concept, the actual work that calculates or loads the value required by your code can be _deferred_ to a task that executes asynchronously. Behind the scenes of PHP.Gt/Promise is a `Deferred` class that is used for exactly this. +With this concept, the actual work that calculates or loads the value required by your code can be _deferred_ to a task that executes asynchronously. Behind the scenes of PHP.GT/Promise is a `Deferred` class that is used for exactly this. Example usage ------------- @@ -120,7 +120,7 @@ Event loop In order for this Promise library to be useful, some code has got to act as an event loop to call the deferred processes. This could be a simple while loop, but for real world tasks a more comprehensive loop system should be used. -The implementation of a Promise-based architecture is complex enough on its own, so the responsibility of an event loop library is maintained separately in [PHP.Gt/Async][gt-async]. +The implementation of a Promise-based architecture is complex enough on its own, so the responsibility of an event loop library is maintained separately in [PHP.GT/Async][gt-async]. Special thanks -------------- diff --git a/phpunit.xml b/phpunit.xml index 3007930..5e1b5d9 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,7 +5,6 @@ colors="true" cacheDirectory="test/phpunit/.phpunit.cache" bootstrap="vendor/autoload.php" - displayDetailsOnTestsThatTriggerDeprecations="true" > diff --git a/src/ExecutesPromiseChain.php b/src/ExecutesPromiseChain.php new file mode 100644 index 0000000..3772b89 --- /dev/null +++ b/src/ExecutesPromiseChain.php @@ -0,0 +1,89 @@ +chain, $this->sortChainItems(...)); + + while($this->getState() !== PromiseState::PENDING) { + $chainItem = $this->getNextChainItem(); + if(!$chainItem) { + break; + } + + if($this->shouldSkipResolution($chainItem)) { + continue; + } + + if($chainItem instanceof ThenChain) { + $this->executeThen($chainItem); + } + elseif($chainItem instanceof FinallyChain) { + $this->executeFinally($chainItem); + } + elseif($chainItem instanceof CatchChain) { + $this->executeCatch($chainItem); + } + } + + $this->throwUnhandledRejection(); + } + + private function shouldSkipResolution(Chainable $chainItem):bool { + if($chainItem instanceof ThenChain || $chainItem instanceof FinallyChain) { + try { + if($this->resolvedValueSet && isset($this->resolvedValue)) { + $chainItem->checkResolutionCallbackType($this->resolvedValue); + } + } + catch(ChainFunctionTypeError) { + return true; + } + } + elseif($chainItem instanceof CatchChain) { + try { + if(isset($this->rejectedReason)) { + $chainItem->checkRejectionCallbackType($this->rejectedReason); + } + } + catch(ChainFunctionTypeError) { + return true; + } + } + return false; + } + + private function executeThen(ThenChain $chainItem):void { + if($this->handleThen($chainItem)) { + $this->emptyChain(); + } + } + + private function executeFinally(FinallyChain $chainItem):void { + if($this->handleFinally($chainItem)) { + $this->emptyChain(); + } + } + + private function executeCatch(CatchChain $chainItem):void { + if($handled = $this->handleCatch($chainItem)) { + array_push($this->handledRejections, $handled); + } + } + + private function sortChainItems(Chainable $a, Chainable $b):int { + if($a instanceof FinallyChain && !($b instanceof FinallyChain)) { + return 1; + } + if($b instanceof FinallyChain && !($a instanceof FinallyChain)) { + return -1; + } + return 0; + } +} diff --git a/src/Promise.php b/src/Promise.php index a5bc2bb..7bfd09f 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -8,30 +8,25 @@ use Gt\Promise\Chain\ThenChain; use Throwable; -/** - * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - */ class Promise implements PromiseInterface { - private mixed $resolvedValue; - /** @var bool This is required due to the ability to set `null` as a resolved value. */ + use ExecutesPromiseChain; + private bool $resolvedValueSet = false; - private Throwable $rejectedReason; + private bool $stopChain = false; - /** @var Chainable[] */ + private mixed $resolvedValue; + private mixed $originalResolvedValue; + private Throwable $rejectedReason; + /** @var array */ private array $chain; - /** @var CatchChain[] */ - private array $uncalledCatchChain; - /** @var Throwable[] */ + /** @var array */ private array $handledRejections; /** @var callable */ private $executor; public function __construct(callable $executor) { $this->chain = []; - $this->uncalledCatchChain = []; $this->handledRejections = []; - $this->executor = $executor; $this->callExecutor(); } @@ -43,15 +38,11 @@ public function getState():PromiseState { elseif($this->resolvedValueSet) { return PromiseState::RESOLVED; } - return PromiseState::PENDING; } public function then(callable $onResolved):PromiseInterface { - array_push($this->chain, new ThenChain( - $onResolved, - null, - )); + array_push($this->chain, new ThenChain($onResolved, null)); $this->tryComplete(); return $this; } @@ -59,7 +50,6 @@ public function then(callable $onResolved):PromiseInterface { private function chainPromise(PromiseInterface $promise):void { $this->reset(); $futureThen = $this->getNextChainItem(); - $promise->then(function(mixed $newResolvedValue)use($futureThen) { $futureThen->callOnResolved($newResolvedValue); $this->resolve($newResolvedValue); @@ -71,22 +61,13 @@ private function chainPromise(PromiseInterface $promise):void { } public function catch(callable $onRejected):PromiseInterface { - array_push($this->chain, new CatchChain( - null, - $onRejected - )); + array_push($this->chain, new CatchChain(null, $onRejected)); $this->tryComplete(); return $this; } - public function finally( - callable $onResolvedOrRejected - ):PromiseInterface { - array_push($this->chain, new FinallyChain( - $onResolvedOrRejected, - $onResolvedOrRejected - )); - + public function finally(callable $onResolvedOrRejected):PromiseInterface { + array_push($this->chain, new FinallyChain($onResolvedOrRejected, $onResolvedOrRejected)); return $this; } @@ -111,14 +92,16 @@ function() { } private function resolve(mixed $value):void { + if($this->getState() === PromiseState::RESOLVED) { + return; + } $this->reset(); if($value instanceof PromiseInterface) { $this->reject(new PromiseResolvedWithAnotherPromiseException()); $this->tryComplete(); return; } - - $this->resolvedValue = $value; + $this->resolvedValue = $this->originalResolvedValue = $value; $this->resolvedValueSet = true; } @@ -137,6 +120,10 @@ private function reset():void { } private function tryComplete():void { + if($this->stopChain) { + $this->stopChain = false; + return; + } if(empty($this->chain)) { $this->throwUnhandledRejection(); return; @@ -146,100 +133,62 @@ private function tryComplete():void { } } - // phpcs:ignore - private function complete():void { - usort( - $this->chain, - function(Chainable $a, Chainable $b) { - if($a instanceof FinallyChain) { - return 1; - } - elseif($b instanceof FinallyChain) { - return -1; - } - - return 0; - } - ); - - while($this->getState() !== PromiseState::PENDING) { - $chainItem = $this->getNextChainItem(); - if(!$chainItem) { - break; - } - - if($chainItem instanceof ThenChain) { - try { - if($this->resolvedValueSet && isset($this->resolvedValue)) { - $chainItem->checkResolutionCallbackType($this->resolvedValue); - } - } - catch(ChainFunctionTypeError) { - continue; - } - - $this->handleThen($chainItem); - } - elseif($chainItem instanceof CatchChain) { - try { - if(isset($this->rejectedReason)) { - $chainItem->checkRejectionCallbackType($this->rejectedReason); - } - if($handled = $this->handleCatch($chainItem)) { - array_push($this->handledRejections, $handled); - } - } - catch(ChainFunctionTypeError) { - continue; - } - } - elseif($chainItem instanceof FinallyChain) { - $this->handleFinally($chainItem); - } - } - - $this->throwUnhandledRejection(); - } - private function getNextChainItem():?Chainable { return array_shift($this->chain); } - private function handleThen(ThenChain $then):void { + protected function handleThen(ThenChain $then):bool { if($this->getState() !== PromiseState::RESOLVED) { - return; + return false; } try { - $result = null; - if(isset($this->resolvedValue)) { - $result = $then->callOnResolved($this->resolvedValue); - } - - if($result instanceof PromiseInterface) { - $this->chainPromise($result); - } - elseif(is_null($result)) { - $this->reset(); - } - else { - $this->resolve($result); - } + $result = $then->callOnResolved($this->resolvedValue); + return $this->handleResolvedResult($result); } catch(Throwable $rejection) { $this->reject($rejection); } + + return false; + } + + protected function handleFinally(FinallyChain $finally):bool { + if($this->getState() === PromiseState::RESOLVED) { + $result = $finally->callOnResolved($this->resolvedValue); + return $this->handleResolvedResult($result); + } + elseif($this->getState() === PromiseState::REJECTED) { + $result = $finally->callOnRejected($this->rejectedReason); + return $this->handleResolvedResult($result); + } + + return false; + } + + private function handleResolvedResult(mixed $result):bool { + if($result instanceof PromiseInterface) { + $this->chainPromise($result); + } + elseif(is_null($result)) { + $this->stopChain = true; + return true; + } + else { + $this->resolvedValue = $result; + $this->resolvedValueSet = true; + $this->tryComplete(); + } + + return false; } - private function handleCatch(CatchChain $catch):?Throwable { + protected function handleCatch(CatchChain $catch):?Throwable { if($this->getState() !== PromiseState::REJECTED) { - array_push($this->uncalledCatchChain, $catch); return null; } - try { $result = $catch->callOnRejected($this->rejectedReason); - if($result instanceof PromiseInterface) { $this->chainPromise($result); } @@ -249,33 +198,13 @@ private function handleCatch(CatchChain $catch):?Throwable { else { return $this->rejectedReason; } - } catch(Throwable $rejection) { $this->reject($rejection); } - return null; } - private function handleFinally(FinallyChain $finally):void { - $result = null; - - if($this->getState() === PromiseState::RESOLVED) { - $result = $finally->callOnResolved($this->resolvedValue ?? null); - } - elseif($this->getState() === PromiseState::REJECTED) { - $result = $finally->callOnRejected($this->rejectedReason ?? null); - } - - if($result instanceof PromiseInterface) { - $this->chainPromise($result); - } - else { - $this->resolve($result); - } - } - protected function throwUnhandledRejection():void { if($this->getState() === PromiseState::REJECTED) { if(!in_array($this->rejectedReason, $this->handledRejections)) { @@ -283,4 +212,12 @@ protected function throwUnhandledRejection():void { } } } + + protected function emptyChain():void { + if(isset($this->originalResolvedValue)) { + $this->resolvedValue = $this->originalResolvedValue; + } + + $this->chain = []; + } } diff --git a/test/phpunit/DeferredTest.php b/test/phpunit/DeferredTest.php index d95932a..2d47966 100644 --- a/test/phpunit/DeferredTest.php +++ b/test/phpunit/DeferredTest.php @@ -79,4 +79,66 @@ public function testRejectPromise() { self::assertEquals(0, $numResolvedCalls); self::assertEquals(1, $numRejectedCalls); } + + public function testMultipleResolution() { + $deferred = new Deferred(); + $promise = $deferred->getPromise(); + + $received = []; + + $deferred->resolve("hello"); + + $promise->then(function(string $thing) use(&$received) { +// 0: Should resolve with "hello", from the above Deferred::resolve() call + array_push($received, $thing); + return "$thing-appended-from-1"; + })->then(function(string $thing) use(&$received) { +// 1: Should resolve with "hello-appended-from-1", due to the previous chained function returning the appended string. + array_push($received, $thing); +// This function does not return a value... + })->then(function(mixed $thing) use(&$received) { +// ... so this chained function should never be called. +// Notice the type hint for the function is mixed, in case there's an attempt at resolving with null. + array_push($received, $thing); + }); + +// 2: This function is at the start of a promise chain, which is already resolved, so it should be resolved with the original resolution of "hello". + $promise->then(function(string $thing) use(&$received) { + array_push($received, $thing); + }); + +// 3: This function is also at the start of a new promise chain, so it should also be resolved with "hello". + $promise->then(function(string $thing) use(&$received) { + array_push($received, $thing); +// but it doesn't return anything... + })->then(function(string $thing) use(&$received) { +// ... so no future promises in this chain should be resolved. + array_push($received, $thing); + })->then(function(string $thing) use(&$received) { + array_push($received, $thing); + }); + +// The Deferred is resolved with a new value, but the Promises/A+ specification +// states that a promise should only resolve once, and subsequent resolutions +// should be ignored. + $deferred->resolve("world"); + + $promise->then(function(string $thing) use(&$received) { +// 4: This promise starts a new chain, so it should resolve with the original resolved value, "hello". + array_push($received, $thing); +// but it doesn't return anything... + })->then(function(string $thing) use (&$received) { +// ... so no future promises in this chain should be resolved. + array_push($received, $thing); + }); + +// The count should match the commented behaviour above, to verify that chains +// after a non-returning handler are not invoked. + self::assertCount(5, $received); + self::assertSame("hello", $received[0], "String check 0"); + self::assertSame("hello-appended-from-1", $received[1], "String check 1"); + self::assertSame("hello", $received[2], "String check 2"); + self::assertSame("hello", $received[3], "String check 3"); + self::assertSame("hello", $received[4], "String check 4"); + } } diff --git a/test/phpunit/PromiseTest.php b/test/phpunit/PromiseTest.php index 6c4c001..7d95d8d 100644 --- a/test/phpunit/PromiseTest.php +++ b/test/phpunit/PromiseTest.php @@ -459,31 +459,41 @@ public function testFinallyCanReturnPromise() { $promiseContainer = $this->getTestPromiseContainer(); $sut = $promiseContainer->getPromise(); $sut->finally(function(mixed $resolvedValueOrRejectedReason) use($otherPromise, &$finallyLog) { + array_push($finallyLog, $resolvedValueOrRejectedReason); + return "First return"; + })->finally(function(mixed $resolvedValueOrRejectedReason) use($otherPromise, &$finallyLog) { array_push($finallyLog, $resolvedValueOrRejectedReason); return $otherPromise; })->finally(function(mixed $resolvedValueOrRejectedReason) use($otherPromise, &$finallyLog) { array_push($finallyLog, $resolvedValueOrRejectedReason); }); + $promiseContainer->resolve("test"); self::assertCount(2, $finallyLog); self::assertSame("test", $finallyLog[0]); - self::assertNull($finallyLog[1]); + self::assertSame("First return", $finallyLog[1]); } - public function testOnRejectedCalledWhenFinallyThrows() { + public function testOnRejectedNotCalledWhenFinallyThrows() { $exception = new PromiseException("Oh dear, oh dear"); + $actualException = null; $promiseContainer = $this->getTestPromiseContainer(); self::expectException(PromiseException::class); self::expectExceptionMessage("Oh dear, oh dear"); $sut = $promiseContainer->getPromise(); - $sut->finally(function() use ($exception) { + $sut->finally(function(string $message) use ($exception) { throw $exception; - })->then( - self::mockCallable(1, "Example resolution"), - self::mockCallable(0) - ); + })->then(function(string $message) { + return "$message-appended"; + })->catch(function(Throwable $reason) use(&$actualException) { + $actualException = $reason; + }); + + self::expectException(PromiseException::class); + self::expectExceptionMessage("Oh dear, oh dear"); $promiseContainer->resolve("Example resolution"); + self::assertNull($actualException); } public function testGetStatePending() {