Skip to content

Commit

Permalink
Chained promises (#14)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
g105b authored May 21, 2021
1 parent ebd0067 commit 1e15108
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 30 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/vendor
composer.lock
/test/phpunit/_coverage
.phpunit.result.cache
/example.*
63 changes: 41 additions & 22 deletions src/Promise.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
}
99 changes: 91 additions & 8 deletions test/phpunit/PromiseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand All @@ -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++;
Expand All @@ -629,18 +639,23 @@ 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);
$sut->then(function($fulfilled) {
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++;
Expand Down Expand Up @@ -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;
Expand Down

0 comments on commit 1e15108

Please sign in to comment.