Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add attribute based apply methods #158

Merged
merged 8 commits into from
Jan 7, 2022
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
138 changes: 136 additions & 2 deletions docs/aggregate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
DavidBadura marked this conversation as resolved.
Show resolved Hide resolved
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`.

Expand Down Expand Up @@ -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.
Expand Down
65 changes: 0 additions & 65 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -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\\<array\\<string, mixed\\>\\>\\:\\:deserialize\\(\\)$#"
count: 1
Expand Down
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ parameters:
level: max
paths:
- src
checkGenericClassInNonGenericObjectType: false
DavidBadura marked this conversation as resolved.
Show resolved Hide resolved
21 changes: 21 additions & 0 deletions src/Aggregate/ApplyAttributeNotFound.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Aggregate;

use function sprintf;

final class ApplyAttributeNotFound extends AggregateException
{
public function __construct(AggregateRoot $aggregate, AggregateChanged $event)
{
parent::__construct(
sprintf(
'Apply method in "%s" could not be found for the event "%s"',
$aggregate::class,
$event::class
)
);
}
}
99 changes: 99 additions & 0 deletions src/Aggregate/AttributeApplyMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Aggregate;

use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\SuppressMissingApply;
use ReflectionClass;

use function array_key_exists;
use function method_exists;

/**
* @psalm-require-extends AggregateRoot
*/
trait AttributeApplyMethod
{
/** @var array<class-string<AggregateChanged>, true> */
private static array $suppressEvents = [];
private static bool $suppressAll = false;

/** @var array<class-string<AggregateChanged>, 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<class-string<AggregateChanged>, 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;
}
}
27 changes: 27 additions & 0 deletions src/Aggregate/DuplicateApplyMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Aggregate;

use function sprintf;

final class DuplicateApplyMethod extends AggregateException
{
/**
* @param class-string<AggregateRoot> $aggregate
* @param class-string<AggregateChanged> $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
)
);
}
}
Loading