diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 1a1913a2..b0bb1673 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -212,43 +212,14 @@
-
- ]]>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- payloads[$index]]]>
-
values]]>
-
- payloads]]>
-
@@ -661,6 +632,11 @@
+
+
+
+
+
@@ -694,12 +670,6 @@
-
- getFields(), $this->converter)]]>
-
-
-
-
@@ -779,7 +749,6 @@
- >]]>
@@ -1306,9 +1275,6 @@
-
- getFields(), $this->dataConverter)]]>
-
$this->parseRequest($command, $info),
@@ -1344,9 +1310,6 @@
getCommand()]]>
-
-
-
getFailure()]]>
getPayloads()]]>
diff --git a/src/DataConverter/EncodedCollection.php b/src/DataConverter/EncodedCollection.php
index d9c80c92..b245ffd4 100644
--- a/src/DataConverter/EncodedCollection.php
+++ b/src/DataConverter/EncodedCollection.php
@@ -11,18 +11,16 @@
namespace Temporal\DataConverter;
-use ArrayAccess;
-use Countable;
-use IteratorAggregate;
use Temporal\Api\Common\V1\Payload;
-use Traversable;
/**
- * @psalm-type TPayloadsCollection = Traversable&ArrayAccess&Countable
+ * Assoc collection of typed values.
+ *
* @psalm-type TKey = array-key
* @psalm-type TValue = mixed
+ * @psalm-type TPayloadsCollection = \Traversable&\ArrayAccess&\Countable
*
- * @implements IteratorAggregate
+ * @implements \IteratorAggregate
*/
class EncodedCollection implements \IteratorAggregate, \Countable
{
@@ -36,16 +34,14 @@ class EncodedCollection implements \IteratorAggregate, \Countable
*/
private ?\ArrayAccess $payloads = null;
+ /** @var array */
private array $values = [];
/**
* Cannot be constructed directly.
*/
- private function __construct() {}
+ final private function __construct() {}
- /**
- * @return static
- */
public static function empty(): static
{
$ev = new static();
@@ -55,10 +51,7 @@ public static function empty(): static
}
/**
- * @param iterable $values
- * @param DataConverterInterface|null $dataConverter
- *
- * @return static
+ * @param iterable $values
*/
public static function fromValues(iterable $values, ?DataConverterInterface $dataConverter = null): static
{
@@ -72,10 +65,7 @@ public static function fromValues(iterable $values, ?DataConverterInterface $dat
}
/**
- * @param iterable $payloads
- * @param DataConverterInterface $dataConverter
- *
- * @return EncodedCollection
+ * @param array|TPayloadsCollection $payloads
*/
public static function fromPayloadCollection(
array|\ArrayAccess $payloads,
@@ -103,8 +93,6 @@ public function isEmpty(): bool
/**
* @param array-key $name
* @param Type|string|null $type
- *
- * @return mixed
*/
public function getValue(int|string $name, mixed $type = null): mixed
{
@@ -143,9 +131,8 @@ public function getValues(): array
public function getIterator(): \Traversable
{
yield from $this->values;
- if ($this->payloads !== null) {
- $this->converter !== null or $this->payloads->count() === 0
- or throw new \LogicException('DataConverter is not set.');
+ if ($this->payloads !== null && $this->payloads->count() > 0) {
+ $this->converter === null and throw new \LogicException('DataConverter is not set.');
foreach ($this->payloads as $key => $payload) {
yield $key => $this->converter->fromPayload($payload, null);
@@ -166,9 +153,7 @@ public function toPayloadArray(): array
return $data;
}
- if ($this->converter === null) {
- throw new \LogicException('DataConverter is not set.');
- }
+ $this->converter === null and throw new \LogicException('DataConverter is not set.');
foreach ($this->values as $key => $value) {
$data[$key] = $this->converter->toPayload($value);
@@ -177,6 +162,10 @@ public function toPayloadArray(): array
return $data;
}
+ /**
+ * @param TKey $name
+ * @param TValue $value
+ */
public function withValue(int|string $name, mixed $value): static
{
$clone = clone $this;
@@ -193,9 +182,6 @@ public function withValue(int|string $name, mixed $value): static
return $clone;
}
- /**
- * @param DataConverterInterface $converter
- */
public function setDataConverter(DataConverterInterface $converter): void
{
$this->converter = $converter;
diff --git a/src/DataConverter/EncodedValues.php b/src/DataConverter/EncodedValues.php
index 896c0460..f1166218 100644
--- a/src/DataConverter/EncodedValues.php
+++ b/src/DataConverter/EncodedValues.php
@@ -20,8 +20,10 @@
use Traversable;
/**
+ * List of typed values.
+ *
* @psalm-type TPayloadsCollection = Traversable&ArrayAccess&Countable
- * @psalm-type TKey = array-key
+ * @psalm-type TKey = int
* @psalm-type TValue = string
*/
class EncodedValues implements ValuesInterface
@@ -100,7 +102,7 @@ public static function sliceValues(
public static function decodePromise(PromiseInterface $promise, $type = null): PromiseInterface
{
return $promise->then(
- static function ($value) use ($type) {
+ static function (mixed $value) use ($type) {
if (!$value instanceof ValuesInterface || $value instanceof \Throwable) {
return $value;
}
@@ -153,22 +155,26 @@ public function getValue(int|string $index, $type = null): mixed
return $this->values[$index];
}
+ $count = $this->count();
// External SDKs might return an empty array with metadata, alias to null
// Most likely this is a void type
- if ($index === 0 && $this->count() === 0 && $this->isVoidType($type)) {
+ if ($index === 0 && $count === 0 && $this->isVoidType($type)) {
return null;
}
- if ($this->converter === null) {
- throw new \LogicException('DataConverter is not set');
- }
+ $count > $index or throw new \OutOfBoundsException("Index {$index} is out of bounds.");
+ $this->converter === null and throw new \LogicException('DataConverter is not set.');
- return $this->converter->fromPayload($this->payloads[$index], $type);
+ \assert($this->payloads !== null);
+ return $this->converter->fromPayload(
+ $this->payloads[$index],
+ $type,
+ );
}
public function getValues(): array
{
- $result = $this->values;
+ $result = (array) $this->values;
if (empty($this->payloads)) {
return $result;
diff --git a/src/Internal/Declaration/Dispatcher/AutowiredPayloads.php b/src/Internal/Declaration/Dispatcher/AutowiredPayloads.php
index e659c933..adb45a07 100644
--- a/src/Internal/Declaration/Dispatcher/AutowiredPayloads.php
+++ b/src/Internal/Declaration/Dispatcher/AutowiredPayloads.php
@@ -23,12 +23,12 @@ class AutowiredPayloads extends Dispatcher
public function dispatchValues(object $ctx, ValuesInterface $values): mixed
{
$arguments = [];
- for ($i = 0; $i < $values->count(); $i++) {
- try {
+ try {
+ for ($i = 0, $count = $values->count(); $i < $count; $i++) {
$arguments[] = $values->getValue($i, $this->getArgumentTypes()[$i] ?? null);
- } catch (\Throwable $e) {
- throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
}
+ } catch (\Throwable $e) {
+ throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
}
try {
diff --git a/src/Internal/Declaration/Graph/ClassNode.php b/src/Internal/Declaration/Graph/ClassNode.php
index ff86cf0a..b0dd7888 100644
--- a/src/Internal/Declaration/Graph/ClassNode.php
+++ b/src/Internal/Declaration/Graph/ClassNode.php
@@ -11,8 +11,6 @@
namespace Temporal\Internal\Declaration\Graph;
-use Temporal\Tests\Workflow\Inheritance\ExtendingWorkflow;
-
final class ClassNode implements NodeInterface
{
/**
@@ -39,7 +37,8 @@ public function getReflection(): \ReflectionClass
*
* @throws \ReflectionException
*/
- public function getAllMethods(): array {
+ public function getAllMethods(): array
+ {
/** @var array $result */
$result = [];
diff --git a/src/Internal/Marshaller/Meta/MarshalAssocArray.php b/src/Internal/Marshaller/Meta/MarshalAssocArray.php
new file mode 100644
index 00000000..c051f606
--- /dev/null
+++ b/src/Internal/Marshaller/Meta/MarshalAssocArray.php
@@ -0,0 +1,41 @@
+
+ */
+class AssocArrayType extends Type
+{
+ /**
+ * @var string
+ */
+ private const ERROR_INVALID_TYPE = 'Passed value must be a type of array, but %s given';
+
+ /**
+ * @var TypeInterface|null
+ */
+ private ?TypeInterface $type = null;
+
+ /**
+ * @param MarshallerInterface $marshaller
+ * @param MarshallingRule|string|null $typeOrClass
+ *
+ * @throws \ReflectionException
+ */
+ public function __construct(MarshallerInterface $marshaller, MarshallingRule|string $typeOrClass = null)
+ {
+ if ($typeOrClass !== null) {
+ $this->type = $this->ofType($marshaller, $typeOrClass);
+ }
+
+ parent::__construct($marshaller);
+ }
+
+ /**
+ * @psalm-assert array $value
+ * @psalm-assert array $current
+ * @param mixed $value
+ * @param mixed $current
+ */
+ public function parse($value, $current): array
+ {
+ \is_array($value) or throw new \InvalidArgumentException(
+ \sprintf(self::ERROR_INVALID_TYPE, \get_debug_type($value)),
+ );
+
+ if ($this->type) {
+ $result = [];
+
+ foreach ($value as $i => $item) {
+ $result[$i] = $this->type->parse($item, $current[$i] ?? null);
+ }
+
+ return $result;
+ }
+
+ return $value;
+ }
+
+ public function serialize($value): object
+ {
+ if ($this->type) {
+ $result = [];
+
+ foreach ($value as $i => $item) {
+ $result[$i] = $this->type->serialize($item);
+ }
+
+ return (object) $result;
+ }
+
+ if (\is_array($value)) {
+ return (object) $value;
+ }
+
+ // Convert iterable to array
+ $result = [];
+ foreach ($value as $i => $item) {
+ $result[$i] = $item;
+ }
+ return (object) $result;
+ }
+}
diff --git a/src/Internal/Marshaller/TypeFactory.php b/src/Internal/Marshaller/TypeFactory.php
index b6f402ad..d5b21575 100644
--- a/src/Internal/Marshaller/TypeFactory.php
+++ b/src/Internal/Marshaller/TypeFactory.php
@@ -19,7 +19,6 @@
use Temporal\Internal\Marshaller\Type\EnumType;
use Temporal\Internal\Marshaller\Type\EnumValueType;
use Temporal\Internal\Marshaller\Type\ObjectType;
-use Temporal\Internal\Marshaller\Type\OneOfType;
use Temporal\Internal\Marshaller\Type\RuleFactoryInterface as TypeRuleFactoryInterface;
use Temporal\Internal\Marshaller\Type\TypeInterface;
use Temporal\Internal\Marshaller\Type\UuidType;
@@ -136,7 +135,7 @@ private function createMatchers(iterable $matchers): void
}
/**
- * @return iterable>
+ * @return iterable>
*/
private function getDefaultMatchers(): iterable
{
@@ -147,7 +146,6 @@ private function getDefaultMatchers(): iterable
yield UuidType::class;
yield ArrayType::class;
yield EncodedCollectionType::class;
- yield OneOfType::class;
yield ObjectType::class;
}
}
diff --git a/src/Internal/Transport/Router/StartWorkflow.php b/src/Internal/Transport/Router/StartWorkflow.php
index ed4c6af4..d24608d5 100644
--- a/src/Internal/Transport/Router/StartWorkflow.php
+++ b/src/Internal/Transport/Router/StartWorkflow.php
@@ -12,6 +12,8 @@
namespace Temporal\Internal\Transport\Router;
use React\Promise\Deferred;
+use Temporal\Api\Common\V1\SearchAttributes;
+use Temporal\DataConverter\EncodedCollection;
use Temporal\DataConverter\EncodedValues;
use Temporal\Interceptor\WorkflowInbound\WorkflowInput;
use Temporal\Interceptor\WorkflowInboundCallsInterceptor;
@@ -53,8 +55,13 @@ public function handle(ServerRequestInterface $request, array $headers, Deferred
$payloads = EncodedValues::sliceValues($this->services->dataConverter, $payloads, 0, $offset);
}
+ // Search Attributes
+ $searchAttributes = $this->convertSearchAttributes($options['info']['SearchAttributes'] ?? null);
+ $options['info']['SearchAttributes'] = $searchAttributes?->getValues();
+
/** @var Input $input */
$input = $this->services->marshaller->unmarshal($options, new Input());
+
/** @psalm-suppress InaccessibleProperty */
$input->input = $payloads;
/** @psalm-suppress InaccessibleProperty */
@@ -115,4 +122,30 @@ private function findWorkflowOrFail(WorkflowInfo $info): WorkflowPrototype
\sprintf(self::ERROR_NOT_FOUND, $info->type->name),
);
}
+
+ private function convertSearchAttributes(?array $param): ?EncodedCollection
+ {
+ if (!\is_array($param)) {
+ return null;
+ }
+
+ if ($param === []) {
+ return EncodedCollection::empty();
+ }
+
+ try {
+ $sa = (new SearchAttributes());
+ $sa->mergeFromJsonString(
+ \json_encode($param),
+ true,
+ );
+
+ return EncodedCollection::fromPayloadCollection(
+ $sa->getIndexedFields(),
+ $this->services->dataConverter,
+ );
+ } catch (\Throwable) {
+ return null;
+ }
+ }
}
diff --git a/src/Workflow/ChildWorkflowOptions.php b/src/Workflow/ChildWorkflowOptions.php
index 95662f38..50d27494 100644
--- a/src/Workflow/ChildWorkflowOptions.php
+++ b/src/Workflow/ChildWorkflowOptions.php
@@ -20,6 +20,7 @@
use Temporal\Common\RetryOptions;
use Temporal\Exception\FailedCancellationException;
use Temporal\Internal\Marshaller\Meta\Marshal;
+use Temporal\Internal\Marshaller\Meta\MarshalAssocArray;
use Temporal\Internal\Marshaller\Type\ArrayType;
use Temporal\Internal\Marshaller\Type\ChildWorkflowCancellationType as ChildWorkflowCancellationMarshalType;
use Temporal\Internal\Marshaller\Type\CronType;
@@ -153,7 +154,7 @@ final class ChildWorkflowOptions extends Options
*
* @psalm-var array|null
*/
- #[Marshal(name: 'SearchAttributes', type: NullableType::class, of: ArrayType::class)]
+ #[MarshalAssocArray(name: 'SearchAttributes', nullable: true)]
public ?array $searchAttributes = null;
/**
diff --git a/tests/Acceptance/Extra/Workflow/WorkflowSearchAttributesTest.php b/tests/Acceptance/Extra/Workflow/WorkflowSearchAttributesTest.php
new file mode 100644
index 00000000..219a0058
--- /dev/null
+++ b/tests/Acceptance/Extra/Workflow/WorkflowSearchAttributesTest.php
@@ -0,0 +1,88 @@
+getResult(timeout: 3);
+ $this->assertSame([], $result, 'Workflow result contains resolved value');
+ }
+
+ #[Test]
+ public function sendNullAsSearchAttributes(
+ #[Stub(
+ 'Extra_Workflow_WorkflowSearchAttributes',
+ args: [
+ null,
+ ],
+ )]
+ WorkflowStubInterface $stub,
+ ): void {
+ $result = $stub->getResult(timeout: 3);
+ $this->assertNull($result);
+ }
+
+ #[Test]
+ public function sendSimpleSearchAttributeSet(
+ #[Stub(
+ 'Extra_Workflow_WorkflowSearchAttributes',
+ args: [
+ ['foo' => 'bar'],
+ ],
+ )]
+ WorkflowStubInterface $stub,
+ ): void {
+ $result = $stub->getResult('array', timeout: 3);
+ $this->assertSame(['foo' => 'bar'], $result, 'Workflow result contains resolved value');
+ }
+}
+
+#[WorkflowInterface]
+class TestWorkflow
+{
+ #[WorkflowMethod(name: "Extra_Workflow_WorkflowSearchAttributes")]
+ public function handle(?array $searchAttributes): \Generator
+ {
+ return yield Workflow::newChildWorkflowStub(
+ TestWorkflowChild::class,
+ Workflow\ChildWorkflowOptions::new()
+ ->withSearchAttributes($searchAttributes)
+ )->handle();
+ }
+}
+
+#[WorkflowInterface]
+class TestWorkflowChild
+{
+ #[WorkflowMethod(name: "Extra_Workflow_WorkflowSearchAttributes_Child")]
+ public function handle(): ?array
+ {
+ return Workflow::getInfo()->searchAttributes;
+ }
+}
diff --git a/tests/Unit/Protocol/EncodingTestCase.php b/tests/Unit/DataConverter/EncodedValuesTestCase.php
similarity index 72%
rename from tests/Unit/Protocol/EncodingTestCase.php
rename to tests/Unit/DataConverter/EncodedValuesTestCase.php
index 18abb9de..e2adb381 100644
--- a/tests/Unit/Protocol/EncodingTestCase.php
+++ b/tests/Unit/DataConverter/EncodedValuesTestCase.php
@@ -1,18 +1,13 @@
assertNull($encodedValues->getValue(0));
- }
-
public static function getNotNullableTypes(): iterable
{
yield [Type::create(Type::TYPE_ARRAY)];
@@ -78,6 +66,13 @@ public static function getNullableTypes(): iterable
yield 'union' => [self::getReturnType(static fn(): int|string|null => null)];
}
+ #[Test]
+ public function nullValuesAreReturned(): void
+ {
+ $encodedValues = EncodedValues::fromValues([null, 'something'], new DataConverter());
+ $this->assertNull($encodedValues->getValue(0));
+ }
+
#[Test]
#[DataProvider('getNullableTypes')]
public function payloadWithoutValueDecoding(mixed $type): void
@@ -91,7 +86,9 @@ public function payloadWithoutValueDecoding(mixed $type): void
#[DataProvider('getNotNullableTypes')]
public function payloadWithoutValueDecodingNotNullable(mixed $type): void
{
- $encodedValues = EncodedValues::fromPayloadCollection(new \ArrayIterator([]));
+ $encodedValues = EncodedValues::fromPayloadCollection(new \ArrayIterator([
+ new Payloads(),
+ ]));
self::expectException(\LogicException::class);
self::expectExceptionMessage('DataConverter is not set');
@@ -99,6 +96,44 @@ public function payloadWithoutValueDecodingNotNullable(mixed $type): void
$encodedValues->getValue(0, $type);
}
+ public function testEmpty(): void
+ {
+ $ev = EncodedValues::empty();
+
+ $this->assertInstanceOf(EncodedValues::class, $ev);
+ $this->assertEmpty($ev->getValues());
+ $this->assertNull($ev->getValue(0));
+ }
+
+ public function testGetValuesFromEmptyPayloads(): void
+ {
+ $dataConverter = new DataConverter();
+ $ev = EncodedValues::fromPayloads(new Payloads(), $dataConverter);
+
+ $this->assertInstanceOf(EncodedValues::class, $ev);
+ $this->assertEmpty($ev->getValues());
+ $this->assertNull($ev->getValue(0));
+ }
+
+ public function testGetValueFromEmptyValues(): void
+ {
+ $ev = EncodedValues::fromValues([]);
+
+ $this->assertInstanceOf(EncodedValues::class, $ev);
+ $this->assertEmpty($ev->getValues());
+ $this->assertNull($ev->getValue(0));
+ }
+
+ public function testOutOfBounds(): void
+ {
+ $ev = EncodedValues::fromValues([]);
+
+ $this->expectException(\OutOfBoundsException::class);
+ $this->expectExceptionMessage('Index 1 is out of bounds.');
+
+ $ev->getValue(1);
+ }
+
private static function getReturnType(\Closure $closure): \ReflectionType
{
return (new \ReflectionFunction($closure))->getReturnType();