diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index b288215d6..b8cba4d44 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -602,6 +602,87 @@ 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. +Only the located headers are registered, the library headers are no longer added implicitly. + +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 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 `Patchlevel\EventSourcing\Message\Translator\AggregateToStreamHeaderTranslator` has been removed. diff --git a/docs/message.md b/docs/message.md index 54b631cd2..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; @@ -122,6 +122,64 @@ 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. +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. If two located classes use the same name, +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/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/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..4f42bbeb6 100644 --- a/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php +++ b/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php @@ -5,32 +5,39 @@ namespace Patchlevel\EventSourcing\Metadata\Message; use Patchlevel\EventSourcing\Attribute\Header; -use Patchlevel\EventSourcing\Metadata\ClassFinder; +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); - $result = []; - foreach ($classes as $class) { + foreach ($locator->locate() as $class) { $reflection = new ReflectionClass($class); $attributes = $reflection->getAttributes(Header::class); if (count($attributes) === 0) { - continue; + throw new ClassIsNotAHeader($class); + } + + $headerName = $attributes[0]->newInstance()->name; + + if (array_key_exists($headerName, $result)) { + if ($result[$headerName] === $class) { + continue; + } + + throw new HeaderAlreadyInRegistry($headerName, $result[$headerName], $class); } - $aggregateName = $attributes[0]->newInstance()->name; - $result[$aggregateName] = $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 @@ +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/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/Repository/MessageDecorator/StreamStartHeader.php b/src/Repository/MessageDecorator/StreamStartHeader.php new file mode 100644 index 000000000..cbd1882f1 --- /dev/null +++ b/src/Repository/MessageDecorator/StreamStartHeader.php @@ -0,0 +1,13 @@ + */ + 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/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/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/StreamStartHeader.php b/src/Store/StreamStartHeader.php deleted file mode 100644 index 2605ee993..000000000 --- a/src/Store/StreamStartHeader.php +++ /dev/null @@ -1,10 +0,0 @@ - */ + 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 102b7c7cc..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'), @@ -91,6 +92,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,10 +134,8 @@ public function testMetadataMessageCanOnlyDependOnAllowedLayers(): Rule return $this->layerCanOnlyDependOnAllowedLayers( $this->layer('Metadata\Message'), [ - $this->layer('Aggregate'), $this->layer('Attribute'), $this->metadataLayer(), - $this->layer('Store'), ], ); } @@ -165,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'), ], @@ -210,6 +213,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/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 beb3f95d9..01b3cdd5d 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -4,17 +4,19 @@ 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\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; use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; @@ -33,36 +35,43 @@ 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( - (new AttributeMessageHeaderRegistryFactory())->create([ - __DIR__ . '/../../Fixture', - ]), + (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), + ), new StackHydrator(), 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, ); @@ -71,19 +80,19 @@ 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'], ); - $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], @@ -96,9 +105,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'], @@ -106,25 +115,25 @@ 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 { $serializer = new DefaultHeadersSerializer( - (new AttributeMessageHeaderRegistryFactory())->create([ - __DIR__ . '/../../Fixture', - ]), + (new AttributeMessageHeaderRegistryFactory())->create( + new FilesystemClassLocator([__DIR__ . '/../../Fixture'], Header::class), + ), new StackHydrator(), new JsonEncoder(), ['*'], ); - $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], @@ -137,15 +146,15 @@ 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(), ); $content = $serializer->serialize([ - new StreamNameHeader('profile-1'), + new FooHeader('foo'), new MissingHeaders([ 'removed' => ['foo' => 'bar'], 'alsoRemoved' => ['baz' => 1], @@ -153,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, ); } @@ -161,9 +170,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(), ); @@ -171,63 +180,95 @@ 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 testCreateDefault(): void + public function testCreateFromLocator(): void { - $serializer = DefaultHeadersSerializer::createDefault(); + $serializer = DefaultHeadersSerializer::createFromLocator( + new InMemoryClassLocator([FooHeader::class]), + ); - $content = $serializer->serialize([new StreamNameHeader('profile-1')]); + $content = $serializer->serialize([new FooHeader('bar')]); - self::assertEquals('{"streamName":{"streamName":"profile-1"}}', $content); - self::assertEquals([new StreamNameHeader('profile-1')], $serializer->deserialize($content)); + self::assertSame('{"foo":{"data":"bar"}}', $content); + self::assertEquals([new FooHeader('bar')], $serializer->deserialize($content)); } - public function testDeserializeWithCustomHydrator(): void + public function testCreateFromLocatorWithGracefulMissingHeaders(): void + { + $serializer = DefaultHeadersSerializer::createFromLocator( + new InMemoryClassLocator([FooHeader::class]), + ['removed'], + ); + + self::assertEquals( + [ + new FooHeader('bar'), + new MissingHeaders(['removed' => ['foo' => 'bar']]), + ], + $serializer->deserialize('{"foo":{"data":"bar"},"removed":{"foo":"bar"}}'), + ); + } + + 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/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..0f8bc8ac4 100644 --- a/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php +++ b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php @@ -4,24 +4,94 @@ 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\Header\StreamNameHeader; +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\StreamNameCollisionHeader; 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')); + self::assertSame( + [ + '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, StreamNameHeader::class, FooHeader::class]), + ); + + self::assertSame( + [ + 'foo' => FooHeader::class, + 'streamName' => StreamNameHeader::class, + ], + $registry->headerClasses(), + ); + } + + 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 testDuplicateHeaderNameAcrossLocators(): void + { + $this->expectException(HeaderAlreadyInRegistry::class); + $this->expectExceptionMessage( + 'The header name "streamName" is already used by "' . StreamNameHeader::class . '" and cannot be used by "' . StreamNameCollisionHeader::class . '".', + ); + + (new AttributeMessageHeaderRegistryFactory())->create( + new ChainClassLocator([ + new StreamStoreHeaderLocator(), + new InMemoryClassLocator([StreamNameCollisionHeader::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')); - } } 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(), + ); + } +}