diff --git a/docs/dynamic-consistency-boundary.md b/docs/dynamic-consistency-boundary.md index 0605d0695..ceeb8827a 100644 --- a/docs/dynamic-consistency-boundary.md +++ b/docs/dynamic-consistency-boundary.md @@ -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 */ + 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. diff --git a/src/Projection/ApplyMethodDetectionError.php b/src/Projection/ApplyMethodDetectionError.php index 76512951f..8033b0d2d 100644 --- a/src/Projection/ApplyMethodDetectionError.php +++ b/src/Projection/ApplyMethodDetectionError.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Projection; +use Patchlevel\EventSourcing\Message\Message; use RuntimeException; use function get_debug_type; @@ -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, + ), + ); + } } diff --git a/src/Projection/BasicProjection.php b/src/Projection/BasicProjection.php index 8a9d0b5c8..eb277a978 100644 --- a/src/Projection/BasicProjection.php +++ b/src/Projection/BasicProjection.php @@ -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; @@ -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); @@ -122,6 +122,8 @@ private function applyMethods(): array ); } + self::validateMessageParameter($method); + foreach ($eventClasses as $eventClass) { if (!class_exists($eventClass)) { throw ApplyMethodDetectionError::argumentTypeIsNotAClass( @@ -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 */ private function getEventClassesByPropertyTypes(ReflectionMethod $method): array { diff --git a/tests/Unit/Projection/ApplyMethodDetectionErrorTest.php b/tests/Unit/Projection/ApplyMethodDetectionErrorTest.php index 2ef4dfcb9..a49dde506 100644 --- a/tests/Unit/Projection/ApplyMethodDetectionErrorTest.php +++ b/tests/Unit/Projection/ApplyMethodDetectionErrorTest.php @@ -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()); + } } diff --git a/tests/Unit/Projection/BasicProjectionTest.php b/tests/Unit/Projection/BasicProjectionTest.php index 12e41ca46..2c8e6d644 100644 --- a/tests/Unit/Projection/BasicProjectionTest.php +++ b/tests/Unit/Projection/BasicProjectionTest.php @@ -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; @@ -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 */ + 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 */ + 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 {