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();