diff --git a/docs/aggregate.md b/docs/aggregate.md index ee092265..883f47a6 100644 --- a/docs/aggregate.md +++ b/docs/aggregate.md @@ -295,12 +295,144 @@ all newly recorded events are then fetched and written to the database. The `apply` method must be implemented so that the events are processed on the aggregate. This method can get quite big with some events. -To make things structured, you can use two different traits +To make things structured, you can use three different traits that allow you to define different apply methods for each event. +### Attribute based apply method (since v1.2) + +The first variant is the preferred one. +This uses [php attributes](https://www.php.net/manual/en/language.attributes.overview.php) +to find the right `apply` method. +You have to set the apply `attribute` to the appropriate method +and specify the event class for which it is responsible. + +```php +use Patchlevel\EventSourcing\Aggregate\AggregateChanged; +use Patchlevel\EventSourcing\Aggregate\AggregateRoot; +use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; +use Patchlevel\EventSourcing\Attribute\Apply; + +final class Profile extends AggregateRoot +{ + use AttributeApplyMethod; + + private string $id; + private string $name; + + // ... + + #[Apply(ProfileCreated::class)] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->profileId(); + $this->name = $event->name(); + } + + #[Apply(NameChanged::class)] + protected function applyNameChanged(NameChanged $event): void + { + $this->name = $event->name(); + } +} +``` + +> :book: If no apply method has been defined for an event, then you get an exception. +> But you can control this with the attribute suppress. + +You can also define several apply attributes with different events using the same method. + +```php +use Patchlevel\EventSourcing\Aggregate\AggregateChanged; +use Patchlevel\EventSourcing\Aggregate\AggregateRoot; +use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; +use Patchlevel\EventSourcing\Attribute\Apply; + +final class Profile extends AggregateRoot +{ + use AttributeApplyMethod; + + private string $id; + private string $name; + + // ... + + #[Apply(ProfileCreated::class)] + #[Apply(NameChanged::class)] + protected function applyProfileCreated(ProfileCreated|NameChanged $event): void + { + if ($event instanceof ProfileCreated) { + $this->id = $event->profileId(); + } + + $this->name = $event->name(); + } +} +``` + +Sometimes you have events that do not change the state of the aggregate itself, +but are still recorded for the future, to listen on it or to create a projection. +So that you are not forced to write an apply method for it, +you can suppress the missing apply exceptions these events with the `SuppressMissingApply` attribute. + +```php +use Patchlevel\EventSourcing\Aggregate\AggregateChanged; +use Patchlevel\EventSourcing\Aggregate\AggregateRoot; +use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; + +#[SuppressMissingApply([NameChanged::class])] +final class Profile extends AggregateRoot +{ + use AttributeApplyMethod; + + private string $id; + private string $name; + + // ... + + #[Apply(ProfileCreated::class)] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->profileId(); + $this->name = $event->name(); + } +} +``` + +You can also completely deactivate the exceptions for missing apply methods. + +```php +use Patchlevel\EventSourcing\Aggregate\AggregateChanged; +use Patchlevel\EventSourcing\Aggregate\AggregateRoot; +use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; + +#[SuppressMissingApply([SuppressMissingApply::ALL])] +final class Profile extends AggregateRoot +{ + use AttributeApplyMethod; + + private string $id; + private string $name; + + // ... + + #[Apply(ProfileCreated::class)] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->profileId(); + $this->name = $event->name(); + } +} +``` + +> :warning: When all events are suppressed, debugging becomes more difficult if you forget an apply method. + ### Strict apply method -The trait implements the apply method for you. +The event name is used here instead of attributes to find the right method. It is looking for a suitable method for the event by using the short name of the event class and prefixing it with an `apply`. @@ -340,6 +472,8 @@ final class Profile extends AggregateRoot } ``` +> :warning: Problems can arise if the short name is the same. To get around this, use the attribute variant. + ### Non strict apply method The non-strict variant works in the same way as the strict one. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6ad8c120..a6257c51 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,75 +1,10 @@ parameters: ignoreErrors: - - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged\\:\\:serialize\\(\\) return type with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#" - count: 1 - path: src/Aggregate/AggregateChanged.php - - - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateRoot\\:\\:apply\\(\\) has parameter \\$event with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#" - count: 1 - path: src/Aggregate/AggregateRoot.php - - - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateRoot\\:\\:createFromEventStream\\(\\) has parameter \\$stream with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#" - count: 1 - path: src/Aggregate/AggregateRoot.php - - - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateRoot\\:\\:releaseEvents\\(\\) return type with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#" - count: 1 - path: src/Aggregate/AggregateRoot.php - - - - message: "#^Property Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateRoot\\:\\:\\$uncommittedEvents with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#" - count: 1 - path: src/Aggregate/AggregateRoot.php - - - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\ApplyMethodNotFound\\:\\:__construct\\(\\) has parameter \\$event with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#" - count: 1 - path: src/Aggregate/ApplyMethodNotFound.php - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Console\\\\DoctrineHelper\\:\\:databaseName\\(\\) should return string but returns mixed\\.$#" count: 2 path: src/Console/DoctrineHelper.php - - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Console\\\\EventPrinter\\:\\:write\\(\\) has parameter \\$event with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#" - count: 1 - path: src/Console/EventPrinter.php - - - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\ExcludeEventMiddleware\\:\\:__construct\\(\\) has parameter \\$classes with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#" - count: 1 - path: src/Pipeline/Middleware/ExcludeEventMiddleware.php - - - - message: "#^Property Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\ExcludeEventMiddleware\\:\\:\\$classes with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#" - count: 1 - path: src/Pipeline/Middleware/ExcludeEventMiddleware.php - - - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\FilterEventMiddleware\\:\\:__construct\\(\\) has parameter \\$callable with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#" - count: 1 - path: src/Pipeline/Middleware/FilterEventMiddleware.php - - - - message: "#^Property Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\FilterEventMiddleware\\:\\:\\$callable with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#" - count: 1 - path: src/Pipeline/Middleware/FilterEventMiddleware.php - - - - message: "#^Method Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\IncludeEventMiddleware\\:\\:__construct\\(\\) has parameter \\$classes with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged but does not specify its types\\: T$#" - count: 1 - path: src/Pipeline/Middleware/IncludeEventMiddleware.php - - - - message: "#^Property Patchlevel\\\\EventSourcing\\\\Pipeline\\\\Middleware\\\\IncludeEventMiddleware\\:\\:\\$classes with generic class Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged does not specify its types\\: T$#" - count: 1 - path: src/Pipeline/Middleware/IncludeEventMiddleware.php - - message: "#^Unable to resolve the template type E in call to method static method Patchlevel\\\\EventSourcing\\\\Aggregate\\\\AggregateChanged\\\\>\\:\\:deserialize\\(\\)$#" count: 1 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index dd84adbf..6dd44bbe 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,3 +5,4 @@ parameters: level: max paths: - src + checkGenericClassInNonGenericObjectType: false diff --git a/src/Aggregate/ApplyAttributeNotFound.php b/src/Aggregate/ApplyAttributeNotFound.php new file mode 100644 index 00000000..ef823d5b --- /dev/null +++ b/src/Aggregate/ApplyAttributeNotFound.php @@ -0,0 +1,21 @@ +, true> */ + private static array $suppressEvents = []; + private static bool $suppressAll = false; + + /** @var array, string>|null */ + private static ?array $aggregateChangeMethodMap = null; + + protected function apply(AggregateChanged $event): void + { + $map = self::aggregateChangeMethodMap(); + + if (!array_key_exists($event::class, $map)) { + if (!self::$suppressAll && !array_key_exists($event::class, self::$suppressEvents)) { + throw new ApplyAttributeNotFound($this, $event); + } + + return; + } + + $method = $map[$event::class]; + + if (!method_exists($this, $method)) { + return; + } + + $this->$method($event); + } + + /** + * @return array, string> + */ + private static function aggregateChangeMethodMap(): array + { + if (self::$aggregateChangeMethodMap !== null) { + return self::$aggregateChangeMethodMap; + } + + $reflector = new ReflectionClass(self::class); + $attributes = $reflector->getAttributes(SuppressMissingApply::class); + + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + + if ($instance->suppressAll()) { + self::$suppressAll = true; + + continue; + } + + foreach ($instance->suppressEvents() as $event) { + self::$suppressEvents[$event] = true; + } + } + + $methods = $reflector->getMethods(); + + self::$aggregateChangeMethodMap = []; + + foreach ($methods as $method) { + $attributes = $method->getAttributes(Apply::class); + + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + $eventClass = $instance->aggregateChangedClass(); + + if (array_key_exists($eventClass, self::$aggregateChangeMethodMap)) { + throw new DuplicateApplyMethod( + self::class, + $eventClass, + self::$aggregateChangeMethodMap[$eventClass], + $method->getName() + ); + } + + self::$aggregateChangeMethodMap[$eventClass] = $method->getName(); + } + } + + return self::$aggregateChangeMethodMap; + } +} diff --git a/src/Aggregate/DuplicateApplyMethod.php b/src/Aggregate/DuplicateApplyMethod.php new file mode 100644 index 00000000..d6ff4e45 --- /dev/null +++ b/src/Aggregate/DuplicateApplyMethod.php @@ -0,0 +1,27 @@ + $aggregate + * @param class-string $event + */ + public function __construct(string $aggregate, string $event, string $fistMethod, string $secondMethod) + { + parent::__construct( + sprintf( + 'Two methods "%s" and "%s" on the aggregate "%s" want to apply the same event "%s".', + $fistMethod, + $secondMethod, + $aggregate, + $event + ) + ); + } +} diff --git a/src/Attribute/Apply.php b/src/Attribute/Apply.php new file mode 100644 index 00000000..77065d9b --- /dev/null +++ b/src/Attribute/Apply.php @@ -0,0 +1,31 @@ + */ + private string $aggregateChangedClass; + + /** + * @param class-string $aggregateChangedClass + */ + public function __construct(string $aggregateChangedClass) + { + $this->aggregateChangedClass = $aggregateChangedClass; + } + + /** + * @return class-string + */ + public function aggregateChangedClass(): string + { + return $this->aggregateChangedClass; + } +} diff --git a/src/Attribute/SuppressMissingApply.php b/src/Attribute/SuppressMissingApply.php new file mode 100644 index 00000000..dca6de1d --- /dev/null +++ b/src/Attribute/SuppressMissingApply.php @@ -0,0 +1,54 @@ +> */ + private array $suppressEvents = []; + private bool $suppressAll = false; + + /** + * @param list>|string $suppress + */ + public function __construct(string|array $suppress) + { + if ($suppress === '*') { + $this->suppressAll = true; + + return; + } + + if (is_string($suppress)) { + throw new InvalidArgumentException( + 'The value should either be an array of aggregate changed classes, or a "*" for all events.' + ); + } + + $this->suppressEvents = $suppress; + } + + /** + * @return list> + */ + public function suppressEvents(): array + { + return $this->suppressEvents; + } + + public function suppressAll(): bool + { + return $this->suppressAll; + } +} diff --git a/tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php b/tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php new file mode 100644 index 00000000..c1d972be --- /dev/null +++ b/tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php @@ -0,0 +1,129 @@ +aggregateRootId()); + self::assertEquals(1, $profile->playhead()); + self::assertEquals($id, $profile->id()); + self::assertEquals($email, $profile->email()); + + $events = $profile->releaseEvents(); + + self::assertCount(1, $events); + $event = $events[0]; + self::assertEquals(1, $event->playhead()); + } + + public function testMultipleApplyOnOneMethod(): void + { + $id = ProfileId::fromString('1'); + $email = Email::fromString('david.badura@patchlevel.de'); + + $target = ProfileId::fromString('2'); + + $profile = ProfileWithAttributeApply::createProfile($id, $email); + $profile->visitProfile($target); + + self::assertEquals('1', $profile->aggregateRootId()); + self::assertEquals(2, $profile->playhead()); + self::assertEquals($id, $profile->id()); + self::assertEquals($email, $profile->email()); + self::assertEquals(1, $profile->visited()); + + $events = $profile->releaseEvents(); + + self::assertCount(2, $events); + } + + public function testEventWithoutApplyMethod(): void + { + $this->expectException(ApplyAttributeNotFound::class); + + $profileId = ProfileId::fromString('1'); + $email = Email::fromString('david.badura@patchlevel.de'); + + $messageId = MessageId::fromString('2'); + + $profile = ProfileWithAttributeApply::createProfile($profileId, $email); + + $events = $profile->releaseEvents(); + + self::assertCount(1, $events); + self::assertEquals(1, $profile->playhead()); + $event = $events[0]; + self::assertEquals(1, $event->playhead()); + + $profile->publishMessage( + Message::create( + $messageId, + 'foo' + ) + ); + } + + public function testSuppressEvent(): void + { + $profileId = ProfileId::fromString('1'); + $email = Email::fromString('david.badura@patchlevel.de'); + + $messageId = MessageId::fromString('2'); + + $profile = ProfileWithAttributeApply::createProfile($profileId, $email); + + $events = $profile->releaseEvents(); + + self::assertCount(1, $events); + self::assertEquals(1, $profile->playhead()); + $event = $events[0]; + self::assertEquals(1, $event->playhead()); + + $profile->deleteMessage($messageId); + } + + public function testSuppressAll(): void + { + $profileId = ProfileId::fromString('1'); + $email = Email::fromString('david.badura@patchlevel.de'); + + $profile = ProfileWithAttributeApplySuppressAll::createProfile($profileId, $email); + + $events = $profile->releaseEvents(); + + self::assertCount(1, $events); + self::assertEquals(1, $profile->playhead()); + $event = $events[0]; + self::assertEquals(1, $event->playhead()); + } + + public function testDuplicateApplyMethods(): void + { + $this->expectException(DuplicateApplyMethod::class); + + $profileId = ProfileId::fromString('1'); + $email = Email::fromString('david.badura@patchlevel.de'); + + ProfileWithAttributeApplyInvalid::createProfile($profileId, $email); + } +} diff --git a/tests/Unit/Attribute/SuppressMissingApplyTest.php b/tests/Unit/Attribute/SuppressMissingApplyTest.php new file mode 100644 index 00000000..aeac41a3 --- /dev/null +++ b/tests/Unit/Attribute/SuppressMissingApplyTest.php @@ -0,0 +1,36 @@ +suppressEvents()); + self::assertEquals(false, $attribute->suppressAll()); + } + + public function testSuppressAll(): void + { + $attribute = new SuppressMissingApply('*'); + + self::assertEquals([], $attribute->suppressEvents()); + self::assertEquals(true, $attribute->suppressAll()); + } + + public function testInvalidString(): void + { + $this->expectException(InvalidArgumentException::class); + + new SuppressMissingApply('foo'); + } +} diff --git a/tests/Unit/Fixture/MessageDeleted.php b/tests/Unit/Fixture/MessageDeleted.php new file mode 100644 index 00000000..511522ba --- /dev/null +++ b/tests/Unit/Fixture/MessageDeleted.php @@ -0,0 +1,35 @@ + + */ +final class MessageDeleted extends AggregateChanged +{ + public static function raise( + ProfileId $profileId, + MessageId $messageId + ): static { + return new static( + $profileId->toString(), + [ + 'messageId' => $messageId->toString(), + ] + ); + } + + public function profileId(): ProfileId + { + return ProfileId::fromString($this->aggregateId); + } + + public function messageId(): MessageId + { + return MessageId::fromString($this->payload['messageId']); + } +} diff --git a/tests/Unit/Fixture/ProfileWithAttributeApply.php b/tests/Unit/Fixture/ProfileWithAttributeApply.php new file mode 100644 index 00000000..044b8094 --- /dev/null +++ b/tests/Unit/Fixture/ProfileWithAttributeApply.php @@ -0,0 +1,83 @@ +id; + } + + public function email(): Email + { + return $this->email; + } + + public function visited(): int + { + return $this->visited; + } + + public static function createProfile(ProfileId $id, Email $email): self + { + $self = new self(); + $self->record(ProfileCreated::raise($id, $email)); + + return $self; + } + + public function publishMessage(Message $message): void + { + $this->record(MessagePublished::raise( + $this->id, + $message, + )); + } + + public function deleteMessage(MessageId $messageId): void + { + $this->record(MessageDeleted::raise( + $this->id, + $messageId, + )); + } + + public function visitProfile(ProfileId $profileId): void + { + $this->record(ProfileVisited::raise($this->id, $profileId)); + } + + #[Apply(ProfileCreated::class)] + #[Apply(ProfileVisited::class)] + protected function applyProfileCreated(ProfileCreated|ProfileVisited $event): void + { + if ($event instanceof ProfileCreated) { + $this->id = $event->profileId(); + $this->email = $event->email(); + + return; + } + + $this->visited++; + } + + public function aggregateRootId(): string + { + return $this->id->toString(); + } +} diff --git a/tests/Unit/Fixture/ProfileWithAttributeApplyInvalid.php b/tests/Unit/Fixture/ProfileWithAttributeApplyInvalid.php new file mode 100644 index 00000000..b5297140 --- /dev/null +++ b/tests/Unit/Fixture/ProfileWithAttributeApplyInvalid.php @@ -0,0 +1,44 @@ +record(ProfileCreated::raise($id, $email)); + + return $self; + } + + #[Apply(ProfileCreated::class)] + protected function applyProfileCreated1(ProfileCreated $event): void + { + $this->id = $event->profileId(); + $this->email = $event->email(); + } + + #[Apply(ProfileCreated::class)] + protected function applyProfileCreated2(ProfileCreated $event): void + { + $this->id = $event->profileId(); + $this->email = $event->email(); + } + + public function aggregateRootId(): string + { + return $this->id->toString(); + } +} diff --git a/tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php b/tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php new file mode 100644 index 00000000..bc3738a3 --- /dev/null +++ b/tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php @@ -0,0 +1,28 @@ +record(ProfileCreated::raise($id, $email)); + + return $self; + } + + public function aggregateRootId(): string + { + return '1'; + } +}