From 4ef41ee2c03869f131b6d188ed682d3fa416ffff Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 3 Oct 2026 20:06:21 +0200 Subject: [PATCH] Support arrays for event tags An event that relates to a variable number of entities, like an order with several products, had no way to tag each of them with #[EventTag]. Arrays were rejected as invalid value type. If a tagged property is an array, every element now becomes its own tag with the same prefix and hash. Elements follow the same rules as single values: null is skipped, nested arrays are still invalid. --- docs/dynamic-consistency-boundary.md | 5 ++ src/Serializer/AttributeEventTagExtractor.php | 66 +++++++++------ .../AttributeEventTagExtractorTest.php | 81 ++++++++++++++++++- 3 files changed, 128 insertions(+), 24 deletions(-) diff --git a/docs/dynamic-consistency-boundary.md b/docs/dynamic-consistency-boundary.md index 0605d0695..b0128b1a2 100644 --- a/docs/dynamic-consistency-boundary.md +++ b/docs/dynamic-consistency-boundary.md @@ -89,6 +89,11 @@ final class GuestIsCheckedOut } } ``` +:::tip +A tagged property can also be an array. Each element becomes its own tag, with the same prefix and hash. +For example, `#[EventTag(prefix: 'guest')] public readonly array $guestNames` creates a `guest:` tag for every guest. +::: + :::note You can find out more about [events](events.md). ::: diff --git a/src/Serializer/AttributeEventTagExtractor.php b/src/Serializer/AttributeEventTagExtractor.php index a38b829fd..7049e9be6 100644 --- a/src/Serializer/AttributeEventTagExtractor.php +++ b/src/Serializer/AttributeEventTagExtractor.php @@ -12,6 +12,7 @@ use function array_keys; use function array_map; use function hash; +use function is_array; use function is_int; use function is_string; use function strval; @@ -37,38 +38,57 @@ public function extract(object $event): array $attribute = $attributes[0]->newInstance(); $value = $property->getValue($event); + $values = is_array($value) ? $value : [$value]; - if ($value === null) { - continue; - } + foreach ($values as $item) { + $tag = $this->tag($event, $property->getName(), $item, $attribute->prefix, $attribute->hash); - if ($value instanceof Stringable || is_int($value)) { - $value = (string)$value; - } + if ($tag === null) { + continue; + } - if ($value instanceof Identifier) { - $value = $value->toString(); + $tags[$tag] = true; } + } - if (!is_string($value)) { - throw EventTagExtractorError::invalidValueType( - $event::class, - $property->getName(), - $value, - ); - } + return array_map(strval(...), array_keys($tags)); + } - if ($attribute->hash) { - $value = hash($attribute->hash, $value); - } + private function tag( + object $event, + string $property, + mixed $value, + string|null $prefix, + string|null $hash, + ): string|null { + if ($value === null) { + return null; + } - if ($attribute->prefix) { - $value = $attribute->prefix . ':' . $value; - } + if ($value instanceof Stringable || is_int($value)) { + $value = (string)$value; + } - $tags[$value] = true; + if ($value instanceof Identifier) { + $value = $value->toString(); } - return array_map(strval(...), array_keys($tags)); + if (!is_string($value)) { + throw EventTagExtractorError::invalidValueType( + $event::class, + $property, + $value, + ); + } + + if ($hash) { + $value = hash($hash, $value); + } + + if ($prefix) { + $value = $prefix . ':' . $value; + } + + return $value; } } diff --git a/tests/Unit/Serializer/AttributeEventTagExtractorTest.php b/tests/Unit/Serializer/AttributeEventTagExtractorTest.php index 6a0f8c649..03ee21e7b 100644 --- a/tests/Unit/Serializer/AttributeEventTagExtractorTest.php +++ b/tests/Unit/Serializer/AttributeEventTagExtractorTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\TestCase; use Stringable; +use function hash; use function sprintf; #[CoversClass(AttributeEventTagExtractor::class)] @@ -175,7 +176,46 @@ public function __construct( self::assertSame(['foo'], $tags); } - public function testExtractInvalidValueType(): void + public function testExtractArray(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class (['1', 2, new CustomId('3'), null, '1']) { + /** @param list $productIds */ + public function __construct( + #[EventTag(prefix: 'product')] + public array $productIds, + ) { + } + }; + + $tags = $extractor->extract($event); + + self::assertSame(['product:1', 'product:2', 'product:3'], $tags); + } + + public function testExtractArrayWithHash(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class (['foo', 'bar']) { + /** @param list $emails */ + public function __construct( + #[EventTag(prefix: 'email', hash: 'sha256')] + public array $emails, + ) { + } + }; + + $tags = $extractor->extract($event); + + self::assertSame( + ['email:' . hash('sha256', 'foo'), 'email:' . hash('sha256', 'bar')], + $tags, + ); + } + + public function testExtractEmptyArray(): void { $extractor = new AttributeEventTagExtractor(); @@ -188,6 +228,22 @@ public function __construct( } }; + self::assertSame([], $extractor->extract($event)); + } + + public function testExtractNestedArrayIsInvalid(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class ([['foo']]) { + /** @param list> $items */ + public function __construct( + #[EventTag] + public array $items, + ) { + } + }; + $this->expectException(EventTagExtractorError::class); $this->expectExceptionMessage( sprintf( @@ -198,4 +254,27 @@ public function __construct( $extractor->extract($event); } + + public function testExtractInvalidValueType(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class (1.5) { + public function __construct( + #[EventTag] + public float $value, + ) { + } + }; + + $this->expectException(EventTagExtractorError::class); + $this->expectExceptionMessage( + sprintf( + 'Event tag value for property "value" in class "%s" must be stringable, float given', + $event::class, + ), + ); + + $extractor->extract($event); + } }