From e9157071964d1dff59e7725b812604bc11b02b90 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Thu, 24 Mar 2022 08:03:54 +0100 Subject: [PATCH] Add template annotations These annotations will aid static analyses like PHPStan and Psalm to enhance type-safety for this project and projects depending on it These changes make the following example understandable by PHPStan: ```php final readonly class User { public function __construct( public string $name, ) } /** * \React\Promise\PromiseInterface */ function getCurrentUserFromDatabase(): \React\Promise\PromiseInterface { // The following line would do the database query and fetch the result from it // but keeping it simple for the sake of the example. return \React\Promise\resolve(new User('WyriHaximus')); } // For the sake of this example we're going to assume the following code runs // in \React\Async\async call echo await(getCurrentUserFromDatabase())->name; // This echos: WyriHaximus ``` --- README.md | 10 +++---- src/functions.php | 44 +++++++++++++++++----------- tests/CoroutineTest.php | 10 +++---- tests/ParallelTest.php | 3 ++ tests/SeriesTest.php | 6 ++++ tests/WaterfallTest.php | 6 ++++ tests/types/await.php | 19 +++++++++++++ tests/types/coroutine.php | 60 +++++++++++++++++++++++++++++++++++++++ tests/types/parallel.php | 33 +++++++++++++++++++++ tests/types/series.php | 33 +++++++++++++++++++++ tests/types/waterfall.php | 42 +++++++++++++++++++++++++++ 11 files changed, 240 insertions(+), 26 deletions(-) create mode 100644 tests/types/await.php create mode 100644 tests/types/coroutine.php create mode 100644 tests/types/parallel.php create mode 100644 tests/types/series.php create mode 100644 tests/types/waterfall.php diff --git a/README.md b/README.md index 8860737..dc2d306 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Async\await(…); ### await() -The `await(PromiseInterface $promise): mixed` function can be used to +The `await(PromiseInterface $promise): T` function can be used to block waiting for the given `$promise` to be fulfilled. ```php @@ -94,7 +94,7 @@ try { ### coroutine() -The `coroutine(callable $function, mixed ...$args): PromiseInterface` function can be used to +The `coroutine(callable(mixed ...$args):(\Generator|PromiseInterface|T) $function, mixed ...$args): PromiseInterface` function can be used to execute a Generator-based coroutine to "await" promises. ```php @@ -277,7 +277,7 @@ trigger at the earliest possible time in the future. ### parallel() -The `parallel(iterable> $tasks): PromiseInterface>` function can be used +The `parallel(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -319,7 +319,7 @@ React\Async\parallel([ ### series() -The `series(iterable> $tasks): PromiseInterface>` function can be used +The `series(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -361,7 +361,7 @@ React\Async\series([ ### waterfall() -The `waterfall(iterable> $tasks): PromiseInterface` function can be used +The `waterfall(iterable> $tasks): PromiseInterface` function can be used like this: ```php diff --git a/src/functions.php b/src/functions.php index 0096496..3ef0e45 100644 --- a/src/functions.php +++ b/src/functions.php @@ -44,8 +44,9 @@ * } * ``` * - * @param PromiseInterface $promise - * @return mixed returns whatever the promise resolves to + * @template T + * @param PromiseInterface $promise + * @return T returns whatever the promise resolves to * @throws \Exception when the promise is rejected with an `Exception` * @throws \Throwable when the promise is rejected with a `Throwable` * @throws \UnexpectedValueException when the promise is rejected with an unexpected value (Promise API v1 or v2 only) @@ -93,13 +94,14 @@ function ($error) use (&$exception, &$rejected, &$wait, &$loopStarted) { // promise is rejected with an unexpected value (Promise API v1 or v2 only) if (!$exception instanceof \Throwable) { $exception = new \UnexpectedValueException( - 'Promise rejected with unexpected value of type ' . (is_object($exception) ? get_class($exception) : gettype($exception)) + 'Promise rejected with unexpected value of type ' . (is_object($exception) ? get_class($exception) : gettype($exception)) // @phpstan-ignore-line ); } throw $exception; } + /** @var T $resolved */ return $resolved; } @@ -296,9 +298,16 @@ function delay(float $seconds): void * }); * ``` * - * @param callable(mixed ...$args):(\Generator|mixed) $function + * @template T + * @template TYield + * @template A1 (any number of function arguments, see https://github.com/phpstan/phpstan/issues/8214) + * @template A2 + * @template A3 + * @template A4 + * @template A5 + * @param callable(A1, A2, A3, A4, A5):(\Generator, TYield, PromiseInterface|T>|PromiseInterface|T) $function * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is - * @return PromiseInterface + * @return PromiseInterface * @since 3.0.0 */ function coroutine(callable $function, ...$args): PromiseInterface @@ -315,7 +324,7 @@ function coroutine(callable $function, ...$args): PromiseInterface $promise = null; $deferred = new Deferred(function () use (&$promise) { - /** @var ?PromiseInterface $promise */ + /** @var ?PromiseInterface $promise */ if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } @@ -336,7 +345,6 @@ function coroutine(callable $function, ...$args): PromiseInterface return; } - /** @var mixed $promise */ $promise = $generator->current(); if (!$promise instanceof PromiseInterface) { $next = null; @@ -346,6 +354,7 @@ function coroutine(callable $function, ...$args): PromiseInterface return; } + /** @var PromiseInterface $promise */ assert($next instanceof \Closure); $promise->then(function ($value) use ($generator, $next) { $generator->send($value); @@ -364,12 +373,13 @@ function coroutine(callable $function, ...$args): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface> + * @template T + * @param iterable|T)> $tasks + * @return PromiseInterface> */ function parallel(iterable $tasks): PromiseInterface { - /** @var array $pending */ + /** @var array> $pending */ $pending = []; $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { @@ -424,14 +434,15 @@ function parallel(iterable $tasks): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface> + * @template T + * @param iterable|T)> $tasks + * @return PromiseInterface> */ function series(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - /** @var ?PromiseInterface $pending */ + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } @@ -478,14 +489,15 @@ function series(iterable $tasks): PromiseInterface } /** - * @param iterable<(callable():PromiseInterface)|(callable(mixed):PromiseInterface)> $tasks - * @return PromiseInterface + * @template T + * @param iterable<(callable():(PromiseInterface|T))|(callable(mixed):(PromiseInterface|T))> $tasks + * @return PromiseInterface<($tasks is non-empty-array|\Traversable ? T : null)> */ function waterfall(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - /** @var ?PromiseInterface $pending */ + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index 2c674c5..1df4cdc 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -22,7 +22,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately { $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } return 42; }); @@ -53,7 +53,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() { $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } throw new \RuntimeException('Foo'); }); @@ -99,7 +99,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue(): void { - $promise = coroutine(function () { + $promise = coroutine(function () { // @phpstan-ignore-line yield 42; }); @@ -169,7 +169,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } return 42; }); @@ -249,7 +249,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYie gc_collect_cycles(); - $promise = coroutine(function () { + $promise = coroutine(function () { // @phpstan-ignore-line yield 42; }); diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index c1ed553..57e1604 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -12,6 +12,9 @@ class ParallelTest extends TestCase { public function testParallelWithoutTasks(): void { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\parallel($tasks); diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 25aa104..99dfd0c 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -12,6 +12,9 @@ class SeriesTest extends TestCase { public function testSeriesWithoutTasks(): void { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\series($tasks); @@ -152,6 +155,9 @@ public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRe /** @var int */ public $called = 0; + /** + * @return \Iterator> + */ public function getIterator(): \Iterator { while (true) { // @phpstan-ignore-line diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index 8aa6c6f..c7140d4 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -12,6 +12,9 @@ class WaterfallTest extends TestCase { public function testWaterfallWithoutTasks(): void { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\waterfall($tasks); @@ -166,6 +169,9 @@ public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromis /** @var int */ public $called = 0; + /** + * @return \Iterator> + */ public function getIterator(): \Iterator { while (true) { // @phpstan-ignore-line diff --git a/tests/types/await.php b/tests/types/await.php new file mode 100644 index 0000000..0f99dcb --- /dev/null +++ b/tests/types/await.php @@ -0,0 +1,19 @@ +name = $name; + } +} + +assertType('string', await(resolve(new AwaitExampleUser('WyriHaximus')))->name); diff --git a/tests/types/coroutine.php b/tests/types/coroutine.php new file mode 100644 index 0000000..4c0f84c --- /dev/null +++ b/tests/types/coroutine.php @@ -0,0 +1,60 @@ +', coroutine(static function () { + return true; +})); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { + return resolve(true); +})); + +// assertType('React\Promise\PromiseInterface', coroutine(static function () { +// return (yield resolve(true)); +// })); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { +// $bool = yield resolve(true); +// assertType('bool', $bool); + + return time(); +})); + +// assertType('React\Promise\PromiseInterface', coroutine(static function () { +// $bool = yield resolve(true); +// assertType('bool', $bool); + +// return $bool; +// })); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { + yield resolve(time()); + + return true; +})); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { + for ($i = 0; $i <= 10; $i++) { + yield resolve($i); + } + + return true; +})); + +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a): int => $a, 42)); +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a, int $b): int => $a + $b, 10, 32)); +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a, int $b, int $c): int => $a + $b + $c, 10, 22, 10)); +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a, int $b, int $c, int $d): int => $a + $b + $c + $d, 10, 22, 5, 5)); +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a, int $b, int $c, int $d, int $e): int => $a + $b + $c + $d + $e, 10, 12, 10, 5, 5)); + +assertType('bool', await(coroutine(static function () { + return true; +}))); + +// assertType('bool', await(coroutine(static function () { +// return (yield resolve(true)); +// }))); diff --git a/tests/types/parallel.php b/tests/types/parallel.php new file mode 100644 index 0000000..dacd024 --- /dev/null +++ b/tests/types/parallel.php @@ -0,0 +1,33 @@ +', parallel([])); + +assertType('React\Promise\PromiseInterface>', parallel([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +])); + +assertType('React\Promise\PromiseInterface>', parallel([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +])); + +assertType('array', await(parallel([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +]))); + +assertType('array', await(parallel([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +]))); diff --git a/tests/types/series.php b/tests/types/series.php new file mode 100644 index 0000000..9a233e3 --- /dev/null +++ b/tests/types/series.php @@ -0,0 +1,33 @@ +', series([])); + +assertType('React\Promise\PromiseInterface>', series([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +])); + +assertType('React\Promise\PromiseInterface>', series([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +])); + +assertType('array', await(series([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +]))); + +assertType('array', await(series([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +]))); diff --git a/tests/types/waterfall.php b/tests/types/waterfall.php new file mode 100644 index 0000000..1470785 --- /dev/null +++ b/tests/types/waterfall.php @@ -0,0 +1,42 @@ +', waterfall([])); + +assertType('React\Promise\PromiseInterface', waterfall([ + static fn (): PromiseInterface => resolve(microtime(true)), +])); + +assertType('React\Promise\PromiseInterface', waterfall([ + static fn (): float => microtime(true), +])); + +// Desired, but currently unsupported with the current set of templates +//assertType('React\Promise\PromiseInterface', waterfall([ +// static fn (): PromiseInterface => resolve(true), +// static fn (bool $bool): PromiseInterface => resolve(time()), +// static fn (int $int): PromiseInterface => resolve(microtime(true)), +//])); + +assertType('float', await(waterfall([ + static fn (): PromiseInterface => resolve(microtime(true)), +]))); + +// Desired, but currently unsupported with the current set of templates +//assertType('float', await(waterfall([ +// static fn (): PromiseInterface => resolve(true), +// static fn (bool $bool): PromiseInterface => resolve(time()), +// static fn (int $int): PromiseInterface => resolve(microtime(true)), +//]))); + +// assertType('React\Promise\PromiseInterface', waterfall(new EmptyIterator())); + +$iterator = new ArrayIterator([ + static fn (): PromiseInterface => resolve(true), +]); +assertType('React\Promise\PromiseInterface', waterfall($iterator));