From cdf05276a39bc7b1865d54b43c4ff37194bb512d Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Fri, 2 Oct 2026 19:14:46 +0200 Subject: [PATCH 1/2] Add HeaderLocator --- docs/UPGRADE-4.0.md | 49 +++++++++++ docs/message.md | 37 +++++++++ .../Serializer/DefaultHeadersSerializer.php | 26 ++++-- src/Metadata/ChainClassLocator.php | 30 +++++++ src/Metadata/ClassLocator.php | 11 +++ src/Metadata/FilesystemClassLocator.php | 41 +++++++++ src/Metadata/InMemoryClassLocator.php | 20 +++++ .../AttributeMessageHeaderRegistryFactory.php | 22 +++-- src/Metadata/Message/ClassIsNotAHeader.php | 18 ++++ .../Message/HeaderAlreadyInRegistry.php | 26 ++++++ .../Message/InternalHeaderLocator.php | 34 ++++++++ .../Message/MessageHeaderRegistry.php | 26 ------ .../Message/MessageHeaderRegistryFactory.php | 5 +- src/Store/ArchivedHeader.php | 3 + src/Store/Header/EventIdHeader.php | 3 + src/Store/Header/IndexHeader.php | 3 + src/Store/Header/PlayheadHeader.php | 3 + src/Store/Header/RecordedOnHeader.php | 2 + src/Store/Header/StreamNameHeader.php | 3 + src/Store/Header/TagsHeader.php | 3 + src/Store/StreamStartHeader.php | 3 + tests/Architecture/LayerDependenciesTest.php | 4 +- .../DefaultHeadersSerializerTest.php | 61 ++++++++++---- tests/Unit/Metadata/ChainClassLocatorTest.php | 34 ++++++++ .../Metadata/FilesystemClassLocatorTest.php | 40 +++++++++ .../Metadata/InMemoryClassLocatorTest.php | 22 +++++ ...ributeMessageHeaderRegistryFactoryTest.php | 83 ++++++++++++++++++- .../Message/ClassIsNotAHeaderTest.php | 27 ++++++ .../Message/Fixture/DuplicateFooHeader.php | 12 +++ .../Message/Fixture/ReservedNameHeader.php | 12 +++ .../Message/HeaderAlreadyInRegistryTest.php | 32 +++++++ .../Message/MessageHeaderRegistryTest.php | 16 ---- 32 files changed, 629 insertions(+), 82 deletions(-) create mode 100644 src/Metadata/ChainClassLocator.php create mode 100644 src/Metadata/ClassLocator.php create mode 100644 src/Metadata/FilesystemClassLocator.php create mode 100644 src/Metadata/InMemoryClassLocator.php create mode 100644 src/Metadata/Message/ClassIsNotAHeader.php create mode 100644 src/Metadata/Message/HeaderAlreadyInRegistry.php create mode 100644 src/Metadata/Message/InternalHeaderLocator.php create mode 100644 tests/Unit/Metadata/ChainClassLocatorTest.php create mode 100644 tests/Unit/Metadata/FilesystemClassLocatorTest.php create mode 100644 tests/Unit/Metadata/InMemoryClassLocatorTest.php create mode 100644 tests/Unit/Metadata/Message/ClassIsNotAHeaderTest.php create mode 100644 tests/Unit/Metadata/Message/Fixture/DuplicateFooHeader.php create mode 100644 tests/Unit/Metadata/Message/Fixture/ReservedNameHeader.php create mode 100644 tests/Unit/Metadata/Message/HeaderAlreadyInRegistryTest.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index b288215d6..c1f42355f 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -602,6 +602,55 @@ and replaced with the following headers: * `Patchlevel\EventSourcing\Store\Header\PlayheadHeader` * `Patchlevel\EventSourcing\Store\Header\RecordedOnHeader` +### Header registration + +`Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistryFactory::create()` now expects a +`Patchlevel\EventSourcing\Metadata\ClassLocator` instead of a list of paths. +The internal headers are always registered by the `AttributeMessageHeaderRegistryFactory`. + +Before: + +```php +use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; + +$registry = (new AttributeMessageHeaderRegistryFactory())->create(['src/Header']); +``` +After: + +```php +use Patchlevel\EventSourcing\Attribute\Header; +use Patchlevel\EventSourcing\Metadata\FilesystemClassLocator; +use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; + +$registry = (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator(['src/Header'], Header::class), +); +``` +`MessageHeaderRegistry::createWithInternalHeaders()` has been removed. +Use the `AttributeMessageHeaderRegistryFactory` with an `InMemoryClassLocator` instead. + +Before: + +```php +use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry; + +$registry = MessageHeaderRegistry::createWithInternalHeaders(['application' => ApplicationHeader::class]); +``` +After: + +```php +use Patchlevel\EventSourcing\Metadata\InMemoryClassLocator; +use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; + +$registry = (new AttributeMessageHeaderRegistryFactory())->create( + new InMemoryClassLocator([ApplicationHeader::class]), +); +``` +Every located class must have a `#[Header]` attribute, otherwise a `ClassIsNotAHeader` exception is thrown. +Header names must be unique. If two classes use the same name, or a custom header uses the name of an +internal header (`streamName`, `playhead`, `recordedOn`, `archived`, `newStreamStart`, `eventId`, `index`, `tags`), +a `HeaderAlreadyInRegistry` exception is thrown. Previously the custom header silently replaced the internal one. + ### AggregateToStreamHeaderTranslator `Patchlevel\EventSourcing\Message\Translator\AggregateToStreamHeaderTranslator` has been removed. diff --git a/docs/message.md b/docs/message.md index 54b631cd2..37a179514 100644 --- a/docs/message.md +++ b/docs/message.md @@ -122,6 +122,43 @@ use Patchlevel\EventSourcing\Message\Message; /** @var Message $message */ $message->header(ApplicationHeader::class); ``` +### Register headers + +The `DefaultHeadersSerializer` needs to know your header classes to resolve the header names. +The easiest way is to scan the directories where your headers are located. + +```php +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; + +$serializer = DefaultHeadersSerializer::createFromPaths(['src/Header']); +``` +If you already know your header classes, or a library wants to provide its own headers, +you can pass a `ClassLocator` instead. The `InMemoryClassLocator` takes a list of classes, +the `ChainClassLocator` combines multiple locators. + +```php +use Patchlevel\EventSourcing\Attribute\Header; +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; +use Patchlevel\EventSourcing\Metadata\ChainClassLocator; +use Patchlevel\EventSourcing\Metadata\FilesystemClassLocator; +use Patchlevel\EventSourcing\Metadata\InMemoryClassLocator; + +$serializer = DefaultHeadersSerializer::createFromLocator( + new ChainClassLocator([ + new FilesystemClassLocator(['src/Header'], Header::class), + new InMemoryClassLocator([ApplicationHeader::class]), + ]), +); +``` +The header name is always taken from the `#[Header]` attribute. +The internal headers of the library are always registered, you don't need to locate them. + +:::warning +Header names must be unique and must not collide with the names of the internal headers +(`streamName`, `playhead`, `recordedOn`, `archived`, `newStreamStart`, `eventId`, `index`, `tags`). +Otherwise a `HeaderAlreadyInRegistry` exception is thrown. +::: + ## Missing headers When a message is deserialized, every header name is resolved to its registered header class. diff --git a/src/Message/Serializer/DefaultHeadersSerializer.php b/src/Message/Serializer/DefaultHeadersSerializer.php index d75fc643a..3930e1f30 100644 --- a/src/Message/Serializer/DefaultHeadersSerializer.php +++ b/src/Message/Serializer/DefaultHeadersSerializer.php @@ -4,7 +4,11 @@ namespace Patchlevel\EventSourcing\Message\Serializer; +use Patchlevel\EventSourcing\Attribute\Header; use Patchlevel\EventSourcing\Message\MissingHeaders; +use Patchlevel\EventSourcing\Metadata\ClassLocator; +use Patchlevel\EventSourcing\Metadata\FilesystemClassLocator; +use Patchlevel\EventSourcing\Metadata\InMemoryClassLocator; use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered; use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry; @@ -98,9 +102,22 @@ public static function createFromPaths( array $paths, array $gracefulMissingHeaders = [], Hydrator $hydrator = new StackHydrator(), + ): static { + return self::createFromLocator( + new FilesystemClassLocator($paths, Header::class), + $gracefulMissingHeaders, + $hydrator, + ); + } + + /** @param list $gracefulMissingHeaders */ + public static function createFromLocator( + ClassLocator $locator, + array $gracefulMissingHeaders = [], + Hydrator $hydrator = new StackHydrator(), ): static { return new self( - (new AttributeMessageHeaderRegistryFactory())->create($paths), + (new AttributeMessageHeaderRegistryFactory())->create($locator), $hydrator, new JsonEncoder(), $gracefulMissingHeaders, @@ -109,11 +126,6 @@ public static function createFromPaths( public static function createDefault(Hydrator $hydrator = new StackHydrator()): static { - return new self( - MessageHeaderRegistry::createWithInternalHeaders(), - $hydrator, - new JsonEncoder(), - [], - ); + return self::createFromLocator(new InMemoryClassLocator([]), [], $hydrator); } } diff --git a/src/Metadata/ChainClassLocator.php b/src/Metadata/ChainClassLocator.php new file mode 100644 index 000000000..a7df58208 --- /dev/null +++ b/src/Metadata/ChainClassLocator.php @@ -0,0 +1,30 @@ + $locators */ + public function __construct( + private readonly iterable $locators, + ) { + } + + /** @return list */ + public function locate(): array + { + $classes = []; + + foreach ($this->locators as $locator) { + $classes[] = $locator->locate(); + } + + return array_values(array_unique(array_merge(...$classes))); + } +} diff --git a/src/Metadata/ClassLocator.php b/src/Metadata/ClassLocator.php new file mode 100644 index 000000000..f32c2df46 --- /dev/null +++ b/src/Metadata/ClassLocator.php @@ -0,0 +1,11 @@ + */ + public function locate(): array; +} diff --git a/src/Metadata/FilesystemClassLocator.php b/src/Metadata/FilesystemClassLocator.php new file mode 100644 index 000000000..e30b84ef8 --- /dev/null +++ b/src/Metadata/FilesystemClassLocator.php @@ -0,0 +1,41 @@ + $paths + * @param class-string|null $attribute only classes with this attribute are returned + */ + public function __construct( + private readonly array $paths, + private readonly string|null $attribute = null, + ) { + } + + /** @return list */ + public function locate(): array + { + $classes = (new ClassFinder())->findClassNames($this->paths); + $attribute = $this->attribute; + + if ($attribute === null) { + return $classes; + } + + return array_values( + array_filter( + $classes, + static fn (string $class): bool => (new ReflectionClass($class))->getAttributes($attribute) !== [], + ), + ); + } +} diff --git a/src/Metadata/InMemoryClassLocator.php b/src/Metadata/InMemoryClassLocator.php new file mode 100644 index 000000000..7e164496c --- /dev/null +++ b/src/Metadata/InMemoryClassLocator.php @@ -0,0 +1,20 @@ + $classes */ + public function __construct( + private readonly array $classes, + ) { + } + + /** @return list */ + public function locate(): array + { + return $this->classes; + } +} diff --git a/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php b/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php index 103e5d432..ae768cf00 100644 --- a/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php +++ b/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php @@ -5,17 +5,18 @@ namespace Patchlevel\EventSourcing\Metadata\Message; use Patchlevel\EventSourcing\Attribute\Header; -use Patchlevel\EventSourcing\Metadata\ClassFinder; +use Patchlevel\EventSourcing\Metadata\ChainClassLocator; +use Patchlevel\EventSourcing\Metadata\ClassLocator; use ReflectionClass; +use function array_key_exists; use function count; final class AttributeMessageHeaderRegistryFactory implements MessageHeaderRegistryFactory { - /** @param list $paths */ - public function create(array $paths): MessageHeaderRegistry + public function create(ClassLocator $locator): MessageHeaderRegistry { - $classes = (new ClassFinder())->findClassNames($paths); + $classes = (new ChainClassLocator([new InternalHeaderLocator(), $locator]))->locate(); $result = []; @@ -24,13 +25,18 @@ public function create(array $paths): MessageHeaderRegistry $attributes = $reflection->getAttributes(Header::class); if (count($attributes) === 0) { - continue; + throw new ClassIsNotAHeader($class); } - $aggregateName = $attributes[0]->newInstance()->name; - $result[$aggregateName] = $class; + $headerName = $attributes[0]->newInstance()->name; + + if (array_key_exists($headerName, $result)) { + throw new HeaderAlreadyInRegistry($headerName, $result[$headerName], $class); + } + + $result[$headerName] = $class; } - return MessageHeaderRegistry::createWithInternalHeaders($result); + return new MessageHeaderRegistry($result); } } diff --git a/src/Metadata/Message/ClassIsNotAHeader.php b/src/Metadata/Message/ClassIsNotAHeader.php new file mode 100644 index 000000000..91b628bf6 --- /dev/null +++ b/src/Metadata/Message/ClassIsNotAHeader.php @@ -0,0 +1,18 @@ + */ + public function locate(): array + { + return [ + StreamNameHeader::class, + PlayheadHeader::class, + RecordedOnHeader::class, + ArchivedHeader::class, + StreamStartHeader::class, + EventIdHeader::class, + IndexHeader::class, + TagsHeader::class, + ]; + } +} diff --git a/src/Metadata/Message/MessageHeaderRegistry.php b/src/Metadata/Message/MessageHeaderRegistry.php index ed15f4d3a..e6821bc11 100644 --- a/src/Metadata/Message/MessageHeaderRegistry.php +++ b/src/Metadata/Message/MessageHeaderRegistry.php @@ -4,15 +4,6 @@ namespace Patchlevel\EventSourcing\Metadata\Message; -use Patchlevel\EventSourcing\Store\ArchivedHeader; -use Patchlevel\EventSourcing\Store\Header\EventIdHeader; -use Patchlevel\EventSourcing\Store\Header\IndexHeader; -use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; -use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; -use Patchlevel\EventSourcing\Store\Header\TagsHeader; -use Patchlevel\EventSourcing\Store\StreamStartHeader; - use function array_flip; use function array_key_exists; @@ -72,21 +63,4 @@ public function headerNames(): array { return $this->classToNameMap; } - - /** @param array $headerNameToClassMap */ - public static function createWithInternalHeaders(array $headerNameToClassMap = []): self - { - $internalHeaders = [ - 'streamName' => StreamNameHeader::class, - 'playhead' => PlayheadHeader::class, - 'recordedOn' => RecordedOnHeader::class, - 'archived' => ArchivedHeader::class, - 'newStreamStart' => StreamStartHeader::class, - 'eventId' => EventIdHeader::class, - 'index' => IndexHeader::class, - 'tags' => TagsHeader::class, - ]; - - return new self($headerNameToClassMap + $internalHeaders); - } } diff --git a/src/Metadata/Message/MessageHeaderRegistryFactory.php b/src/Metadata/Message/MessageHeaderRegistryFactory.php index a2532ebf5..e39c672c9 100644 --- a/src/Metadata/Message/MessageHeaderRegistryFactory.php +++ b/src/Metadata/Message/MessageHeaderRegistryFactory.php @@ -4,8 +4,9 @@ namespace Patchlevel\EventSourcing\Metadata\Message; +use Patchlevel\EventSourcing\Metadata\ClassLocator; + interface MessageHeaderRegistryFactory { - /** @param list $paths */ - public function create(array $paths): MessageHeaderRegistry; + public function create(ClassLocator $locator): MessageHeaderRegistry; } diff --git a/src/Store/ArchivedHeader.php b/src/Store/ArchivedHeader.php index 706fadbaa..57b837e7d 100644 --- a/src/Store/ArchivedHeader.php +++ b/src/Store/ArchivedHeader.php @@ -4,7 +4,10 @@ namespace Patchlevel\EventSourcing\Store; +use Patchlevel\EventSourcing\Attribute\Header; + /** @immutable */ +#[Header('archived')] final class ArchivedHeader { } diff --git a/src/Store/Header/EventIdHeader.php b/src/Store/Header/EventIdHeader.php index 122487938..a5f3cc28b 100644 --- a/src/Store/Header/EventIdHeader.php +++ b/src/Store/Header/EventIdHeader.php @@ -4,7 +4,10 @@ namespace Patchlevel\EventSourcing\Store\Header; +use Patchlevel\EventSourcing\Attribute\Header; + /** @immutable */ +#[Header('eventId')] final class EventIdHeader { public function __construct( diff --git a/src/Store/Header/IndexHeader.php b/src/Store/Header/IndexHeader.php index 6494eab81..035adc121 100644 --- a/src/Store/Header/IndexHeader.php +++ b/src/Store/Header/IndexHeader.php @@ -4,7 +4,10 @@ namespace Patchlevel\EventSourcing\Store\Header; +use Patchlevel\EventSourcing\Attribute\Header; + /** @immutable */ +#[Header('index')] final class IndexHeader { /** @param positive-int $index */ diff --git a/src/Store/Header/PlayheadHeader.php b/src/Store/Header/PlayheadHeader.php index fe4c69561..b8ef2d6d5 100644 --- a/src/Store/Header/PlayheadHeader.php +++ b/src/Store/Header/PlayheadHeader.php @@ -4,7 +4,10 @@ namespace Patchlevel\EventSourcing\Store\Header; +use Patchlevel\EventSourcing\Attribute\Header; + /** @immutable */ +#[Header('playhead')] final class PlayheadHeader { /** @param positive-int $playhead */ diff --git a/src/Store/Header/RecordedOnHeader.php b/src/Store/Header/RecordedOnHeader.php index bd488aa75..08719bd98 100644 --- a/src/Store/Header/RecordedOnHeader.php +++ b/src/Store/Header/RecordedOnHeader.php @@ -5,8 +5,10 @@ namespace Patchlevel\EventSourcing\Store\Header; use DateTimeImmutable; +use Patchlevel\EventSourcing\Attribute\Header; /** @immutable */ +#[Header('recordedOn')] final class RecordedOnHeader { public function __construct( diff --git a/src/Store/Header/StreamNameHeader.php b/src/Store/Header/StreamNameHeader.php index 72acf32cf..42dce0a9c 100644 --- a/src/Store/Header/StreamNameHeader.php +++ b/src/Store/Header/StreamNameHeader.php @@ -4,7 +4,10 @@ namespace Patchlevel\EventSourcing\Store\Header; +use Patchlevel\EventSourcing\Attribute\Header; + /** @immutable */ +#[Header('streamName')] final class StreamNameHeader { public function __construct( diff --git a/src/Store/Header/TagsHeader.php b/src/Store/Header/TagsHeader.php index 9cd3b2a8c..9ed75486d 100644 --- a/src/Store/Header/TagsHeader.php +++ b/src/Store/Header/TagsHeader.php @@ -4,10 +4,13 @@ namespace Patchlevel\EventSourcing\Store\Header; +use Patchlevel\EventSourcing\Attribute\Header; + /** * @experimental * @psalm-immutable */ +#[Header('tags')] final class TagsHeader { /** @param list $tags */ diff --git a/src/Store/StreamStartHeader.php b/src/Store/StreamStartHeader.php index 2605ee993..715f235bb 100644 --- a/src/Store/StreamStartHeader.php +++ b/src/Store/StreamStartHeader.php @@ -4,7 +4,10 @@ namespace Patchlevel\EventSourcing\Store; +use Patchlevel\EventSourcing\Attribute\Header; + /** @immutable */ +#[Header('newStreamStart')] final class StreamStartHeader { } diff --git a/tests/Architecture/LayerDependenciesTest.php b/tests/Architecture/LayerDependenciesTest.php index 102b7c7cc..fb9d0c89d 100644 --- a/tests/Architecture/LayerDependenciesTest.php +++ b/tests/Architecture/LayerDependenciesTest.php @@ -91,6 +91,8 @@ public function testMessageCanOnlyDependOnAllowedLayers(): Rule $this->layer('Message'), [ $this->layer('Aggregate'), + $this->layer('Attribute'), + $this->metadataLayer(), $this->layer('Metadata\Message'), $this->layer('Serializer'), $this->layer('Store'), @@ -131,7 +133,6 @@ public function testMetadataMessageCanOnlyDependOnAllowedLayers(): Rule return $this->layerCanOnlyDependOnAllowedLayers( $this->layer('Metadata\Message'), [ - $this->layer('Aggregate'), $this->layer('Attribute'), $this->metadataLayer(), $this->layer('Store'), @@ -210,6 +211,7 @@ public function testStoreCanOnlyDependOnAllowedLayers(): Rule return $this->layerCanOnlyDependOnAllowedLayers( $this->layer('Store'), [ + $this->layer('Attribute'), $this->layer('Clock'), $this->layer('Message'), $this->metadataLayer(), diff --git a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php index beb3f95d9..607579070 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -5,9 +5,12 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Message\Serializer; use DateTimeImmutable; +use Patchlevel\EventSourcing\Attribute\Header; use Patchlevel\EventSourcing\Message\MissingHeaders; use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Message\Serializer\InvalidArgument; +use Patchlevel\EventSourcing\Metadata\FilesystemClassLocator; +use Patchlevel\EventSourcing\Metadata\InMemoryClassLocator; use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered; use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; @@ -15,6 +18,7 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\FooHeader; use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; @@ -48,9 +52,9 @@ public function testSerialize(): void public function testDeserialize(): void { $serializer = new DefaultHeadersSerializer( - (new AttributeMessageHeaderRegistryFactory())->create([ - __DIR__ . '/../../Fixture', - ]), + (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), + ), new StackHydrator(), new JsonEncoder(), ); @@ -71,9 +75,9 @@ public function testDeserialize(): void public function testDeserializeUnknownHeadersAsMissingHeaders(): void { $serializer = new DefaultHeadersSerializer( - (new AttributeMessageHeaderRegistryFactory())->create([ - __DIR__ . '/../../Fixture', - ]), + (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), + ), new StackHydrator(), new JsonEncoder(), ['removed', 'alsoRemoved'], @@ -96,9 +100,9 @@ public function testDeserializeUnknownHeadersAsMissingHeaders(): void public function testDeserializeUnknownHeaderNotConfiguredCrashes(): void { $serializer = new DefaultHeadersSerializer( - (new AttributeMessageHeaderRegistryFactory())->create([ - __DIR__ . '/../../Fixture', - ]), + (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), + ), new StackHydrator(), new JsonEncoder(), ['removed'], @@ -112,9 +116,9 @@ public function testDeserializeUnknownHeaderNotConfiguredCrashes(): void public function testDeserializeWildcardHandlesAllUnknownHeaders(): void { $serializer = new DefaultHeadersSerializer( - (new AttributeMessageHeaderRegistryFactory())->create([ - __DIR__ . '/../../Fixture', - ]), + (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), + ), new StackHydrator(), new JsonEncoder(), ['*'], @@ -137,9 +141,9 @@ public function testDeserializeWildcardHandlesAllUnknownHeaders(): void public function testSerializeMissingHeadersRoundTrip(): void { $serializer = new DefaultHeadersSerializer( - (new AttributeMessageHeaderRegistryFactory())->create([ - __DIR__ . '/../../Fixture', - ]), + (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), + ), new StackHydrator(), new JsonEncoder(), ); @@ -161,9 +165,9 @@ public function testSerializeMissingHeadersRoundTrip(): void public function testDeserializeWithInvalidHeaderPayload(): void { $serializer = new DefaultHeadersSerializer( - (new AttributeMessageHeaderRegistryFactory())->create([ - __DIR__ . '/../../Fixture', - ]), + (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), + ), new StackHydrator(), new JsonEncoder(), ); @@ -174,6 +178,27 @@ public function testDeserializeWithInvalidHeaderPayload(): void $serializer->deserialize('{"streamName":"profile-1"}'); } + public function testCreateFromLocator(): void + { + $serializer = DefaultHeadersSerializer::createFromLocator( + new InMemoryClassLocator([FooHeader::class]), + ); + + $content = $serializer->serialize([ + new StreamNameHeader('profile-1'), + new FooHeader('bar'), + ]); + + self::assertSame('{"streamName":{"streamName":"profile-1"},"foo":{"data":"bar"}}', $content); + self::assertEquals( + [ + new StreamNameHeader('profile-1'), + new FooHeader('bar'), + ], + $serializer->deserialize($content), + ); + } + public function testCreateDefault(): void { $serializer = DefaultHeadersSerializer::createDefault(); diff --git a/tests/Unit/Metadata/ChainClassLocatorTest.php b/tests/Unit/Metadata/ChainClassLocatorTest.php new file mode 100644 index 000000000..c42e58269 --- /dev/null +++ b/tests/Unit/Metadata/ChainClassLocatorTest.php @@ -0,0 +1,34 @@ +locate()); + } + + public function testEmpty(): void + { + $locator = new ChainClassLocator([]); + + self::assertSame([], $locator->locate()); + } +} diff --git a/tests/Unit/Metadata/FilesystemClassLocatorTest.php b/tests/Unit/Metadata/FilesystemClassLocatorTest.php new file mode 100644 index 000000000..b5f167da8 --- /dev/null +++ b/tests/Unit/Metadata/FilesystemClassLocatorTest.php @@ -0,0 +1,40 @@ +locate(); + + self::assertContains(Profile::class, $classes); + self::assertContains(FooHeader::class, $classes); + } + + public function testLocateClassesWithAttribute(): void + { + $locator = new FilesystemClassLocator([__DIR__ . '/../Fixture'], Header::class); + + self::assertSame([BazHeader::class, FooHeader::class], $locator->locate()); + } + + public function testNoPaths(): void + { + $locator = new FilesystemClassLocator([], Header::class); + + self::assertSame([], $locator->locate()); + } +} diff --git a/tests/Unit/Metadata/InMemoryClassLocatorTest.php b/tests/Unit/Metadata/InMemoryClassLocatorTest.php new file mode 100644 index 000000000..9d74984aa --- /dev/null +++ b/tests/Unit/Metadata/InMemoryClassLocatorTest.php @@ -0,0 +1,22 @@ +locate()); + } +} diff --git a/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php index e90ee0543..d001a2969 100644 --- a/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php +++ b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php @@ -4,24 +4,99 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Metadata\Message; +use Patchlevel\EventSourcing\Attribute\Header; +use Patchlevel\EventSourcing\Metadata\FilesystemClassLocator; +use Patchlevel\EventSourcing\Metadata\InMemoryClassLocator; use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; +use Patchlevel\EventSourcing\Metadata\Message\ClassIsNotAHeader; +use Patchlevel\EventSourcing\Metadata\Message\HeaderAlreadyInRegistry; +use Patchlevel\EventSourcing\Store\ArchivedHeader; +use Patchlevel\EventSourcing\Store\Header\EventIdHeader; +use Patchlevel\EventSourcing\Store\Header\IndexHeader; +use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; +use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\Header\TagsHeader; +use Patchlevel\EventSourcing\Store\StreamStartHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\BazHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\FooHeader; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; +use Patchlevel\EventSourcing\Tests\Unit\Metadata\Message\Fixture\DuplicateFooHeader; +use Patchlevel\EventSourcing\Tests\Unit\Metadata\Message\Fixture\ReservedNameHeader; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(AttributeMessageHeaderRegistryFactory::class)] final class AttributeMessageHeaderRegistryFactoryTest extends TestCase { - public function testCreate(): void + public function testCreateFromFilesystem(): void { - $registry = (new AttributeMessageHeaderRegistryFactory())->create([ - __DIR__ . '/../../Fixture', - ]); + $registry = (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), + ); self::assertSame(FooHeader::class, $registry->headerClass('foo')); self::assertSame(BazHeader::class, $registry->headerClass('baz')); self::assertSame(StreamNameHeader::class, $registry->headerClass('streamName')); } + + public function testInternalHeadersAreAlwaysRegistered(): void + { + $registry = (new AttributeMessageHeaderRegistryFactory())->create(new InMemoryClassLocator([])); + + self::assertSame( + [ + 'streamName' => StreamNameHeader::class, + 'playhead' => PlayheadHeader::class, + 'recordedOn' => RecordedOnHeader::class, + 'archived' => ArchivedHeader::class, + 'newStreamStart' => StreamStartHeader::class, + 'eventId' => EventIdHeader::class, + 'index' => IndexHeader::class, + 'tags' => TagsHeader::class, + ], + $registry->headerClasses(), + ); + } + + public function testSameClassLocatedTwice(): void + { + $registry = (new AttributeMessageHeaderRegistryFactory())->create( + new InMemoryClassLocator([FooHeader::class, FooHeader::class, StreamNameHeader::class]), + ); + + self::assertSame('foo', $registry->headerName(FooHeader::class)); + self::assertSame('streamName', $registry->headerName(StreamNameHeader::class)); + } + + public function testClassIsNotAHeader(): void + { + $this->expectException(ClassIsNotAHeader::class); + + (new AttributeMessageHeaderRegistryFactory())->create(new InMemoryClassLocator([ProfileCreated::class])); + } + + public function testDuplicateHeaderName(): void + { + $this->expectException(HeaderAlreadyInRegistry::class); + $this->expectExceptionMessage( + 'The header name "foo" is already used by "' . FooHeader::class . '" and cannot be used by "' . DuplicateFooHeader::class . '".', + ); + + (new AttributeMessageHeaderRegistryFactory())->create( + new InMemoryClassLocator([FooHeader::class, DuplicateFooHeader::class]), + ); + } + + public function testReservedHeaderName(): void + { + $this->expectException(HeaderAlreadyInRegistry::class); + $this->expectExceptionMessage( + 'The header name "streamName" is already used by "' . StreamNameHeader::class . '" and cannot be used by "' . ReservedNameHeader::class . '".', + ); + + (new AttributeMessageHeaderRegistryFactory())->create( + new InMemoryClassLocator([ReservedNameHeader::class]), + ); + } } diff --git a/tests/Unit/Metadata/Message/ClassIsNotAHeaderTest.php b/tests/Unit/Metadata/Message/ClassIsNotAHeaderTest.php new file mode 100644 index 000000000..8af647417 --- /dev/null +++ b/tests/Unit/Metadata/Message/ClassIsNotAHeaderTest.php @@ -0,0 +1,27 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Message/Fixture/DuplicateFooHeader.php b/tests/Unit/Metadata/Message/Fixture/DuplicateFooHeader.php new file mode 100644 index 000000000..74b1679e3 --- /dev/null +++ b/tests/Unit/Metadata/Message/Fixture/DuplicateFooHeader.php @@ -0,0 +1,12 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Metadata/Message/MessageHeaderRegistryTest.php b/tests/Unit/Metadata/Message/MessageHeaderRegistryTest.php index 87e913cc8..d65bda8f6 100644 --- a/tests/Unit/Metadata/Message/MessageHeaderRegistryTest.php +++ b/tests/Unit/Metadata/Message/MessageHeaderRegistryTest.php @@ -7,7 +7,6 @@ use Patchlevel\EventSourcing\Metadata\Message\HeaderClassNotRegistered; use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered; use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\BazHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\FooHeader; use PHPUnit\Framework\Attributes\CoversClass; @@ -71,19 +70,4 @@ public function testHeaderClassesAndNames(): void self::assertSame(['foo' => FooHeader::class], $registry->headerClasses()); self::assertSame([FooHeader::class => 'foo'], $registry->headerNames()); } - - public function testCreateWithInternalHeaders(): void - { - $registry = MessageHeaderRegistry::createWithInternalHeaders(['foo' => FooHeader::class]); - - self::assertSame(FooHeader::class, $registry->headerClass('foo')); - self::assertSame(StreamNameHeader::class, $registry->headerClass('streamName')); - self::assertTrue($registry->hasHeaderName('playhead')); - self::assertTrue($registry->hasHeaderName('recordedOn')); - self::assertTrue($registry->hasHeaderName('archived')); - self::assertTrue($registry->hasHeaderName('newStreamStart')); - self::assertTrue($registry->hasHeaderName('eventId')); - self::assertTrue($registry->hasHeaderName('index')); - self::assertTrue($registry->hasHeaderName('tags')); - } } From 101eaa132fa5af8a2bba9fc66580c371517afd4a Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Fri, 2 Oct 2026 19:41:15 +0200 Subject: [PATCH 2/2] Move store based headers into separate locators instead of one Internal locator: this pushes the boundaries to the right places --- docs/UPGRADE-4.0.md | 40 +++++- docs/message.md | 33 ++++- docs/split-stream.md | 34 +++++ src/Console/OutputStyle.php | 2 +- .../AttributeMessageHeaderRegistryFactory.php | 9 +- .../Message/InternalHeaderLocator.php | 34 ----- src/Repository/DefaultRepository.php | 2 +- .../MessageDecorator/SplitStreamDecorator.php | 1 - .../SplitStreamHeaderLocator.php | 16 +++ .../MessageDecorator}/StreamStartHeader.php | 2 +- src/Store/Header/StreamStoreHeaderLocator.php | 25 ++++ .../Header/TaggableStoreHeaderLocator.php | 26 ++++ src/Store/StreamDoctrineDbalStore.php | 16 +-- src/Store/TaggableDoctrineDbalStore.php | 17 +-- tests/Architecture/LayerDependenciesTest.php | 4 +- tests/Benchmark/SplitStreamBench.php | 3 + .../IntegrationTest.php | 4 + tests/Unit/Message/MessageTest.php | 2 +- .../DefaultHeadersSerializerTest.php | 128 ++++++++++-------- ...ributeMessageHeaderRegistryFactoryTest.php | 57 ++++---- ...ader.php => StreamNameCollisionHeader.php} | 2 +- .../SplitStreamDecoratorTest.php | 2 +- .../SplitStreamHeaderLocatorTest.php | 19 +++ .../Header/StreamStoreHeaderLocatorTest.php | 34 +++++ .../Header/TaggableStoreHeaderLocatorTest.php | 36 +++++ 25 files changed, 384 insertions(+), 164 deletions(-) delete mode 100644 src/Metadata/Message/InternalHeaderLocator.php create mode 100644 src/Repository/MessageDecorator/SplitStreamHeaderLocator.php rename src/{Store => Repository/MessageDecorator}/StreamStartHeader.php (71%) create mode 100644 src/Store/Header/StreamStoreHeaderLocator.php create mode 100644 src/Store/Header/TaggableStoreHeaderLocator.php rename tests/Unit/Metadata/Message/Fixture/{ReservedNameHeader.php => StreamNameCollisionHeader.php} (82%) create mode 100644 tests/Unit/Repository/MessageDecorator/SplitStreamHeaderLocatorTest.php create mode 100644 tests/Unit/Store/Header/StreamStoreHeaderLocatorTest.php create mode 100644 tests/Unit/Store/Header/TaggableStoreHeaderLocatorTest.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index c1f42355f..b8cba4d44 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -606,7 +606,7 @@ and replaced with the following headers: `Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistryFactory::create()` now expects a `Patchlevel\EventSourcing\Metadata\ClassLocator` instead of a list of paths. -The internal headers are always registered by the `AttributeMessageHeaderRegistryFactory`. +Only the located headers are registered, the library headers are no longer added implicitly. Before: @@ -647,9 +647,41 @@ $registry = (new AttributeMessageHeaderRegistryFactory())->create( ); ``` Every located class must have a `#[Header]` attribute, otherwise a `ClassIsNotAHeader` exception is thrown. -Header names must be unique. If two classes use the same name, or a custom header uses the name of an -internal header (`streamName`, `playhead`, `recordedOn`, `archived`, `newStreamStart`, `eventId`, `index`, `tags`), -a `HeaderAlreadyInRegistry` exception is thrown. Previously the custom header silently replaced the internal one. +Header names must be unique. If two located classes use the same name, a `HeaderAlreadyInRegistry` exception is thrown. + +`DefaultHeadersSerializer::createDefault()` no longer registers any header. +The stores keep their own headers in separate columns, so they don't need them. +If you serialize the store headers yourself, register them with the locator of the store: +`Patchlevel\EventSourcing\Store\Header\StreamStoreHeaderLocator` or +`Patchlevel\EventSourcing\Store\Header\TaggableStoreHeaderLocator`. + +### StreamStartHeader + +`Patchlevel\EventSourcing\Store\StreamStartHeader` has been moved to +`Patchlevel\EventSourcing\Repository\MessageDecorator\StreamStartHeader`. +The header name `newStreamStart` is unchanged, so stored messages stay readable. + +If you use the split stream feature, you need to register the header in the headers serializer of your store +with the `SplitStreamHeaderLocator`. +Keep it registered as long as your store contains messages with this header. + +```php +use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; +use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamHeaderLocator; +use Patchlevel\EventSourcing\Serializer\EventSerializer; +use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; + +/** + * @var Connection $connection + * @var EventSerializer $eventSerializer + */ +$store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + DefaultHeadersSerializer::createFromLocator(new SplitStreamHeaderLocator()), +); +``` ### AggregateToStreamHeaderTranslator diff --git a/docs/message.md b/docs/message.md index 37a179514..9271f9a74 100644 --- a/docs/message.md +++ b/docs/message.md @@ -48,7 +48,7 @@ $message->headers(); // [StreamNameHeader object, PlayheadHeader object, ...] ``` ## Built-in headers -The message object has some built-in headers which are used internally. +The library ships some headers which are set by the stores and the repository. * `StreamNameHeader` - The name of the stream the message belongs to, in the format `[aggregateName]-[aggregateId]`. * `PlayheadHeader` - The position of the message within its stream. @@ -57,7 +57,7 @@ The message object has some built-in headers which are used internally. * `IndexHeader` - The global position of the message in the store. * `TagsHeader` - The tags attached to the message (experimental). * `ArchivedHeader` - Flag if the message is archived. -* `StreamStartHeader` - Flag if the message is the first message in a new stream. +* `StreamStartHeader` - Flag if the message is the first message in a new stream, set by the [split stream](split-stream.md) feature. ```php use Patchlevel\EventSourcing\Message\Message; @@ -151,12 +151,33 @@ $serializer = DefaultHeadersSerializer::createFromLocator( ); ``` The header name is always taken from the `#[Header]` attribute. -The internal headers of the library are always registered, you don't need to locate them. +Only the located headers are registered, nothing is added implicitly. +The stores keep their own headers like `StreamNameHeader` or `PlayheadHeader` in separate columns, +so you don't need to register them for the store. If you serialize these headers yourself, +you can use the locator of the store, e.g. `StreamStoreHeaderLocator` or `TaggableStoreHeaderLocator`. +Features which add headers to the messages provide their own locator, +like the `SplitStreamHeaderLocator` for the [split stream](split-stream.md) feature. + +```php +use Patchlevel\EventSourcing\Attribute\Header; +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; +use Patchlevel\EventSourcing\Metadata\ChainClassLocator; +use Patchlevel\EventSourcing\Metadata\FilesystemClassLocator; +use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamHeaderLocator; +use Patchlevel\EventSourcing\Store\Header\StreamStoreHeaderLocator; + +$serializer = DefaultHeadersSerializer::createFromLocator( + new ChainClassLocator([ + new StreamStoreHeaderLocator(), + new SplitStreamHeaderLocator(), + new FilesystemClassLocator(['src/Header'], Header::class), + ]), +); +``` :::warning -Header names must be unique and must not collide with the names of the internal headers -(`streamName`, `playhead`, `recordedOn`, `archived`, `newStreamStart`, `eventId`, `index`, `tags`). -Otherwise a `HeaderAlreadyInRegistry` exception is thrown. +Header names must be unique. If two located classes use the same name, +a `HeaderAlreadyInRegistry` exception is thrown. ::: ## Missing headers diff --git a/docs/split-stream.md b/docs/split-stream.md index ad10e6122..a0eb61014 100644 --- a/docs/split-stream.md +++ b/docs/split-stream.md @@ -44,6 +44,40 @@ $repositoryManager = new DefaultRepositoryManager( You can find out more about the [message decorator](message-decorator.md). ::: +The decorator marks the first message of a new stream with the `StreamStartHeader`. +This header is stored with the message, so it must be registered in the headers serializer of your store. +The `SplitStreamHeaderLocator` provides it. + +```php +use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\Attribute\Header; +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; +use Patchlevel\EventSourcing\Metadata\ChainClassLocator; +use Patchlevel\EventSourcing\Metadata\FilesystemClassLocator; +use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamHeaderLocator; +use Patchlevel\EventSourcing\Serializer\EventSerializer; +use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; + +/** + * @var Connection $connection + * @var EventSerializer $eventSerializer + */ +$store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + DefaultHeadersSerializer::createFromLocator( + new ChainClassLocator([ + new SplitStreamHeaderLocator(), + new FilesystemClassLocator(['src/Header'], Header::class), + ]), + ), +); +``` +:::warning +Keep the `SplitStreamHeaderLocator` registered as long as your store contains messages with this header, +even if you remove the `SplitStreamDecorator` later. Otherwise these messages can no longer be loaded. +::: + :::tip You can use multiple decorators with the `ChainMessageDecorator`. ::: diff --git a/src/Console/OutputStyle.php b/src/Console/OutputStyle.php index 52e8188d6..d6c4391a3 100644 --- a/src/Console/OutputStyle.php +++ b/src/Console/OutputStyle.php @@ -6,13 +6,13 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Serializer\HeadersSerializer; +use Patchlevel\EventSourcing\Repository\MessageDecorator\StreamStartHeader; use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; use Patchlevel\EventSourcing\Serializer\EventSerializer; use Patchlevel\EventSourcing\Store\ArchivedHeader; use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; -use Patchlevel\EventSourcing\Store\StreamStartHeader; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; diff --git a/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php b/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php index ae768cf00..4f42bbeb6 100644 --- a/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php +++ b/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php @@ -5,7 +5,6 @@ namespace Patchlevel\EventSourcing\Metadata\Message; use Patchlevel\EventSourcing\Attribute\Header; -use Patchlevel\EventSourcing\Metadata\ChainClassLocator; use Patchlevel\EventSourcing\Metadata\ClassLocator; use ReflectionClass; @@ -16,11 +15,9 @@ final class AttributeMessageHeaderRegistryFactory implements MessageHeaderRegist { public function create(ClassLocator $locator): MessageHeaderRegistry { - $classes = (new ChainClassLocator([new InternalHeaderLocator(), $locator]))->locate(); - $result = []; - foreach ($classes as $class) { + foreach ($locator->locate() as $class) { $reflection = new ReflectionClass($class); $attributes = $reflection->getAttributes(Header::class); @@ -31,6 +28,10 @@ public function create(ClassLocator $locator): MessageHeaderRegistry $headerName = $attributes[0]->newInstance()->name; if (array_key_exists($headerName, $result)) { + if ($result[$headerName] === $class) { + continue; + } + throw new HeaderAlreadyInRegistry($headerName, $result[$headerName], $class); } diff --git a/src/Metadata/Message/InternalHeaderLocator.php b/src/Metadata/Message/InternalHeaderLocator.php deleted file mode 100644 index 109645916..000000000 --- a/src/Metadata/Message/InternalHeaderLocator.php +++ /dev/null @@ -1,34 +0,0 @@ - */ - public function locate(): array - { - return [ - StreamNameHeader::class, - PlayheadHeader::class, - RecordedOnHeader::class, - ArchivedHeader::class, - StreamStartHeader::class, - EventIdHeader::class, - IndexHeader::class, - TagsHeader::class, - ]; - } -} diff --git a/src/Repository/DefaultRepository.php b/src/Repository/DefaultRepository.php index 0eebc2508..3e0febda8 100644 --- a/src/Repository/DefaultRepository.php +++ b/src/Repository/DefaultRepository.php @@ -12,6 +12,7 @@ use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadata; use Patchlevel\EventSourcing\Repository\MessageDecorator\MessageDecorator; +use Patchlevel\EventSourcing\Repository\MessageDecorator\StreamStartHeader; use Patchlevel\EventSourcing\Snapshot\SnapshotNotFound; use Patchlevel\EventSourcing\Snapshot\SnapshotStore; use Patchlevel\EventSourcing\Snapshot\SnapshotVersionInvalid; @@ -24,7 +25,6 @@ use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Store; -use Patchlevel\EventSourcing\Store\StreamStartHeader; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; diff --git a/src/Repository/MessageDecorator/SplitStreamDecorator.php b/src/Repository/MessageDecorator/SplitStreamDecorator.php index b2e482247..ee5cac99d 100644 --- a/src/Repository/MessageDecorator/SplitStreamDecorator.php +++ b/src/Repository/MessageDecorator/SplitStreamDecorator.php @@ -6,7 +6,6 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; -use Patchlevel\EventSourcing\Store\StreamStartHeader; final class SplitStreamDecorator implements MessageDecorator { diff --git a/src/Repository/MessageDecorator/SplitStreamHeaderLocator.php b/src/Repository/MessageDecorator/SplitStreamHeaderLocator.php new file mode 100644 index 000000000..aaa34409b --- /dev/null +++ b/src/Repository/MessageDecorator/SplitStreamHeaderLocator.php @@ -0,0 +1,16 @@ + */ + public function locate(): array + { + return [StreamStartHeader::class]; + } +} diff --git a/src/Store/StreamStartHeader.php b/src/Repository/MessageDecorator/StreamStartHeader.php similarity index 71% rename from src/Store/StreamStartHeader.php rename to src/Repository/MessageDecorator/StreamStartHeader.php index 715f235bb..cbd1882f1 100644 --- a/src/Store/StreamStartHeader.php +++ b/src/Repository/MessageDecorator/StreamStartHeader.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Store; +namespace Patchlevel\EventSourcing\Repository\MessageDecorator; use Patchlevel\EventSourcing\Attribute\Header; diff --git a/src/Store/Header/StreamStoreHeaderLocator.php b/src/Store/Header/StreamStoreHeaderLocator.php new file mode 100644 index 000000000..8f2782990 --- /dev/null +++ b/src/Store/Header/StreamStoreHeaderLocator.php @@ -0,0 +1,25 @@ + */ + public function locate(): array + { + return [ + StreamNameHeader::class, + PlayheadHeader::class, + RecordedOnHeader::class, + ArchivedHeader::class, + EventIdHeader::class, + IndexHeader::class, + ]; + } +} diff --git a/src/Store/Header/TaggableStoreHeaderLocator.php b/src/Store/Header/TaggableStoreHeaderLocator.php new file mode 100644 index 000000000..1a4652785 --- /dev/null +++ b/src/Store/Header/TaggableStoreHeaderLocator.php @@ -0,0 +1,26 @@ + */ + public function locate(): array + { + return [ + StreamNameHeader::class, + PlayheadHeader::class, + RecordedOnHeader::class, + ArchivedHeader::class, + EventIdHeader::class, + IndexHeader::class, + TagsHeader::class, + ]; + } +} diff --git a/src/Store/StreamDoctrineDbalStore.php b/src/Store/StreamDoctrineDbalStore.php index e06628cbe..3dbad9e0e 100644 --- a/src/Store/StreamDoctrineDbalStore.php +++ b/src/Store/StreamDoctrineDbalStore.php @@ -44,6 +44,7 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\Header\StreamStoreHeaderLocator; use PDO; use Pdo\Pgsql; use Psr\Clock\ClockInterface; @@ -88,6 +89,9 @@ final class StreamDoctrineDbalStore implements Store, ListenableStore, DoctrineS private readonly HeadersSerializer $headersSerializer; + /** @var list */ + private readonly array $columnHeaders; + private readonly ClockInterface $clock; /** @var array{table_name: string, locking: bool, lock_id: int, lock_timeout: int, keep_index: bool} */ @@ -107,6 +111,7 @@ public function __construct( array $config = [], ) { $this->headersSerializer = $headersSerializer ?? DefaultHeadersSerializer::createDefault(); + $this->columnHeaders = (new StreamStoreHeaderLocator())->locate(); $this->clock = $clock ?? new SystemClock(); $this->config = array_merge([ @@ -461,19 +466,10 @@ public function configureSchema(Schema $schema, Connection $connection): void /** @return list */ private function getCustomHeaders(Message $message): array { - $filteredHeaders = [ - IndexHeader::class, - StreamNameHeader::class, - EventIdHeader::class, - PlayheadHeader::class, - RecordedOnHeader::class, - ArchivedHeader::class, - ]; - return array_values( array_filter( $message->headers(), - static fn (object $header) => !in_array($header::class, $filteredHeaders, true), + fn (object $header) => !in_array($header::class, $this->columnHeaders, true), ), ); } diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index 4d3dcc7dc..a46794050 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -47,6 +47,7 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\Header\TaggableStoreHeaderLocator; use Patchlevel\EventSourcing\Store\Header\TagsHeader; use PDO; use Pdo\Pgsql; @@ -99,6 +100,9 @@ final class TaggableDoctrineDbalStore implements Store, AppendStore, ListenableS private readonly HeadersSerializer $headersSerializer; + /** @var list */ + private readonly array $columnHeaders; + private readonly ClockInterface $clock; /** @var array{table_name: string, locking: bool, lock_id: int, lock_timeout: int, keep_index: bool, default_stream_name: string} */ @@ -127,6 +131,7 @@ public function __construct( array $config = [], ) { $this->headersSerializer = $headersSerializer ?? DefaultHeadersSerializer::createDefault(); + $this->columnHeaders = (new TaggableStoreHeaderLocator())->locate(); $this->clock = $clock ?? new SystemClock(); $this->config = array_merge([ @@ -660,20 +665,10 @@ public function configureSchema(Schema $schema, Connection $connection): void /** @return list */ private function getCustomHeaders(Message $message): array { - $filteredHeaders = [ - IndexHeader::class, - StreamNameHeader::class, - EventIdHeader::class, - PlayheadHeader::class, - RecordedOnHeader::class, - ArchivedHeader::class, - TagsHeader::class, - ]; - return array_values( array_filter( $message->headers(), - static fn (object $header) => !in_array($header::class, $filteredHeaders, true), + fn (object $header) => !in_array($header::class, $this->columnHeaders, true), ), ); } diff --git a/tests/Architecture/LayerDependenciesTest.php b/tests/Architecture/LayerDependenciesTest.php index fb9d0c89d..c7e8f23f7 100644 --- a/tests/Architecture/LayerDependenciesTest.php +++ b/tests/Architecture/LayerDependenciesTest.php @@ -58,6 +58,7 @@ public function testConsoleCanOnlyDependOnAllowedLayers(): Rule $this->layer('Message'), $this->layer('Metadata\AggregateRoot'), $this->layer('Metadata\Event'), + $this->layer('Repository'), $this->layer('Schema'), $this->layer('Serializer'), $this->layer('Store'), @@ -135,7 +136,6 @@ public function testMetadataMessageCanOnlyDependOnAllowedLayers(): Rule [ $this->layer('Attribute'), $this->metadataLayer(), - $this->layer('Store'), ], ); } @@ -166,11 +166,13 @@ public function testRepositoryCanOnlyDependOnAllowedLayers(): Rule $this->layer('Repository'), [ $this->layer('Aggregate'), + $this->layer('Attribute'), $this->layer('Clock'), $this->layer('Message'), $this->layer('Metadata\AggregateRoot'), $this->layer('Metadata\Event'), $this->layer('EventBus'), + $this->metadataLayer(), $this->layer('Snapshot'), $this->layer('Store'), ], diff --git a/tests/Benchmark/SplitStreamBench.php b/tests/Benchmark/SplitStreamBench.php index 48ad920cf..38abde6cc 100644 --- a/tests/Benchmark/SplitStreamBench.php +++ b/tests/Benchmark/SplitStreamBench.php @@ -4,9 +4,11 @@ namespace Patchlevel\EventSourcing\Tests\Benchmark; +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; use Patchlevel\EventSourcing\Repository\DefaultRepository; use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; +use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamHeaderLocator; use Patchlevel\EventSourcing\Repository\Repository; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; @@ -34,6 +36,7 @@ public function setUp(): void $this->store = new StreamDoctrineDbalStore( $connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/BasicImplementation/Events']), + DefaultHeadersSerializer::createFromLocator(new SplitStreamHeaderLocator()), ); $this->repository = new DefaultRepository( diff --git a/tests/Integration/BankAccountSplitStream/IntegrationTest.php b/tests/Integration/BankAccountSplitStream/IntegrationTest.php index e6b03db65..e1a7f1e2c 100644 --- a/tests/Integration/BankAccountSplitStream/IntegrationTest.php +++ b/tests/Integration/BankAccountSplitStream/IntegrationTest.php @@ -5,11 +5,13 @@ namespace Patchlevel\EventSourcing\Tests\Integration\BankAccountSplitStream; use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Repository\MessageDecorator\ChainMessageDecorator; use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; +use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamHeaderLocator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; @@ -50,6 +52,7 @@ public function testSuccessful(): void $store = new StreamDoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + DefaultHeadersSerializer::createFromLocator(new SplitStreamHeaderLocator()), ); $bankAccountProjector = new BankAccountProjector($this->connection); @@ -165,6 +168,7 @@ public function testRemoveArchived(): void $store = new StreamDoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + DefaultHeadersSerializer::createFromLocator(new SplitStreamHeaderLocator()), ); $bankAccountProjector = new BankAccountProjector($this->connection); diff --git a/tests/Unit/Message/MessageTest.php b/tests/Unit/Message/MessageTest.php index 5ed7dd2a0..b68de9807 100644 --- a/tests/Unit/Message/MessageTest.php +++ b/tests/Unit/Message/MessageTest.php @@ -7,11 +7,11 @@ use DateTimeImmutable; use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Repository\MessageDecorator\StreamStartHeader; use Patchlevel\EventSourcing\Store\ArchivedHeader; use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; -use Patchlevel\EventSourcing\Store\StreamStartHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; diff --git a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php index 607579070..01b3cdd5d 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -4,7 +4,6 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Message\Serializer; -use DateTimeImmutable; use Patchlevel\EventSourcing\Attribute\Header; use Patchlevel\EventSourcing\Message\MissingHeaders; use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; @@ -12,12 +11,11 @@ use Patchlevel\EventSourcing\Metadata\FilesystemClassLocator; use Patchlevel\EventSourcing\Metadata\InMemoryClassLocator; use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; +use Patchlevel\EventSourcing\Metadata\Message\HeaderClassNotRegistered; use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered; use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; -use Patchlevel\EventSourcing\Store\ArchivedHeader; -use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; -use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\BazHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\FooHeader; use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; @@ -37,18 +35,27 @@ public function testSerialize(): void ]); $content = $serializer->serialize([ - new StreamNameHeader('profile-1'), - new PlayheadHeader(1), - new RecordedOnHeader(new DateTimeImmutable('2020-01-01T20:00:00.000000+0100')), - new ArchivedHeader(), + new FooHeader('foo'), + new BazHeader('baz'), ]); self::assertEquals( - '{"streamName":{"streamName":"profile-1"},"playhead":{"playhead":1},"recordedOn":{"recordedOn":"2020-01-01T20:00:00+01:00"},"archived":[]}', + '{"foo":{"data":"foo"},"baz":{"data":"baz"}}', $content, ); } + public function testSerializeNotRegisteredHeader(): void + { + $serializer = DefaultHeadersSerializer::createFromPaths([ + __DIR__ . '/../../Fixture', + ]); + + $this->expectException(HeaderClassNotRegistered::class); + + $serializer->serialize([new StreamNameHeader('profile-1')]); + } + public function testDeserialize(): void { $serializer = new DefaultHeadersSerializer( @@ -59,14 +66,12 @@ public function testDeserialize(): void new JsonEncoder(), ); - $deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"playhead":{"playhead":1},"recordedOn":{"recordedOn":"2020-01-01T20:00:00+01:00"},"archived":[]}'); + $deserializedMessage = $serializer->deserialize('{"foo":{"data":"foo"},"baz":{"data":"baz"}}'); self::assertEquals( [ - new StreamNameHeader('profile-1'), - new PlayheadHeader(1), - new RecordedOnHeader(new DateTimeImmutable('2020-01-01T20:00:00.000000+0100')), - new ArchivedHeader(), + new FooHeader('foo'), + new BazHeader('baz'), ], $deserializedMessage, ); @@ -83,11 +88,11 @@ public function testDeserializeUnknownHeadersAsMissingHeaders(): void ['removed', 'alsoRemoved'], ); - $deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}'); + $deserializedMessage = $serializer->deserialize('{"foo":{"data":"foo"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}'); self::assertEquals( [ - new StreamNameHeader('profile-1'), + new FooHeader('foo'), new MissingHeaders([ 'removed' => ['foo' => 'bar'], 'alsoRemoved' => ['baz' => 1], @@ -110,7 +115,7 @@ public function testDeserializeUnknownHeaderNotConfiguredCrashes(): void $this->expectException(HeaderNameNotRegistered::class); - $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"notListed":{"baz":1}}'); + $serializer->deserialize('{"foo":{"data":"foo"},"removed":{"foo":"bar"},"notListed":{"baz":1}}'); } public function testDeserializeWildcardHandlesAllUnknownHeaders(): void @@ -124,11 +129,11 @@ public function testDeserializeWildcardHandlesAllUnknownHeaders(): void ['*'], ); - $deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}'); + $deserializedMessage = $serializer->deserialize('{"foo":{"data":"foo"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}'); self::assertEquals( [ - new StreamNameHeader('profile-1'), + new FooHeader('foo'), new MissingHeaders([ 'removed' => ['foo' => 'bar'], 'alsoRemoved' => ['baz' => 1], @@ -149,7 +154,7 @@ public function testSerializeMissingHeadersRoundTrip(): void ); $content = $serializer->serialize([ - new StreamNameHeader('profile-1'), + new FooHeader('foo'), new MissingHeaders([ 'removed' => ['foo' => 'bar'], 'alsoRemoved' => ['baz' => 1], @@ -157,7 +162,7 @@ public function testSerializeMissingHeadersRoundTrip(): void ]); self::assertEquals( - '{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}', + '{"foo":{"data":"foo"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}', $content, ); } @@ -175,7 +180,7 @@ public function testDeserializeWithInvalidHeaderPayload(): void $this->expectException(InvalidArgument::class); $this->expectExceptionMessage('header payload must be an array'); - $serializer->deserialize('{"streamName":"profile-1"}'); + $serializer->deserialize('{"foo":"foo"}'); } public function testCreateFromLocator(): void @@ -184,75 +189,86 @@ public function testCreateFromLocator(): void new InMemoryClassLocator([FooHeader::class]), ); - $content = $serializer->serialize([ - new StreamNameHeader('profile-1'), - new FooHeader('bar'), - ]); + $content = $serializer->serialize([new FooHeader('bar')]); + + self::assertSame('{"foo":{"data":"bar"}}', $content); + self::assertEquals([new FooHeader('bar')], $serializer->deserialize($content)); + } + + public function testCreateFromLocatorWithGracefulMissingHeaders(): void + { + $serializer = DefaultHeadersSerializer::createFromLocator( + new InMemoryClassLocator([FooHeader::class]), + ['removed'], + ); - self::assertSame('{"streamName":{"streamName":"profile-1"},"foo":{"data":"bar"}}', $content); self::assertEquals( [ - new StreamNameHeader('profile-1'), new FooHeader('bar'), + new MissingHeaders(['removed' => ['foo' => 'bar']]), ], - $serializer->deserialize($content), + $serializer->deserialize('{"foo":{"data":"bar"},"removed":{"foo":"bar"}}'), ); } - public function testCreateDefault(): void - { - $serializer = DefaultHeadersSerializer::createDefault(); - - $content = $serializer->serialize([new StreamNameHeader('profile-1')]); - - self::assertEquals('{"streamName":{"streamName":"profile-1"}}', $content); - self::assertEquals([new StreamNameHeader('profile-1')], $serializer->deserialize($content)); - } - - public function testDeserializeWithCustomHydrator(): void + public function testCreateFromLocatorWithCustomHydrator(): void { $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->useExtension(new UpcastExtension([ CallbackUpcaster::forClass( - StreamNameHeader::class, - static function (array $data): array { - self::assertIsString($data['id']); - - return ['streamName' => 'profile-' . $data['id']]; - }, + FooHeader::class, + static fn (array $data): array => ['data' => 'upcasted'], ), ])) ->build(); - $serializer = DefaultHeadersSerializer::createFromPaths( - [__DIR__ . '/../../Fixture'], + $serializer = DefaultHeadersSerializer::createFromLocator( + new InMemoryClassLocator([FooHeader::class]), hydrator: $hydrator, ); self::assertEquals( - [new StreamNameHeader('profile-1')], - $serializer->deserialize('{"streamName":{"id":"1"}}'), + [new FooHeader('upcasted')], + $serializer->deserialize('{"foo":{"data":"bar"}}'), ); } - public function testCreateDefaultWithCustomHydrator(): void + public function testCreateDefault(): void + { + $serializer = DefaultHeadersSerializer::createDefault(); + + self::assertSame('[]', $serializer->serialize([])); + + $this->expectException(HeaderNameNotRegistered::class); + + $serializer->deserialize('{"foo":{"data":"bar"}}'); + } + + public function testDeserializeWithCustomHydrator(): void { $hydrator = (new StackHydratorBuilder()) ->useExtension(new CoreExtension()) ->useExtension(new UpcastExtension([ CallbackUpcaster::forClass( - PlayheadHeader::class, - static fn (array $data): array => ['playhead' => 42], + FooHeader::class, + static function (array $data): array { + self::assertIsString($data['id']); + + return ['data' => 'foo-' . $data['id']]; + }, ), ])) ->build(); - $serializer = DefaultHeadersSerializer::createDefault($hydrator); + $serializer = DefaultHeadersSerializer::createFromPaths( + [__DIR__ . '/../../Fixture'], + hydrator: $hydrator, + ); self::assertEquals( - [new PlayheadHeader(42)], - $serializer->deserialize('{"playhead":{"playhead":1}}'), + [new FooHeader('foo-1')], + $serializer->deserialize('{"foo":{"id":"1"}}'), ); } } diff --git a/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php index d001a2969..0f8bc8ac4 100644 --- a/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php +++ b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php @@ -5,24 +5,19 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Metadata\Message; use Patchlevel\EventSourcing\Attribute\Header; +use Patchlevel\EventSourcing\Metadata\ChainClassLocator; use Patchlevel\EventSourcing\Metadata\FilesystemClassLocator; use Patchlevel\EventSourcing\Metadata\InMemoryClassLocator; use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; use Patchlevel\EventSourcing\Metadata\Message\ClassIsNotAHeader; use Patchlevel\EventSourcing\Metadata\Message\HeaderAlreadyInRegistry; -use Patchlevel\EventSourcing\Store\ArchivedHeader; -use Patchlevel\EventSourcing\Store\Header\EventIdHeader; -use Patchlevel\EventSourcing\Store\Header\IndexHeader; -use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; -use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; -use Patchlevel\EventSourcing\Store\Header\TagsHeader; -use Patchlevel\EventSourcing\Store\StreamStartHeader; +use Patchlevel\EventSourcing\Store\Header\StreamStoreHeaderLocator; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\BazHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\FooHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Metadata\Message\Fixture\DuplicateFooHeader; -use Patchlevel\EventSourcing\Tests\Unit\Metadata\Message\Fixture\ReservedNameHeader; +use Patchlevel\EventSourcing\Tests\Unit\Metadata\Message\Fixture\StreamNameCollisionHeader; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -35,38 +30,35 @@ public function testCreateFromFilesystem(): void new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), ); - self::assertSame(FooHeader::class, $registry->headerClass('foo')); - self::assertSame(BazHeader::class, $registry->headerClass('baz')); - self::assertSame(StreamNameHeader::class, $registry->headerClass('streamName')); - } - - public function testInternalHeadersAreAlwaysRegistered(): void - { - $registry = (new AttributeMessageHeaderRegistryFactory())->create(new InMemoryClassLocator([])); - self::assertSame( [ - 'streamName' => StreamNameHeader::class, - 'playhead' => PlayheadHeader::class, - 'recordedOn' => RecordedOnHeader::class, - 'archived' => ArchivedHeader::class, - 'newStreamStart' => StreamStartHeader::class, - 'eventId' => EventIdHeader::class, - 'index' => IndexHeader::class, - 'tags' => TagsHeader::class, + 'baz' => BazHeader::class, + 'foo' => FooHeader::class, ], $registry->headerClasses(), ); } + public function testNoHeadersAreRegisteredImplicitly(): void + { + $registry = (new AttributeMessageHeaderRegistryFactory())->create(new InMemoryClassLocator([])); + + self::assertSame([], $registry->headerClasses()); + } + public function testSameClassLocatedTwice(): void { $registry = (new AttributeMessageHeaderRegistryFactory())->create( - new InMemoryClassLocator([FooHeader::class, FooHeader::class, StreamNameHeader::class]), + new InMemoryClassLocator([FooHeader::class, StreamNameHeader::class, FooHeader::class]), ); - self::assertSame('foo', $registry->headerName(FooHeader::class)); - self::assertSame('streamName', $registry->headerName(StreamNameHeader::class)); + self::assertSame( + [ + 'foo' => FooHeader::class, + 'streamName' => StreamNameHeader::class, + ], + $registry->headerClasses(), + ); } public function testClassIsNotAHeader(): void @@ -88,15 +80,18 @@ public function testDuplicateHeaderName(): void ); } - public function testReservedHeaderName(): void + public function testDuplicateHeaderNameAcrossLocators(): void { $this->expectException(HeaderAlreadyInRegistry::class); $this->expectExceptionMessage( - 'The header name "streamName" is already used by "' . StreamNameHeader::class . '" and cannot be used by "' . ReservedNameHeader::class . '".', + 'The header name "streamName" is already used by "' . StreamNameHeader::class . '" and cannot be used by "' . StreamNameCollisionHeader::class . '".', ); (new AttributeMessageHeaderRegistryFactory())->create( - new InMemoryClassLocator([ReservedNameHeader::class]), + new ChainClassLocator([ + new StreamStoreHeaderLocator(), + new InMemoryClassLocator([StreamNameCollisionHeader::class]), + ]), ); } } diff --git a/tests/Unit/Metadata/Message/Fixture/ReservedNameHeader.php b/tests/Unit/Metadata/Message/Fixture/StreamNameCollisionHeader.php similarity index 82% rename from tests/Unit/Metadata/Message/Fixture/ReservedNameHeader.php rename to tests/Unit/Metadata/Message/Fixture/StreamNameCollisionHeader.php index 3e415c0e6..e1f70924e 100644 --- a/tests/Unit/Metadata/Message/Fixture/ReservedNameHeader.php +++ b/tests/Unit/Metadata/Message/Fixture/StreamNameCollisionHeader.php @@ -7,6 +7,6 @@ use Patchlevel\EventSourcing\Attribute\Header; #[Header('streamName')] -final class ReservedNameHeader +final class StreamNameCollisionHeader { } diff --git a/tests/Unit/Repository/MessageDecorator/SplitStreamDecoratorTest.php b/tests/Unit/Repository/MessageDecorator/SplitStreamDecoratorTest.php index cd1636387..f8a43a9ca 100644 --- a/tests/Unit/Repository/MessageDecorator/SplitStreamDecoratorTest.php +++ b/tests/Unit/Repository/MessageDecorator/SplitStreamDecoratorTest.php @@ -7,7 +7,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; -use Patchlevel\EventSourcing\Store\StreamStartHeader; +use Patchlevel\EventSourcing\Repository\MessageDecorator\StreamStartHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; diff --git a/tests/Unit/Repository/MessageDecorator/SplitStreamHeaderLocatorTest.php b/tests/Unit/Repository/MessageDecorator/SplitStreamHeaderLocatorTest.php new file mode 100644 index 000000000..4b7ccbfc9 --- /dev/null +++ b/tests/Unit/Repository/MessageDecorator/SplitStreamHeaderLocatorTest.php @@ -0,0 +1,19 @@ +locate()); + } +} diff --git a/tests/Unit/Store/Header/StreamStoreHeaderLocatorTest.php b/tests/Unit/Store/Header/StreamStoreHeaderLocatorTest.php new file mode 100644 index 000000000..f1e4d4448 --- /dev/null +++ b/tests/Unit/Store/Header/StreamStoreHeaderLocatorTest.php @@ -0,0 +1,34 @@ +locate(), + ); + } +} diff --git a/tests/Unit/Store/Header/TaggableStoreHeaderLocatorTest.php b/tests/Unit/Store/Header/TaggableStoreHeaderLocatorTest.php new file mode 100644 index 000000000..9e614f78c --- /dev/null +++ b/tests/Unit/Store/Header/TaggableStoreHeaderLocatorTest.php @@ -0,0 +1,36 @@ +locate(), + ); + } +}