Repository: reactphp/promise (php)

Issue:
Allow `iterable` instead of `array` for `all()`, `race()` and `any()`
We should support accepting an `iterable` instead of only an `array` for `all()`, `race()` and `any()`. The `iterable` pseudo type is available as of PHP 7.1, so we can take advantage of this for our Promise v3 (see also #149). It supports passing an `array` just like now (see also #25), but also accepts `Iterator`s and `Generator`s.

(The same type also applies to the `some()`, `map()` and `reduce()` functions. I've filed #219 to discuss removing these functions for the Promise v3). 

I'm not currently working on this, but figured it makes sense to report here in order to track this feature request. In case anybody feels like picking this up, any input and PRs would be much appreciated :+1: 

Pull request description:
Support `iterable` type for `all()` + `race()` + `any()`
This changeset adds support for the `iterable` type for `all()` + `race()` + `any()`. Instead of only accepting an `array`, each function now also accepts instances implementing `Traversable` such as `Iterator` and `Generator` instances.

This is a pure feature addition that does not break BC. With these changes applied, our API is also pretty much identical to ES6 promises commonly used in JavaScript.

The first commit highlights how this is essentially only a new type definition and a call to `iterator_to_array()`. The second commit then takes advantage of a more iterative approach that uses less memory and also supports consuming infinite iterators if the resulting promise settles. The test suite confirms this has 100% code coverage.

Resolves #221
Builds on top of #149, #35, #219 and others

Patch:
diff --git a/README.md b/README.md
index 9967bc3..d2e9453 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 ad788f4..fbe4961 100644
--- a/src/functions.php
+++ b/src/functions.php
@@ -68,36 +68,45 @@ 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 (!$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);
+                }
+            );
+
+            if (!$continue) {
+                break;
+            }
+        }
 
-            resolve($promiseOrValue)
-                ->then(
-                    function ($mapped) use ($i, &$values, &$toResolve, $resolve): void {
-                        $values[$i] = $mapped;
-
-                        if (0 === --$toResolve) {
-                            $resolve($values);
-                        }
-                    },
-                    $reject
-                );
+        $continue = false;
+        if ($toResolve === 0) {
+            $resolve($values);
         }
     }, $cancellationQueue);
 }
@@ -109,23 +118,26 @@ function all(array $promisesOrValues): PromiseInterface
  * 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 (!$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);
 }
@@ -141,53 +153,54 @@ 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
 {
-    $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.',
-                    $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);
 }


Test patch:
diff --git a/tests/FunctionAllTest.php b/tests/FunctionAllTest.php
index a268c21..4d91eb0 100644
--- a/tests/FunctionAllTest.php
+++ b/tests/FunctionAllTest.php
@@ -58,6 +58,42 @@ class FunctionAllTest extends TestCase
             ->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 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()
     {
@@ -74,6 +110,24 @@ class FunctionAllTest extends TestCase
             ->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 09e7354..5766164 100644
--- a/tests/FunctionAnyTest.php
+++ b/tests/FunctionAnyTest.php
@@ -18,7 +18,7 @@ class FunctionAnyTest extends TestCase
             ->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();
                 })
             );
 
@@ -26,6 +26,24 @@ class FunctionAnyTest extends TestCase
             ->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,58 @@ class FunctionAnyTest extends TestCase
             ->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()
+    {
+        $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 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()
     {
@@ -74,6 +144,29 @@ class FunctionAnyTest extends TestCase
             ->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 4020025..6e69be0 100644
--- a/tests/FunctionRaceTest.php
+++ b/tests/FunctionRaceTest.php
@@ -65,6 +65,42 @@ class FunctionRaceTest extends TestCase
         )->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 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()
     {