Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/dynamic-consistency-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
:::
Expand Down
2 changes: 1 addition & 1 deletion src/Attribute/EventTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)]
final class EventTag
{
public function __construct(
Expand Down
35 changes: 25 additions & 10 deletions src/Serializer/AttributeEventTagExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions src/Serializer/EventTagExtractorError.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
);
}
}
129 changes: 129 additions & 0 deletions tests/Unit/Serializer/AttributeEventTagExtractorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +15,7 @@

use function hash;
use function sprintf;
use function strtoupper;

#[CoversClass(AttributeEventTagExtractor::class)]
final class AttributeEventTagExtractorTest extends TestCase
Expand Down Expand Up @@ -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<string> $accountIds */
public function __construct(
public array $accountIds,
) {
}

/** @return list<string|null> */
#[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);
}
}
Loading