diff --git a/composer.json b/composer.json
index 68f68b6f..a41ca632 100644
--- a/composer.json
+++ b/composer.json
@@ -29,18 +29,18 @@
"grpc/grpc": "^1.42",
"nesbot/carbon": "^2.66",
"psr/log": "^2.0 || ^3.0",
- "react/promise": "^2.9",
"ramsey/uuid": "^4.7",
+ "react/promise": "2.9 || ^3.0",
"roadrunner-php/roadrunner-api-dto": "^1.3",
+ "roadrunner-php/version-checker": "^1.0",
"spiral/attributes": "^2.8 || ^3.0",
+ "spiral/roadrunner": "^v2023.2",
"spiral/roadrunner-cli": "^2.5",
"spiral/roadrunner-kv": "^4.0",
"spiral/roadrunner-worker": "^3.0",
"symfony/filesystem": "^4.4.20 || ^5.0 || ^6.0",
"symfony/http-client": "^4.4.27 || ^5.0 || ^6.0",
- "symfony/process": "^5.4 || ^6.0",
- "roadrunner-php/version-checker": "^1.0",
- "spiral/roadrunner": "^v2023.2"
+ "symfony/process": "^5.4 || ^6.0"
},
"autoload": {
"psr-4": {
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 71bb774a..97ad17a0 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -783,6 +783,15 @@
getID()]]>
+
+
+ wrapContext($onFulfilledOrRejected)]]>
+ wrapContext($onFulfilledOrRejected)]]>
+
+
+ $value
+
+
totalMilliseconds]]>
@@ -939,27 +948,18 @@
-
- Scope
- parent::__construct($services, $ctx)
- parent::start($handler, $values)
-
-
- createScope
- getContext
- makeCurrent
- onClose
- parent::__construct($services, $ctx)
- parent::start($handler, $values)
- promise
- startSignal
-
+
+ class Process extends Scope implements ProcessInterface
+
$result
$result
+
+ ProcessInterface
+
Process
@@ -973,69 +973,10 @@
context]]>
-
- services, $this->context)]]>
-
-
- attach
- call
- callSignalHandler
- createScope
- createScope
- defer
- defer
- handleError
- handleError
- makeCurrent
- makeCurrent
- makeCurrent
- makeCurrent
- makeCurrent
- makeCurrent
- makeCurrent
- makeCurrent
- services, $this->context)]]>
- next
- next
- next
- next
- next
- nextPromise
- nextPromise
- nextPromise
- nextPromise
- onClose
- onException
- onException
- onException
- onException
- onException
- onResult
- start
-
-
- detached]]>
- layer]]>
- cancelID]]>
- cancelID]]>
- cancelID]]>
- cancelID]]>
- cancelled]]>
- context]]>
- coroutine]]>
- coroutine]]>
- coroutine]]>
- deferred]]>
- exception]]>
- onCancel]]>
- onCancel]]>
- onCancel]]>
- onCancel]]>
- onClose]]>
- result]]>
- scopeContext]]>
- services]]>
-
+
+ $onFulfilled
+ $onRejected
+
context]]>
@@ -1067,6 +1008,9 @@
resolveConditions
resolveConditions
+
+ $e
+
@@ -1082,14 +1026,6 @@
-
- getLayer
- getLayer
- isCancelled
- onAwait
- startScope
- startScope
-
$onRequest
$parent
@@ -1153,6 +1089,12 @@
$promiseOrValue
$promiseOrValue
+
+ $reasons
+
+
+ $reduce($c, $value, $i++, $total)
+
diff --git a/src/Internal/Promise/CancellationQueue.php b/src/Internal/Promise/CancellationQueue.php
new file mode 100644
index 00000000..f4b5478f
--- /dev/null
+++ b/src/Internal/Promise/CancellationQueue.php
@@ -0,0 +1,63 @@
+started) {
+ return;
+ }
+
+ $this->started = true;
+ $this->drain();
+ }
+
+ public function enqueue(mixed $cancellable): void
+ {
+ if (!\is_object($cancellable)
+ || !\method_exists($cancellable, 'then')
+ || !\method_exists($cancellable, 'cancel')
+ ) {
+ return;
+ }
+
+ $length = \array_push($this->queue, $cancellable);
+
+ if ($this->started && 1 === $length) {
+ $this->drain();
+ }
+ }
+
+ private function drain(): void
+ {
+ for ($i = \key($this->queue); isset($this->queue[$i]); $i++) {
+ $cancellable = $this->queue[$i];
+
+ $exception = null;
+
+ try {
+ $cancellable->cancel();
+ } catch (\Throwable $exception) {
+ }
+
+ unset($this->queue[$i]);
+
+ if ($exception) {
+ throw $exception;
+ }
+ }
+
+ $this->queue = [];
+ }
+}
diff --git a/src/Internal/Promise/Reasons.php b/src/Internal/Promise/Reasons.php
new file mode 100644
index 00000000..91ed47da
--- /dev/null
+++ b/src/Internal/Promise/Reasons.php
@@ -0,0 +1,89 @@
+
+ * @implements ArrayAccess
+ */
+final class Reasons extends RuntimeException implements Iterator, ArrayAccess, Countable
+{
+ /**
+ * @param array $collection
+ */
+ public function __construct(
+ public array $collection,
+ ) {
+ parent::__construct();
+ }
+
+ public function current(): mixed
+ {
+ return \current($this->collection);
+ }
+
+ public function next(): void
+ {
+ \next($this->collection);
+ }
+
+ public function key(): string|int|null
+ {
+ return \key($this->collection);
+ }
+
+ public function valid(): bool
+ {
+ return null !== \key($this->collection);
+ }
+
+ public function rewind(): void
+ {
+ \reset($this->collection);
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ return isset($this->collection[$offset]);
+ }
+
+ /**
+ * @param TKey $offset
+ * @return TValue
+ */
+ public function offsetGet(mixed $offset): Traversable
+ {
+ return $this->collection[$offset];
+ }
+
+ /**
+ * @param TKey $offset
+ * @param TValue $value
+ */
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ $this->collection[$offset] = $value;
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ unset($this->collection[$offset]);
+ }
+
+ public function count(): int
+ {
+ return \count($this->collection);
+ }
+}
diff --git a/src/Internal/Transport/CompletableResult.php b/src/Internal/Transport/CompletableResult.php
index e077e181..6b2b5dc9 100644
--- a/src/Internal/Transport/CompletableResult.php
+++ b/src/Internal/Transport/CompletableResult.php
@@ -11,13 +11,16 @@
namespace Temporal\Internal\Transport;
-use React\Promise\CancellablePromiseInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use Temporal\Worker\LoopInterface;
use Temporal\Workflow;
use Temporal\Workflow\WorkflowContextInterface;
+/**
+ * @template T of mixed
+ * @implements CompletableResultInterface
+ */
class CompletableResult implements CompletableResultInterface
{
/**
@@ -73,7 +76,6 @@ public function __construct(
$this->deferred = new Deferred();
$this->layer = $layer;
- /** @var CancellablePromiseInterface $current */
$this->promise = $promise->then(
\Closure::fromCallable([$this, 'onFulfilled']),
\Closure::fromCallable([$this, 'onRejected']),
@@ -97,35 +99,31 @@ public function getValue()
}
/**
- * {@inheritDoc}
+ * @param (callable(mixed): mixed)|null $onFulfilled
+ * @param (callable(\Throwable): mixed)|null $onRejected
+ * @param callable|null $onProgress
+ *
+ * @return PromiseInterface
*/
public function then(
- callable $onFulfilled = null,
- callable $onRejected = null,
- callable $onProgress = null
+ ?callable $onFulfilled = null,
+ ?callable $onRejected = null,
+ ?callable $onProgress = null,
): PromiseInterface {
- Workflow::setCurrentContext($this->context);
-
- /** @var CancellablePromiseInterface $promise */
- $promise = $this->promise()
- ->then($onFulfilled, $onRejected, $onProgress);
-
- return $promise;
+ return $this->promise()
+ ->then($this->wrapContext($onFulfilled), $this->wrapContext($onRejected));
//return new Future($promise, $this->worker);
}
/**
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public function promise(): PromiseInterface
{
return $this->deferred->promise();
}
- /**
- * @param mixed $result
- */
- private function onFulfilled($result): void
+ private function onFulfilled(mixed $result): void
{
$this->resolved = true;
$this->value = $result;
@@ -154,4 +152,50 @@ function () use ($e): void {
}
);
}
+
+ public function catch(callable $onRejected): PromiseInterface
+ {
+ return $this->promise()
+ ->catch($this->wrapContext($onRejected));
+ }
+
+ public function finally(callable $onFulfilledOrRejected): PromiseInterface
+ {
+ return $this->promise()
+ ->finally($this->wrapContext($onFulfilledOrRejected));
+ }
+
+ public function cancel(): void
+ {
+ Workflow::setCurrentContext($this->context);
+ $this->promise()->cancel();
+ }
+
+ public function otherwise(callable $onRejected): PromiseInterface
+ {
+ return $this->catch($this->wrapContext($onRejected));
+ }
+
+ public function always(callable $onFulfilledOrRejected): PromiseInterface
+ {
+ return $this->finally($this->wrapContext($onFulfilledOrRejected));
+ }
+
+ /**
+ * @template TParam of mixed
+ * @template TReturn of mixed
+ * @param (callable(TParam): TReturn)|null $callback
+ * @return ($callback is null ? null : (callable(TParam): TReturn))
+ */
+ private function wrapContext(?callable $callback): ?callable
+ {
+ if ($callback === null) {
+ return null;
+ }
+
+ return function (mixed $value = null) use ($callback): mixed {
+ Workflow::setCurrentContext($this->context);
+ return $callback($value);
+ };
+ }
}
diff --git a/src/Internal/Transport/CompletableResultInterface.php b/src/Internal/Transport/CompletableResultInterface.php
index 921696fc..f4ceff28 100644
--- a/src/Internal/Transport/CompletableResultInterface.php
+++ b/src/Internal/Transport/CompletableResultInterface.php
@@ -12,9 +12,12 @@
namespace Temporal\Internal\Transport;
use React\Promise\PromiseInterface;
-use React\Promise\PromisorInterface;
-interface CompletableResultInterface extends PromisorInterface, PromiseInterface
+/**
+ * @template T
+ * @extends PromiseInterface
+ */
+interface CompletableResultInterface extends PromiseInterface
{
/**
* @return bool
diff --git a/src/Internal/Workflow/Process/Process.php b/src/Internal/Workflow/Process/Process.php
index cbec76b9..8e5459a0 100644
--- a/src/Internal/Workflow/Process/Process.php
+++ b/src/Internal/Workflow/Process/Process.php
@@ -18,8 +18,12 @@
use Temporal\Internal\ServiceContainer;
use Temporal\Internal\Workflow\WorkflowContext;
use Temporal\Worker\LoopInterface;
+use Temporal\Workflow\CancellationScopeInterface;
use Temporal\Workflow\ProcessInterface;
+/**
+ * @implements CancellationScopeInterface
+ */
class Process extends Scope implements ProcessInterface
{
/**
diff --git a/src/Internal/Workflow/Process/Scope.php b/src/Internal/Workflow/Process/Scope.php
index 2e76ddf6..86eb8efe 100644
--- a/src/Internal/Workflow/Process/Scope.php
+++ b/src/Internal/Workflow/Process/Scope.php
@@ -13,7 +13,6 @@
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
-use React\Promise\PromisorInterface;
use Temporal\DataConverter\EncodedValues;
use Temporal\DataConverter\ValuesInterface;
use Temporal\Exception\DestructMemorizedInstanceException;
@@ -34,9 +33,10 @@
* Unlike Java implementation, PHP merged coroutine and cancellation scope into single instance.
*
* @internal CoroutineScope is an internal library class, please do not use it in your code.
- * @psalm-internal Temporal\Client
+ * @psalm-internal Temporal\Internal\Workflow
+ * @implements CancellationScopeInterface
*/
-class Scope implements CancellationScopeInterface, PromisorInterface
+class Scope implements CancellationScopeInterface
{
/**
* @var ServiceContainer
@@ -272,11 +272,29 @@ public function promise(): PromiseInterface
public function then(
callable $onFulfilled = null,
callable $onRejected = null,
- callable $onProgress = null
+ callable $onProgress = null,
): PromiseInterface {
- $promise = $this->deferred->promise();
+ return $this->deferred->promise()->then($onFulfilled, $onRejected);
+ }
+
+ public function catch(callable $onRejected): PromiseInterface
+ {
+ return $this->deferred->promise()->catch($onRejected);
+ }
- return $promise->then($onFulfilled, $onRejected, $onProgress);
+ public function finally(callable $onFulfilledOrRejected): PromiseInterface
+ {
+ return $this->deferred->promise()->finally($onFulfilledOrRejected);
+ }
+
+ public function otherwise(callable $onRejected): PromiseInterface
+ {
+ return $this->catch($onRejected);
+ }
+
+ public function always(callable $onFulfilledOrRejected): PromiseInterface
+ {
+ return $this->finally($onFulfilledOrRejected);
}
/**
@@ -386,7 +404,7 @@ protected function callSignalHandler(callable $handler): \Generator
*/
protected function onRequest(RequestInterface $request, PromiseInterface $promise): void
{
- $this->onCancel[++$this->cancelID] = function (\Throwable $reason = null) use ($request): void {
+ $this->onCancel[++$this->cancelID] = function (?\Throwable $reason = null) use ($request): void {
if ($reason instanceof DestructMemorizedInstanceException) {
// memory flush
$this->context->getClient()->reject($request, $reason);
@@ -442,7 +460,7 @@ protected function next(): void
$this->nextPromise($current);
break;
- case $current instanceof PromisorInterface:
+ case $current instanceof Deferred:
$this->nextPromise($current->promise());
break;
@@ -504,7 +522,10 @@ function () use ($e): void {
throw $e;
};
- $promise->then($onFulfilled, $onRejected);
+ $promise
+ ->then($onFulfilled, $onRejected)
+ // Handle last error
+ ->then(null, fn (\Throwable $e) => null);
}
/**
diff --git a/src/Internal/Workflow/WorkflowContext.php b/src/Internal/Workflow/WorkflowContext.php
index 82da41ae..ca29e769 100644
--- a/src/Internal/Workflow/WorkflowContext.php
+++ b/src/Internal/Workflow/WorkflowContext.php
@@ -509,7 +509,7 @@ public function resolveConditions(): void
foreach ($this->awaits as $awaitsGroupId => $awaitsGroup) {
foreach ($awaitsGroup as $i => [$condition, $deferred]) {
if ($condition()) {
- $deferred->resolve();
+ $deferred->resolve(null);
unset($this->awaits[$awaitsGroupId][$i]);
$this->resolveConditionGroup($awaitsGroupId);
}
diff --git a/src/Promise.php b/src/Promise.php
index 685b09fe..5c0f25cd 100644
--- a/src/Promise.php
+++ b/src/Promise.php
@@ -11,16 +11,16 @@
namespace Temporal;
+use React\Promise\Exception\LengthException;
+use React\Promise\Internal\RejectedPromise;
use React\Promise\PromiseInterface;
+use Temporal\Internal\Promise\CancellationQueue;
+
+use Temporal\Internal\Promise\Reasons;
-use function React\Promise\all;
-use function React\Promise\any;
-use function React\Promise\map;
-use function React\Promise\reduce;
-use function React\Promise\some;
-use function React\Promise\resolve;
-use function React\Promise\reject;
use function React\Promise\race;
+use function React\Promise\reject;
+use function React\Promise\resolve;
/**
* @psalm-type PromiseMapCallback = callable(mixed $value): mixed
@@ -39,7 +39,7 @@ final class Promise
*/
public static function all(iterable $promises): PromiseInterface
{
- return all([...$promises]);
+ return self::map($promises, static fn($val): mixed => $val);
}
/**
@@ -58,7 +58,8 @@ public static function all(iterable $promises): PromiseInterface
*/
public static function any(iterable $promises): PromiseInterface
{
- return any([...$promises]);
+ return self::some([...$promises], 1)
+ ->then(static fn(array $values): mixed => \array_shift($values));
}
/**
@@ -69,7 +70,7 @@ public static function any(iterable $promises): PromiseInterface
*
* The returned promise will reject if it becomes impossible for `$count`
* items to resolve (that is, when `(count($promises) - $count) + 1` items
- * reject). The rejection value will be an array of
+ * reject). The rejection value will be an iterable-exception of
* `(count($promises) - $howMany) + 1` rejection reasons.
*
* The returned promise will also reject with a {@see LengthException} if
@@ -81,7 +82,70 @@ public static function any(iterable $promises): PromiseInterface
*/
public static function some(iterable $promises, int $count): PromiseInterface
{
- return some([...$promises], $count);
+ $cancellationQueue = new CancellationQueue();
+ $cancellationQueue->enqueue($promises);
+
+ return new \React\Promise\Promise(
+ static function (callable $resolve, callable $reject) use ($promises, $count, $cancellationQueue): void {
+ resolve($promises)->then(
+ static function (iterable $array) use ($count, $cancellationQueue, $resolve, $reject): void {
+ if (!\is_array($array) || $count < 1) {
+ $resolve([]);
+ return;
+ }
+
+ $len = \count($array);
+
+ if ($len < $count) {
+ $reject(new LengthException(
+ \sprintf(
+ 'Input array must contain at least %d item%s but contains only %s item%s.',
+ $count,
+ 1 === $count ? '' : 's',
+ $len,
+ 1 === $len ? '' : 's'
+ )
+ ));
+ return;
+ }
+
+ $toResolve = $count;
+ $toReject = ($len - $toResolve) + 1;
+ $values = [];
+ $reasons = [];
+
+ foreach ($array as $i => $promiseOrValue) {
+ $fulfiller = static function (mixed $val) use ($i, &$values, &$toResolve, $toReject, $resolve): void {
+ if ($toResolve < 1 || $toReject < 1) {
+ return;
+ }
+
+ $values[$i] = $val;
+
+ if (0 === --$toResolve) {
+ $resolve($values);
+ }
+ };
+
+ $rejecter = static function (\Throwable $reason) use ($i, &$reasons, &$toReject, $toResolve, $reject): void {
+ if ($toResolve < 1 || $toReject < 1) {
+ return;
+ }
+
+ $reasons[$i] = $reason;
+
+ if (0 === --$toReject) {
+ $reject(new Reasons($reasons));
+ }
+ };
+
+ $cancellationQueue->enqueue($promiseOrValue);
+
+ resolve($promiseOrValue)->then($fulfiller, $rejecter);
+ }
+ }, $reject);
+ }, $cancellationQueue
+ );
}
/**
@@ -99,7 +163,41 @@ public static function some(iterable $promises, int $count): PromiseInterface
*/
public static function map(iterable $promises, callable $map): PromiseInterface
{
- return map([...$promises], $map);
+ $cancellationQueue = new CancellationQueue();
+ $cancellationQueue->enqueue($promises);
+
+ return new \React\Promise\Promise(
+ function (callable $resolve, callable $reject) use ($promises, $map, $cancellationQueue): void {
+ resolve($promises)
+ ->then(static function (iterable $array) use ($map, $cancellationQueue, $resolve, $reject): void {
+ if (!\is_array($array) || !$array) {
+ $resolve([]);
+ return;
+ }
+
+ $toResolve = \count($array);
+ $values = [];
+
+ foreach ($array as $i => $promiseOrValue) {
+ $cancellationQueue->enqueue($promiseOrValue);
+ $values[$i] = null;
+
+ resolve($promiseOrValue)
+ ->then($map)
+ ->then(
+ static function (mixed $mapped) use ($i, &$values, &$toResolve, $resolve): void {
+ $values[$i] = $mapped;
+
+ if (0 === --$toResolve) {
+ $resolve($values);
+ }
+ },
+ $reject,
+ );
+ }
+ }, $reject);
+ }, $cancellationQueue
+ );
}
/**
@@ -110,13 +208,61 @@ public static function map(iterable $promises, callable $map): PromiseInterface
*
* @psalm-param PromiseReduceCallback $reduce
* @param iterable $promises
- * @param callable $reduce
+ * @param callable(mixed $current, mixed $carry, int $current, positive-int $items): mixed $reduce
* @param mixed $initial
* @return PromiseInterface
*/
public static function reduce(iterable $promises, callable $reduce, $initial = null): PromiseInterface
{
- return reduce([...$promises], $reduce, $initial);
+ $cancellationQueue = new CancellationQueue();
+ $cancellationQueue->enqueue($promises);
+
+ return new \React\Promise\Promise(
+ function (callable $resolve, callable $reject) use ($promises, $reduce, $initial, $cancellationQueue): void {
+ resolve($promises)
+ ->then(
+ static function (iterable $array) use (
+ $reduce,
+ $initial,
+ $cancellationQueue,
+ $resolve,
+ $reject,
+ ): void {
+ if (!\is_array($array)) {
+ $array = [];
+ }
+
+ $total = \count($array);
+ $i = 0;
+
+ // Wrap the supplied $reduce with one that handles promises and then
+ // delegates to the supplied.
+ $wrappedReduceFunc = static function (PromiseInterface $current, mixed $val) use (
+ $reduce,
+ $cancellationQueue,
+ $total,
+ &$i
+ ): PromiseInterface {
+ $cancellationQueue->enqueue($val);
+
+ return $current
+ ->then(static function (mixed $c) use ($reduce, $total, &$i, $val): PromiseInterface {
+ return resolve($val)
+ ->then(static function (mixed $value) use ($reduce, $total, &$i, $c): mixed {
+ return $reduce($c, $value, $i++, $total);
+ });
+ });
+ };
+
+ $cancellationQueue->enqueue($initial);
+
+ \array_reduce($array, $wrappedReduceFunc, resolve($initial))
+ ->then($resolve, $reject);
+ },
+ $reject,
+ );
+ }, $cancellationQueue
+ );
}
/**
@@ -152,7 +298,7 @@ public static function resolve($promiseOrValue = null): PromiseInterface
* the value of another promise.
*
* @param $promiseOrValue
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public static function reject($promiseOrValue = null): PromiseInterface
{
@@ -166,8 +312,9 @@ public static function reject($promiseOrValue = null): PromiseInterface
* The returned promise will become **infinitely pending** if `$promisesOrValues`
* contains 0 items.
*
- * @param iterable $promisesOrValues
- * @return PromiseInterface
+ * @template T
+ * @param iterable|T> $promisesOrValues
+ * @return PromiseInterface
*/
public static function race(iterable $promisesOrValues): PromiseInterface
{
diff --git a/src/Workflow.php b/src/Workflow.php
index 24426a35..3bedb197 100644
--- a/src/Workflow.php
+++ b/src/Workflow.php
@@ -27,7 +27,6 @@
use Temporal\Workflow\ChildWorkflowStubInterface;
use Temporal\Workflow\ContinueAsNewOptions;
use Temporal\Workflow\ExternalWorkflowStubInterface;
-use Temporal\Workflow\ParentClosePolicy;
use Temporal\Workflow\ScopedContextInterface;
use Temporal\Internal\Workflow\WorkflowContext;
use Temporal\Workflow\WorkflowExecution;
@@ -318,7 +317,7 @@ public static function await(...$conditions): PromiseInterface
*
* @param DateIntervalValue $interval
* @param callable|PromiseInterface ...$conditions
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public static function awaitWithTimeout($interval, ...$conditions): PromiseInterface
{
@@ -424,7 +423,7 @@ public static function registerSignal(string $queryType, callable $handler): Sco
* @param string $changeId
* @param int $minSupported
* @param int $maxSupported
- * @return PromiseInterface
+ * @return PromiseInterface
* @throws OutOfContextException in the absence of the workflow execution context.
*/
public static function getVersion(string $changeId, int $minSupported, int $maxSupported): PromiseInterface
@@ -453,8 +452,9 @@ public static function getVersion(string $changeId, int $minSupported, int $maxS
* }
*
*
- * @param callable $value
- * @return PromiseInterface
+ * @template TReturn
+ * @param callable(): TReturn $value
+ * @return PromiseInterface
* @throws OutOfContextException in the absence of the workflow execution context.
*/
public static function sideEffect(callable $value): PromiseInterface
@@ -489,7 +489,7 @@ public static function sideEffect(callable $value): PromiseInterface
*
*
* @param DateIntervalValue $interval
- * @return PromiseInterface
+ * @return PromiseInterface
* @throws OutOfContextException in the absence of the workflow execution context.
*/
public static function timer($interval): PromiseInterface
@@ -830,7 +830,7 @@ public static function newUntypedExternalWorkflowStub(WorkflowExecution $executi
* @param array $args
* @param ActivityOptions|null $options
* @param Type|string|null|\ReflectionClass|\ReflectionType $returnType
- * @return PromiseInterface
+ * @return PromiseInterface
* @throws OutOfContextException in the absence of the workflow execution context.
*/
public static function executeActivity(
@@ -946,7 +946,7 @@ public static function upsertSearchAttributes(array $searchAttributes): void
/**
* Generate a UUID.
*
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public static function uuid(): PromiseInterface
{
@@ -959,7 +959,7 @@ public static function uuid(): PromiseInterface
/**
* Generate a UUID version 4 (random).
*
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public static function uuid4(): PromiseInterface
{
@@ -976,7 +976,7 @@ public static function uuid4(): PromiseInterface
* to create the version 7 UUID. If not provided, the UUID is generated
* using the current date/time.
*
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public static function uuid7(?DateTimeInterface $dateTime = null): PromiseInterface
{
diff --git a/src/Workflow/CancellationScopeInterface.php b/src/Workflow/CancellationScopeInterface.php
index ecc7f612..adf75658 100644
--- a/src/Workflow/CancellationScopeInterface.php
+++ b/src/Workflow/CancellationScopeInterface.php
@@ -11,10 +11,13 @@
namespace Temporal\Workflow;
-use React\Promise\CancellablePromiseInterface;
use React\Promise\PromiseInterface;
-interface CancellationScopeInterface extends PromiseInterface, CancellablePromiseInterface
+/**
+ * @template T
+ * @extends PromiseInterface
+ */
+interface CancellationScopeInterface extends PromiseInterface
{
/**
* Detached scopes can continue working even if parent scope was cancelled.
@@ -37,4 +40,15 @@ public function isCancelled(): bool;
* @return $this
*/
public function onCancel(callable $then): self;
+
+ /**
+ * The `cancel()` method notifies the creator of the promise that there is no
+ * further interest in the results of the operation.
+ *
+ * Once a promise is settled (either fulfilled or rejected), calling `cancel()` on
+ * a promise has no effect.
+ *
+ * @return void
+ */
+ public function cancel(): void;
}
diff --git a/src/Workflow/ProcessInterface.php b/src/Workflow/ProcessInterface.php
index 29baaa3d..e35d2787 100644
--- a/src/Workflow/ProcessInterface.php
+++ b/src/Workflow/ProcessInterface.php
@@ -13,6 +13,10 @@
use Temporal\Internal\Repository\Identifiable;
+/**
+ * @template T
+ * @extends CancellationScopeInterface
+ */
interface ProcessInterface extends CancellationScopeInterface, Identifiable
{
/**
diff --git a/src/Workflow/WorkflowContextInterface.php b/src/Workflow/WorkflowContextInterface.php
index bf8e150d..800bc540 100644
--- a/src/Workflow/WorkflowContextInterface.php
+++ b/src/Workflow/WorkflowContextInterface.php
@@ -94,11 +94,9 @@ public function getVersion(string $changeId, int $minSupported, int $maxSupporte
/**
* @see Workflow::sideEffect()
*
- * @psalm-type SideEffectCallback = callable(): mixed
- * @psalm-param SideEffectCallback $context
- *
- * @param callable $context
- * @return PromiseInterface
+ * @template TReturn
+ * @param callable(): TReturn $context
+ * @return PromiseInterface
*/
public function sideEffect(callable $context): PromiseInterface;
@@ -223,7 +221,7 @@ public function newUntypedExternalWorkflowStub(WorkflowExecution $execution): Ex
* @param array $args
* @param ActivityOptions|null $options
* @param Type|string|null|\ReflectionClass|\ReflectionType $returnType
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public function executeActivity(
string $type,
@@ -266,7 +264,7 @@ public function await(...$conditions): PromiseInterface;
*
* @param DateIntervalValue $interval
* @param callable|PromiseInterface ...$conditions
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public function awaitWithTimeout($interval, ...$conditions): PromiseInterface;
@@ -287,7 +285,7 @@ public function upsertSearchAttributes(array $searchAttributes): void;
*
* Generate a UUID.
*
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public function uuid(): PromiseInterface;
@@ -296,7 +294,7 @@ public function uuid(): PromiseInterface;
*
* Generate a UUID version 4 (random).
*
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public function uuid4(): PromiseInterface;
@@ -309,7 +307,7 @@ public function uuid4(): PromiseInterface;
* to create the version 7 UUID. If not provided, the UUID is generated
* using the current date/time.
*
- * @return PromiseInterface
+ * @return PromiseInterface
*/
public function uuid7(?DateTimeInterface $dateTime = null): PromiseInterface;
}
diff --git a/tests/Fixtures/src/Workflow/CancelSignalledChildWorkflow.php b/tests/Fixtures/src/Workflow/CancelSignalledChildWorkflow.php
index 0b72eb53..bb277eb4 100644
--- a/tests/Fixtures/src/Workflow/CancelSignalledChildWorkflow.php
+++ b/tests/Fixtures/src/Workflow/CancelSignalledChildWorkflow.php
@@ -44,7 +44,7 @@ function () use ($simple, $waitSignalled) {
yield $simple->add(8);
$this->status[] = 'child signalled';
- $waitSignalled->resolve();
+ $waitSignalled->resolve(null);
return yield $call;
}
diff --git a/tests/Unit/Promise/BaseFunction.php b/tests/Unit/Promise/BaseFunction.php
new file mode 100644
index 00000000..decd7e93
--- /dev/null
+++ b/tests/Unit/Promise/BaseFunction.php
@@ -0,0 +1,75 @@
+createCallableMock();
+ $mock
+ ->expects($this->exactly($amount))
+ ->method('__invoke');
+
+ return $mock;
+ }
+
+ protected function expectCallableOnce()
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke');
+
+ return $mock;
+ }
+
+ protected function expectCallableNever(): callable
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->never())
+ ->method('__invoke');
+
+ return $mock;
+ }
+
+ protected function createCallableMock()
+ {
+ return $this->getMockBuilder('stdClass')->addMethods(array('__invoke'))->getMock();
+ }
+
+ protected function setExpectedException($exception, $exceptionMessage = '', $exceptionCode = null)
+ {
+ $this->expectException($exception);
+ if ($exceptionMessage !== '') {
+ $this->expectExceptionMessage($exceptionMessage);
+ }
+ if ($exceptionCode !== null) {
+ $this->expectExceptionCode($exceptionCode);
+ }
+ }
+
+ protected function creteCancellableMock(): MockObject
+ {
+ return \interface_exists(CancellablePromiseInterface::class)
+ ? $this
+ ->getMockBuilder(CancellablePromiseInterface::class)
+ ->getMock()
+ : $this
+ ->getMockBuilder(PromiseInterface::class)
+ ->getMock();
+ }
+}
diff --git a/tests/Unit/Promise/FunctionAllTestCase.php b/tests/Unit/Promise/FunctionAllTestCase.php
new file mode 100644
index 00000000..7944f081
--- /dev/null
+++ b/tests/Unit/Promise/FunctionAllTestCase.php
@@ -0,0 +1,106 @@
+createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([]));
+
+ Promise::all([])
+ ->then($mock);
+ }
+
+ public function testResolveValuesArray(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([1, 2, 3]));
+
+ Promise::all([1, 2, 3])
+ ->then($mock);
+ }
+
+ public function testResolvePromisesArray(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([1, 2, 3]));
+
+ Promise::all([Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)])
+ ->then($mock);
+ }
+
+ public function testResolveSparseArrayInput(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([null, 1, null, 1, 1]));
+
+ Promise::all([null, 1, null, 1, 1])
+ ->then($mock);
+ }
+
+ public function testRejectIfAnyInputPromiseRejects(): void
+ {
+ $e = new \Exception();
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo($e));
+
+ Promise::all([Promise::resolve(1), Promise::reject($e), Promise::resolve(3)])
+ ->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testAcceptAPromiseForAnArray(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([1, 2, 3]));
+
+ Promise::all([Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)])
+ ->then($mock);
+ }
+
+ public function testPreserveTheOrderOfArrayWhenResolvingAsyncPromises(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([1, 2, 3]));
+
+ $deferred = new Deferred();
+
+ Promise::all([Promise::resolve(1), $deferred->promise(), Promise::resolve(3)])
+ ->then($mock);
+
+ $deferred->resolve(2);
+ }
+}
diff --git a/tests/Unit/Promise/FunctionAnyTestCase.php b/tests/Unit/Promise/FunctionAnyTestCase.php
new file mode 100644
index 00000000..e7e6792d
--- /dev/null
+++ b/tests/Unit/Promise/FunctionAnyTestCase.php
@@ -0,0 +1,166 @@
+createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with(
+ $this->callback(function ($exception) {
+ return $exception instanceof LengthException &&
+ 'Input array must contain at least 1 item but contains only 0 items.' === $exception->getMessage();
+ })
+ );
+
+ Promise::any([])
+ ->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testResolveWithAnInputValue(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(1));
+
+ Promise::any([1, 2, 3])
+ ->then($mock);
+ }
+
+ public function testResolveWithAPromisedInputValue(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(1));
+
+ Promise::any([Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)])
+ ->then($mock);
+ }
+
+ public function testRejectWithAllRejectedInputValuesIfAllInputsAreRejected(): void
+ {
+ $e1 = new \Exception();
+ $e2 = new \Exception();
+ $e3 = new \Exception();
+
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->callback(fn (Reasons $e): bool => \iterator_to_array($e) === [0 => $e1, 1 => $e2, 2 => $e3]));
+
+ Promise::any([Promise::reject($e1), Promise::reject($e2), Promise::reject($e3)])
+ ->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testResolveWhenFirstInputPromiseResolves(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(1));
+
+ Promise::any([Promise::resolve(1), Promise::reject(new \Exception()), Promise::reject(new \Exception())])
+ ->then($mock);
+ }
+
+ public function testNotRelyOnArryIndexesWhenUnwrappingToASingleResolutionValue(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(2));
+
+ $d1 = new Deferred();
+ $d2 = new Deferred();
+
+ Promise::any(['abc' => $d1->promise(), 1 => $d2->promise()])
+ ->then($mock);
+
+ $d2->resolve(2);
+ $d1->resolve(1);
+ }
+
+ public function testRejectWhenInputPromiseRejects(): void
+ {
+ $e = new \Exception();
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ // ->with($this->identicalTo(null));
+ ->with($this->callback(fn (Reasons $reason): bool => \iterator_to_array($reason) === [$e]));
+
+ Promise::any([Promise::reject($e)])
+ ->then($this->expectCallableNever(), $mock)
+ ->then(null, fn(\Throwable $e) => null);
+ }
+
+ public function testCancelInputPromise(): void
+ {
+ $mock = $this->creteCancellableMock();
+ $mock
+ ->expects($this->once())
+ ->method('cancel');
+
+ Promise::any([$mock])->cancel();
+ }
+
+ public function testCancelInputArrayPromises(): void
+ {
+ $mock1 = $this->creteCancellableMock();
+ $mock1
+ ->expects($this->once())
+ ->method('cancel');
+
+ $mock2 = $this->creteCancellableMock();
+ $mock2
+ ->expects($this->once())
+ ->method('cancel');
+
+ Promise::any([$mock1, $mock2])->cancel();
+ }
+
+ public function testNotCancelOtherPendingInputArrayPromisesIfOnePromiseFulfills(): void
+ {
+ $e = new \Exception();
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->never())
+ ->method('__invoke');
+
+
+ $deferred = new Deferred($mock);
+ $deferred->resolve($e);
+
+ $mock2 = $this->creteCancellableMock();
+ $mock2
+ ->expects($this->never())
+ ->method('cancel');
+
+ Promise::some([$deferred->promise(), $mock2], 1)->cancel();
+ }
+}
diff --git a/tests/Unit/Promise/FunctionMapTestCase.php b/tests/Unit/Promise/FunctionMapTestCase.php
new file mode 100644
index 00000000..21f3c743
--- /dev/null
+++ b/tests/Unit/Promise/FunctionMapTestCase.php
@@ -0,0 +1,164 @@
+createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([2, 4, 6]));
+
+ Promise::map(
+ [1, 2, 3],
+ $this->mapper()
+ )->then($mock);
+ }
+
+ public function testMapInputPromisesArray(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([2, 4, 6]));
+
+ Promise::map(
+ [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)],
+ $this->mapper()
+ )->then($mock);
+ }
+
+ public function testMapMixedInputArray(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([2, 4, 6]));
+
+ Promise::map(
+ [1, Promise::resolve(2), 3],
+ $this->mapper()
+ )->then($mock);
+ }
+
+ public function testMapInputWhenMapperReturnsAPromise(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([2, 4, 6]));
+
+ Promise::map(
+ [1, 2, 3],
+ $this->promiseMapper()
+ )->then($mock);
+ }
+
+ public function testPreserveTheOrderOfArrayWhenResolvingAsyncPromises(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([2, 4, 6]));
+
+ $deferred = new Deferred();
+
+ Promise::map(
+ [Promise::resolve(1), $deferred->promise(), Promise::resolve(3)],
+ $this->mapper()
+ )->then($mock);
+
+ $deferred->resolve(2);
+ }
+
+ public function testRejectWhenInputContainsRejection(): void
+ {
+ $e = new Exception();
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo($e));
+
+ Promise::map(
+ [Promise::resolve(1), Promise::reject($e), Promise::resolve(3)],
+ $this->mapper()
+ )->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testRejectWhenInputPromiseRejects(): void
+ {
+ $e = new Exception();
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo($e));
+
+ Promise::map(
+ [Promise::reject($e)],
+ $this->mapper()
+ )->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testCancelInputPromise(): void
+ {
+ $mock = $this->creteCancellableMock();
+ $mock
+ ->expects($this->once())
+ ->method('cancel');
+
+ Promise::map(
+ [$mock],
+ $this->mapper()
+ )->cancel();
+ }
+
+ public function testCancelInputArrayPromises(): void
+ {
+ $mock1 = $this
+ ->creteCancellableMock();
+ $mock1
+ ->expects($this->once())
+ ->method('cancel');
+
+ $mock2 = $this
+ ->creteCancellableMock();
+ $mock2
+ ->expects($this->once())
+ ->method('cancel');
+
+ Promise::map(
+ [$mock1, $mock2],
+ $this->mapper()
+ )->cancel();
+ }
+
+ protected function mapper(): callable
+ {
+ return static fn($val) => $val * 2;
+ }
+
+ protected function promiseMapper(): callable
+ {
+ return static fn($val) => Promise::resolve($val * 2);
+ }
+}
diff --git a/tests/Unit/Promise/FunctionReduceTestCase.php b/tests/Unit/Promise/FunctionReduceTestCase.php
new file mode 100644
index 00000000..9ebe9499
--- /dev/null
+++ b/tests/Unit/Promise/FunctionReduceTestCase.php
@@ -0,0 +1,301 @@
+createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(6));
+
+ Promise::reduce(
+ [1, 2, 3],
+ $this->plus()
+ )->then($mock);
+ }
+
+ public function testReduceValuesWithInitialValue(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(7));
+
+ Promise::reduce(
+ [1, 2, 3],
+ $this->plus(),
+ 1
+ )->then($mock);
+ }
+
+ public function testReduceValuesWithInitialPromise(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(7));
+
+ Promise::reduce(
+ [1, 2, 3],
+ $this->plus(),
+ Promise::resolve(1)
+ )->then($mock);
+ }
+
+ public function testReducePromisedValuesWithoutInitialValue(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(6));
+
+ Promise::reduce(
+ [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)],
+ $this->plus()
+ )->then($mock);
+ }
+
+ public function testReducePromisedValuesWithInitialValue(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(7));
+
+ Promise::reduce(
+ [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)],
+ $this->plus(),
+ 1
+ )->then($mock);
+ }
+
+ public function testReducePromisedValuesWithInitialPromise(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(7));
+
+ Promise::reduce(
+ [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)],
+ $this->plus(),
+ Promise::resolve(1)
+ )->then($mock);
+ }
+
+ public function testReduceEmptyInputWithInitialValue(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(1));
+
+ Promise::reduce(
+ [],
+ $this->plus(),
+ 1
+ )->then($mock);
+ }
+
+ public function testReduceEmptyInputWithInitialPromise(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(1));
+
+ Promise::reduce(
+ [],
+ $this->plus(),
+ Promise::resolve(1)
+ )->then($mock);
+ }
+
+ public function testRejectWhenInputContainsRejection(): void
+ {
+ $e = new Exception();
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo($e));
+
+ Promise::reduce(
+ [Promise::resolve(1), Promise::reject($e), Promise::resolve(3)],
+ $this->plus(),
+ Promise::resolve(1)
+ )->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testResolveWithNullWhenInputIsEmptyAndNoInitialValueOrPromiseProvided(): void
+ {
+ // Note: this is different from when.js's behavior!
+ // In when.Promise::reduce(), this rejects with a TypeError exception (following
+ // JavaScript's [].reduce behavior.
+ // We're following PHP's array_reduce behavior and resolve with NULL.
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(null));
+
+ Promise::reduce(
+ [],
+ $this->plus()
+ )->then($mock);
+ }
+
+ public function testAllowSparseArrayInputWithoutInitialValue(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(3));
+
+ Promise::reduce(
+ [null, null, 1, null, 1, 1],
+ $this->plus()
+ )->then($mock);
+ }
+
+ public function testAllowSparseArrayInputWithInitialValue(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo(4));
+
+ Promise::reduce(
+ [null, null, 1, null, 1, 1],
+ $this->plus(),
+ 1
+ )->then($mock);
+ }
+
+ public function testReduceInInputOrder(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo('123'));
+
+ Promise::reduce(
+ [1, 2, 3],
+ $this->append(),
+ ''
+ )->then($mock);
+ }
+
+ public function testProvideCorrectBasisValue(): void
+ {
+ $insertIntoArray = function ($arr, $val, $i) {
+ $arr[$i] = $val;
+
+ return $arr;
+ };
+
+ $d1 = new Deferred();
+ $d2 = new Deferred();
+ $d3 = new Deferred();
+
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([1, 2, 3]));
+
+ Promise::reduce(
+ [$d1->promise(), $d2->promise(), $d3->promise()],
+ $insertIntoArray,
+ []
+ )->then($mock);
+
+ $d3->resolve(3);
+ $d1->resolve(1);
+ $d2->resolve(2);
+ }
+
+ public function testRejectWhenInputPromiseRejects(): void
+ {
+ $e = new Exception();
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo($e));
+
+ Promise::reduce(
+ [Promise::reject($e)],
+ $this->plus(),
+ 1
+ )->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testCancelInputPromise(): void
+ {
+ $mock = $this->creteCancellableMock();
+ $mock
+ ->expects($this->once())
+ ->method('cancel');
+
+ Promise::reduce(
+ [$mock],
+ $this->plus(),
+ 1
+ )->cancel();
+ }
+
+ public function testCancelInputArrayPromises(): void
+ {
+ $mock1 = $this->creteCancellableMock();
+ $mock1
+ ->expects($this->once())
+ ->method('cancel');
+
+ $mock2 = $this->creteCancellableMock();
+ $mock2
+ ->expects($this->once())
+ ->method('cancel');
+
+ Promise::reduce(
+ [$mock1, $mock2],
+ $this->plus(),
+ 1
+ )->cancel();
+ }
+
+ protected function plus(): callable
+ {
+ return static fn($sum, $val) => $sum + $val;
+ }
+
+ protected function append(): callable
+ {
+ return static fn($sum, $val) => $sum . $val;
+ }
+}
diff --git a/tests/Unit/Promise/FunctionSomeTestCase.php b/tests/Unit/Promise/FunctionSomeTestCase.php
new file mode 100644
index 00000000..0d98abf0
--- /dev/null
+++ b/tests/Unit/Promise/FunctionSomeTestCase.php
@@ -0,0 +1,186 @@
+createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with(
+ $this->callback(static function ($exception): bool {
+ return $exception instanceof LengthException &&
+ 'Input array must contain at least 1 item but contains only 0 items.' === $exception->getMessage();
+ })
+ );
+
+ Promise::some([], 1)
+ ->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testRejectWithLengthExceptionWithInputArrayContainingNotEnoughItems(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with(
+ $this->callback(function ($exception) {
+ return $exception instanceof LengthException &&
+ 'Input array must contain at least 4 items but contains only 3 items.' === $exception->getMessage();
+ })
+ );
+
+ Promise::some(
+ [1, 2, 3],
+ 4
+ )->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testResolveValuesArray(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([1, 2]));
+
+ Promise::some(
+ [1, 2, 3],
+ 2
+ )->then($mock);
+ }
+
+ public function testResolvePromisesArray(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([1, 2]));
+
+ Promise::some(
+ [Promise::resolve(1), Promise::resolve(2), Promise::resolve(3)],
+ 2
+ )->then($mock);
+ }
+
+ public function testResolveSparseArrayInput(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([null, 1]));
+
+ Promise::some(
+ [null, 1, null, 2, 3],
+ 2
+ )->then($mock);
+ }
+
+ public function testRejectIfAnyInputPromiseRejectsBeforeDesiredNumberOfInputsAreResolved(): void
+ {
+ $e = new Exception();
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->callback(function (mixed $exception) use ($e) {
+ return $exception instanceof Reasons &&
+ \in_array($e, \iterator_to_array($exception));
+ }));
+
+ Promise::some(
+ [Promise::resolve(1), Promise::reject($e), Promise::reject($e)],
+ 2
+ )->then($this->expectCallableNever(), $mock);
+ }
+
+ public function testResolveWithEmptyArrayIfHowManyIsLessThanOne(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->once())
+ ->method('__invoke')
+ ->with($this->identicalTo([]));
+
+ Promise::some(
+ [1],
+ 0
+ )->then($mock);
+ }
+
+ public function testCancelInputArrayPromises(): void
+ {
+ $mock1 = $this
+ ->creteCancellableMock();
+ $mock1
+ ->expects($this->once())
+ ->method('cancel');
+
+ $mock2 = $this
+ ->creteCancellableMock();
+ $mock2
+ ->expects($this->once())
+ ->method('cancel');
+
+ Promise::some([$mock1, $mock2], 1)->cancel();
+ }
+
+ public function testNotCancelOtherPendingInputArrayPromisesIfEnoughPromisesFulfill(): void
+ {
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->never())
+ ->method('__invoke');
+
+ $deferred = new Deferred($mock);
+ $deferred->resolve(null);
+
+ $mock2 = $this
+ ->creteCancellableMock();
+ $mock2
+ ->expects($this->never())
+ ->method('cancel');
+
+ Promise::some([$deferred->promise(), $mock2], 1);
+ }
+
+ public function testNotCancelOtherPendingInputArrayPromisesIfEnoughPromisesReject(): void
+ {
+ $e = new Exception();
+ $mock = $this->createCallableMock();
+ $mock
+ ->expects($this->never())
+ ->method('__invoke');
+
+ $deferred = new Deferred($mock);
+ $deferred->reject($e);
+
+ $mock2 = $this
+ ->creteCancellableMock();
+ $mock2
+ ->expects($this->never())
+ ->method('cancel');
+
+ Promise::some([$deferred->promise(), $mock2], 2);
+ }
+}