From c6a2525db3e543c6e78f7b1900ea59236d539e17 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 3 Oct 2026 21:56:01 +0200 Subject: [PATCH] Allow event tags on methods #[EventTag] only worked on properties, so a tag that combines several values, like an account and a booking period, had to be stored as an extra property. The attribute can now be put on methods without required parameters. The return value is handled like a property value, including arrays and null. --- docs/dynamic-consistency-boundary.md | 29 ++++ src/Attribute/EventTag.php | 2 +- src/Serializer/AttributeEventTagExtractor.php | 35 +++-- src/Serializer/EventTagExtractorError.php | 25 ++++ .../AttributeEventTagExtractorTest.php | 129 ++++++++++++++++++ 5 files changed, 209 insertions(+), 11 deletions(-) diff --git a/docs/dynamic-consistency-boundary.md b/docs/dynamic-consistency-boundary.md index 2e60444c6..90e16291e 100644 --- a/docs/dynamic-consistency-boundary.md +++ b/docs/dynamic-consistency-boundary.md @@ -94,6 +94,35 @@ A tagged property can also be an array. Each element becomes its own tag, with t For example, `#[EventTag(prefix: 'guest')] public readonly array $guestNames` creates a `guest:` tag for every guest. ::: +If a tag is not stored as a property, you can put `#[EventTag]` on a method without parameters and compute it. +This is useful for tags that combine several values, like the hotel and the day of a check-in. + +```php +use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Attribute\EventTag; +use Patchlevel\EventSourcing\Identifier\Uuid; + +#[Event('hotel.guest_checked_in')] +final class GuestIsCheckedIn +{ + public function __construct( + #[EventTag(prefix: 'hotel')] + public readonly Uuid $hotelId, + #[EventTag(prefix: 'guest')] + public readonly string $guestName, + public readonly DateTimeImmutable $checkedInAt, + ) { + } + + #[EventTag(prefix: 'hotel-day')] + public function hotelDay(): string + { + return $this->hotelId->toString() . '/' . $this->checkedInAt->format('Y-m-d'); + } +} +``` +The method can return the same types as a property, including arrays and `null`. + :::note You can find out more about [events](events.md). ::: diff --git a/src/Attribute/EventTag.php b/src/Attribute/EventTag.php index 9e05dce78..9cd58b8e4 100644 --- a/src/Attribute/EventTag.php +++ b/src/Attribute/EventTag.php @@ -6,7 +6,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_PROPERTY)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] final class EventTag { public function __construct( diff --git a/src/Serializer/AttributeEventTagExtractor.php b/src/Serializer/AttributeEventTagExtractor.php index 7049e9be6..a11ecd8f2 100644 --- a/src/Serializer/AttributeEventTagExtractor.php +++ b/src/Serializer/AttributeEventTagExtractor.php @@ -7,6 +7,8 @@ use Patchlevel\EventSourcing\Attribute\EventTag; use Patchlevel\EventSourcing\Identifier\Identifier; use ReflectionClass; +use ReflectionMethod; +use ReflectionProperty; use Stringable; use function array_keys; @@ -27,8 +29,8 @@ public function extract(object $event): array $tags = []; - foreach ($reflectionClass->getProperties() as $property) { - $attributes = $property->getAttributes(EventTag::class); + foreach ([...$reflectionClass->getProperties(), ...$reflectionClass->getMethods()] as $member) { + $attributes = $member->getAttributes(EventTag::class); if ($attributes === []) { continue; @@ -37,11 +39,11 @@ public function extract(object $event): array /** @var EventTag $attribute */ $attribute = $attributes[0]->newInstance(); - $value = $property->getValue($event); + $value = $this->value($event, $member); $values = is_array($value) ? $value : [$value]; foreach ($values as $item) { - $tag = $this->tag($event, $property->getName(), $item, $attribute->prefix, $attribute->hash); + $tag = $this->tag($event, $member, $item, $attribute->prefix, $attribute->hash); if ($tag === null) { continue; @@ -54,9 +56,22 @@ public function extract(object $event): array return array_map(strval(...), array_keys($tags)); } + private function value(object $event, ReflectionProperty|ReflectionMethod $member): mixed + { + if ($member instanceof ReflectionProperty) { + return $member->getValue($event); + } + + if ($member->getNumberOfRequiredParameters() > 0) { + throw EventTagExtractorError::methodHasRequiredParameters($event::class, $member->getName()); + } + + return $member->invoke($event); + } + private function tag( object $event, - string $property, + ReflectionProperty|ReflectionMethod $member, mixed $value, string|null $prefix, string|null $hash, @@ -74,11 +89,11 @@ private function tag( } if (!is_string($value)) { - throw EventTagExtractorError::invalidValueType( - $event::class, - $property, - $value, - ); + if ($member instanceof ReflectionMethod) { + throw EventTagExtractorError::invalidMethodValueType($event::class, $member->getName(), $value); + } + + throw EventTagExtractorError::invalidValueType($event::class, $member->getName(), $value); } if ($hash) { diff --git a/src/Serializer/EventTagExtractorError.php b/src/Serializer/EventTagExtractorError.php index 5f80d9a99..321b88b07 100644 --- a/src/Serializer/EventTagExtractorError.php +++ b/src/Serializer/EventTagExtractorError.php @@ -23,4 +23,29 @@ public static function invalidValueType(string $class, string $property, mixed $ ), ); } + + /** @param class-string $class */ + public static function invalidMethodValueType(string $class, string $method, mixed $value): self + { + return new self( + sprintf( + 'Event tag value returned by method "%s" in class "%s" must be stringable, %s given', + $method, + $class, + get_debug_type($value), + ), + ); + } + + /** @param class-string $class */ + public static function methodHasRequiredParameters(string $class, string $method): self + { + return new self( + sprintf( + 'Event tag method "%s" in class "%s" must not have required parameters', + $method, + $class, + ), + ); + } } diff --git a/tests/Unit/Serializer/AttributeEventTagExtractorTest.php b/tests/Unit/Serializer/AttributeEventTagExtractorTest.php index 03ee21e7b..b677687f8 100644 --- a/tests/Unit/Serializer/AttributeEventTagExtractorTest.php +++ b/tests/Unit/Serializer/AttributeEventTagExtractorTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Serializer; +use DateTimeImmutable; use Patchlevel\EventSourcing\Attribute\EventTag; use Patchlevel\EventSourcing\Identifier\CustomId; use Patchlevel\EventSourcing\Serializer\AttributeEventTagExtractor; @@ -14,6 +15,7 @@ use function hash; use function sprintf; +use function strtoupper; #[CoversClass(AttributeEventTagExtractor::class)] final class AttributeEventTagExtractorTest extends TestCase @@ -277,4 +279,131 @@ public function __construct( $extractor->extract($event); } + + public function testExtractFromMethod(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class ('1', new DateTimeImmutable('2026-03-01')) { + public function __construct( + #[EventTag(prefix: 'account')] + public string $accountId, + public DateTimeImmutable $bookedAt, + ) { + } + + #[EventTag(prefix: 'account-period')] + public function accountPeriod(): string + { + return $this->accountId . '/' . $this->bookedAt->format('Y'); + } + }; + + self::assertSame(['account:1', 'account-period:1/2026'], $extractor->extract($event)); + } + + public function testExtractFromProtectedMethodWithHash(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class ('foo') { + public function __construct( + public string $email, + ) { + } + + #[EventTag(prefix: 'email', hash: 'sha256')] + protected function normalizedEmail(): string + { + return strtoupper($this->email); + } + }; + + self::assertSame(['email:' . hash('sha256', 'FOO')], $extractor->extract($event)); + } + + public function testExtractFromMethodReturningArray(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class (['1', '2']) { + /** @param list $accountIds */ + public function __construct( + public array $accountIds, + ) { + } + + /** @return list */ + #[EventTag(prefix: 'account')] + public function accounts(): array + { + return [...$this->accountIds, null]; + } + }; + + self::assertSame(['account:1', 'account:2'], $extractor->extract($event)); + } + + public function testExtractFromMethodReturningNull(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class (null) { + public function __construct( + public string|null $value, + ) { + } + + #[EventTag] + public function nothing(): string|null + { + return $this->value; + } + }; + + self::assertSame([], $extractor->extract($event)); + } + + public function testExtractFromMethodWithRequiredParameter(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class { + #[EventTag] + public function tag(string $value): string + { + return $value; + } + }; + + $this->expectException(EventTagExtractorError::class); + $this->expectExceptionMessage( + sprintf('Event tag method "tag" in class "%s" must not have required parameters', $event::class), + ); + + $extractor->extract($event); + } + + public function testExtractFromMethodWithInvalidValueType(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class { + #[EventTag] + public function tag(): float + { + return 1.5; + } + }; + + $this->expectException(EventTagExtractorError::class); + $this->expectExceptionMessage( + sprintf( + 'Event tag value returned by method "tag" in class "%s" must be stringable, float given', + $event::class, + ), + ); + + $extractor->extract($event); + } }