From 9219299b418802b9d14ee824737651e8feb910c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 27 May 2022 19:35:40 +0200 Subject: [PATCH 1/2] Support `iterable` type for `all()` + `race()` + `any()` --- README.md | 6 +++--- src/functions.php | 26 +++++++++++++++++++------- tests/FunctionAllTest.php | 18 ++++++++++++++++++ tests/FunctionAnyTest.php | 20 +++++++++++++++++++- tests/FunctionRaceTest.php | 18 ++++++++++++++++++ 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9967bc3f..d2e94534 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,7 @@ reject a promise, any language error or user land exception can be used to rejec #### all() ```php -$promise = React\Promise\all(array $promisesOrValues); +$promise = React\Promise\all(iterable $promisesOrValues); ``` Returns a promise that will resolve only once all the items in @@ -387,7 +387,7 @@ will be an array containing the resolution values of each of the items in #### race() ```php -$promise = React\Promise\race(array $promisesOrValues); +$promise = React\Promise\race(iterable $promisesOrValues); ``` Initiates a competitive race that allows one winner. Returns a promise which is @@ -399,7 +399,7 @@ contains 0 items. #### any() ```php -$promise = React\Promise\any(array $promisesOrValues); +$promise = React\Promise\any(iterable $promisesOrValues); ``` Returns a promise that will resolve when any one of the items in diff --git a/src/functions.php b/src/functions.php index ad788f4f..87bfbf14 100644 --- a/src/functions.php +++ b/src/functions.php @@ -68,11 +68,15 @@ function reject(\Throwable $reason): PromiseInterface * will be an array containing the resolution values of each of the items in * `$promisesOrValues`. * - * @param array $promisesOrValues + * @param iterable $promisesOrValues * @return PromiseInterface */ -function all(array $promisesOrValues): PromiseInterface +function all(iterable $promisesOrValues): PromiseInterface { + if (!\is_array($promisesOrValues)) { + $promisesOrValues = \iterator_to_array($promisesOrValues); + } + if (!$promisesOrValues) { return resolve([]); } @@ -109,11 +113,15 @@ function ($mapped) use ($i, &$values, &$toResolve, $resolve): void { * The returned promise will become **infinitely pending** if `$promisesOrValues` * contains 0 items. * - * @param array $promisesOrValues + * @param iterable $promisesOrValues * @return PromiseInterface */ -function race(array $promisesOrValues): PromiseInterface +function race(iterable $promisesOrValues): PromiseInterface { + if (!\is_array($promisesOrValues)) { + $promisesOrValues = \iterator_to_array($promisesOrValues); + } + if (!$promisesOrValues) { return new Promise(function (): void {}); } @@ -141,18 +149,22 @@ function race(array $promisesOrValues): PromiseInterface * The returned promise will also reject with a `React\Promise\Exception\LengthException` * if `$promisesOrValues` contains 0 items. * - * @param array $promisesOrValues + * @param iterable $promisesOrValues * @return PromiseInterface */ -function any(array $promisesOrValues): PromiseInterface +function any(iterable $promisesOrValues): PromiseInterface { + if (!\is_array($promisesOrValues)) { + $promisesOrValues = \iterator_to_array($promisesOrValues); + } + $len = \count($promisesOrValues); if (!$promisesOrValues) { return reject( new Exception\LengthException( \sprintf( - 'Input array must contain at least 1 item but contains only %s item%s.', + 'Must contain at least 1 item but contains only %s item%s.', $len, 1 === $len ? '' : 's' ) diff --git a/tests/FunctionAllTest.php b/tests/FunctionAllTest.php index a268c21a..e82cd638 100644 --- a/tests/FunctionAllTest.php +++ b/tests/FunctionAllTest.php @@ -58,6 +58,24 @@ public function shouldResolveSparseArrayInput() ->then($mock); } + /** @test */ + public function shouldResolveValuesGenerator() + { + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with(self::identicalTo([1, 2, 3])); + + $gen = (function () { + for ($i = 1; $i <= 3; ++$i) { + yield $i; + } + })(); + + all($gen)->then($mock); + } + /** @test */ public function shouldRejectIfAnyInputPromiseRejects() { diff --git a/tests/FunctionAnyTest.php b/tests/FunctionAnyTest.php index 09e73548..90f215d3 100644 --- a/tests/FunctionAnyTest.php +++ b/tests/FunctionAnyTest.php @@ -18,7 +18,7 @@ public function shouldRejectWithLengthExceptionWithEmptyInputArray() ->with( self::callback(function ($exception) { return $exception instanceof LengthException && - 'Input array must contain at least 1 item but contains only 0 items.' === $exception->getMessage(); + 'Must contain at least 1 item but contains only 0 items.' === $exception->getMessage(); }) ); @@ -52,6 +52,24 @@ public function shouldResolveWithAPromisedInputValue() ->then($mock); } + /** @test */ + public function shouldResolveValuesGenerator() + { + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with(self::identicalTo(1)); + + $gen = (function () { + for ($i = 1; $i <= 3; ++$i) { + yield $i; + } + })(); + + any($gen)->then($mock); + } + /** @test */ public function shouldRejectWithAllRejectedInputValuesIfAllInputsAreRejected() { diff --git a/tests/FunctionRaceTest.php b/tests/FunctionRaceTest.php index 40200256..78017ccb 100644 --- a/tests/FunctionRaceTest.php +++ b/tests/FunctionRaceTest.php @@ -65,6 +65,24 @@ public function shouldResolveSparseArrayInput() )->then($mock); } + /** @test */ + public function shouldResolveValuesGenerator() + { + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with(self::identicalTo(1)); + + $gen = (function () { + for ($i = 1; $i <= 3; ++$i) { + yield $i; + } + })(); + + race($gen)->then($mock); + } + /** @test */ public function shouldRejectIfFirstSettledPromiseRejects() { From 57fa798300582ad1ccf7b87d735bd9f3297e4afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 7 Jun 2022 21:44:29 +0200 Subject: [PATCH 2/2] Take advantage of iterators instead of converting to array first --- src/functions.php | 135 +++++++++++++++++++------------------ tests/FunctionAllTest.php | 36 ++++++++++ tests/FunctionAnyTest.php | 75 +++++++++++++++++++++ tests/FunctionRaceTest.php | 18 +++++ 4 files changed, 197 insertions(+), 67 deletions(-) diff --git a/src/functions.php b/src/functions.php index 87bfbf14..fbe4961d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -73,35 +73,40 @@ function reject(\Throwable $reason): PromiseInterface */ function all(iterable $promisesOrValues): PromiseInterface { - if (!\is_array($promisesOrValues)) { - $promisesOrValues = \iterator_to_array($promisesOrValues); - } - - if (!$promisesOrValues) { - return resolve([]); - } - $cancellationQueue = new Internal\CancellationQueue(); return new Promise(function ($resolve, $reject) use ($promisesOrValues, $cancellationQueue): void { - $toResolve = \count($promisesOrValues); + $toResolve = 0; + $continue = true; $values = []; foreach ($promisesOrValues as $i => $promiseOrValue) { $cancellationQueue->enqueue($promiseOrValue); $values[$i] = null; + ++$toResolve; + + resolve($promiseOrValue)->then( + function ($value) use ($i, &$values, &$toResolve, &$continue, $resolve): void { + $values[$i] = $value; + + if (0 === --$toResolve && !$continue) { + $resolve($values); + } + }, + function (\Throwable $reason) use (&$continue, $reject): void { + $continue = false; + $reject($reason); + } + ); - resolve($promiseOrValue) - ->then( - function ($mapped) use ($i, &$values, &$toResolve, $resolve): void { - $values[$i] = $mapped; - - if (0 === --$toResolve) { - $resolve($values); - } - }, - $reject - ); + if (!$continue) { + break; + } + } + + $continue = false; + if ($toResolve === 0) { + $resolve($values); } }, $cancellationQueue); } @@ -118,22 +123,21 @@ function ($mapped) use ($i, &$values, &$toResolve, $resolve): void { */ function race(iterable $promisesOrValues): PromiseInterface { - if (!\is_array($promisesOrValues)) { - $promisesOrValues = \iterator_to_array($promisesOrValues); - } - - if (!$promisesOrValues) { - return new Promise(function (): void {}); - } - $cancellationQueue = new Internal\CancellationQueue(); return new Promise(function ($resolve, $reject) use ($promisesOrValues, $cancellationQueue): void { + $continue = true; + foreach ($promisesOrValues as $promiseOrValue) { $cancellationQueue->enqueue($promiseOrValue); - resolve($promiseOrValue) - ->then($resolve, $reject); + resolve($promiseOrValue)->then($resolve, $reject)->finally(function () use (&$continue): void { + $continue = false; + }); + + if (!$continue) { + break; + } } }, $cancellationQueue); } @@ -154,52 +158,49 @@ function race(iterable $promisesOrValues): PromiseInterface */ function any(iterable $promisesOrValues): PromiseInterface { - if (!\is_array($promisesOrValues)) { - $promisesOrValues = \iterator_to_array($promisesOrValues); - } - - $len = \count($promisesOrValues); - - if (!$promisesOrValues) { - return reject( - new Exception\LengthException( - \sprintf( - 'Must contain at least 1 item but contains only %s item%s.', - $len, - 1 === $len ? '' : 's' - ) - ) - ); - } - $cancellationQueue = new Internal\CancellationQueue(); - return new Promise(function ($resolve, $reject) use ($len, $promisesOrValues, $cancellationQueue): void { - $toReject = $len; - $reasons = []; + return new Promise(function ($resolve, $reject) use ($promisesOrValues, $cancellationQueue): void { + $toReject = 0; + $continue = true; + $reasons = []; foreach ($promisesOrValues as $i => $promiseOrValue) { - $fulfiller = function ($val) use ($resolve): void { - $resolve($val); - }; - - $rejecter = function (\Throwable $reason) use ($i, &$reasons, &$toReject, $reject): void { - $reasons[$i] = $reason; - - if (0 === --$toReject) { - $reject( - new CompositeException( + $cancellationQueue->enqueue($promiseOrValue); + ++$toReject; + + resolve($promiseOrValue)->then( + function ($value) use ($resolve, &$continue): void { + $continue = false; + $resolve($value); + }, + function (\Throwable $reason) use ($i, &$reasons, &$toReject, $reject, &$continue): void { + $reasons[$i] = $reason; + + if (0 === --$toReject && !$continue) { + $reject(new CompositeException( $reasons, 'All promises rejected.' - ) - ); + )); + } } - }; + ); - $cancellationQueue->enqueue($promiseOrValue); + if (!$continue) { + break; + } + } - resolve($promiseOrValue) - ->then($fulfiller, $rejecter); + $continue = false; + if ($toReject === 0 && !$reasons) { + $reject(new Exception\LengthException( + 'Must contain at least 1 item but contains only 0 items.' + )); + } elseif ($toReject === 0) { + $reject(new CompositeException( + $reasons, + 'All promises rejected.' + )); } }, $cancellationQueue); } diff --git a/tests/FunctionAllTest.php b/tests/FunctionAllTest.php index e82cd638..4d91eb04 100644 --- a/tests/FunctionAllTest.php +++ b/tests/FunctionAllTest.php @@ -76,6 +76,24 @@ public function shouldResolveValuesGenerator() all($gen)->then($mock); } + /** @test */ + public function shouldResolveValuesGeneratorEmpty() + { + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with(self::identicalTo([])); + + $gen = (function () { + if (false) { + yield; + } + })(); + + all($gen)->then($mock); + } + /** @test */ public function shouldRejectIfAnyInputPromiseRejects() { @@ -92,6 +110,24 @@ public function shouldRejectIfAnyInputPromiseRejects() ->then($this->expectCallableNever(), $mock); } + /** @test */ + public function shouldRejectInfiteGeneratorOrRejectedPromises() + { + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with(new \RuntimeException('Iteration 1')); + + $gen = (function () { + for ($i = 1; ; ++$i) { + yield reject(new \RuntimeException('Iteration ' . $i)); + } + })(); + + all($gen)->then(null, $mock); + } + /** @test */ public function shouldPreserveTheOrderOfArrayWhenResolvingAsyncPromises() { diff --git a/tests/FunctionAnyTest.php b/tests/FunctionAnyTest.php index 90f215d3..57661641 100644 --- a/tests/FunctionAnyTest.php +++ b/tests/FunctionAnyTest.php @@ -26,6 +26,24 @@ public function shouldRejectWithLengthExceptionWithEmptyInputArray() ->then($this->expectCallableNever(), $mock); } + /** @test */ + public function shouldRejectWithLengthExceptionWithEmptyInputGenerator() + { + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with(new LengthException('Must contain at least 1 item but contains only 0 items.')); + + $gen = (function () { + if (false) { + yield; + } + })(); + + any($gen)->then($this->expectCallableNever(), $mock); + } + /** @test */ public function shouldResolveWithAnInputValue() { @@ -52,6 +70,22 @@ public function shouldResolveWithAPromisedInputValue() ->then($mock); } + /** @test */ + public function shouldResolveWithAnInputValueFromDeferred() + { + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with(self::identicalTo(1)); + + $deferred = new Deferred(); + + any([$deferred->promise()])->then($mock); + + $deferred->resolve(1); + } + /** @test */ public function shouldResolveValuesGenerator() { @@ -70,6 +104,24 @@ public function shouldResolveValuesGenerator() any($gen)->then($mock); } + /** @test */ + public function shouldResolveValuesInfiniteGenerator() + { + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with(self::identicalTo(1)); + + $gen = (function () { + for ($i = 1; ; ++$i) { + yield $i; + } + })(); + + any($gen)->then($mock); + } + /** @test */ public function shouldRejectWithAllRejectedInputValuesIfAllInputsAreRejected() { @@ -92,6 +144,29 @@ public function shouldRejectWithAllRejectedInputValuesIfAllInputsAreRejected() ->then($this->expectCallableNever(), $mock); } + /** @test */ + public function shouldRejectWithAllRejectedInputValuesIfInputIsRejectedFromDeferred() + { + $exception = new Exception(); + + $compositeException = new CompositeException( + [2 => $exception], + 'All promises rejected.' + ); + + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with($compositeException); + + $deferred = new Deferred(); + + any([2 => $deferred->promise()])->then($this->expectCallableNever(), $mock); + + $deferred->reject($exception); + } + /** @test */ public function shouldResolveWhenFirstInputPromiseResolves() { diff --git a/tests/FunctionRaceTest.php b/tests/FunctionRaceTest.php index 78017ccb..6e69be07 100644 --- a/tests/FunctionRaceTest.php +++ b/tests/FunctionRaceTest.php @@ -83,6 +83,24 @@ public function shouldResolveValuesGenerator() race($gen)->then($mock); } + /** @test */ + public function shouldResolveValuesInfiniteGenerator() + { + $mock = $this->createCallableMock(); + $mock + ->expects(self::once()) + ->method('__invoke') + ->with(self::identicalTo(1)); + + $gen = (function () { + for ($i = 1; ; ++$i) { + yield $i; + } + })(); + + race($gen)->then($mock); + } + /** @test */ public function shouldRejectIfFirstSettledPromiseRejects() {