diff --git a/composer.json b/composer.json index 22dae5a2..2955e558 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,8 @@ {"name": "Jan Sorgalla", "email": "jsorgalla@gmail.com"} ], "require": { - "php": ">=5.4.0" + "php": ">=5.4.0", + "async-interop/promise": "^0.2.0" }, "autoload": { "psr-4": { diff --git a/src/FulfilledPromise.php b/src/FulfilledPromise.php index 914bb5c1..4414d116 100644 --- a/src/FulfilledPromise.php +++ b/src/FulfilledPromise.php @@ -2,13 +2,15 @@ namespace React\Promise; -class FulfilledPromise implements ExtendedPromiseInterface, CancellablePromiseInterface +use Interop\Async\Promise as AsyncInteropPromise; + +class FulfilledPromise implements ExtendedPromiseInterface, CancellablePromiseInterface, AsyncInteropPromise { private $value; public function __construct($value = null) { - if ($value instanceof PromiseInterface) { + if ($value instanceof PromiseInterface || $value instanceof AsyncInteropPromise) { throw new \InvalidArgumentException('You cannot create React\Promise\FulfilledPromise with a promise. Use React\Promise\resolve($promiseOrValue) instead.'); } @@ -65,4 +67,9 @@ public function progress(callable $onProgress) public function cancel() { } + + public function when(callable $onResolved) + { + $onResolved(null, $this->value); + } } diff --git a/src/LazyPromise.php b/src/LazyPromise.php index 7e3a3d3d..ec683866 100644 --- a/src/LazyPromise.php +++ b/src/LazyPromise.php @@ -2,7 +2,9 @@ namespace React\Promise; -class LazyPromise implements ExtendedPromiseInterface, CancellablePromiseInterface +use Interop\Async\Promise as AsyncInteropPromise; + +class LazyPromise implements ExtendedPromiseInterface, CancellablePromiseInterface, AsyncInteropPromise { private $factory; private $promise; @@ -42,6 +44,11 @@ public function cancel() return $this->promise()->cancel(); } + public function when(callable $onResolved) + { + return $this->promise()->when($onResolved); + } + /** * @internal * @see Promise::settle() diff --git a/src/Promise.php b/src/Promise.php index c8a3f7d6..8b2e2a76 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -2,7 +2,9 @@ namespace React\Promise; -class Promise implements ExtendedPromiseInterface, CancellablePromiseInterface +use Interop\Async\Promise as AsyncInteropPromise; + +class Promise implements ExtendedPromiseInterface, CancellablePromiseInterface, AsyncInteropPromise { private $canceller; private $result; @@ -97,6 +99,18 @@ public function cancel() $this->call($canceller); } + public function when(callable $onResolved) + { + $this->done(function ($value) use ($onResolved) { + $onResolved(null, $value); + }, function ($reason) use ($onResolved) { + $onResolved( + UnhandledRejectionException::resolve($reason), + null + ); + }); + } + private function resolver(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) { return function ($resolve, $reject, $notify) use ($onFulfilled, $onRejected, $onProgress) { diff --git a/src/RejectedPromise.php b/src/RejectedPromise.php index 479a746b..258f6573 100644 --- a/src/RejectedPromise.php +++ b/src/RejectedPromise.php @@ -2,13 +2,15 @@ namespace React\Promise; -class RejectedPromise implements ExtendedPromiseInterface, CancellablePromiseInterface +use Interop\Async\Promise as AsyncInteropPromise; + +class RejectedPromise implements ExtendedPromiseInterface, CancellablePromiseInterface, AsyncInteropPromise { private $reason; public function __construct($reason = null) { - if ($reason instanceof PromiseInterface) { + if ($reason instanceof PromiseInterface || $reason instanceof AsyncInteropPromise) { throw new \InvalidArgumentException('You cannot create React\Promise\RejectedPromise with a promise. Use React\Promise\reject($promiseOrValue) instead.'); } @@ -73,4 +75,12 @@ public function progress(callable $onProgress) public function cancel() { } + + public function when(callable $onResolved) + { + $onResolved( + UnhandledRejectionException::resolve($this->reason), + null + ); + } } diff --git a/src/functions.php b/src/functions.php index 4f49b011..37b65ee5 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,12 +2,27 @@ namespace React\Promise; +use Interop\Async\Promise as AsyncInteropPromise; + function resolve($promiseOrValue = null) { if ($promiseOrValue instanceof ExtendedPromiseInterface) { return $promiseOrValue; } + if ($promiseOrValue instanceof AsyncInteropPromise) { + return new Promise(function ($resolve, $reject) use ($promiseOrValue) { + $promiseOrValue->when(function ($reason = null, $value = null) use ($resolve, $reject) { + if ($reason) { + $reject($reason); + return; + } + + $resolve($value); + }); + }); + } + if (method_exists($promiseOrValue, 'then')) { $canceller = null; @@ -31,6 +46,14 @@ function reject($promiseOrValue = null) }); } + if ($promiseOrValue instanceof AsyncInteropPromise) { + return new Promise(function ($resolve, $reject) use ($promiseOrValue) { + $promiseOrValue->when(function ($reason = null, $value = null) use ($resolve, $reject) { + $reject($reason ? $reason : $value); + }); + }); + } + return new RejectedPromise($promiseOrValue); } diff --git a/tests/FulfilledPromiseTest.php b/tests/FulfilledPromiseTest.php index 97fc8f6c..d1a1f9fc 100644 --- a/tests/FulfilledPromiseTest.php +++ b/tests/FulfilledPromiseTest.php @@ -7,7 +7,8 @@ class FulfilledPromiseTest extends TestCase { use PromiseTest\PromiseSettledTestTrait, - PromiseTest\PromiseFulfilledTestTrait; + PromiseTest\PromiseFulfilledTestTrait, + PromiseTest\AsyncInteropResolvedTestTrait; public function getPromiseTestAdapter(callable $canceller = null) { @@ -47,4 +48,12 @@ public function shouldThrowExceptionIfConstructedWithAPromise() return new FulfilledPromise(new FulfilledPromise()); } + + /** @test */ + public function shouldThrowExceptionIfConstructedWithAAsyncInteropPromise() + { + $this->setExpectedException('\InvalidArgumentException'); + + return new FulfilledPromise(new FulfilledPromise()); + } } diff --git a/tests/FunctionRejectTest.php b/tests/FunctionRejectTest.php index 84b8ec6a..dd4ab7b6 100644 --- a/tests/FunctionRejectTest.php +++ b/tests/FunctionRejectTest.php @@ -42,6 +42,24 @@ public function shouldRejectAFulfilledPromise() ); } + /** @test */ + public function shouldRejectAFulfilledAsyncInteropPromise() + { + $resolved = new SimpleFulfilledAsyncInteropTestPromise(); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo('foo')); + + reject($resolved) + ->then( + $this->expectCallableNever(), + $mock + ); + } + /** @test */ public function shouldRejectARejectedPromise() { @@ -61,4 +79,23 @@ public function shouldRejectARejectedPromise() $mock ); } + + /** @test */ + public function shouldRejectARejectedAsyncInteropPromise() + { + $exception = new \Exception('foo'); + $resolved = new SimpleRejectedAsyncInteropTestPromise($exception); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo($exception)); + + reject($resolved) + ->then( + $this->expectCallableNever(), + $mock + ); + } } diff --git a/tests/FunctionResolveTest.php b/tests/FunctionResolveTest.php index 53126bc0..045d6ca7 100644 --- a/tests/FunctionResolveTest.php +++ b/tests/FunctionResolveTest.php @@ -71,6 +71,24 @@ public function shouldResolveACancellableThenable() $this->assertTrue($thenable->cancelCalled); } + /** @test */ + public function shouldResolveAFulfilledAsyncInteropPromise() + { + $promise = new SimpleFulfilledAsyncInteropTestPromise(); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo('foo')); + + resolve($promise) + ->then( + $mock, + $this->expectCallableNever() + ); + } + /** @test */ public function shouldRejectARejectedPromise() { @@ -91,6 +109,25 @@ public function shouldRejectARejectedPromise() ); } + /** @test */ + public function shouldRejectARejectedAsyncInteropPromise() + { + $exception = new \Exception('foo'); + $promise = new SimpleRejectedAsyncInteropTestPromise($exception); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo($exception)); + + resolve($promise) + ->then( + $this->expectCallableNever(), + $mock + ); + } + /** @test */ public function shouldSupportDeepNestingInPromiseChains() { diff --git a/tests/PromiseTest/AsyncInteropRejectedTestTrait.php b/tests/PromiseTest/AsyncInteropRejectedTestTrait.php new file mode 100644 index 00000000..c98f88fd --- /dev/null +++ b/tests/PromiseTest/AsyncInteropRejectedTestTrait.php @@ -0,0 +1,51 @@ +getPromiseTestAdapter(); + + $adapter->reject(new \RuntimeException); + $adapter->promise()->when(function ($e) use (&$invoked) { + $this->assertSame(get_class($e), "RuntimeException"); + $invoked = true; + }); + $this->assertTrue($invoked); + } + + public function testWhenOnErrorFailedAsyncInteropPromise() + { + if (PHP_VERSION_ID < 70000) { + $this->markTestSkipped("Error only exists on PHP 7+"); + } + + $adapter = $this->getPromiseTestAdapter(); + $adapter->reject(new \Error); + $adapter->promise()->when(function ($e) use (&$invoked) { + $this->assertSame(get_class($e), "Error"); + $invoked = true; + }); + $this->assertTrue($invoked); + } + + public function testWhenOnExceptionFailedAsyncInteropPromiseWhenRejectedWithoutReason() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->reject(); + $adapter->promise()->when(function ($e, $value) use (&$invoked) { + $this->assertInstanceOf("Exception", $e); + $this->assertNull($value); + $invoked = true; + }); + $this->assertTrue($invoked); + } +} diff --git a/tests/PromiseTest/AsyncInteropResolvedTestTrait.php b/tests/PromiseTest/AsyncInteropResolvedTestTrait.php new file mode 100644 index 00000000..401ec343 --- /dev/null +++ b/tests/PromiseTest/AsyncInteropResolvedTestTrait.php @@ -0,0 +1,72 @@ +getPromiseTestAdapter(); + + $adapter->resolve($value); + $adapter->promise()->when(function ($e, $v) use (&$invoked, $value) { + $this->assertSame(null, $e); + $this->assertSame($value, $v); + $invoked = true; + }); + $this->assertTrue($invoked); + } + + /** + * Implementations MAY fail upon resolution with an AsyncInteropPromise, + * but they definitely MUST NOT return an AsyncInteropPromise + */ + public function testAsyncInteropPromiseResolutionWithAsyncInteropPromise() + { + $adapter = $this->getPromiseTestAdapter(); + $adapter->resolve(true); + $success = $adapter->promise(); + + $adapter = $this->getPromiseTestAdapter(); + + $ex = false; + try { + $adapter->resolve($success); + } catch (\Throwable $e) { + $ex = true; + } catch (\Exception $e) { + $ex = true; + } + if (!$ex) { + $adapter->promise()->when(function ($e, $v) use (&$invoked) { + $invoked = true; + $this->assertFalse($v instanceof AsyncInteropPromise); + }); + $this->assertTrue($invoked); + } + } +} diff --git a/tests/PromiseTest/AsyncInteropTestTrait.php b/tests/PromiseTest/AsyncInteropTestTrait.php new file mode 100644 index 00000000..a8095830 --- /dev/null +++ b/tests/PromiseTest/AsyncInteropTestTrait.php @@ -0,0 +1,142 @@ +getPromiseTestAdapter(); + + $adapter->promise()->when(function ($e, $v) use (&$invoked, $value) { + $this->assertSame(null, $e); + $this->assertSame($value, $v); + $invoked = true; + }); + $adapter->resolve($value); + $this->assertTrue($invoked); + } + + public function testSuccessAllWhensExecuted() + { + $adapter = $this->getPromiseTestAdapter(); + $invoked = 0; + + $adapter->promise()->when(function ($e, $v) use (&$invoked) { + $this->assertSame(null, $e); + $this->assertSame(true, $v); + $invoked++; + }); + $adapter->promise()->when(function ($e, $v) use (&$invoked) { + $this->assertSame(null, $e); + $this->assertSame(true, $v); + $invoked++; + }); + + $adapter->resolve(true); + + $adapter->promise()->when(function ($e, $v) use (&$invoked) { + $this->assertSame(null, $e); + $this->assertSame(true, $v); + $invoked++; + }); + $adapter->promise()->when(function ($e, $v) use (&$invoked) { + $this->assertSame(null, $e); + $this->assertSame(true, $v); + $invoked++; + }); + + $this->assertSame(4, $invoked); + } + + public function testAsyncInteropPromiseExceptionFailure() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->promise()->when(function ($e) use (&$invoked) { + $this->assertSame(get_class($e), "RuntimeException"); + $invoked = true; + }); + $adapter->reject(new \RuntimeException); + $this->assertTrue($invoked); + } + + public function testAsyncInteropPromiseExceptionFailureWithoutReason() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->promise()->when(function ($e, $value) use (&$invoked) { + $this->assertInstanceOf("Exception", $e); + $this->assertNull($value); + $invoked = true; + }); + $adapter->reject(); + $this->assertTrue($invoked); + } + + public function testFailureAllWhensExecuted() + { + $adapter = $this->getPromiseTestAdapter(); + $invoked = 0; + + $adapter->promise()->when(function ($e) use (&$invoked) { + $this->assertSame(get_class($e), "RuntimeException"); + $invoked++; + }); + $adapter->promise()->when(function ($e) use (&$invoked) { + $this->assertSame(get_class($e), "RuntimeException"); + $invoked++; + }); + + $adapter->reject(new \RuntimeException); + + $adapter->promise()->when(function ($e) use (&$invoked) { + $this->assertSame(get_class($e), "RuntimeException"); + $invoked++; + }); + $adapter->promise()->when(function ($e) use (&$invoked) { + $this->assertSame(get_class($e), "RuntimeException"); + $invoked++; + }); + + $this->assertSame(4, $invoked); + } + + public function testAsyncInteropPromiseErrorFailure() + { + if (PHP_VERSION_ID < 70000) { + $this->markTestSkipped("Error only exists on PHP 7+"); + } + + $adapter = $this->getPromiseTestAdapter(); + + $adapter->promise()->when(function ($e) use (&$invoked) { + $this->assertSame(get_class($e), "Error"); + $invoked = true; + }); + $adapter->reject(new \Error); + $this->assertTrue($invoked); + } +} diff --git a/tests/PromiseTest/FullTestTrait.php b/tests/PromiseTest/FullTestTrait.php index 3ce45d61..dea71551 100644 --- a/tests/PromiseTest/FullTestTrait.php +++ b/tests/PromiseTest/FullTestTrait.php @@ -11,5 +11,8 @@ trait FullTestTrait ResolveTestTrait, RejectTestTrait, NotifyTestTrait, - CancelTestTrait; + CancelTestTrait, + AsyncInteropResolvedTestTrait, + AsyncInteropRejectedTestTrait, + AsyncInteropTestTrait; } diff --git a/tests/RejectedPromiseTest.php b/tests/RejectedPromiseTest.php index c886b009..695bee4b 100644 --- a/tests/RejectedPromiseTest.php +++ b/tests/RejectedPromiseTest.php @@ -7,7 +7,8 @@ class RejectedPromiseTest extends TestCase { use PromiseTest\PromiseSettledTestTrait, - PromiseTest\PromiseRejectedTestTrait; + PromiseTest\PromiseRejectedTestTrait, + PromiseTest\AsyncInteropRejectedTestTrait; public function getPromiseTestAdapter(callable $canceller = null) { @@ -47,4 +48,12 @@ public function shouldThrowExceptionIfConstructedWithAPromise() return new RejectedPromise(new RejectedPromise()); } + + /** @test */ + public function shouldThrowExceptionIfConstructedWithAAsyncInteropPromise() + { + $this->setExpectedException('\InvalidArgumentException'); + + return new RejectedPromise(new SimpleFulfilledAsyncInteropTestPromise()); + } } diff --git a/tests/fixtures/SimpleFulfilledAsyncInteropTestPromise.php b/tests/fixtures/SimpleFulfilledAsyncInteropTestPromise.php new file mode 100644 index 00000000..f6184bb3 --- /dev/null +++ b/tests/fixtures/SimpleFulfilledAsyncInteropTestPromise.php @@ -0,0 +1,13 @@ +exception = $exception; + } + + public function when(callable $onResolved) + { + $onResolved($this->exception, null); + } +}