diff --git a/src/Common/SearchAttributes/SearchAttributeKey/DatetimeValue.php b/src/Common/SearchAttributes/SearchAttributeKey/DatetimeValue.php index dbc2349a..6e8cd4a0 100644 --- a/src/Common/SearchAttributes/SearchAttributeKey/DatetimeValue.php +++ b/src/Common/SearchAttributes/SearchAttributeKey/DatetimeValue.php @@ -18,11 +18,8 @@ final class DatetimeValue extends SearchAttributeKey */ public function valueSet(string|\DateTimeInterface $value): SearchAttributeUpdate { - return $this->prepareValueSet(match (true) { - \is_string($value) => new \DateTimeImmutable($value), - $value instanceof \DateTimeImmutable => $value, - default => \DateTimeImmutable::createFromInterface($value), - }); + $datetime = \is_string($value) ? new \DateTimeImmutable($value) : $value; + return $this->prepareValueSet($datetime->format(\DateTimeInterface::RFC3339)); } public function getType(): ValueType diff --git a/src/Common/SearchAttributes/ValueType.php b/src/Common/SearchAttributes/ValueType.php index bd1342a8..e43daa9c 100644 --- a/src/Common/SearchAttributes/ValueType.php +++ b/src/Common/SearchAttributes/ValueType.php @@ -11,7 +11,7 @@ enum ValueType: string { case Bool = 'bool'; case Float = 'float64'; - case Int = 'int'; + case Int = 'int64'; case Keyword = 'keyword'; case KeywordList = 'keyword_list'; case String = 'string'; diff --git a/src/Common/TypedSearchAttributes.php b/src/Common/TypedSearchAttributes.php index 4f728ba6..f41bd056 100644 --- a/src/Common/TypedSearchAttributes.php +++ b/src/Common/TypedSearchAttributes.php @@ -94,11 +94,8 @@ public function hasKey(SearchAttributeKey $key): bool public function withValue(SearchAttributeKey $key, mixed $value): self { - $collection = $this->collection === null - ? new \SplObjectStorage() - : clone $this->collection; + $collection = $this->withoutValue($key)->collection ?? new \SplObjectStorage(); $collection->offsetSet($key, $value); - return new self($collection); } @@ -121,6 +118,21 @@ public function withUntypedValue(string $name, mixed $value): self }; } + /** + * @param SearchAttributeKey|non-empty-string $key + */ + public function withoutValue(SearchAttributeKey|string $key): self + { + $found = $this->getKeyByName(\is_string($key) ? $key : $key->getName()); + if ($found === null) { + return new self($this->collection === null ? null : clone $this->collection); + } + + $collection = clone $this->collection; + $collection->offsetUnset($found); + return new self($collection); + } + /** * @return int<0, max> */ @@ -157,6 +169,20 @@ public function offsetGet(string $name): mixed return $key === null ? null : $this->collection[$key]; } + /** + * @return array + */ + public function toArray(): array + { + $result = []; + /** @var SearchAttributeKey $key */ + foreach ($this as $key => $value) { + $result[$key->getName()] = $value; + } + + return $result; + } + /** * @param non-empty-string $name */ diff --git a/src/Internal/Workflow/ScopeContext.php b/src/Internal/Workflow/ScopeContext.php index 8f9da5eb..78948a27 100644 --- a/src/Internal/Workflow/ScopeContext.php +++ b/src/Internal/Workflow/ScopeContext.php @@ -13,6 +13,7 @@ use React\Promise\Deferred; use React\Promise\PromiseInterface; +use Temporal\Common\SearchAttributes\SearchAttributeKey; use Temporal\Common\SearchAttributes\SearchAttributeUpdate; use Temporal\Exception\Failure\CanceledFailure; use Temporal\Internal\Transport\CompletableResult; @@ -121,6 +122,23 @@ public function upsertSearchAttributes(array $searchAttributes): void public function upsertTypedSearchAttributes(SearchAttributeUpdate ...$updates): void { $this->request(new UpsertTypedSearchAttributes($updates), waitResponse: false); + + // Merge changes + $tsa = $this->input->info->typedSearchAttributes; + foreach ($updates as $update) { + if ($update instanceof SearchAttributeUpdate\ValueUnset) { + $tsa = $tsa->withoutValue($update->name); + continue; + } + + \assert($update instanceof SearchAttributeUpdate\ValueSet); + $tsa = $tsa->withValue( + SearchAttributeKey::for($update->type, $update->name), + $update->value, + ); + } + + $this->input->info->typedSearchAttributes = $tsa; } #[\Override] diff --git a/tests/Acceptance/Extra/Workflow/TypedSearchAttributesTest.php b/tests/Acceptance/Extra/Workflow/TypedSearchAttributesTest.php index 7b879b37..ad998bf8 100644 --- a/tests/Acceptance/Extra/Workflow/TypedSearchAttributesTest.php +++ b/tests/Acceptance/Extra/Workflow/TypedSearchAttributesTest.php @@ -72,16 +72,19 @@ public function testUpsertTypedSearchAttributes( 'Extra_Workflow_TypedSearchAttributes', WorkflowOptions::new() ->withTaskQueue($feature->taskQueue) - ->withSearchAttributes([ - 'testBool' => false, - 'testInt' => -2, - 'testFloat' => 1.1, - 'testString' => 'foo', - 'testKeyword' => 'bar', - 'testKeywordList' => ['baz'], - 'testDatetime' => (new \DateTimeImmutable('2019-01-01T00:00:00Z')) - ->format(\DateTimeInterface::RFC3339), - ]) + ->withTypedSearchAttributes( + TypedSearchAttributes::empty() + ->withValue(SearchAttributeKey::forFloat('testFloat'), 1.1) + ->withValue(SearchAttributeKey::forInteger('testInt'), -2) + ->withValue(SearchAttributeKey::forBool('testBool'), false) + ->withValue(SearchAttributeKey::forString('testString'), 'foo') + ->withValue(SearchAttributeKey::forKeyword('testKeyword'), 'bar') + ->withValue(SearchAttributeKey::forKeywordList('testKeywordList'), ['baz']) + ->withValue( + SearchAttributeKey::forDatetime('testDatetime'), + new \DateTimeImmutable('2019-01-01T00:00:00Z'), + ) + ), ); $toSend = [ @@ -91,7 +94,7 @@ public function testUpsertTypedSearchAttributes( 'testString' => 'foo bar baz', 'testKeyword' => 'foo-bar-baz', 'testKeywordList' => ['foo', 'bar', 'baz'], - 'testDatetime' => (new \DateTimeImmutable('2021-01-01T00:00:00Z'))->format(\DateTimeInterface::RFC3339), + 'testDatetime' => '2021-01-01T00:00:00+00:00', ]; /** @see TestWorkflow::handle() */ @@ -104,8 +107,8 @@ public function testUpsertTypedSearchAttributes( // Get Search Attributes using Client API $clientSA = \array_intersect_key( - $toSend, $stub->describe()->info->searchAttributes->getValues(), + $toSend, ); // Complete workflow @@ -119,7 +122,71 @@ public function testUpsertTypedSearchAttributes( // Get Search Attributes as a Workflow result $result = $stub->getResult(); - $this->assertSame($toSend, $clientSA); + // Normalize datetime field + $clientSA['testDatetime'] = (new \DateTimeImmutable($clientSA['testDatetime'])) + ->format(\DateTimeInterface::RFC3339); + + $this->assertEquals($toSend, $clientSA); + $this->assertEquals($toSend, (array) $result); + } + + #[Test] + public function testUpsertTypedSearchAttributesUnset( + WorkflowClientInterface $client, + Feature $feature, + ): void { + $stub = $client->newUntypedWorkflowStub( + 'Extra_Workflow_TypedSearchAttributes', + WorkflowOptions::new() + ->withTaskQueue($feature->taskQueue) + ->withTypedSearchAttributes( + TypedSearchAttributes::empty() + ->withValue(SearchAttributeKey::forFloat('testFloat'), 1.1) + ->withValue(SearchAttributeKey::forInteger('testInt'), -2) + ->withValue(SearchAttributeKey::forBool('testBool'), false) + ->withValue(SearchAttributeKey::forString('testString'), 'foo') + ->withValue(SearchAttributeKey::forKeyword('testKeyword'), 'bar') + ->withValue(SearchAttributeKey::forKeywordList('testKeywordList'), ['baz']) + ->withValue( + SearchAttributeKey::forDatetime('testDatetime'), + new \DateTimeImmutable('2019-01-01T00:00:00Z'), + ) + ), + ); + + $toSend = [ + 'testInt' => 42, + 'testBool' => null, + 'testString' => 'bar', + 'testKeyword' => null, + 'testKeywordList' => ['red'], + 'testDatetime' => null, + ]; + + /** @see TestWorkflow::handle() */ + $client->start($stub); + try { + $stub->update('setAttributes', $toSend); + + // Get Search Attributes using Client API + $clientSA = \array_intersect_key( + $stub->describe()->info->searchAttributes->getValues(), + $toSend, + ); + + // Complete workflow + /** @see TestWorkflow::exit */ + $stub->signal('exit'); + } catch (\Throwable $e) { + $stub->terminate('test failed'); + throw $e; + } + + // Get Search Attributes as a Workflow result + $result = \array_intersect_key((array) $stub->getResult(), $toSend); + + $this->assertEquals(\array_filter($toSend), $clientSA); + $this->assertEquals(\array_filter($toSend), $result); } } @@ -135,7 +202,7 @@ public function handle() fn(): bool => $this->exit, ); - return Workflow::getInfo()->searchAttributes; + return Workflow::getInfo()->typedSearchAttributes->toArray(); } #[Workflow\UpdateMethod] @@ -148,7 +215,9 @@ public function setAttributes(array $searchAttributes): void continue; } - $updates[] = $key->valueSet($searchAttributes[$key->getName()]); + $updates[] = isset($searchAttributes[$key->getName()]) + ? $key->valueSet($searchAttributes[$key->getName()]) + : $updates[] = $key->valueUnset(); } Workflow::upsertTypedSearchAttributes(...$updates); diff --git a/tests/Unit/Common/TypedSearchAttributesTest.php b/tests/Unit/Common/TypedSearchAttributesTest.php index 5da1c302..c36dcf04 100644 --- a/tests/Unit/Common/TypedSearchAttributesTest.php +++ b/tests/Unit/Common/TypedSearchAttributesTest.php @@ -21,6 +21,24 @@ public function testCount(): void self::assertCount(2, $collection3); } + public function testWithoutValueImmutability(): void + { + $collection1 = TypedSearchAttributes::empty(); + $collection2 = $collection1->withValue(SearchAttributeKey::forBool('name1'), true); + $collection3 = $collection2->withoutValue(SearchAttributeKey::forBool('name1')); + $collection4 = $collection3->withoutValue(SearchAttributeKey::forBool('name1')); + + self::assertNotSame($collection1, $collection2); + self::assertNotSame($collection2, $collection3); + self::assertNotSame($collection1, $collection3); + self::assertNotSame($collection3, $collection4); + + self::assertFalse($collection1->hasKey(SearchAttributeKey::forBool('name1'))); + self::assertTrue($collection2->hasKey(SearchAttributeKey::forBool('name1'))); + self::assertFalse($collection2->hasKey(SearchAttributeKey::forBool('name2'))); + self::assertFalse($collection3->hasKey(SearchAttributeKey::forBool('name1'))); + } + public function testWithValueImmutability(): void { $collection1 = TypedSearchAttributes::empty(); @@ -38,6 +56,16 @@ public function testWithValueImmutability(): void self::assertTrue($collection3->hasKey(SearchAttributeKey::forBool('name2'))); } + public function testWithValueOverride(): void + { + $collection = TypedSearchAttributes::empty() + ->withValue(SearchAttributeKey::forBool('name2'), false) + ->withValue(SearchAttributeKey::forBool('name2'), true); + + self::assertCount(1, $collection); + self::assertTrue($collection->offsetGet('name2')); + } + public function testWithUntypedValueImmutability(): void { $collection1 = TypedSearchAttributes::empty();