From 034570f89f2d78d092600e1bcb4f9940485504b6 Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 3 Jan 2022 00:06:21 +0100 Subject: [PATCH 1/8] add attribute based apply methods --- docs/aggregate.md | 94 ++++++++++++++++++- src/Aggregate/ApplyAttributeNotFound.php | 21 +++++ src/Aggregate/AttributeApplyMethod.php | 73 ++++++++++++++ src/Attribute/Apply.php | 31 ++++++ src/Attribute/StrictApply.php | 10 ++ .../AggregateRootWithAttributeApplyTest.php | 82 ++++++++++++++++ .../Fixture/ProfileWithAttributeApply.php | 73 ++++++++++++++ 7 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 src/Aggregate/ApplyAttributeNotFound.php create mode 100644 src/Aggregate/AttributeApplyMethod.php create mode 100644 src/Attribute/Apply.php create mode 100644 src/Attribute/StrictApply.php create mode 100644 tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php create mode 100644 tests/Unit/Fixture/ProfileWithAttributeApply.php diff --git a/docs/aggregate.md b/docs/aggregate.md index ee092265..0c8febfb 100644 --- a/docs/aggregate.md +++ b/docs/aggregate.md @@ -295,9 +295,99 @@ 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) + +```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(); + } +} +``` + + +```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\StrictApply; + +#[StrictApply] +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(); + } +} +``` + + +```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\StrictApply; + +#[StrictApply] +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(); + } +} +``` + ### Strict apply method The trait implements the apply method for you. @@ -340,6 +430,8 @@ final class Profile extends AggregateRoot } ``` +> :warning: indentical short name errors + ### Non strict apply method The non-strict variant works in the same way as the strict one. 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 @@ +, string>|null + */ + private static ?array $map = null; + + protected function apply(AggregateChanged $event): void + { + $map = self::aggregateChangeMethodMap(); + + if (!array_key_exists($event::class, $map)) { + if (self::$strictApply) { + throw new ApplyAttributeNotFound($this, $event); + } + + return; + } + + $method = $map[$event::class]; + + if (!method_exists($this, $method)) { + return; + } + + $this->$method($event); + } + + private static function aggregateChangeMethodMap(): array + { + if (self::$map !== null) { + return self::$map; + } + + $reflector = new \ReflectionClass(self::class); + $attributes = $reflector->getAttributes(StrictApply::class); + + if (count($attributes) > 0) { + self::$strictApply = true; + } + + $methods = $reflector->getMethods(); + + $map = []; + + foreach ($methods as $method) { + $attributes = $method->getAttributes(Apply::class); + + foreach ($attributes as $attribute) { + $instance = $attribute->newInstance(); + + $map[$instance->aggregateChangedClass()] = $method->getName(); + } + } + + return self::$map = $map; + } +} diff --git a/src/Attribute/Apply.php b/src/Attribute/Apply.php new file mode 100644 index 00000000..a593fe49 --- /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; + } +} \ No newline at end of file diff --git a/src/Attribute/StrictApply.php b/src/Attribute/StrictApply.php new file mode 100644 index 00000000..7c3510c3 --- /dev/null +++ b/src/Attribute/StrictApply.php @@ -0,0 +1,10 @@ +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(1, $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' + ) + ); + } +} diff --git a/tests/Unit/Fixture/ProfileWithAttributeApply.php b/tests/Unit/Fixture/ProfileWithAttributeApply.php new file mode 100644 index 00000000..1a927592 --- /dev/null +++ b/tests/Unit/Fixture/ProfileWithAttributeApply.php @@ -0,0 +1,73 @@ +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 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(); + } +} From 57918858a919c1dc3a499fbd8aa32afc23520aa7 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 6 Jan 2022 13:44:19 +0100 Subject: [PATCH 2/8] change strict apply to suppress for more flexibility --- docs/aggregate.md | 43 +++++++++++---- src/Aggregate/AttributeApplyMethod.php | 48 +++++++++++------ src/Attribute/Apply.php | 10 ++-- src/Attribute/StrictApply.php | 10 ---- src/Attribute/Suppress.php | 52 +++++++++++++++++++ .../AggregateRootWithAttributeApplyTest.php | 37 ++++++++++++- tests/Unit/Attribute/SuppressTest.php | 36 +++++++++++++ tests/Unit/Fixture/MessageDeleted.php | 35 +++++++++++++ .../Fixture/ProfileWithAttributeApply.php | 10 ++++ .../ProfileWithAttributeApplySuppressAll.php | 28 ++++++++++ 10 files changed, 267 insertions(+), 42 deletions(-) delete mode 100644 src/Attribute/StrictApply.php create mode 100644 src/Attribute/Suppress.php create mode 100644 tests/Unit/Attribute/SuppressTest.php create mode 100644 tests/Unit/Fixture/MessageDeleted.php create mode 100644 tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php diff --git a/docs/aggregate.md b/docs/aggregate.md index 0c8febfb..e59537cf 100644 --- a/docs/aggregate.md +++ b/docs/aggregate.md @@ -330,7 +330,6 @@ final class Profile extends AggregateRoot } ``` - ```php use Patchlevel\EventSourcing\Aggregate\AggregateChanged; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; @@ -338,7 +337,36 @@ use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\StrictApply; -#[StrictApply] +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(); + } +} +``` + +```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\Suppress; + +#[Suppress([NameChanged::class])] final class Profile extends AggregateRoot { use AttributeApplyMethod; @@ -357,7 +385,6 @@ final class Profile extends AggregateRoot } ``` - ```php use Patchlevel\EventSourcing\Aggregate\AggregateChanged; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; @@ -365,7 +392,7 @@ use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\StrictApply; -#[StrictApply] +#[Suppress([Suppress::ALL])] final class Profile extends AggregateRoot { use AttributeApplyMethod; @@ -376,13 +403,9 @@ final class Profile extends AggregateRoot // ... #[Apply(ProfileCreated::class)] - #[Apply(NameChanged::class)] - protected function applyProfileCreated(ProfileCreated|NameChanged $event): void + protected function applyProfileCreated(ProfileCreated $event): void { - if ($event instanceof ProfileCreated) { - $this->id = $event->profileId(); - } - + $this->id = $event->profileId(); $this->name = $event->name(); } } diff --git a/src/Aggregate/AttributeApplyMethod.php b/src/Aggregate/AttributeApplyMethod.php index 84011010..5df72a7a 100644 --- a/src/Aggregate/AttributeApplyMethod.php +++ b/src/Aggregate/AttributeApplyMethod.php @@ -5,7 +5,10 @@ namespace Patchlevel\EventSourcing\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\Attribute\StrictApply; +use Patchlevel\EventSourcing\Attribute\Suppress; +use ReflectionClass; + +use function array_key_exists; use function method_exists; /** @@ -13,19 +16,19 @@ */ trait AttributeApplyMethod { - private static bool $strictApply = false; + /** @var array, true> */ + private static array $suppressEvents = []; + private static bool $suppressAll = false; - /** - * @var array, string>|null - */ - private static ?array $map = null; + /** @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::$strictApply) { + if (!self::$suppressAll && !array_key_exists($event::class, self::$suppressEvents)) { throw new ApplyAttributeNotFound($this, $event); } @@ -41,22 +44,35 @@ protected function apply(AggregateChanged $event): void $this->$method($event); } + /** + * @return array, string> + */ private static function aggregateChangeMethodMap(): array { - if (self::$map !== null) { - return self::$map; + if (self::$aggregateChangeMethodMap !== null) { + return self::$aggregateChangeMethodMap; } - $reflector = new \ReflectionClass(self::class); - $attributes = $reflector->getAttributes(StrictApply::class); + $reflector = new ReflectionClass(self::class); + $attributes = $reflector->getAttributes(Suppress::class); - if (count($attributes) > 0) { - self::$strictApply = true; + 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(); - $map = []; + self::$aggregateChangeMethodMap = []; foreach ($methods as $method) { $attributes = $method->getAttributes(Apply::class); @@ -64,10 +80,10 @@ private static function aggregateChangeMethodMap(): array foreach ($attributes as $attribute) { $instance = $attribute->newInstance(); - $map[$instance->aggregateChangedClass()] = $method->getName(); + self::$aggregateChangeMethodMap[$instance->aggregateChangedClass()] = $method->getName(); } } - return self::$map = $map; + return self::$aggregateChangeMethodMap; } } diff --git a/src/Attribute/Apply.php b/src/Attribute/Apply.php index a593fe49..77065d9b 100644 --- a/src/Attribute/Apply.php +++ b/src/Attribute/Apply.php @@ -1,16 +1,16 @@ - */ + /** @var class-string */ private string $aggregateChangedClass; /** @@ -28,4 +28,4 @@ public function aggregateChangedClass(): string { return $this->aggregateChangedClass; } -} \ No newline at end of file +} diff --git a/src/Attribute/StrictApply.php b/src/Attribute/StrictApply.php deleted file mode 100644 index 7c3510c3..00000000 --- a/src/Attribute/StrictApply.php +++ /dev/null @@ -1,10 +0,0 @@ -> */ + 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("list>|'*'"); + } + + $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 index 9e4522a8..7749c891 100644 --- a/tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php +++ b/tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\MessageId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithAttributeApply; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithAttributeApplySuppressAll; use PHPUnit\Framework\TestCase; class AggregateRootWithAttributeApplyTest extends TestCase @@ -44,7 +45,7 @@ public function testMultipleApplyOnOneMethod(): void $profile->visitProfile($target); self::assertEquals('1', $profile->aggregateRootId()); - self::assertEquals(1, $profile->playhead()); + self::assertEquals(2, $profile->playhead()); self::assertEquals($id, $profile->id()); self::assertEquals($email, $profile->email()); self::assertEquals(1, $profile->visited()); @@ -79,4 +80,38 @@ public function testEventWithoutApplyMethod(): void ) ); } + + 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()); + } } diff --git a/tests/Unit/Attribute/SuppressTest.php b/tests/Unit/Attribute/SuppressTest.php new file mode 100644 index 00000000..9514291b --- /dev/null +++ b/tests/Unit/Attribute/SuppressTest.php @@ -0,0 +1,36 @@ +suppressEvents()); + self::assertEquals(false, $attribute->suppressAll()); + } + + public function testSuppressAll(): void + { + $attribute = new Suppress('*'); + + self::assertEquals([], $attribute->suppressEvents()); + self::assertEquals(true, $attribute->suppressAll()); + } + + public function testInvalidString(): void + { + $this->expectException(InvalidArgumentException::class); + + new Suppress('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 index 1a927592..b9a9b7a9 100644 --- a/tests/Unit/Fixture/ProfileWithAttributeApply.php +++ b/tests/Unit/Fixture/ProfileWithAttributeApply.php @@ -7,7 +7,9 @@ use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\Attribute\Suppress; +#[Suppress([MessageDeleted::class])] final class ProfileWithAttributeApply extends AggregateRoot { use AttributeApplyMethod; @@ -47,6 +49,14 @@ public function publishMessage(Message $message): void )); } + 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)); diff --git a/tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php b/tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php new file mode 100644 index 00000000..66a3d8f7 --- /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'; + } +} From 577487ff28f00f8a7ec11ec34f05bd20d39bf103 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 6 Jan 2022 13:58:45 +0100 Subject: [PATCH 3/8] disable checkGenericClassInNonGenericObjectType in phpstan because it is annoying --- phpstan-baseline.neon | 65 ------------------------------------------- phpstan.neon.dist | 1 + 2 files changed, 1 insertion(+), 65 deletions(-) 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 From 2a55dafdfd9ec60a49c4a99e765e9b3f6e27a54a Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 6 Jan 2022 14:40:42 +0100 Subject: [PATCH 4/8] add documentation --- docs/aggregate.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/aggregate.md b/docs/aggregate.md index e59537cf..cdad0ba3 100644 --- a/docs/aggregate.md +++ b/docs/aggregate.md @@ -300,6 +300,12 @@ that allow you to define different apply methods for each event. ### Attribute based apply method (since v1.2) +The first variant is the preferred variant. +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; @@ -330,6 +336,11 @@ final class Profile extends AggregateRoot } ``` +> :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; @@ -359,6 +370,11 @@ final class Profile extends AggregateRoot } ``` +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. + ```php use Patchlevel\EventSourcing\Aggregate\AggregateChanged; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; @@ -385,6 +401,8 @@ final class Profile extends AggregateRoot } ``` +You can also completely deactivate the exceptions for missing apply methods. + ```php use Patchlevel\EventSourcing\Aggregate\AggregateChanged; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; @@ -411,9 +429,11 @@ final class Profile extends AggregateRoot } ``` +> :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`. @@ -453,7 +473,7 @@ final class Profile extends AggregateRoot } ``` -> :warning: indentical short name errors +> :warning: Problems can arise if the short name is the same. To get around this, use the attribute variant. ### Non strict apply method From 71cc97b5e4c5f6edc1f252d89ab9af095edd0bc0 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 6 Jan 2022 14:55:11 +0100 Subject: [PATCH 5/8] add dupplicate apply method exception to prevent usage errors --- src/Aggregate/AttributeApplyMethod.php | 14 +++++- src/Aggregate/DuplicateApplyMethod.php | 27 ++++++++++++ .../AggregateRootWithAttributeApplyTest.php | 12 +++++ .../ProfileWithAttributeApplyInvalid.php | 44 +++++++++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/Aggregate/DuplicateApplyMethod.php create mode 100644 tests/Unit/Fixture/ProfileWithAttributeApplyInvalid.php diff --git a/src/Aggregate/AttributeApplyMethod.php b/src/Aggregate/AttributeApplyMethod.php index 5df72a7a..c265a3bf 100644 --- a/src/Aggregate/AttributeApplyMethod.php +++ b/src/Aggregate/AttributeApplyMethod.php @@ -79,8 +79,18 @@ private static function aggregateChangeMethodMap(): array foreach ($attributes as $attribute) { $instance = $attribute->newInstance(); - - self::$aggregateChangeMethodMap[$instance->aggregateChangedClass()] = $method->getName(); + $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(); } } 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/tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php b/tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php index 7749c891..c1d972be 100644 --- a/tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php +++ b/tests/Unit/Aggregate/AggregateRootWithAttributeApplyTest.php @@ -5,11 +5,13 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Aggregate; use Patchlevel\EventSourcing\Aggregate\ApplyAttributeNotFound; +use Patchlevel\EventSourcing\Aggregate\DuplicateApplyMethod; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Message; use Patchlevel\EventSourcing\Tests\Unit\Fixture\MessageId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithAttributeApply; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithAttributeApplyInvalid; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithAttributeApplySuppressAll; use PHPUnit\Framework\TestCase; @@ -114,4 +116,14 @@ public function testSuppressAll(): void $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/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(); + } +} From 683b4a6a41d21aee61d447d979fff481391789ab Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 6 Jan 2022 15:03:07 +0100 Subject: [PATCH 6/8] improve invalid argument exception message for suppress attribute --- src/Attribute/Suppress.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Attribute/Suppress.php b/src/Attribute/Suppress.php index 8e03b10e..adca1c99 100644 --- a/src/Attribute/Suppress.php +++ b/src/Attribute/Suppress.php @@ -31,7 +31,9 @@ public function __construct(string|array $suppress) } if (is_string($suppress)) { - throw new InvalidArgumentException("list>|'*'"); + throw new InvalidArgumentException( + 'The value should either be an array of aggregate changed classes, or a "*" for all events.' + ); } $this->suppressEvents = $suppress; From efe6ac41bb8131c5c14cb50bfda7b9c6e7d879a8 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 6 Jan 2022 15:54:36 +0100 Subject: [PATCH 7/8] improve documentation Co-authored-by: Daniel Badura --- docs/aggregate.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/aggregate.md b/docs/aggregate.md index cdad0ba3..dee96963 100644 --- a/docs/aggregate.md +++ b/docs/aggregate.md @@ -300,7 +300,7 @@ that allow you to define different apply methods for each event. ### Attribute based apply method (since v1.2) -The first variant is the preferred variant. +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 From f6d7bd6f4c9a55d714f88ef383b6bdb8270b220a Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 6 Jan 2022 18:36:52 +0100 Subject: [PATCH 8/8] rename attribute from Supress to SuppressMissingApply --- docs/aggregate.md | 11 +++++------ src/Aggregate/AttributeApplyMethod.php | 4 ++-- .../{Suppress.php => SuppressMissingApply.php} | 2 +- ...{SuppressTest.php => SuppressMissingApplyTest.php} | 10 +++++----- tests/Unit/Fixture/ProfileWithAttributeApply.php | 4 ++-- .../Fixture/ProfileWithAttributeApplySuppressAll.php | 4 ++-- 6 files changed, 17 insertions(+), 18 deletions(-) rename src/Attribute/{Suppress.php => SuppressMissingApply.php} (97%) rename tests/Unit/Attribute/{SuppressTest.php => SuppressMissingApplyTest.php} (72%) diff --git a/docs/aggregate.md b/docs/aggregate.md index dee96963..883f47a6 100644 --- a/docs/aggregate.md +++ b/docs/aggregate.md @@ -346,7 +346,6 @@ use Patchlevel\EventSourcing\Aggregate\AggregateChanged; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\Attribute\StrictApply; final class Profile extends AggregateRoot { @@ -373,16 +372,16 @@ final class Profile extends AggregateRoot 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. +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\Suppress; +use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; -#[Suppress([NameChanged::class])] +#[SuppressMissingApply([NameChanged::class])] final class Profile extends AggregateRoot { use AttributeApplyMethod; @@ -408,9 +407,9 @@ use Patchlevel\EventSourcing\Aggregate\AggregateChanged; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\Attribute\StrictApply; +use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; -#[Suppress([Suppress::ALL])] +#[SuppressMissingApply([SuppressMissingApply::ALL])] final class Profile extends AggregateRoot { use AttributeApplyMethod; diff --git a/src/Aggregate/AttributeApplyMethod.php b/src/Aggregate/AttributeApplyMethod.php index c265a3bf..be0ad504 100644 --- a/src/Aggregate/AttributeApplyMethod.php +++ b/src/Aggregate/AttributeApplyMethod.php @@ -5,7 +5,7 @@ namespace Patchlevel\EventSourcing\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\Attribute\Suppress; +use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; use ReflectionClass; use function array_key_exists; @@ -54,7 +54,7 @@ private static function aggregateChangeMethodMap(): array } $reflector = new ReflectionClass(self::class); - $attributes = $reflector->getAttributes(Suppress::class); + $attributes = $reflector->getAttributes(SuppressMissingApply::class); foreach ($attributes as $attribute) { $instance = $attribute->newInstance(); diff --git a/src/Attribute/Suppress.php b/src/Attribute/SuppressMissingApply.php similarity index 97% rename from src/Attribute/Suppress.php rename to src/Attribute/SuppressMissingApply.php index adca1c99..dca6de1d 100644 --- a/src/Attribute/Suppress.php +++ b/src/Attribute/SuppressMissingApply.php @@ -11,7 +11,7 @@ use function is_string; #[Attribute(Attribute::TARGET_CLASS)] -class Suppress +class SuppressMissingApply { public const ALL = '*'; diff --git a/tests/Unit/Attribute/SuppressTest.php b/tests/Unit/Attribute/SuppressMissingApplyTest.php similarity index 72% rename from tests/Unit/Attribute/SuppressTest.php rename to tests/Unit/Attribute/SuppressMissingApplyTest.php index 9514291b..aeac41a3 100644 --- a/tests/Unit/Attribute/SuppressTest.php +++ b/tests/Unit/Attribute/SuppressMissingApplyTest.php @@ -5,15 +5,15 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Attribute; use InvalidArgumentException; -use Patchlevel\EventSourcing\Attribute\Suppress; +use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\TestCase; -class SuppressTest extends TestCase +class SuppressMissingApplyTest extends TestCase { public function testSuppressEvents(): void { - $attribute = new Suppress([ProfileCreated::class]); + $attribute = new SuppressMissingApply([ProfileCreated::class]); self::assertEquals([ProfileCreated::class], $attribute->suppressEvents()); self::assertEquals(false, $attribute->suppressAll()); @@ -21,7 +21,7 @@ public function testSuppressEvents(): void public function testSuppressAll(): void { - $attribute = new Suppress('*'); + $attribute = new SuppressMissingApply('*'); self::assertEquals([], $attribute->suppressEvents()); self::assertEquals(true, $attribute->suppressAll()); @@ -31,6 +31,6 @@ public function testInvalidString(): void { $this->expectException(InvalidArgumentException::class); - new Suppress('foo'); + new SuppressMissingApply('foo'); } } diff --git a/tests/Unit/Fixture/ProfileWithAttributeApply.php b/tests/Unit/Fixture/ProfileWithAttributeApply.php index b9a9b7a9..044b8094 100644 --- a/tests/Unit/Fixture/ProfileWithAttributeApply.php +++ b/tests/Unit/Fixture/ProfileWithAttributeApply.php @@ -7,9 +7,9 @@ use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\Attribute\Suppress; +use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; -#[Suppress([MessageDeleted::class])] +#[SuppressMissingApply([MessageDeleted::class])] final class ProfileWithAttributeApply extends AggregateRoot { use AttributeApplyMethod; diff --git a/tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php b/tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php index 66a3d8f7..bc3738a3 100644 --- a/tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php +++ b/tests/Unit/Fixture/ProfileWithAttributeApplySuppressAll.php @@ -6,9 +6,9 @@ use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Aggregate\AttributeApplyMethod; -use Patchlevel\EventSourcing\Attribute\Suppress; +use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; -#[Suppress(Suppress::ALL)] +#[SuppressMissingApply(SuppressMissingApply::ALL)] final class ProfileWithAttributeApplySuppressAll extends AggregateRoot { use AttributeApplyMethod;