From 1e151085363f3070e7548a38489c903728d9d189 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Fri, 21 May 2021 19:24:51 +0100 Subject: [PATCH] Chained promises (#14) * Add missing typehint * Add missing typehint * Add missing typehint * Update dependencies * Add badges and reorder intro docs * Update dependencies * Isolate bug #12 * Add support for 7.4 and 8.0 * Improve code quality * Upgrade dependencies with PHP 8 * Remove composer's lockfile * Update tests with more realistic usages * Add documentation comments to complex test * Introduce dependent deferred processes * Pass tests for #12 by nulling resolved value after completion callback * feature: add process to Deferred --- .gitignore | 1 + src/Promise.php | 63 +++++++++++++++-------- test/phpunit/PromiseTest.php | 99 +++++++++++++++++++++++++++++++++--- 3 files changed, 133 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 8d7aea1..a5109b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor +composer.lock /test/phpunit/_coverage .phpunit.result.cache /example.* \ No newline at end of file diff --git a/src/Promise.php b/src/Promise.php index 2debff5..0c077d5 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -14,6 +14,8 @@ class Promise implements PromiseInterface, HttpPromiseInterface { private $resolvedValue; /** @var Chainable[] */ private array $chain; + /** @var Chainable[] */ + private array $pendingChain; /** @var callable */ private $executor; private ?Throwable $rejection; @@ -24,6 +26,7 @@ class Promise implements PromiseInterface, HttpPromiseInterface { public function __construct(callable $executor) { $this->state = HttpPromiseInterface::PENDING; $this->chain = []; + $this->pendingChain = []; $this->rejection = null; $this->executor = $executor; @@ -79,13 +82,6 @@ private function complete( callable $onFulfilled = null, callable $onRejected = null ):void { - if(isset($this->rejection)) { - $this->state = HttpPromiseInterface::REJECTED; - } - elseif(isset($this->resolvedValue)) { - $this->state = HttpPromiseInterface::FULFILLED; - } - if($onFulfilled || $onRejected) { $this->then($onFulfilled, $onRejected); } @@ -125,18 +121,34 @@ private function handleChain():void { } } else { - $value = $then->callOnFulfilled($this->resolvedValue); - if($value instanceof PromiseInterface) { - $value->then(function($resolvedValue) { - $this->resolvedValue = $resolvedValue; - $this->complete(); - }); - break; + if(isset($this->resolvedValue)) { + $value = $then->callOnFulfilled($this->resolvedValue); + + if($value instanceof PromiseInterface) { + unset($this->resolvedValue); + + array_push($this->pendingChain, $this->chain[0] ?? null); + + $value->then(function($resolvedValue) { + $this->resolvedValue = $resolvedValue; + $then = array_pop($this->pendingChain); + if($then) { + $then->callOnFulfilled($this->resolvedValue); + $this->resolvedValue = null; + } + $this->complete(); + }); + break; + } + + $this->state = HttpPromiseInterface::FULFILLED; + if(!is_null($value)) { + $this->resolvedValue = $value; + } } - - $this->state = HttpPromiseInterface::FULFILLED; - if(!is_null($value)) { - $this->resolvedValue = $value; + elseif($then instanceof FinallyChain + && isset($this->rejection)) { + $then->callOnFulfilled($this->rejection); } } } @@ -145,10 +157,19 @@ private function handleChain():void { } } - if(!$emptyChain - && $reason = array_shift($rejectedForwardQueue)) { + $reason = array_shift($rejectedForwardQueue); + if($reason && !$emptyChain) { throw $reason; } + + if($emptyChain) { + if($reason) { + $this->state = HttpPromiseInterface::REJECTED; + } + else { + $this->state = HttpPromiseInterface::FULFILLED; + } + } } public function getState():string { @@ -212,11 +233,9 @@ private function resolve($value):void { } $this->resolvedValue = $value; - $this->state = HttpPromiseInterface::FULFILLED; } private function reject(Throwable $reason):void { $this->rejection = $reason; - $this->state = HttpPromiseInterface::REJECTED; } } \ No newline at end of file diff --git a/test/phpunit/PromiseTest.php b/test/phpunit/PromiseTest.php index d7b0714..125ce7c 100644 --- a/test/phpunit/PromiseTest.php +++ b/test/phpunit/PromiseTest.php @@ -583,15 +583,20 @@ public function testNoCatchMethodBubblesThrowables() { public function testWait() { $callCount = 0; $resolveCallback = null; - $executor = function(callable $resolve, callable $reject) use(&$resolveCallback):void { + $rejectCallback = null; + $completeCallback = null; + $executor = function(callable $resolve, callable $reject, callable $complete) use(&$resolveCallback, &$rejectCallback, &$completeCallback):void { $resolveCallback = $resolve; + $rejectCallback = $reject; + $completeCallback = $complete; }; $resolvedValue = "Done!"; $sut = new Promise($executor); - $waitTask = function() use(&$callCount, $resolveCallback, $resolvedValue) { + $waitTask = function() use(&$callCount, $resolveCallback, $resolvedValue, $completeCallback) { if($callCount >= 10) { call_user_func($resolveCallback, $resolvedValue); + call_user_func($completeCallback); } else { $callCount++; @@ -606,15 +611,20 @@ public function testWait() { public function testWaitNotUnwrapped() { $callCount = 0; $resolveCallback = null; - $executor = function(callable $resolve, callable $reject) use(&$resolveCallback):void { + $rejectCallback = null; + $completeCallback = null; + $executor = function(callable $resolve, callable $reject, callable $complete) use(&$resolveCallback, &$rejectCallback, &$completeCallback):void { $resolveCallback = $resolve; + $rejectCallback = $reject; + $completeCallback = $complete; }; $resolvedValue = "Done!"; $sut = new Promise($executor); - $waitTask = function() use(&$callCount, $resolveCallback, $resolvedValue) { + $waitTask = function() use(&$callCount, $resolveCallback, $resolvedValue, $completeCallback) { if($callCount >= 10) { call_user_func($resolveCallback, $resolvedValue); + call_user_func($completeCallback); } else { $callCount++; @@ -629,8 +639,12 @@ public function testWaitNotUnwrapped() { public function testWaitUnwrapsFinalValue() { $callCount = 0; $resolveCallback = null; - $executor = function(callable $resolve, callable $reject) use(&$resolveCallback):void { + $rejectCallback = null; + $completeCallback = null; + $executor = function(callable $resolve, callable $reject, callable $complete) use(&$resolveCallback, &$rejectCallback, &$completeCallback):void { $resolveCallback = $resolve; + $rejectCallback = $reject; + $completeCallback = $complete; }; $resolvedValue = "Done!"; $sut = new Promise($executor); @@ -638,9 +652,10 @@ public function testWaitUnwrapsFinalValue() { return "Returned from within onFulfilled!"; }); - $waitTask = function() use(&$callCount, $resolveCallback, $resolvedValue) { + $waitTask = function() use(&$callCount, $resolveCallback, $resolvedValue, $completeCallback) { if($callCount >= 10) { call_user_func($resolveCallback, $resolvedValue); + call_user_func($completeCallback); } else { $callCount++; @@ -680,12 +695,80 @@ public function testFulfilledReturnsNewPromiseThatIsResolved() { return $messagePromise; })->then(self::mockCallable(1, "Your number is 105")); -// TODO: Issue #12: 105 resolves before the message does, so the numberPromise's then function will return a pending Promise. -// The chained then should only be called after the message is resolved, so this needs to be stored internally somewhere for fulfillment. $numberPromiseContainer->resolve(105); $messagePromiseContainer->resolve("Your number is $numberToResolveWith"); } + /** + * Similar test to the one above, but done in a different style. + * Closer to a real-world usage, this emulates getting a person's + * address from their name, from an external list. + */ + public function testFulfilledReturnsNewPromiseThatIsResolved2() { +// Our fake data source that will be "searched" by a deferred task (not using an +// actual Deferred object, but instead, longhand performing a loop outside +// of the Promise callback). + $addressBook = [ + "Adrian Appleby" => "16B Acorn Grove", + "Bentley Buttersworth" => "59 Brambetwicket Drive", + "Cacey Coggleton" => "10 Cambridge Road", + ]; +// The search term used to resolve the first promise with. + $searchTerm = null; +// We will store any parameters received by the promise fulfilment callbacks. + $receivedNames = []; + $receivedAddresses = []; + +// All references to the various callbacks, usually handled by a Deferred: + $fulfill = null; + $reject = null; + $complete = null; + $innerFulfill = null; + $innerReject = null; + $innerComplete = null; + $innerPromise = null; + + $sut = new Promise(function($f, $r, $c) use(&$fulfill, &$reject, &$complete) { + $fulfill = $f; + $reject = $r; + $complete = $c; + }); + +// Define asynchronous behaviour: + $sut->then(function(string $name) use(&$innerFulfil, &$innerReject, &$innerComplete, &$innerPromise, &$searchTerm, &$receivedNames) { + array_push($receivedNames, $name); + $searchTerm = $name; + + $innerPromise = new Promise(function($f, $r, $c) use(&$innerFulfil, &$innerReject, &$innerComplete) { + $innerFulfil = $f; + $innerReject = $r; + $innerComplete = $c; + }); + return $innerPromise; + })->then(function(string $address) use(&$receivedAddresses) { + array_push($receivedAddresses, $address); + }); + +// This is the "user code" that initiates the search. +// Completing the promise resolution with "Butter" will call the Promise's +// onFulfilled callback, thus our $searchTerm variable should contain "Butter". + call_user_func($fulfill, "Butter"); + call_user_func($complete); + self::assertEquals("Butter", $searchTerm); + +// This is the deferred task for the search: + foreach($addressBook as $name => $address) { + if(strstr($name, $searchTerm)) { + call_user_func($innerFulfil, $address); + call_user_func($innerComplete); + } + } + + self::assertCount(1, $receivedNames); + self::assertCount(1, $receivedAddresses); + self::assertEquals($addressBook["Bentley Buttersworth"], $receivedAddresses[0]); + } + protected function getTestPromiseContainer():TestPromiseContainer { $resolveCallback = null; $rejectCallback = null;