Skip to content
Open
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
41 changes: 41 additions & 0 deletions docs/dynamic-consistency-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,47 @@ final class GuestAlreadyCheckedIn extends BasicProjection
}
}
```
### Access the message

If a projection needs metadata like the time an event was recorded,
the apply method can take the `Message` as a third parameter.

```php
use DateTimeImmutable;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Identifier\Uuid;
use Patchlevel\EventSourcing\Message\Message;
use Patchlevel\EventSourcing\Projection\BasicProjection;
use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader;

final class LastCheckIn extends BasicProjection
{
public function __construct(
private readonly Uuid $hotelId,
) {
}

public function initialState(): DateTimeImmutable|null
{
return null;
}

/** @return list<string> */
protected function tagFilter(): array
{
return ["hotel:{$this->hotelId->toString()}"];
}

#[Apply]
public function applyGuestIsCheckedIn(
DateTimeImmutable|null $state,
GuestIsCheckedIn $event,
Message $message,
): DateTimeImmutable {
return $message->header(RecordedOnHeader::class)->recordedOn;
}
}
```
## Define handlers

We’ll implement three command handlers corresponding to our commands.
Expand Down
12 changes: 12 additions & 0 deletions src/Projection/ApplyMethodDetectionError.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Patchlevel\EventSourcing\Projection;

use Patchlevel\EventSourcing\Message\Message;
use RuntimeException;

use function get_debug_type;
Expand Down Expand Up @@ -80,4 +81,15 @@ public static function argumentTypeIsMissing(string $methodName): self
),
);
}

public static function messageParameterHasWrongType(string $methodName): self
{
return new self(
sprintf(
'The method "%s" has an #[Apply] attribute, but its third parameter is not of type "%s".',
$methodName,
Message::class,
),
);
}
}
31 changes: 26 additions & 5 deletions src/Projection/BasicProjection.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public function apply(mixed $state, Message $message): mixed

if (array_key_exists($event::class, $applyMethods)) {
/* @phpstan-ignore return.type */
return $this->{$applyMethods[$event::class]}($state, $event);
return $this->{$applyMethods[$event::class]}($state, $event, $message);
}

return $state;
Expand Down Expand Up @@ -82,7 +82,7 @@ private function applyMethods(): array

$reflector = new ReflectionClass($this);

$this->applyMethods = [];
$applyMethods = [];

foreach ($reflector->getMethods() as $method) {
$attributes = $method->getAttributes(Apply::class);
Expand Down Expand Up @@ -122,6 +122,8 @@ private function applyMethods(): array
);
}

self::validateMessageParameter($method);

foreach ($eventClasses as $eventClass) {
if (!class_exists($eventClass)) {
throw ApplyMethodDetectionError::argumentTypeIsNotAClass(
Expand All @@ -130,22 +132,41 @@ private function applyMethods(): array
);
}

if (array_key_exists($eventClass, $this->applyMethods)) {
if (array_key_exists($eventClass, $applyMethods)) {
throw ApplyMethodDetectionError::duplicateApplyMethod(
static::class,
$eventClass,
$this->applyMethods[$eventClass],
$applyMethods[$eventClass],
$method->getName(),
);
}

$this->applyMethods[$eventClass] = $method->getName();
$applyMethods[$eventClass] = $method->getName();
}
}

$this->applyMethods = $applyMethods;

return $this->applyMethods;
}

private static function validateMessageParameter(ReflectionMethod $method): void
{
$parameters = $method->getParameters();

if (!array_key_exists(2, $parameters)) {
return;
}

$type = $parameters[2]->getType();

if ($type instanceof ReflectionNamedType && $type->getName() === Message::class) {
return;
}

throw ApplyMethodDetectionError::messageParameterHasWrongType($method->getName());
}

/** @return array<string> */
private function getEventClassesByPropertyTypes(ReflectionMethod $method): array
{
Expand Down
11 changes: 11 additions & 0 deletions tests/Unit/Projection/ApplyMethodDetectionErrorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,15 @@ public function testArgumentTypeIsMissing(): void
);
self::assertSame(0, $exception->getCode());
}

public function testMessageParameterHasWrongType(): void
{
$exception = ApplyMethodDetectionError::messageParameterHasWrongType('applyEvent');

self::assertSame(
'The method "applyEvent" has an #[Apply] attribute, but its third parameter is not of type "Patchlevel\EventSourcing\Message\Message".',
$exception->getMessage(),
);
self::assertSame(0, $exception->getCode());
}
}
67 changes: 67 additions & 0 deletions tests/Unit/Projection/BasicProjectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
namespace Patchlevel\EventSourcing\Tests\Unit\Projection;

use Countable;
use DateTimeImmutable;
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Message\Message;
use Patchlevel\EventSourcing\Projection\ApplyMethodDetectionError;
use Patchlevel\EventSourcing\Projection\BasicProjection;
use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader;
use Patchlevel\EventSourcing\Store\Header\TagsHeader;
use Patchlevel\EventSourcing\Store\SubQuery;
use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email;
Expand Down Expand Up @@ -53,6 +55,71 @@ public function applyProfileCreated(int $state, ProfileCreated $event): int
self::assertSame($projection->subQuery(), $projection->subQuery());
}

public function testApplyWithMessage(): void
{
$projection = new class extends BasicProjection {
public function initialState(): DateTimeImmutable|null
{
return null;
}

/** @return list<string> */
protected function tagFilter(): array
{
return ['match'];
}

#[Apply]
public function applyProfileCreated(
DateTimeImmutable|null $state,
ProfileCreated $event,
Message $message,
): DateTimeImmutable {
return $message->header(RecordedOnHeader::class)->recordedOn;
}
};

$recordedOn = new DateTimeImmutable('2020-01-01 00:00:00');

$state = $projection->apply(
null,
$this->message(['match'])->withHeader(new RecordedOnHeader($recordedOn)),
);

self::assertSame($recordedOn, $state);
self::assertEquals(
new SubQuery(['match'], [ProfileCreated::class]),
$projection->subQuery(),
);
}

public function testMessageParameterWithWrongType(): void
{
$projection = new class extends BasicProjection {
public function initialState(): int
{
return 0;
}

/** @return list<string> */
protected function tagFilter(): array
{
return [];
}

#[Apply]
public function applyProfileCreated(int $state, ProfileCreated $event, DateTimeImmutable $recordedOn): int
{
return $state;
}
};

$this->expectException(ApplyMethodDetectionError::class);
$this->expectExceptionMessage('third parameter is not of type');

$projection->subQuery();
}

public function testApplyWithNonMatchingMessage(): void
{
$projection = new class extends BasicProjection {
Expand Down
Loading