From 198ed4953d2ff2ff25323801545d2b0adc8bcbc8 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 11:58:05 +0200 Subject: [PATCH 01/15] add stream store --- src/Console/OutputStyle.php | 7 +- .../RecalculatePlayheadTranslator.php | 46 +- .../Translator/UntilEventTranslator.php | 14 +- .../Message/MessageHeaderRegistry.php | 2 + src/Repository/DefaultRepository.php | 90 +++- src/Store/Criteria/CriteriaBuilder.php | 12 + src/Store/Criteria/StreamCriterion.php | 13 + src/Store/StreamDoctrineDbalStore.php | 500 ++++++++++++++++++ src/Store/StreamDoctrineDbalStoreStream.php | 157 ++++++ src/Store/StreamHeader.php | 19 + ...toreTest.php => DoctrineDbalStoreTest.php} | 29 +- .../Store/StreamDoctrineDbalStoreTest.php | 258 +++++++++ 12 files changed, 1110 insertions(+), 37 deletions(-) create mode 100644 src/Store/Criteria/StreamCriterion.php create mode 100644 src/Store/StreamDoctrineDbalStore.php create mode 100644 src/Store/StreamDoctrineDbalStoreStream.php create mode 100644 src/Store/StreamHeader.php rename tests/Integration/Store/{StoreTest.php => DoctrineDbalStoreTest.php} (88%) create mode 100644 tests/Integration/Store/StreamDoctrineDbalStoreTest.php diff --git a/src/Console/OutputStyle.php b/src/Console/OutputStyle.php index b5d62e3ee..3c11687c9 100644 --- a/src/Console/OutputStyle.php +++ b/src/Console/OutputStyle.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; use Patchlevel\EventSourcing\Serializer\EventSerializer; use Patchlevel\EventSourcing\Store\ArchivedHeader; +use Patchlevel\EventSourcing\Store\StreamHeader; use Patchlevel\EventSourcing\Store\StreamStartHeader; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; @@ -47,7 +48,8 @@ public function message( $customHeaders = array_filter( $message->headers(), - static fn ($header) => !$header instanceof AggregateHeader + static fn ($header) => !$header instanceof StreamHeader + && !$header instanceof AggregateHeader && !$header instanceof ArchivedHeader && !$header instanceof StreamStartHeader, ); @@ -59,8 +61,7 @@ public function message( $this->title($data->name); $this->horizontalTable( [ - 'aggregateName', - 'aggregateId', + 'stream', 'playhead', 'recordedOn', 'streamStart', diff --git a/src/Message/Translator/RecalculatePlayheadTranslator.php b/src/Message/Translator/RecalculatePlayheadTranslator.php index 1efc95adf..9246b8631 100644 --- a/src/Message/Translator/RecalculatePlayheadTranslator.php +++ b/src/Message/Translator/RecalculatePlayheadTranslator.php @@ -5,9 +5,12 @@ namespace Patchlevel\EventSourcing\Message\Translator; use Patchlevel\EventSourcing\Aggregate\AggregateHeader; +use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Store\StreamHeader; use function array_key_exists; +use function sprintf; final class RecalculatePlayheadTranslator implements Translator { @@ -17,14 +20,37 @@ final class RecalculatePlayheadTranslator implements Translator /** @return list */ public function __invoke(Message $message): array { - $header = $message->header(AggregateHeader::class); - $playhead = $this->nextPlayhead($header->aggregateName, $header->aggregateId); + try { + $header = $message->header(AggregateHeader::class); + } catch (HeaderNotFound) { + try { + $header = $message->header(StreamHeader::class); + } catch (HeaderNotFound) { + return [$message]; + } + } + + if ($header instanceof StreamHeader) { + $stream = $header->streamName; + } else { + $stream = sprintf('%s-%s', $header->aggregateName, $header->aggregateId); + } + + $playhead = $this->nextPlayhead($stream); if ($header->playhead === $playhead) { return [$message]; } - $header = $message->header(AggregateHeader::class); + if ($header instanceof StreamHeader) { + return [ + $message->withHeader(new StreamHeader( + $header->streamName, + $playhead, + $header->recordedOn, + )), + ]; + } return [ $message->withHeader(new AggregateHeader( @@ -42,18 +68,14 @@ public function reset(): void } /** @return positive-int */ - private function nextPlayhead(string $aggregateName, string $aggregateId): int + private function nextPlayhead(string $stream): int { - if (!array_key_exists($aggregateName, $this->index)) { - $this->index[$aggregateName] = []; - } - - if (!array_key_exists($aggregateId, $this->index[$aggregateName])) { - $this->index[$aggregateName][$aggregateId] = 1; + if (!array_key_exists($stream, $this->index)) { + $this->index[$stream] = 1; } else { - $this->index[$aggregateName][$aggregateId]++; + $this->index[$stream]++; } - return $this->index[$aggregateName][$aggregateId]; + return $this->index[$stream]; } } diff --git a/src/Message/Translator/UntilEventTranslator.php b/src/Message/Translator/UntilEventTranslator.php index d49e9b9c1..3cdf739ad 100644 --- a/src/Message/Translator/UntilEventTranslator.php +++ b/src/Message/Translator/UntilEventTranslator.php @@ -6,7 +6,9 @@ use DateTimeImmutable; use Patchlevel\EventSourcing\Aggregate\AggregateHeader; +use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Store\StreamHeader; final class UntilEventTranslator implements Translator { @@ -18,7 +20,17 @@ public function __construct( /** @return list */ public function __invoke(Message $message): array { - $recordedOn = $message->header(AggregateHeader::class)->recordedOn; + try { + $header = $message->header(AggregateHeader::class); + } catch (HeaderNotFound) { + try { + $header = $message->header(StreamHeader::class); + } catch (HeaderNotFound) { + return [$message]; + } + } + + $recordedOn = $header->recordedOn; if ($recordedOn < $this->until) { return [$message]; diff --git a/src/Metadata/Message/MessageHeaderRegistry.php b/src/Metadata/Message/MessageHeaderRegistry.php index 934f0e626..948dd40aa 100644 --- a/src/Metadata/Message/MessageHeaderRegistry.php +++ b/src/Metadata/Message/MessageHeaderRegistry.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Aggregate\AggregateHeader; use Patchlevel\EventSourcing\Debug\Trace\TraceHeader; use Patchlevel\EventSourcing\Store\ArchivedHeader; +use Patchlevel\EventSourcing\Store\StreamHeader; use Patchlevel\EventSourcing\Store\StreamStartHeader; use function array_flip; @@ -73,6 +74,7 @@ public function headerNames(): array public static function createWithInternalHeaders(array $headerNameToClassMap = []): self { $internalHeaders = [ + 'stream' => StreamHeader::class, 'aggregate' => AggregateHeader::class, 'trace' => TraceHeader::class, 'archived' => ArchivedHeader::class, diff --git a/src/Repository/DefaultRepository.php b/src/Repository/DefaultRepository.php index d45d90f7e..51fbd5387 100644 --- a/src/Repository/DefaultRepository.php +++ b/src/Repository/DefaultRepository.php @@ -18,6 +18,8 @@ use Patchlevel\EventSourcing\Store\Criteria\CriteriaBuilder; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\Stream; +use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Store\StreamHeader; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; @@ -43,6 +45,8 @@ final class DefaultRepository implements Repository /** @var WeakMap */ private WeakMap $aggregateIsValid; + private bool $useStreamHeader; + /** @param AggregateRootMetadata $metadata */ public function __construct( private Store $store, @@ -56,6 +60,7 @@ public function __construct( $this->clock = $clock ?? new SystemClock(); $this->logger = $logger ?? new NullLogger(); $this->aggregateIsValid = new WeakMap(); + $this->useStreamHeader = $store instanceof StreamDoctrineDbalStore; } /** @return T */ @@ -103,11 +108,18 @@ public function load(AggregateRootId $id): AggregateRoot } } - $criteria = (new CriteriaBuilder()) - ->aggregateName($this->metadata->name) - ->aggregateId($id->toString()) - ->archived(false) - ->build(); + if ($this->useStreamHeader) { + $criteria = (new CriteriaBuilder()) + ->streamName($this->streamName($this->metadata->name, $id->toString())) + ->archived(false) + ->build(); + } else { + $criteria = (new CriteriaBuilder()) + ->aggregateName($this->metadata->name) + ->aggregateId($id->toString()) + ->archived(false) + ->build(); + } $stream = null; @@ -128,10 +140,19 @@ public function load(AggregateRootId $id): AggregateRoot throw new AggregateNotFound($this->metadata->className, $id); } - $aggregateHeader = $firstMessage->header(AggregateHeader::class); + if ($this->useStreamHeader) { + $playhead = $firstMessage->header(StreamHeader::class)->playhead; + + if ($playhead === null) { + throw new AggregateNotFound($this->metadata->className, $id); + } + } else { + $playhead = $firstMessage->header(AggregateHeader::class)->playhead; + } + $aggregate = $this->metadata->className::createFromEvents( $this->unpack($stream), - $aggregateHeader->playhead - 1, + $playhead - 1, ); if ($this->snapshotStore && $this->metadata->snapshot) { @@ -156,10 +177,16 @@ public function load(AggregateRootId $id): AggregateRoot public function has(AggregateRootId $id): bool { - $criteria = (new CriteriaBuilder()) - ->aggregateName($this->metadata->name) - ->aggregateId($id->toString()) - ->build(); + if ($this->useStreamHeader) { + $criteria = (new CriteriaBuilder()) + ->streamName($this->streamName($this->metadata->name, $id->toString())) + ->build(); + } else { + $criteria = (new CriteriaBuilder()) + ->aggregateName($this->metadata->name) + ->aggregateId($id->toString()) + ->build(); + } return $this->store->count($criteria) > 0; } @@ -217,15 +244,26 @@ public function save(AggregateRoot $aggregate): void $aggregateName = $this->metadata->name; + $useStreamHeader = $this->useStreamHeader; + $messages = array_map( - static function (object $event) use ($aggregateName, $aggregateId, &$playhead, $messageDecorator, $clock) { - $message = Message::create($event) - ->withHeader(new AggregateHeader( + static function (object $event) use ($aggregateName, $aggregateId, &$playhead, $messageDecorator, $clock, $useStreamHeader) { + if ($useStreamHeader) { + $header = new StreamHeader( + sprintf('%s-%s', $aggregateName, $aggregateId), + ++$playhead, + $clock->now(), + ); + } else { + $header = new AggregateHeader( $aggregateName, $aggregateId, ++$playhead, $clock->now(), - )); + ); + } + + $message = Message::create($event)->withHeader($header); if ($messageDecorator) { return $messageDecorator($message); @@ -291,11 +329,18 @@ private function loadFromSnapshot(string $aggregateClass, AggregateRootId $id): $aggregate = $this->snapshotStore->load($aggregateClass, $id); - $criteria = (new CriteriaBuilder()) - ->aggregateName($this->metadata->name) - ->aggregateId($id->toString()) - ->fromPlayhead($aggregate->playhead()) - ->build(); + if ($this->useStreamHeader) { + $criteria = (new CriteriaBuilder()) + ->streamName($this->streamName($this->metadata->name, $id->toString())) + ->fromPlayhead($aggregate->playhead()) + ->build(); + } else { + $criteria = (new CriteriaBuilder()) + ->aggregateName($this->metadata->name) + ->aggregateId($id->toString()) + ->fromPlayhead($aggregate->playhead()) + ->build(); + } $stream = null; @@ -369,4 +414,9 @@ private function unpack(Stream $stream): Traversable yield $message->event(); } } + + private function streamName(string $aggregateName, string $aggregateId): string + { + return sprintf('%s-%s', $aggregateName, $aggregateId); + } } diff --git a/src/Store/Criteria/CriteriaBuilder.php b/src/Store/Criteria/CriteriaBuilder.php index 08ebd5a4e..cfb80367c 100644 --- a/src/Store/Criteria/CriteriaBuilder.php +++ b/src/Store/Criteria/CriteriaBuilder.php @@ -6,12 +6,20 @@ final class CriteriaBuilder { + private string|null $streamName = null; private string|null $aggregateName = null; private string|null $aggregateId = null; private int|null $fromIndex = null; private int|null $fromPlayhead = null; private bool|null $archived = null; + public function streamName(string|null $streamName): self + { + $this->streamName = $streamName; + + return $this; + } + public function aggregateName(string|null $aggregateName): self { $this->aggregateName = $aggregateName; @@ -51,6 +59,10 @@ public function build(): Criteria { $criteria = []; + if ($this->streamName !== null) { + $criteria[] = new StreamCriterion($this->streamName); + } + if ($this->aggregateName !== null) { $criteria[] = new AggregateNameCriterion($this->aggregateName); } diff --git a/src/Store/Criteria/StreamCriterion.php b/src/Store/Criteria/StreamCriterion.php new file mode 100644 index 000000000..48c014687 --- /dev/null +++ b/src/Store/Criteria/StreamCriterion.php @@ -0,0 +1,13 @@ +headersSerializer = $headersSerializer ?? DefaultHeadersSerializer::createDefault(); + $this->clock = $clock ?? new SystemClock(); + + $this->config = array_merge([ + 'table_name' => 'event_store', + 'locking' => true, + 'lock_id' => self::DEFAULT_LOCK_ID, + 'lock_timeout' => -1, + ], $config); + } + + public function load( + Criteria|null $criteria = null, + int|null $limit = null, + int|null $offset = null, + bool $backwards = false, + ): StreamDoctrineDbalStoreStream { + $builder = $this->connection->createQueryBuilder() + ->select('*') + ->from($this->config['table_name']) + ->orderBy('id', $backwards ? 'DESC' : 'ASC'); + + $this->applyCriteria($builder, $criteria ?? new Criteria()); + + $builder->setMaxResults($limit); + $builder->setFirstResult($offset ?? 0); + + return new StreamDoctrineDbalStoreStream( + $this->connection->executeQuery( + $builder->getSQL(), + $builder->getParameters(), + $builder->getParameterTypes(), + ), + $this->eventSerializer, + $this->headersSerializer, + $this->connection->getDatabasePlatform(), + ); + } + + public function count(Criteria|null $criteria = null): int + { + $builder = $this->connection->createQueryBuilder() + ->select('COUNT(*)') + ->from($this->config['table_name']); + + $this->applyCriteria($builder, $criteria ?? new Criteria()); + + $result = $this->connection->fetchOne( + $builder->getSQL(), + $builder->getParameters(), + $builder->getParameterTypes(), + ); + + if (!is_int($result) && !is_string($result)) { + throw new WrongQueryResult(); + } + + return (int)$result; + } + + private function applyCriteria(QueryBuilder $builder, Criteria $criteria): void + { + $criteriaList = $criteria->all(); + + foreach ($criteriaList as $criterion) { + switch ($criterion::class) { + case StreamCriterion::class: + $builder->andWhere('stream = :stream'); + $builder->setParameter('stream', $criterion->streamName); + break; + case FromPlayheadCriterion::class: + $builder->andWhere('playhead > :playhead'); + $builder->setParameter('playhead', $criterion->fromPlayhead, Types::INTEGER); + break; + case ArchivedCriterion::class: + $builder->andWhere('archived = :archived'); + $builder->setParameter('archived', $criterion->archived, Types::BOOLEAN); + break; + case FromIndexCriterion::class: + $builder->andWhere('id > :index'); + $builder->setParameter('index', $criterion->fromIndex, Types::INTEGER); + break; + default: + throw new UnsupportedCriterion($criterion::class); + } + } + } + + public function save(Message ...$messages): void + { + if ($messages === []) { + return; + } + + $this->transactional( + function () use ($messages): void { + /** @var array $achievedUntilPlayhead */ + $achievedUntilPlayhead = []; + + $booleanType = Type::getType(Types::BOOLEAN); + $dateTimeType = Type::getType(Types::DATETIMETZ_IMMUTABLE); + + $columns = [ + 'stream', + 'playhead', + 'event', + 'payload', + 'recorded_on', + 'new_stream_start', + 'archived', + 'custom_headers', + ]; + + $columnsLength = count($columns); + $batchSize = (int)floor(self::MAX_UNSIGNED_SMALL_INT / $columnsLength); + $placeholder = implode(', ', array_fill(0, $columnsLength, '?')); + + $parameters = []; + $placeholders = []; + /** @var array, Type> $types */ + $types = []; + $position = 0; + foreach ($messages as $message) { + /** @var int<0, max> $offset */ + $offset = $position * $columnsLength; + $placeholders[] = $placeholder; + + $data = $this->eventSerializer->serialize($message->event()); + + try { + $streamHeader = $message->header(StreamHeader::class); + } catch (HeaderNotFound $e) { + throw new MissingDataForStorage($e->name, $e); + } + + $parameters[] = $streamHeader->streamName; + $parameters[] = $streamHeader->playhead; + $parameters[] = $data->name; + $parameters[] = $data->payload; + + $parameters[] = $streamHeader->recordedOn ?: $this->clock->now(); + $types[$offset + 4] = $dateTimeType; + + $streamStart = $message->hasHeader(StreamStartHeader::class); + + if ($streamStart) { + $achievedUntilPlayhead[$streamHeader->streamName] = $streamHeader->playhead; + } + + $parameters[] = $streamStart; + $types[$offset + 5] = $booleanType; + + $parameters[] = $message->hasHeader(ArchivedHeader::class); + $types[$offset + 6] = $booleanType; + + $parameters[] = $this->headersSerializer->serialize($this->getCustomHeaders($message)); + + $position++; + + if ($position !== $batchSize) { + continue; + } + + $this->executeSave($columns, $placeholders, $parameters, $types, $this->connection); + + $parameters = []; + $placeholders = []; + $types = []; + + $position = 0; + } + + if ($position !== 0) { + $this->executeSave($columns, $placeholders, $parameters, $types, $this->connection); + } + + foreach ($achievedUntilPlayhead as $stream => $playhead) { + $this->connection->executeStatement( + sprintf( + <<<'SQL' + UPDATE %s + SET archived = true + WHERE stream = :stream + AND playhead < :playhead + AND archived = false + SQL, + $this->config['table_name'], + ), + [ + 'stream' => $stream, + 'playhead' => $playhead, + ], + ); + } + }, + ); + } + + /** + * @param Closure():ClosureReturn $function + * + * @template ClosureReturn + */ + public function transactional(Closure $function): void + { + if ($this->hasLock || !$this->config['locking']) { + $this->connection->transactional($function); + } else { + $this->connection->transactional(function () use ($function): void { + $this->lock(); + try { + $function(); + } finally { + $this->unlock(); + } + }); + } + } + + public function configureSchema(Schema $schema, Connection $connection): void + { + if ($this->connection !== $connection) { + return; + } + + $table = $schema->createTable($this->config['table_name']); + + $table->addColumn('id', Types::BIGINT) + ->setAutoincrement(true); + $table->addColumn('stream', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('playhead', Types::INTEGER) + ->setNotnull(false); + $table->addColumn('event', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('payload', Types::JSON) + ->setNotnull(true); + $table->addColumn('recorded_on', Types::DATETIMETZ_IMMUTABLE) + ->setNotnull(true); + $table->addColumn('new_stream_start', Types::BOOLEAN) + ->setNotnull(true) + ->setDefault(false); + $table->addColumn('archived', Types::BOOLEAN) + ->setNotnull(true) + ->setDefault(false); + $table->addColumn('custom_headers', Types::JSON) + ->setNotnull(true); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['stream', 'playhead']); + $table->addIndex(['stream', 'playhead', 'archived']); + } + + /** @return list */ + private function getCustomHeaders(Message $message): array + { + $filteredHeaders = [ + StreamHeader::class, + StreamStartHeader::class, + ArchivedHeader::class, + ]; + + return array_values( + array_filter( + $message->headers(), + static fn (object $header) => !in_array($header::class, $filteredHeaders, true), + ), + ); + } + + public function supportSubscription(): bool + { + return $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform && class_exists(PDO::class); + } + + public function wait(int $timeoutMilliseconds): void + { + if (!$this->supportSubscription()) { + return; + } + + $this->connection->executeStatement(sprintf('LISTEN "%s"', $this->config['table_name'])); + + /** @var PDO $nativeConnection */ + $nativeConnection = $this->connection->getNativeConnection(); + + $nativeConnection->pgsqlGetNotify(PDO::FETCH_ASSOC, $timeoutMilliseconds); + } + + public function setupSubscription(): void + { + if (!$this->supportSubscription()) { + return; + } + + $functionName = $this->createTriggerFunctionName(); + + $this->connection->executeStatement(sprintf( + <<<'SQL' + CREATE OR REPLACE FUNCTION %1$s() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify('%2$s', 'update'); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + SQL, + $functionName, + $this->config['table_name'], + )); + + $this->connection->executeStatement(sprintf( + 'DROP TRIGGER IF EXISTS notify_trigger ON %s;', + $this->config['table_name'], + )); + $this->connection->executeStatement(sprintf( + 'CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON %1$s FOR EACH ROW EXECUTE PROCEDURE %2$s();', + $this->config['table_name'], + $functionName, + )); + } + + private function createTriggerFunctionName(): string + { + $tableConfig = explode('.', $this->config['table_name']); + + if (count($tableConfig) === 1) { + return sprintf('notify_%1$s', $tableConfig[0]); + } + + return sprintf('%1$s.notify_%2$s', $tableConfig[0], $tableConfig[1]); + } + + /** + * @param array $columns + * @param array $placeholders + * @param list $parameters + * @param array<0|positive-int, Type> $types + */ + private function executeSave( + array $columns, + array $placeholders, + array $parameters, + array $types, + Connection $connection, + ): void { + $query = sprintf( + "INSERT INTO %s (%s) VALUES\n(%s)", + $this->config['table_name'], + implode(', ', $columns), + implode("),\n(", $placeholders), + ); + + try { + $connection->executeStatement($query, $parameters, $types); + } catch (UniqueConstraintViolationException $e) { + throw new UniqueConstraintViolation($e); + } + } + + private function lock(): void + { + $this->hasLock = true; + + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof PostgreSQLPlatform) { + $this->connection->executeStatement( + sprintf( + 'SELECT pg_advisory_xact_lock(%s)', + $this->config['lock_id'], + ), + ); + + return; + } + + if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { + $this->connection->fetchAllAssociative( + sprintf( + 'SELECT GET_LOCK("%s", %d)', + $this->config['lock_id'], + $this->config['lock_timeout'], + ), + ); + + return; + } + + if ($platform instanceof SQLitePlatform) { + return; // sql locking is not needed because of file locking + } + + throw new LockingNotImplemented($platform::class); + } + + private function unlock(): void + { + $this->hasLock = false; + + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof PostgreSQLPlatform) { + return; // lock is released automatically after transaction + } + + if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { + $this->connection->fetchAllAssociative( + sprintf( + 'SELECT RELEASE_LOCK("%s")', + $this->config['lock_id'], + ), + ); + + return; + } + + if ($platform instanceof SQLitePlatform) { + return; // sql locking is not needed because of file locking + } + + throw new LockingNotImplemented($platform::class); + } +} diff --git a/src/Store/StreamDoctrineDbalStoreStream.php b/src/Store/StreamDoctrineDbalStoreStream.php new file mode 100644 index 000000000..e9eab489a --- /dev/null +++ b/src/Store/StreamDoctrineDbalStoreStream.php @@ -0,0 +1,157 @@ + */ +final class StreamDoctrineDbalStoreStream implements Stream, IteratorAggregate +{ + private Result|null $result; + + /** @var Generator */ + private Generator|null $generator; + + /** @var positive-int|0|null */ + private int|null $position; + + /** @var positive-int|null */ + private int|null $index; + + public function __construct( + Result $result, + EventSerializer $eventSerializer, + HeadersSerializer $headersSerializer, + AbstractPlatform $platform, + ) { + $this->result = $result; + $this->generator = $this->buildGenerator($result, $eventSerializer, $headersSerializer, $platform); + $this->position = null; + $this->index = null; + } + + public function close(): void + { + $this->result?->free(); + + $this->result = null; + $this->generator = null; + } + + public function next(): void + { + $this->assertNotClosed(); + + $this->generator->next(); + } + + public function end(): bool + { + $this->assertNotClosed(); + + return !$this->generator->valid(); + } + + public function current(): Message|null + { + $this->assertNotClosed(); + + return $this->generator->current() ?: null; + } + + /** @return positive-int|0|null */ + public function position(): int|null + { + $this->assertNotClosed(); + + if ($this->position === null) { + $this->generator->key(); + } + + return $this->position; + } + + /** @return positive-int|null */ + public function index(): int|null + { + $this->assertNotClosed(); + + if ($this->index === null) { + $this->generator->key(); + } + + return $this->index; + } + + /** @return Traversable */ + public function getIterator(): Traversable + { + $this->assertNotClosed(); + + return $this->generator; + } + + /** @return Generator */ + private function buildGenerator( + Result $result, + EventSerializer $eventSerializer, + HeadersSerializer $headersSerializer, + AbstractPlatform $platform, + ): Generator { + $dateTimeType = Type::getType(Types::DATETIMETZ_IMMUTABLE); + + /** @var array{id: positive-int, stream: string, playhead: int|string|null, event: string, payload: string, recorded_on: string, archived: int|string, new_stream_start: int|string, custom_headers: string} $data */ + foreach ($result->iterateAssociative() as $data) { + if ($this->position === null) { + $this->position = 0; + } else { + ++$this->position; + } + + $this->index = $data['id']; + $event = $eventSerializer->deserialize(new SerializedEvent($data['event'], $data['payload'])); + + $message = Message::create($event) + ->withHeader(new StreamHeader( + $data['stream'], + $data['playhead'] === null ? null : (int)$data['playhead'], + $dateTimeType->convertToPHPValue($data['recorded_on'], $platform), + )); + + if ($data['archived']) { + $message = $message->withHeader(new ArchivedHeader()); + } + + if ($data['new_stream_start']) { + $message = $message->withHeader(new StreamStartHeader()); + } + + $customHeaders = $headersSerializer->deserialize($data['custom_headers']); + + yield $message->withHeaders($customHeaders); + } + } + + /** + * @psalm-assert !null $this->result + * @psalm-assert !null $this->generator + */ + private function assertNotClosed(): void + { + if ($this->result === null || $this->generator === null) { + throw new StreamClosed(); + } + } +} diff --git a/src/Store/StreamHeader.php b/src/Store/StreamHeader.php new file mode 100644 index 000000000..d70b8889a --- /dev/null +++ b/src/Store/StreamHeader.php @@ -0,0 +1,19 @@ + $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); } + public function testUniqueConstraint(): void + { + $this->expectException(UniqueConstraintViolation::class); + + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new AggregateHeader( + 'profile', + $profileId->toString(), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new AggregateHeader( + 'profile', + $profileId->toString(), + 1, + new DateTimeImmutable('2020-01-02 00:00:00'), + )), + ]; + + $this->store->save(...$messages); + } + public function testSave10000Messages(): void { $profileId = ProfileId::generate(); diff --git a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php new file mode 100644 index 000000000..cc5a6629f --- /dev/null +++ b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php @@ -0,0 +1,258 @@ +connection = DbalManager::createConnection(); + + $clock = new FrozenClock(new DateTimeImmutable('2020-01-01 00:00:00')); + + $this->store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + clock: $clock, + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $this->store, + ); + + $schemaDirector->create(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testSave(): void + { + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 2, + new DateTimeImmutable('2020-01-02 00:00:00'), + )), + ]; + + $this->store->save(...$messages); + + /** @var list> $result */ + $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store'); + + self::assertCount(2, $result); + + $result1 = $result[0]; + + self::assertEquals(sprintf('profile-%s', $profileId->toString()), $result1['stream']); + self::assertEquals('1', $result1['playhead']); + self::assertStringContainsString('2020-01-01 00:00:00', $result1['recorded_on']); + self::assertEquals('profile.created', $result1['event']); + self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + + $result2 = $result[1]; + + self::assertEquals(sprintf('profile-%s', $profileId->toString()), $result2['stream']); + self::assertEquals('2', $result2['playhead']); + self::assertStringContainsString('2020-01-02 00:00:00', $result2['recorded_on']); + self::assertEquals('profile.created', $result2['event']); + self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + } + + public function testSaveWithNullableValues(): void + { + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader('extern')), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader('extern')), + ]; + + $this->store->save(...$messages); + + /** @var list> $result */ + $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store'); + + self::assertCount(2, $result); + + $result1 = $result[0]; + + self::assertEquals('extern', $result1['stream']); + self::assertEquals(null, $result1['playhead']); + self::assertStringContainsString('2020-01-01 00:00:00', $result1['recorded_on']); + self::assertEquals('profile.created', $result1['event']); + self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + + $result2 = $result[1]; + + self::assertEquals('extern', $result2['stream']); + self::assertEquals(null, $result2['playhead']); + self::assertStringContainsString('2020-01-01 00:00:00', $result2['recorded_on']); + self::assertEquals('profile.created', $result2['event']); + self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + } + + public function testSaveWithTransactional(): void + { + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 2, + new DateTimeImmutable('2020-01-02 00:00:00'), + )), + ]; + + $this->store->transactional(function () use ($messages): void { + $this->store->save(...$messages); + }); + + /** @var list> $result */ + $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store'); + + self::assertCount(2, $result); + + $result1 = $result[0]; + + self::assertEquals(sprintf('profile-%s', $profileId->toString()), $result1['stream']); + self::assertEquals('1', $result1['playhead']); + self::assertStringContainsString('2020-01-01 00:00:00', $result1['recorded_on']); + self::assertEquals('profile.created', $result1['event']); + self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + + $result2 = $result[1]; + + self::assertEquals(sprintf('profile-%s', $profileId->toString()), $result2['stream']); + self::assertEquals('2', $result2['playhead']); + self::assertStringContainsString('2020-01-02 00:00:00', $result2['recorded_on']); + self::assertEquals('profile.created', $result2['event']); + self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + } + + public function testUniqueConstraint(): void + { + $this->expectException(UniqueConstraintViolation::class); + + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 1, + new DateTimeImmutable('2020-01-02 00:00:00'), + )), + ]; + + $this->store->save(...$messages); + } + + public function testSave10000Messages(): void + { + $profileId = ProfileId::generate(); + + $messages = []; + + for ($i = 1; $i <= 10000; $i++) { + $messages[] = Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + $i, + new DateTimeImmutable('2020-01-01 00:00:00'), + )); + } + + $this->store->save(...$messages); + + /** @var int $result */ + $result = $this->connection->fetchFirstColumn('SELECT COUNT(*) FROM event_store')[0]; + + self::assertEquals(10000, $result); + } + + public function testLoad(): void + { + $profileId = ProfileId::generate(); + + $message = Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )); + + $this->store->save($message); + + $stream = null; + + try { + $stream = $this->store->load(); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + + $loadedMessage = $stream->current(); + + self::assertInstanceOf(Message::class, $loadedMessage); + self::assertNotSame($message, $loadedMessage); + self::assertEquals($message->event(), $loadedMessage->event()); + self::assertEquals($message->header(StreamHeader::class)->streamName, $loadedMessage->header(StreamHeader::class)->streamName); + self::assertEquals($message->header(StreamHeader::class)->playhead, $loadedMessage->header(StreamHeader::class)->playhead); + self::assertEquals($message->header(StreamHeader::class)->recordedOn, $loadedMessage->header(StreamHeader::class)->recordedOn); + } finally { + $stream?->close(); + } + } +} From bb8aa52ebffc61ed46308078d292b549a47e775c Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 12:16:25 +0200 Subject: [PATCH 02/15] add benchmark --- .../Benchmark/SimpleSetupStreamStoreBench.php | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/Benchmark/SimpleSetupStreamStoreBench.php diff --git a/tests/Benchmark/SimpleSetupStreamStoreBench.php b/tests/Benchmark/SimpleSetupStreamStoreBench.php new file mode 100644 index 000000000..55597f471 --- /dev/null +++ b/tests/Benchmark/SimpleSetupStreamStoreBench.php @@ -0,0 +1,110 @@ +store = new StreamDoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/BasicImplementation/Events']), + ); + + $this->repository = new DefaultRepository($this->store, Profile::metadata()); + + $schemaDirector = new DoctrineSchemaDirector( + $connection, + $this->store, + ); + + $schemaDirector->create(); + + $this->singleEventId = ProfileId::generate(); + $profile = Profile::create($this->singleEventId, 'Peter'); + $this->repository->save($profile); + + $this->multipleEventsId = ProfileId::generate(); + $profile = Profile::create($this->multipleEventsId, 'Peter'); + + for ($i = 0; $i < 10_000; $i++) { + $profile->changeName('Peter'); + } + + $this->repository->save($profile); + } + + #[Bench\Revs(10)] + public function benchLoad1Event(): void + { + $this->repository->load($this->singleEventId); + } + + #[Bench\Revs(10)] + public function benchLoad10000Events(): void + { + $this->repository->load($this->multipleEventsId); + } + + #[Bench\Revs(10)] + public function benchSave1Event(): void + { + $profile = Profile::create(ProfileId::generate(), 'Peter'); + $this->repository->save($profile); + } + + #[Bench\Revs(10)] + public function benchSave10000Events(): void + { + $profile = Profile::create(ProfileId::generate(), 'Peter'); + + for ($i = 1; $i < 10_000; $i++) { + $profile->changeName('Peter'); + } + + $this->repository->save($profile); + } + + #[Bench\Revs(1)] + public function benchSave10000Aggregates(): void + { + for ($i = 1; $i < 10_000; $i++) { + $profile = Profile::create(ProfileId::generate(), 'Peter'); + $this->repository->save($profile); + } + } + + #[Bench\Revs(10)] + public function benchSave10000AggregatesTransaction(): void + { + $this->store->transactional(function (): void { + for ($i = 1; $i < 10_000; $i++) { + $profile = Profile::create(ProfileId::generate(), 'Peter'); + $this->repository->save($profile); + } + }); + } +} From 00438bddc948a959367859fc20ebfea273aa8318 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 12:41:52 +0200 Subject: [PATCH 03/15] handle more stream header --- src/Console/OutputStyle.php | 28 +++++++++++++++---- .../AggregateIdArgumentResolver.php | 16 +++++++++-- tests/Unit/Console/OutputStyleTest.php | 4 +-- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/Console/OutputStyle.php b/src/Console/OutputStyle.php index 3c11687c9..7ec0c6eb4 100644 --- a/src/Console/OutputStyle.php +++ b/src/Console/OutputStyle.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Console; use Patchlevel\EventSourcing\Aggregate\AggregateHeader; +use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Serializer\HeadersSerializer; use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; @@ -54,7 +55,7 @@ public function message( && !$header instanceof StreamStartHeader, ); - $aggregateHeader = $message->header(AggregateHeader::class); + $metaHeader = $this->metaHeader($message); $streamStart = $message->hasHeader(StreamStartHeader::class); $achieved = $message->hasHeader(ArchivedHeader::class); @@ -69,10 +70,9 @@ public function message( ], [ [ - $aggregateHeader->aggregateName, - $aggregateHeader->aggregateId, - $aggregateHeader->playhead, - $aggregateHeader->recordedOn->format('Y-m-d H:i:s'), + $this->streamName($metaHeader), + $metaHeader->playhead, + $metaHeader->recordedOn?->format('Y-m-d H:i:s'), $streamStart ? 'yes' : 'no', $achieved ? 'yes' : 'no', ], @@ -98,4 +98,22 @@ public function throwable(Throwable $error): void $error = $error->getPrevious(); } while ($error !== null); } + + private function metaHeader(Message $message): AggregateHeader|StreamHeader + { + try { + return $message->header(AggregateHeader::class); + } catch (HeaderNotFound) { + return $message->header(StreamHeader::class); + } + } + + private function streamName(AggregateHeader|StreamHeader $header): string + { + if ($header instanceof AggregateHeader) { + return sprintf('%s-%s', $header->aggregateName, $header->aggregateId); + } + + return $header->streamName; + } } diff --git a/src/Subscription/Subscriber/ArgumentResolver/AggregateIdArgumentResolver.php b/src/Subscription/Subscriber/ArgumentResolver/AggregateIdArgumentResolver.php index 9472624d5..3a25a0b1c 100644 --- a/src/Subscription/Subscriber/ArgumentResolver/AggregateIdArgumentResolver.php +++ b/src/Subscription/Subscriber/ArgumentResolver/AggregateIdArgumentResolver.php @@ -6,10 +6,13 @@ use Patchlevel\EventSourcing\Aggregate\AggregateHeader; use Patchlevel\EventSourcing\Aggregate\AggregateRootId; +use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Subscriber\ArgumentMetadata; +use Patchlevel\EventSourcing\Store\StreamHeader; use function class_exists; +use function explode; use function is_a; final class AggregateIdArgumentResolver implements ArgumentResolver @@ -19,9 +22,18 @@ public function resolve(ArgumentMetadata $argument, Message $message): Aggregate /** @var class-string $class */ $class = $argument->type; - $id = $message->header(AggregateHeader::class)->aggregateId; + try { + $id = $message->header(AggregateHeader::class)->aggregateId; - return $class::fromString($id); + return $class::fromString($id); + } catch (HeaderNotFound) { + // do nothing + } + + $stream = $message->header(StreamHeader::class)->streamName; + $parts = explode('-', $stream, 2); + + return $class::fromString($parts[1]); } public function support(ArgumentMetadata $argument, string $eventClass): bool diff --git a/tests/Unit/Console/OutputStyleTest.php b/tests/Unit/Console/OutputStyleTest.php index e6dcc8356..02fd96f44 100644 --- a/tests/Unit/Console/OutputStyleTest.php +++ b/tests/Unit/Console/OutputStyleTest.php @@ -62,7 +62,7 @@ public function testMessage(): void self::assertStringContainsString('profile.created', $content); self::assertStringContainsString('profile', $content); self::assertStringContainsString('{"id":"1","email":"foo@bar.com"}', $content); - self::assertStringContainsString('aggregate', $content); + self::assertStringContainsString('stream', $content); self::assertStringContainsString('profile', $content); } @@ -106,7 +106,7 @@ public function testMessageWithCustomHeaders(): void self::assertStringContainsString('profile.created', $content); self::assertStringContainsString('profile', $content); self::assertStringContainsString('{"id":"1","email":"foo@bar.com"}', $content); - self::assertStringContainsString('aggregate', $content); + self::assertStringContainsString('stream', $content); self::assertStringContainsString('profile', $content); } } From 18d6f77cc4480330d6ffd016a7aac7dc188343ac Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 13:41:43 +0200 Subject: [PATCH 04/15] add StreamStore interface --- deptrac.yaml | 5 ++ src/Console/Command/ShowAggregateCommand.php | 22 ++++-- src/Console/Command/WatchCommand.php | 17 +++-- src/Repository/DefaultRepository.php | 4 +- src/Store/StreamDoctrineDbalStore.php | 24 ++++++- src/Store/StreamStore.php | 13 ++++ .../Store/StreamDoctrineDbalStoreTest.php | 72 +++++++++++++++++++ 7 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 src/Store/StreamStore.php diff --git a/deptrac.yaml b/deptrac.yaml index 703db335c..86b07702c 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -92,6 +92,10 @@ deptrac: collectors: - type: directory value: src/Subscription/.* + - name: Test + collectors: + - type: directory + value: src/Test/.* ruleset: Aggregate: @@ -175,6 +179,7 @@ deptrac: Store: - Aggregate - Attribute + - Clock - Message - Metadata - Schema diff --git a/src/Console/Command/ShowAggregateCommand.php b/src/Console/Command/ShowAggregateCommand.php index f5be2880b..d3e5d6b50 100644 --- a/src/Console/Command/ShowAggregateCommand.php +++ b/src/Console/Command/ShowAggregateCommand.php @@ -12,7 +12,9 @@ use Patchlevel\EventSourcing\Store\Criteria\AggregateIdCriterion; use Patchlevel\EventSourcing\Store\Criteria\AggregateNameCriterion; use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Store; +use Patchlevel\EventSourcing\Store\StreamStore; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -73,12 +75,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } - $stream = $this->store->load( - new Criteria( - new AggregateNameCriterion($aggregate), - new AggregateIdCriterion($id), - ), - ); + if ($this->store instanceof StreamStore) { + $stream = $this->store->load( + new Criteria( + new StreamCriterion(sprintf('%s-%s', $aggregate, $id)), + ), + ); + } else { + $stream = $this->store->load( + new Criteria( + new AggregateNameCriterion($aggregate), + new AggregateIdCriterion($id), + ), + ); + } $hasMessage = false; foreach ($stream as $message) { diff --git a/src/Console/Command/WatchCommand.php b/src/Console/Command/WatchCommand.php index 0ac3aa352..61a118aa0 100644 --- a/src/Console/Command/WatchCommand.php +++ b/src/Console/Command/WatchCommand.php @@ -11,6 +11,7 @@ use Patchlevel\EventSourcing\Store\Criteria\CriteriaBuilder; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Store; +use Patchlevel\EventSourcing\Store\StreamStore; use Patchlevel\EventSourcing\Store\SubscriptionStore; use Patchlevel\Worker\DefaultWorker; use Symfony\Component\Console\Attribute\AsCommand; @@ -19,6 +20,8 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use function sprintf; + #[AsCommand( 'event-sourcing:watch', 'live stream of all aggregate events', @@ -71,10 +74,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->store->setupSubscription(); } - $criteria = (new CriteriaBuilder()) - ->aggregateName($aggregate) - ->aggregateId($aggregateId) - ->build(); + if ($this->store instanceof StreamStore) { + $criteria = (new CriteriaBuilder()) + ->streamName(sprintf('%s-%s', $aggregate, $aggregateId)) + ->build(); + } else { + $criteria = (new CriteriaBuilder()) + ->aggregateName($aggregate) + ->aggregateId($aggregateId) + ->build(); + } $worker = DefaultWorker::create( function () use ($console, &$index, $criteria, $sleep): void { diff --git a/src/Repository/DefaultRepository.php b/src/Repository/DefaultRepository.php index 51fbd5387..18085c276 100644 --- a/src/Repository/DefaultRepository.php +++ b/src/Repository/DefaultRepository.php @@ -18,8 +18,8 @@ use Patchlevel\EventSourcing\Store\Criteria\CriteriaBuilder; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\Stream; -use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Store\StreamHeader; +use Patchlevel\EventSourcing\Store\StreamStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; @@ -60,7 +60,7 @@ public function __construct( $this->clock = $clock ?? new SystemClock(); $this->logger = $logger ?? new NullLogger(); $this->aggregateIsValid = new WeakMap(); - $this->useStreamHeader = $store instanceof StreamDoctrineDbalStore; + $this->useStreamHeader = $store instanceof StreamStore; } /** @return T */ diff --git a/src/Store/StreamDoctrineDbalStore.php b/src/Store/StreamDoctrineDbalStore.php index cc302a6f4..8146cffeb 100644 --- a/src/Store/StreamDoctrineDbalStore.php +++ b/src/Store/StreamDoctrineDbalStore.php @@ -44,7 +44,7 @@ use function is_string; use function sprintf; -final class StreamDoctrineDbalStore implements Store, SubscriptionStore, DoctrineSchemaConfigurator +final class StreamDoctrineDbalStore implements StreamStore, SubscriptionStore, DoctrineSchemaConfigurator { /** * PostgreSQL has a limit of 65535 parameters in a single query. @@ -292,6 +292,28 @@ public function transactional(Closure $function): void } } + /** @return list */ + public function streams(): array + { + $builder = $this->connection->createQueryBuilder() + ->select('stream') + ->distinct() + ->from($this->config['table_name']) + ->orderBy('stream'); + + return $builder->fetchFirstColumn(); + } + + public function remove(string $streamName): void + { + $builder = $this->connection->createQueryBuilder() + ->delete($this->config['table_name']) + ->andWhere('stream = :stream') + ->setParameter('stream', $streamName); + + $builder->executeStatement(); + } + public function configureSchema(Schema $schema, Connection $connection): void { if ($this->connection !== $connection) { diff --git a/src/Store/StreamStore.php b/src/Store/StreamStore.php new file mode 100644 index 000000000..66e2487a0 --- /dev/null +++ b/src/Store/StreamStore.php @@ -0,0 +1,13 @@ + */ + public function streams(): array; + + public function remove(string $streamName): void; +} diff --git a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php index cc5a6629f..c9709e334 100644 --- a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php @@ -255,4 +255,76 @@ public function testLoad(): void $stream?->close(); } } + + public function testStreams(): void + { + $profileId = ProfileId::fromString('0190e47e-77e9-7b90-bf62-08bbf0ab9b4b'); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 2, + new DateTimeImmutable('2020-01-02 00:00:00'), + )), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('foo'), + )), + ]; + + $this->store->save(...$messages); + + $streams = $this->store->streams(); + + self::assertEquals([ + 'foo', + 'profile-0190e47e-77e9-7b90-bf62-08bbf0ab9b4b', + ], $streams); + } + + public function testRemote(): void + { + $profileId = ProfileId::fromString('0190e47e-77e9-7b90-bf62-08bbf0ab9b4b'); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId->toString()), + 2, + new DateTimeImmutable('2020-01-02 00:00:00'), + )), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamHeader( + sprintf('foo'), + )), + ]; + + $this->store->save(...$messages); + + $streams = $this->store->streams(); + + self::assertEquals([ + 'foo', + 'profile-0190e47e-77e9-7b90-bf62-08bbf0ab9b4b', + ], $streams); + + $this->store->remove('profile-0190e47e-77e9-7b90-bf62-08bbf0ab9b4b'); + + $streams = $this->store->streams(); + + self::assertEquals(['foo'], $streams); + } } From 66e3b722e8f83c11c5c269ecc8842f4a51817f8d Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 14:22:58 +0200 Subject: [PATCH 05/15] add stream name translator --- deptrac.yaml | 1 + src/Aggregate/StreamNameTranslator.php | 30 +++++++++++++++++++ src/Console/Command/ShowAggregateCommand.php | 5 +++- src/Console/Command/WatchCommand.php | 5 ++-- src/Console/OutputStyle.php | 3 +- .../RecalculatePlayheadTranslator.php | 4 +-- src/Repository/DefaultRepository.php | 14 ++++----- .../AggregateIdArgumentResolver.php | 6 ++-- 8 files changed, 49 insertions(+), 19 deletions(-) create mode 100644 src/Aggregate/StreamNameTranslator.php diff --git a/deptrac.yaml b/deptrac.yaml index 86b07702c..b405ba863 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -184,3 +184,4 @@ deptrac: - Metadata - Schema - Serializer + Test: diff --git a/src/Aggregate/StreamNameTranslator.php b/src/Aggregate/StreamNameTranslator.php new file mode 100644 index 000000000..8b96311e8 --- /dev/null +++ b/src/Aggregate/StreamNameTranslator.php @@ -0,0 +1,30 @@ +store instanceof StreamStore) { $stream = $this->store->load( new Criteria( - new StreamCriterion(sprintf('%s-%s', $aggregate, $id)), + new StreamCriterion( + StreamNameTranslator::streamName($aggregate, $id), + ), ), ); } else { diff --git a/src/Console/Command/WatchCommand.php b/src/Console/Command/WatchCommand.php index 61a118aa0..fba922a5a 100644 --- a/src/Console/Command/WatchCommand.php +++ b/src/Console/Command/WatchCommand.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Console\Command; +use Patchlevel\EventSourcing\Aggregate\StreamNameTranslator; use Patchlevel\EventSourcing\Console\InputHelper; use Patchlevel\EventSourcing\Console\OutputStyle; use Patchlevel\EventSourcing\Message\Serializer\HeadersSerializer; @@ -20,8 +21,6 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use function sprintf; - #[AsCommand( 'event-sourcing:watch', 'live stream of all aggregate events', @@ -76,7 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($this->store instanceof StreamStore) { $criteria = (new CriteriaBuilder()) - ->streamName(sprintf('%s-%s', $aggregate, $aggregateId)) + ->streamName(StreamNameTranslator::streamName($aggregate, $aggregateId)) ->build(); } else { $criteria = (new CriteriaBuilder()) diff --git a/src/Console/OutputStyle.php b/src/Console/OutputStyle.php index 7ec0c6eb4..e00faf909 100644 --- a/src/Console/OutputStyle.php +++ b/src/Console/OutputStyle.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Console; use Patchlevel\EventSourcing\Aggregate\AggregateHeader; +use Patchlevel\EventSourcing\Aggregate\StreamNameTranslator; use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Serializer\HeadersSerializer; @@ -111,7 +112,7 @@ private function metaHeader(Message $message): AggregateHeader|StreamHeader private function streamName(AggregateHeader|StreamHeader $header): string { if ($header instanceof AggregateHeader) { - return sprintf('%s-%s', $header->aggregateName, $header->aggregateId); + return StreamNameTranslator::streamName($header->aggregateName, $header->aggregateId); } return $header->streamName; diff --git a/src/Message/Translator/RecalculatePlayheadTranslator.php b/src/Message/Translator/RecalculatePlayheadTranslator.php index 9246b8631..b1161a29f 100644 --- a/src/Message/Translator/RecalculatePlayheadTranslator.php +++ b/src/Message/Translator/RecalculatePlayheadTranslator.php @@ -5,12 +5,12 @@ namespace Patchlevel\EventSourcing\Message\Translator; use Patchlevel\EventSourcing\Aggregate\AggregateHeader; +use Patchlevel\EventSourcing\Aggregate\StreamNameTranslator; use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Store\StreamHeader; use function array_key_exists; -use function sprintf; final class RecalculatePlayheadTranslator implements Translator { @@ -33,7 +33,7 @@ public function __invoke(Message $message): array if ($header instanceof StreamHeader) { $stream = $header->streamName; } else { - $stream = sprintf('%s-%s', $header->aggregateName, $header->aggregateId); + $stream = StreamNameTranslator::streamName($header->aggregateName, $header->aggregateId); } $playhead = $this->nextPlayhead($stream); diff --git a/src/Repository/DefaultRepository.php b/src/Repository/DefaultRepository.php index 18085c276..22b77ac5d 100644 --- a/src/Repository/DefaultRepository.php +++ b/src/Repository/DefaultRepository.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Aggregate\AggregateHeader; use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Aggregate\AggregateRootId; +use Patchlevel\EventSourcing\Aggregate\StreamNameTranslator; use Patchlevel\EventSourcing\Clock\SystemClock; use Patchlevel\EventSourcing\EventBus\EventBus; use Patchlevel\EventSourcing\Message\Message; @@ -110,7 +111,7 @@ public function load(AggregateRootId $id): AggregateRoot if ($this->useStreamHeader) { $criteria = (new CriteriaBuilder()) - ->streamName($this->streamName($this->metadata->name, $id->toString())) + ->streamName(StreamNameTranslator::streamName($this->metadata->name, $id->toString())) ->archived(false) ->build(); } else { @@ -179,7 +180,7 @@ public function has(AggregateRootId $id): bool { if ($this->useStreamHeader) { $criteria = (new CriteriaBuilder()) - ->streamName($this->streamName($this->metadata->name, $id->toString())) + ->streamName(StreamNameTranslator::streamName($this->metadata->name, $id->toString())) ->build(); } else { $criteria = (new CriteriaBuilder()) @@ -250,7 +251,7 @@ public function save(AggregateRoot $aggregate): void static function (object $event) use ($aggregateName, $aggregateId, &$playhead, $messageDecorator, $clock, $useStreamHeader) { if ($useStreamHeader) { $header = new StreamHeader( - sprintf('%s-%s', $aggregateName, $aggregateId), + StreamNameTranslator::streamName($aggregateName, $aggregateId), ++$playhead, $clock->now(), ); @@ -331,7 +332,7 @@ private function loadFromSnapshot(string $aggregateClass, AggregateRootId $id): if ($this->useStreamHeader) { $criteria = (new CriteriaBuilder()) - ->streamName($this->streamName($this->metadata->name, $id->toString())) + ->streamName(StreamNameTranslator::streamName($this->metadata->name, $id->toString())) ->fromPlayhead($aggregate->playhead()) ->build(); } else { @@ -414,9 +415,4 @@ private function unpack(Stream $stream): Traversable yield $message->event(); } } - - private function streamName(string $aggregateName, string $aggregateId): string - { - return sprintf('%s-%s', $aggregateName, $aggregateId); - } } diff --git a/src/Subscription/Subscriber/ArgumentResolver/AggregateIdArgumentResolver.php b/src/Subscription/Subscriber/ArgumentResolver/AggregateIdArgumentResolver.php index 3a25a0b1c..8fdae4746 100644 --- a/src/Subscription/Subscriber/ArgumentResolver/AggregateIdArgumentResolver.php +++ b/src/Subscription/Subscriber/ArgumentResolver/AggregateIdArgumentResolver.php @@ -6,13 +6,13 @@ use Patchlevel\EventSourcing\Aggregate\AggregateHeader; use Patchlevel\EventSourcing\Aggregate\AggregateRootId; +use Patchlevel\EventSourcing\Aggregate\StreamNameTranslator; use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Subscriber\ArgumentMetadata; use Patchlevel\EventSourcing\Store\StreamHeader; use function class_exists; -use function explode; use function is_a; final class AggregateIdArgumentResolver implements ArgumentResolver @@ -31,9 +31,9 @@ public function resolve(ArgumentMetadata $argument, Message $message): Aggregate } $stream = $message->header(StreamHeader::class)->streamName; - $parts = explode('-', $stream, 2); + $aggregateId = StreamNameTranslator::aggregateId($stream); - return $class::fromString($parts[1]); + return $class::fromString($aggregateId); } public function support(ArgumentMetadata $argument, string $eventClass): bool From 247d1759adcb1581299ba3777ef7dbb4aecc07cb Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 15:59:46 +0200 Subject: [PATCH 06/15] add wildcard support --- src/Store/Criteria/StreamCriterion.php | 5 + src/Store/InvalidStreamName.php | 15 +++ src/Store/StreamDoctrineDbalStore.php | 20 ++++ .../Store/DoctrineDbalStoreTest.php | 4 +- .../Integration/Store/Events/ExternEvent.php | 16 +++ .../Store/StreamDoctrineDbalStoreTest.php | 102 +++++++++++++++--- 6 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 src/Store/InvalidStreamName.php create mode 100644 tests/Integration/Store/Events/ExternEvent.php diff --git a/src/Store/Criteria/StreamCriterion.php b/src/Store/Criteria/StreamCriterion.php index 48c014687..050fd5c74 100644 --- a/src/Store/Criteria/StreamCriterion.php +++ b/src/Store/Criteria/StreamCriterion.php @@ -10,4 +10,9 @@ public function __construct( public readonly string $streamName, ) { } + + public static function startWith(string $streamName): self + { + return new self($streamName . '*'); + } } diff --git a/src/Store/InvalidStreamName.php b/src/Store/InvalidStreamName.php new file mode 100644 index 000000000..d93f42820 --- /dev/null +++ b/src/Store/InvalidStreamName.php @@ -0,0 +1,15 @@ +streamName === '*') { + break; + } + + if (str_ends_with($criterion->streamName, '*')) { + $streamName = mb_substr($criterion->streamName, 0, -1); + + if (str_contains($streamName, '*')) { + throw new InvalidStreamName($criterion->streamName); + } + + $builder->andWhere('stream LIKE :stream'); + $builder->setParameter('stream', $streamName . '%'); + + break; + } + $builder->andWhere('stream = :stream'); $builder->setParameter('stream', $criterion->streamName); break; diff --git a/tests/Integration/Store/DoctrineDbalStoreTest.php b/tests/Integration/Store/DoctrineDbalStoreTest.php index 898a06e3a..9256cc335 100644 --- a/tests/Integration/Store/DoctrineDbalStoreTest.php +++ b/tests/Integration/Store/DoctrineDbalStoreTest.php @@ -91,7 +91,7 @@ public function testSave(): void self::assertEquals('2', $result2['playhead']); self::assertStringContainsString('2020-01-02 00:00:00', $result2['recorded_on']); self::assertEquals('profile.created', $result2['event']); - self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result2['payload'], true)); } public function testSaveWithTransactional(): void @@ -140,7 +140,7 @@ public function testSaveWithTransactional(): void self::assertEquals('2', $result2['playhead']); self::assertStringContainsString('2020-01-02 00:00:00', $result2['recorded_on']); self::assertEquals('profile.created', $result2['event']); - self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result2['payload'], true)); } public function testUniqueConstraint(): void diff --git a/tests/Integration/Store/Events/ExternEvent.php b/tests/Integration/Store/Events/ExternEvent.php new file mode 100644 index 000000000..bf0fae395 --- /dev/null +++ b/tests/Integration/Store/Events/ExternEvent.php @@ -0,0 +1,16 @@ + $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result1['payload'], true), + ); $result2 = $result[1]; @@ -92,17 +99,18 @@ public function testSave(): void self::assertEquals('2', $result2['playhead']); self::assertStringContainsString('2020-01-02 00:00:00', $result2['recorded_on']); self::assertEquals('profile.created', $result2['event']); - self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result2['payload'], true), + ); } public function testSaveWithNullableValues(): void { - $profileId = ProfileId::generate(); - $messages = [ - Message::create(new ProfileCreated($profileId, 'test')) + Message::create(new ExternEvent('test 1')) ->withHeader(new StreamHeader('extern')), - Message::create(new ProfileCreated($profileId, 'test')) + Message::create(new ExternEvent('test 2')) ->withHeader(new StreamHeader('extern')), ]; @@ -118,16 +126,22 @@ public function testSaveWithNullableValues(): void self::assertEquals('extern', $result1['stream']); self::assertEquals(null, $result1['playhead']); self::assertStringContainsString('2020-01-01 00:00:00', $result1['recorded_on']); - self::assertEquals('profile.created', $result1['event']); - self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + self::assertEquals('extern', $result1['event']); + self::assertEquals( + ['message' => 'test 1'], + json_decode($result1['payload'], true), + ); $result2 = $result[1]; self::assertEquals('extern', $result2['stream']); self::assertEquals(null, $result2['playhead']); self::assertStringContainsString('2020-01-01 00:00:00', $result2['recorded_on']); - self::assertEquals('profile.created', $result2['event']); - self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + self::assertEquals('extern', $result2['event']); + self::assertEquals( + ['message' => 'test 2'], + json_decode($result2['payload'], true), + ); } public function testSaveWithTransactional(): void @@ -164,7 +178,10 @@ public function testSaveWithTransactional(): void self::assertEquals('1', $result1['playhead']); self::assertStringContainsString('2020-01-01 00:00:00', $result1['recorded_on']); self::assertEquals('profile.created', $result1['event']); - self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result1['payload'], true), + ); $result2 = $result[1]; @@ -172,7 +189,10 @@ public function testSaveWithTransactional(): void self::assertEquals('2', $result2['playhead']); self::assertStringContainsString('2020-01-02 00:00:00', $result2['recorded_on']); self::assertEquals('profile.created', $result2['event']); - self::assertEquals(['profileId' => $profileId->toString(), 'name' => 'test'], json_decode($result1['payload'], true)); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result2['payload'], true), + ); } public function testUniqueConstraint(): void @@ -248,9 +268,57 @@ public function testLoad(): void self::assertInstanceOf(Message::class, $loadedMessage); self::assertNotSame($message, $loadedMessage); self::assertEquals($message->event(), $loadedMessage->event()); - self::assertEquals($message->header(StreamHeader::class)->streamName, $loadedMessage->header(StreamHeader::class)->streamName); - self::assertEquals($message->header(StreamHeader::class)->playhead, $loadedMessage->header(StreamHeader::class)->playhead); - self::assertEquals($message->header(StreamHeader::class)->recordedOn, $loadedMessage->header(StreamHeader::class)->recordedOn); + self::assertEquals( + $message->header(StreamHeader::class)->streamName, + $loadedMessage->header(StreamHeader::class)->streamName, + ); + self::assertEquals( + $message->header(StreamHeader::class)->playhead, + $loadedMessage->header(StreamHeader::class)->playhead, + ); + self::assertEquals( + $message->header(StreamHeader::class)->recordedOn, + $loadedMessage->header(StreamHeader::class)->recordedOn, + ); + } finally { + $stream?->close(); + } + } + + public function testLoadWithWildcard(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId1->toString()), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )), + Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamHeader( + sprintf('profile-%s', $profileId2->toString()), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamHeader( + 'foo', + )), + ]; + + $this->store->save(...$messages); + + $stream = null; + + try { + $stream = $this->store->load(new Criteria(new StreamCriterion('profile-*'))); + + $messages = iterator_to_array($stream); + + self::assertCount(2, $messages); } finally { $stream?->close(); } @@ -273,7 +341,7 @@ public function testStreams(): void 2, new DateTimeImmutable('2020-01-02 00:00:00'), )), - Message::create(new ProfileCreated($profileId, 'test')) + Message::create(new ExternEvent('foo bar')) ->withHeader(new StreamHeader( sprintf('foo'), )), @@ -306,7 +374,7 @@ public function testRemote(): void 2, new DateTimeImmutable('2020-01-02 00:00:00'), )), - Message::create(new ProfileCreated($profileId, 'test')) + Message::create(new ExternEvent('foo bar')) ->withHeader(new StreamHeader( sprintf('foo'), )), From e2c63a1d68484ba68745dbeabb8aff08533ddbec Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 16:18:07 +0200 Subject: [PATCH 07/15] fix benchmarks --- Makefile | 10 ++++++++-- phpbench.json | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 111fafef3..e964a560f 100644 --- a/Makefile +++ b/Makefile @@ -68,11 +68,17 @@ test: phpunit benchmark: vendor ## run benchmarks DB_URL=sqlite3:///:memory: vendor/bin/phpbench run tests/Benchmark --report=default -.PHONY: benchmark-diff-test -benchmark-diff-test: vendor ## run benchmarks +.PHONY: benchmark-base +benchmark-base: vendor ## run benchmarks DB_URL=sqlite3:///:memory: vendor/bin/phpbench run tests/Benchmark --revs=1 --report=default --progress=none --tag=base + +.PHONY: benchmark-diff +benchmark-diff: vendor ## run benchmarks DB_URL=sqlite3:///:memory: vendor/bin/phpbench run tests/Benchmark --revs=1 --report=diff --progress=none --ref=base +.PHONY: benchmark-diff-test +benchmark-diff-test: benchmark-base benchmark-diff ## run benchmarks + .PHONY: dev dev: static test ## run dev tools diff --git a/phpbench.json b/phpbench.json index 59cd41ba4..89bcf4eb7 100644 --- a/phpbench.json +++ b/phpbench.json @@ -43,7 +43,7 @@ "partition": "subject_name", "cols": { - "time-diff": "percent_diff(partition['result_time_avg'][1], partition['result_time_avg'][0])" + "time-diff": "percent_diff(coalesce(partition['result_time_avg']?[1], 0), coalesce(partition['result_time_avg']?[0], 0))" } }, "memory": @@ -61,7 +61,7 @@ "partition": "subject_name", "cols": { - "memory-diff": "percent_diff(partition['result_mem_peak'][1], partition['result_mem_peak'][0])" + "memory-diff": "percent_diff(coalesce(partition['result_mem_peak']?[1], 0), coalesce(partition['result_mem_peak']?[0], 0))" } } } From 12842ebe81e65fa15199af5d8e3c0651589caabb Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 22:00:53 +0200 Subject: [PATCH 08/15] fix static analyser --- baseline.xml | 15 ++++++++++++- phpstan-baseline.neon | 10 +++++++++ src/Aggregate/InvalidAggregateStreamName.php | 17 ++++++++++++++ src/Aggregate/StreamNameTranslator.php | 16 ++++++++++++-- src/Console/Command/WatchCommand.php | 22 +++++++++++++------ .../RecalculatePlayheadTranslator.php | 2 +- src/Store/InvalidStreamName.php | 2 +- src/Store/StreamDoctrineDbalStore.php | 5 ++++- src/Store/StreamDoctrineDbalStoreStream.php | 2 ++ .../Store/StreamDoctrineDbalStoreTest.php | 12 ++++------ 10 files changed, 82 insertions(+), 21 deletions(-) create mode 100644 src/Aggregate/InvalidAggregateStreamName.php diff --git a/baseline.xml b/baseline.xml index 146dc35b8..cc6be2b42 100644 --- a/baseline.xml +++ b/baseline.xml @@ -1,5 +1,5 @@ - + @@ -86,6 +86,11 @@ convertToPHPValue($data['recorded_on'], $platform)]]> + + + + + @@ -131,6 +136,14 @@ + + + + + + + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 98eaa6309..26a4be10c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -50,6 +50,16 @@ parameters: count: 1 path: src/Store/DoctrineDbalStoreStream.php + - + message: "#^Parameter \\#2 \\$playhead of class Patchlevel\\\\EventSourcing\\\\Store\\\\StreamHeader constructor expects int\\<1, max\\>\\|null, int\\|null given\\.$#" + count: 1 + path: src/Store/StreamDoctrineDbalStoreStream.php + + - + message: "#^Ternary operator condition is always true\\.$#" + count: 1 + path: src/Store/StreamDoctrineDbalStoreStream.php + - message: "#^Parameter \\#3 \\$errorContext of class Patchlevel\\\\EventSourcing\\\\Subscription\\\\SubscriptionError constructor expects array\\\\}\\>\\|null, mixed given\\.$#" count: 1 diff --git a/src/Aggregate/InvalidAggregateStreamName.php b/src/Aggregate/InvalidAggregateStreamName.php new file mode 100644 index 000000000..6abe25508 --- /dev/null +++ b/src/Aggregate/InvalidAggregateStreamName.php @@ -0,0 +1,17 @@ +store->setupSubscription(); } + $criteriaBuilder = new CriteriaBuilder(); + if ($this->store instanceof StreamStore) { - $criteria = (new CriteriaBuilder()) - ->streamName(StreamNameTranslator::streamName($aggregate, $aggregateId)) - ->build(); + if ($aggregate !== null || $aggregateId !== null) { + if ($aggregate === null || $aggregateId === null) { + $console->error('You must provide both aggregate and aggregate-id or none of them'); + + return 1; + } + + $criteriaBuilder->streamName(StreamNameTranslator::streamName($aggregate, $aggregateId)); + } } else { - $criteria = (new CriteriaBuilder()) - ->aggregateName($aggregate) - ->aggregateId($aggregateId) - ->build(); + $criteriaBuilder->aggregateName($aggregate); + $criteriaBuilder->aggregateId($aggregateId); } + $criteria = $criteriaBuilder->build(); + $worker = DefaultWorker::create( function () use ($console, &$index, $criteria, $sleep): void { $stream = $this->store->load( diff --git a/src/Message/Translator/RecalculatePlayheadTranslator.php b/src/Message/Translator/RecalculatePlayheadTranslator.php index b1161a29f..b9084567f 100644 --- a/src/Message/Translator/RecalculatePlayheadTranslator.php +++ b/src/Message/Translator/RecalculatePlayheadTranslator.php @@ -14,7 +14,7 @@ final class RecalculatePlayheadTranslator implements Translator { - /** @var array> */ + /** @var array */ private array $index = []; /** @return list */ diff --git a/src/Store/InvalidStreamName.php b/src/Store/InvalidStreamName.php index d93f42820..17df4c7c6 100644 --- a/src/Store/InvalidStreamName.php +++ b/src/Store/InvalidStreamName.php @@ -6,7 +6,7 @@ use function sprintf; -class InvalidStreamName extends StoreException +final class InvalidStreamName extends StoreException { public function __construct(string $streamName) { diff --git a/src/Store/StreamDoctrineDbalStore.php b/src/Store/StreamDoctrineDbalStore.php index 815cf906e..18c3e5c34 100644 --- a/src/Store/StreamDoctrineDbalStore.php +++ b/src/Store/StreamDoctrineDbalStore.php @@ -321,7 +321,10 @@ public function streams(): array ->from($this->config['table_name']) ->orderBy('stream'); - return $builder->fetchFirstColumn(); + /** @var list $streams */ + $streams = $builder->fetchFirstColumn(); + + return $streams; } public function remove(string $streamName): void diff --git a/src/Store/StreamDoctrineDbalStoreStream.php b/src/Store/StreamDoctrineDbalStoreStream.php index e9eab489a..f9effa8e1 100644 --- a/src/Store/StreamDoctrineDbalStoreStream.php +++ b/src/Store/StreamDoctrineDbalStoreStream.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Result; +use Doctrine\DBAL\Types\DateTimeTzImmutableType; use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use Generator; @@ -110,6 +111,7 @@ private function buildGenerator( HeadersSerializer $headersSerializer, AbstractPlatform $platform, ): Generator { + /** @var DateTimeTzImmutableType $dateTimeType */ $dateTimeType = Type::getType(Types::DATETIMETZ_IMMUTABLE); /** @var array{id: positive-int, stream: string, playhead: int|string|null, event: string, payload: string, recorded_on: string, archived: int|string, new_stream_start: int|string, custom_headers: string} $data */ diff --git a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php index 98bc29f94..9812dbb1d 100644 --- a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php @@ -12,9 +12,9 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; -use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Store\StreamHeader; +use Patchlevel\EventSourcing\Store\StreamStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\Store\Events\ExternEvent; @@ -29,7 +29,7 @@ final class StreamDoctrineDbalStoreTest extends TestCase { private Connection $connection; - private Store $store; + private StreamStore $store; public function setUp(): void { @@ -342,9 +342,7 @@ public function testStreams(): void new DateTimeImmutable('2020-01-02 00:00:00'), )), Message::create(new ExternEvent('foo bar')) - ->withHeader(new StreamHeader( - sprintf('foo'), - )), + ->withHeader(new StreamHeader('foo')), ]; $this->store->save(...$messages); @@ -375,9 +373,7 @@ public function testRemote(): void new DateTimeImmutable('2020-01-02 00:00:00'), )), Message::create(new ExternEvent('foo bar')) - ->withHeader(new StreamHeader( - sprintf('foo'), - )), + ->withHeader(new StreamHeader('foo')), ]; $this->store->save(...$messages); From df620c8f03ecc079f63586197fcd3c55984a5408 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 22:47:45 +0200 Subject: [PATCH 09/15] add docs --- docs/pages/store.md | 117 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 96 insertions(+), 21 deletions(-) diff --git a/docs/pages/store.md b/docs/pages/store.md index e05c8b0f7..af05b9273 100644 --- a/docs/pages/store.md +++ b/docs/pages/store.md @@ -8,7 +8,6 @@ Each message contains an event and the associated headers. More information about the message can be found [here](message.md). The store is optimized to efficiently store and load events for aggregates. -We currently only offer one [doctrine dbal](https://www.doctrine-project.org/projects/dbal.html) store. ## Create DBAL connection @@ -29,8 +28,14 @@ $connection = DriverManager::getConnection( ## Configure Store +We currently offer two stores, both based on the [doctrine dbal](https://www.doctrine-project.org/projects/dbal.html) library. +The default store is the `DoctrineDbalStore` and the new experimental store is the `StreamDoctrineDbalStore`. + +### DoctrineDbalStore + +This is the current default store for event sourcing. You can create a store with the `DoctrineDbalStore` class. -The store needs a dbal connection, an event serializer, an aggregate registry and some options. +The store needs a dbal connection, an event serializer and has some optional parameters like options. ```php use Doctrine\DBAL\Connection; @@ -41,21 +46,17 @@ use Patchlevel\EventSourcing\Store\DoctrineDbalStore; $store = new DoctrineDbalStore( $connection, DefaultEventSerializer::createFromPaths(['src/Event']), - null, - [/** options */], ); ``` Following options are available in `DoctrineDbalStore`: -| Option | Type | Default | Description | -|-------------------|------------------|------------|----------------------------------------------| -| table_name | string | eventstore | The name of the table in the database | -| aggregate_id_type | "uuid"|"string" | uuid | The type of the `aggregate_id` column | -| locking | bool | true | If the store should use locking for writing | -| lock_id | int | 133742 | The id of the lock | -| lock_timeout | int | -1 | The timeout of the lock. -1 means no timeout | - -## Schema +| Option | Type | Default | Description | +|-------------------|-----------------|------------|----------------------------------------------| +| table_name | string | eventstore | The name of the table in the database | +| aggregate_id_type | "uuid"/"string" | uuid | The type of the `aggregate_id` column | +| locking | bool | true | If the store should use locking for writing | +| lock_id | int | 133742 | The id of the lock | +| lock_timeout | int | -1 | The timeout of the lock. -1 means no timeout | The table structure of the `DoctrineDbalStore` looks like this: @@ -72,13 +73,59 @@ The table structure of the `DoctrineDbalStore` looks like this: | archived | bool | If the event is archived | | custom_headers | json | Custom headers for the event | -With the help of the `SchemaDirector`, the database structure can be created, updated and deleted. - !!! note The default type of the `aggregate_id` column is `uuid` if the database supports it and `string` if not. You can change the type with the `aggregate_id_type` to `string` if you want use custom id. +### StreamDoctrineDbalStore + +We offer a new experimental store called `StreamDoctrineDbalStore`. +This store is decoupled from the aggregate and can be used to store events from other sources. +The difference to the `DoctrineDbalStore` is that the `StreamDoctrineDbalStore` merge the aggregate id +and the aggregate name into one column named `stream`. Additionally, the column `playhead` is nullable. +This store introduces two new methods `streams` and `remove`. + +The store needs a dbal connection, an event serializer and has some optional parameters like options. + +```php +use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; + +/** @var Connection $connection */ +$store = new StreamDoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths(['src/Event']), +); +``` +Following options are available in `StreamDoctrineDbalStore`: + +| Option | Type | Default | Description | +|-------------------|-----------------|-------------|----------------------------------------------| +| table_name | string | event_store | The name of the table in the database | +| locking | bool | true | If the store should use locking for writing | +| lock_id | int | 133742 | The id of the lock | +| lock_timeout | int | -1 | The timeout of the lock. -1 means no timeout | + +The table structure of the `StreamDoctrineDbalStore` looks like this: + +| Column | Type | Description | +|------------------|----------|--------------------------------------------------| +| id | bigint | The index of the whole stream (autoincrement) | +| stream | string | The name of the stream | +| playhead | ?int | The current playhead of the aggregate | +| event | string | The name of the event | +| payload | json | The payload of the event | +| recorded_on | datetime | The date when the event was recorded | +| new_stream_start | bool | If the event is the first event of the aggregate | +| archived | bool | If the event is archived | +| custom_headers | json | Custom headers for the event | + +## Schema + +With the help of the `SchemaDirector`, the database structure can be created, updated and deleted. + !!! tip You can also use doctrine migration to create and keep your schema in sync. @@ -92,11 +139,11 @@ Additionally, it implements the `DryRunSchemaDirector` interface, to show the sq ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; -use Patchlevel\EventSourcing\Store\DoctrineDbalStore; +use Patchlevel\EventSourcing\Store\Store; /** * @var Connection $connection - * @var DoctrineDbalStore $store + * @var Store $store */ $schemaDirector = new DoctrineSchemaDirector( $connection, @@ -179,13 +226,13 @@ use Doctrine\Migrations\DependencyFactory; use Doctrine\Migrations\Provider\SchemaProvider; use Patchlevel\EventSourcing\Schema\DoctrineMigrationSchemaProvider; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; -use Patchlevel\EventSourcing\Store\DoctrineDbalStore; +use Patchlevel\EventSourcing\Store\Store; // event sourcing schema director configuration /** * @var Connection $connection - * @var DoctrineDbalStore $store + * @var Store $store */ $schemaDirector = new DoctrineSchemaDirector( $connection, @@ -355,11 +402,39 @@ $store->save(...$messages); Use transactional method if you want call multiple save methods in a transaction. -### Delete & Update +### Update -It is not possible to delete or update events. +It is not possible to update events. In event sourcing, the events are immutable. +### Remove + +You can remove a stream with the `remove` method. + +```php +use Patchlevel\EventSourcing\Store\StreamStore; + +/** @var StreamStore $store */ +$store->remove('profile-*'); +``` +!!! note + + The method is only available in the `StreamStore` like `StreamDoctrineDbalStore`. + +### List Streams + +You can list all streams with the `streams` method. + +```php +use Patchlevel\EventSourcing\Store\StreamStore; + +/** @var StreamStore $store */ +$streams = $store->streams(); // ['profile-1', 'profile-2', 'profile-3'] +``` +!!! note + + The method is only available in the `StreamStore` like `StreamDoctrineDbalStore`. + ### Transaction There is also the possibility of executing a function in a transaction. From 941ff88d80ad069f67ad55290ee26127b4fe151b Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 24 Jul 2024 22:53:17 +0200 Subject: [PATCH 10/15] add experimental annotation --- src/Aggregate/InvalidAggregateStreamName.php | 1 + src/Aggregate/StreamNameTranslator.php | 1 + src/Store/Criteria/CriteriaBuilder.php | 1 + src/Store/Criteria/StreamCriterion.php | 1 + src/Store/InvalidStreamName.php | 1 + src/Store/StreamDoctrineDbalStore.php | 1 + src/Store/StreamDoctrineDbalStoreStream.php | 5 ++++- src/Store/StreamHeader.php | 5 ++++- src/Store/StreamStore.php | 1 + 9 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Aggregate/InvalidAggregateStreamName.php b/src/Aggregate/InvalidAggregateStreamName.php index 6abe25508..97a2ddf5b 100644 --- a/src/Aggregate/InvalidAggregateStreamName.php +++ b/src/Aggregate/InvalidAggregateStreamName.php @@ -8,6 +8,7 @@ use function sprintf; +/** @experimental */ final class InvalidAggregateStreamName extends RuntimeException { public function __construct(string $stream) diff --git a/src/Aggregate/StreamNameTranslator.php b/src/Aggregate/StreamNameTranslator.php index d25c9e3c6..e3030b341 100644 --- a/src/Aggregate/StreamNameTranslator.php +++ b/src/Aggregate/StreamNameTranslator.php @@ -7,6 +7,7 @@ use function strpos; use function substr; +/** @experimental */ final class StreamNameTranslator { private function __construct() diff --git a/src/Store/Criteria/CriteriaBuilder.php b/src/Store/Criteria/CriteriaBuilder.php index cfb80367c..1a45f1e74 100644 --- a/src/Store/Criteria/CriteriaBuilder.php +++ b/src/Store/Criteria/CriteriaBuilder.php @@ -13,6 +13,7 @@ final class CriteriaBuilder private int|null $fromPlayhead = null; private bool|null $archived = null; + /** @experimental */ public function streamName(string|null $streamName): self { $this->streamName = $streamName; diff --git a/src/Store/Criteria/StreamCriterion.php b/src/Store/Criteria/StreamCriterion.php index 050fd5c74..070c1c830 100644 --- a/src/Store/Criteria/StreamCriterion.php +++ b/src/Store/Criteria/StreamCriterion.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Store\Criteria; +/** @experimental */ final class StreamCriterion { public function __construct( diff --git a/src/Store/InvalidStreamName.php b/src/Store/InvalidStreamName.php index 17df4c7c6..0496c2ed4 100644 --- a/src/Store/InvalidStreamName.php +++ b/src/Store/InvalidStreamName.php @@ -6,6 +6,7 @@ use function sprintf; +/** @experimental */ final class InvalidStreamName extends StoreException { public function __construct(string $streamName) diff --git a/src/Store/StreamDoctrineDbalStore.php b/src/Store/StreamDoctrineDbalStore.php index 18c3e5c34..e9fa93b40 100644 --- a/src/Store/StreamDoctrineDbalStore.php +++ b/src/Store/StreamDoctrineDbalStore.php @@ -47,6 +47,7 @@ use function str_contains; use function str_ends_with; +/** @experimental */ final class StreamDoctrineDbalStore implements StreamStore, SubscriptionStore, DoctrineSchemaConfigurator { /** diff --git a/src/Store/StreamDoctrineDbalStoreStream.php b/src/Store/StreamDoctrineDbalStoreStream.php index f9effa8e1..caf8514ad 100644 --- a/src/Store/StreamDoctrineDbalStoreStream.php +++ b/src/Store/StreamDoctrineDbalStoreStream.php @@ -17,7 +17,10 @@ use Patchlevel\EventSourcing\Serializer\SerializedEvent; use Traversable; -/** @implements IteratorAggregate */ +/** + * @implements IteratorAggregate + * @experimental + */ final class StreamDoctrineDbalStoreStream implements Stream, IteratorAggregate { private Result|null $result; diff --git a/src/Store/StreamHeader.php b/src/Store/StreamHeader.php index d70b8889a..24c7ece07 100644 --- a/src/Store/StreamHeader.php +++ b/src/Store/StreamHeader.php @@ -6,7 +6,10 @@ use DateTimeImmutable; -/** @psalm-immutable */ +/** + * @psalm-immutable + * @experimental + */ final class StreamHeader { /** @param positive-int|null $playhead */ diff --git a/src/Store/StreamStore.php b/src/Store/StreamStore.php index 66e2487a0..f9e5d545a 100644 --- a/src/Store/StreamStore.php +++ b/src/Store/StreamStore.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Store; +/** @experimental */ interface StreamStore extends Store { /** @return list */ From 214cbdc4adcfdcf776eb4c955c952e21c470a9ce Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 25 Jul 2024 09:13:50 +0200 Subject: [PATCH 11/15] add store unit tests --- baseline.xml | 47 + .../Store/StreamDoctrineDbalStoreTest.php | 1503 +++++++++++++++++ .../Store/StreamDoctrineDbalStreamTest.php | 400 +++++ 3 files changed, 1950 insertions(+) create mode 100644 tests/Unit/Store/StreamDoctrineDbalStoreTest.php create mode 100644 tests/Unit/Store/StreamDoctrineDbalStreamTest.php diff --git a/baseline.xml b/baseline.xml index cc6be2b42..45ab6dee9 100644 --- a/baseline.xml +++ b/baseline.xml @@ -333,6 +333,53 @@ )]]> + + + + + + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> + + diff --git a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php new file mode 100644 index 000000000..7dddf2ec6 --- /dev/null +++ b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php @@ -0,0 +1,1503 @@ +prophesize(Connection::class); + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->willReturn(new EmptyIterator()); + + $connection->executeQuery( + 'SELECT * FROM event_store WHERE (stream = :stream) AND (playhead > :playhead) AND (archived = :archived) ORDER BY id ASC', + [ + 'stream' => 'profile-1', + 'playhead' => 0, + 'archived' => false, + ], + Argument::type('array'), + )->willReturn($result->reveal()); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithLimit(): void + { + $connection = $this->prophesize(Connection::class); + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->willReturn(new EmptyIterator()); + + $connection->executeQuery( + 'SELECT * FROM event_store WHERE (stream = :stream) AND (playhead > :playhead) AND (archived = :archived) ORDER BY id ASC LIMIT 10', + [ + 'stream' => 'profile-1', + 'playhead' => 0, + 'archived' => false, + ], + Argument::type('array'), + )->willReturn($result->reveal()); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + 10, + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithOffset(): void + { + if (method_exists(AbstractPlatform::class, 'supportsLimitOffset')) { + $this->markTestSkipped('In older DBAL versions platforms did not need to support this'); + } + + $connection = $this->prophesize(Connection::class); + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->willReturn(new EmptyIterator()); + + $connection->executeQuery( + 'SELECT * FROM event_store WHERE (stream = :stream) AND (playhead > :playhead) AND (archived = :archived) ORDER BY id ASC OFFSET 5', + [ + 'stream' => 'profile-1', + 'playhead' => 0, + 'archived' => false, + ], + Argument::type('array'), + )->willReturn($result->reveal()); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + offset: 5, + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithIndex(): void + { + $connection = $this->prophesize(Connection::class); + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->willReturn(new EmptyIterator()); + + $connection->executeQuery( + 'SELECT * FROM event_store WHERE (stream = :stream) AND (playhead > :playhead) AND (id > :index) AND (archived = :archived) ORDER BY id ASC', + [ + 'stream' => 'profile-1', + 'playhead' => 0, + 'archived' => false, + 'index' => 1, + ], + Argument::type('array'), + )->willReturn($result->reveal()); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->fromIndex(1) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithOneEvent(): void + { + $connection = $this->prophesize(Connection::class); + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->willReturn(new ArrayIterator( + [ + [ + 'id' => 1, + 'stream' => 'profile-1', + 'playhead' => '1', + 'event' => 'profile.created', + 'payload' => '{"profileId": "1", "email": "s"}', + 'recorded_on' => '2021-02-17 10:00:00', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '[]', + ], + ], + )); + + $connection->executeQuery( + 'SELECT * FROM event_store WHERE (stream = :stream) AND (playhead > :playhead) AND (archived = :archived) ORDER BY id ASC', + [ + 'stream' => 'profile-1', + 'playhead' => 0, + 'archived' => false, + ], + Argument::type('array'), + )->willReturn($result->reveal()); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + $abstractPlatform->getDateTimeTzFormatString()->shouldBeCalledOnce()->willReturn('Y-m-d H:i:s'); + + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->deserialize( + new SerializedEvent('profile.created', '{"profileId": "1", "email": "s"}'), + )->willReturn(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->deserialize('[]')->willReturn([]); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + + $message = $stream->current(); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + + self::assertInstanceOf(Message::class, $message); + self::assertInstanceOf(ProfileCreated::class, $message->event()); + self::assertSame('profile-1', $message->header(StreamHeader::class)->streamName); + self::assertSame(1, $message->header(StreamHeader::class)->playhead); + self::assertEquals( + new DateTimeImmutable('2021-02-17 10:00:00'), + $message->header(StreamHeader::class)->recordedOn, + ); + + iterator_to_array($stream); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + } + + public function testLoadWithTwoEvents(): void + { + $connection = $this->prophesize(Connection::class); + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->willReturn(new ArrayIterator( + [ + [ + 'id' => 1, + 'stream' => 'profile-1', + 'playhead' => '1', + 'event' => 'profile.created', + 'payload' => '{"profileId": "1", "email": "s"}', + 'recorded_on' => '2021-02-17 10:00:00', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '[]', + ], + [ + 'id' => 2, + 'stream' => 'profile-1', + 'playhead' => '2', + 'event' => 'profile.email_changed', + 'payload' => '{"profileId": "1", "email": "d"}', + 'recorded_on' => '2021-02-17 11:00:00', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '[]', + ], + ], + )); + + $connection->executeQuery( + 'SELECT * FROM event_store WHERE (stream = :stream) AND (playhead > :playhead) AND (archived = :archived) ORDER BY id ASC', + [ + 'stream' => 'profile-1', + 'playhead' => 0, + 'archived' => false, + ], + Argument::type('array'), + )->willReturn($result->reveal()); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + $abstractPlatform->getDateTimeTzFormatString()->shouldBeCalledTimes(2)->willReturn('Y-m-d H:i:s'); + + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->deserialize( + new SerializedEvent('profile.created', '{"profileId": "1", "email": "s"}'), + )->willReturn(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))); + $eventSerializer->deserialize( + new SerializedEvent('profile.email_changed', '{"profileId": "1", "email": "d"}'), + )->willReturn(new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('d'))); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->deserialize('[]')->willReturn([]); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + + $message = $stream->current(); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + + self::assertInstanceOf(Message::class, $message); + self::assertInstanceOf(ProfileCreated::class, $message->event()); + self::assertSame('profile-1', $message->header(StreamHeader::class)->streamName); + self::assertSame(1, $message->header(StreamHeader::class)->playhead); + self::assertEquals( + new DateTimeImmutable('2021-02-17 10:00:00'), + $message->header(StreamHeader::class)->recordedOn, + ); + + $stream->next(); + $message = $stream->current(); + + self::assertSame(2, $stream->index()); + self::assertSame(1, $stream->position()); + + self::assertInstanceOf(Message::class, $message); + self::assertInstanceOf(ProfileEmailChanged::class, $message->event()); + self::assertSame('profile-1', $message->header(StreamHeader::class)->streamName); + self::assertSame(2, $message->header(StreamHeader::class)->playhead); + self::assertEquals( + new DateTimeImmutable('2021-02-17 11:00:00'), + $message->header(StreamHeader::class)->recordedOn, + ); + } + + public function testTransactional(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->prophesize(Connection::class); + $connection->getDatabasePlatform()->willReturn(new SQLitePlatform()); + $connection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + )->shouldBeCalled(); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithMySQL(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->prophesize(Connection::class); + $connection->getDatabasePlatform()->willReturn(new MySQLPlatform()); + $connection->fetchAllAssociative('SELECT GET_LOCK("133742", -1)')->shouldBeCalledOnce()->willReturn([]); + $connection->fetchAllAssociative('SELECT RELEASE_LOCK("133742")')->shouldBeCalledOnce()->willReturn([]); + + $connection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + )->shouldBeCalled(); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithMariaDB(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->prophesize(Connection::class); + $connection->getDatabasePlatform()->willReturn(new MariaDBPlatform()); + $connection->fetchAllAssociative('SELECT GET_LOCK("133742", -1)')->shouldBeCalledOnce()->willReturn([]); + $connection->fetchAllAssociative('SELECT RELEASE_LOCK("133742")')->shouldBeCalledOnce()->willReturn([]); + + $connection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + )->shouldBeCalled(); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithPostgreSQL(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->prophesize(Connection::class); + $connection->getDatabasePlatform()->willReturn(new PostgreSQLPlatform()); + $connection->executeStatement('SELECT pg_advisory_xact_lock(133742)')->shouldBeCalledOnce(); + + $connection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + )->shouldBeCalled(); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalNested(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->prophesize(Connection::class); + $connection->getDatabasePlatform()->willReturn(new PostgreSQLPlatform()); + $connection->executeStatement('SELECT pg_advisory_xact_lock(133742)')->shouldBeCalledOnce(); + + $connection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + )->shouldBeCalledTimes(2); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $store->transactional(static function () use ($store, $callback): void { + $store->transactional($callback(...)); + }); + + self::assertTrue($callback->called); + } + + public function testTransactionalTwice(): void + { + $callback = new class () { + public int $called = 0; + + public function __invoke(): void + { + $this->called++; + } + }; + + $connection = $this->prophesize(Connection::class); + $connection->getDatabasePlatform()->willReturn(new PostgreSQLPlatform()); + $connection->executeStatement('SELECT pg_advisory_xact_lock(133742)')->shouldBeCalledTimes(2); + + $connection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + )->shouldBeCalled(); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $store->transactional($callback(...)); + $store->transactional($callback(...)); + + self::assertEquals(2, $callback->called); + } + + public function testTransactionalUnlockByException(): void + { + $callback = new class () { + public function __invoke(): void + { + throw new RuntimeException(); + } + }; + + $connection = $this->prophesize(Connection::class); + $connection->getDatabasePlatform()->willReturn(new MariaDBPlatform()); + $connection->fetchAllAssociative('SELECT GET_LOCK("133742", -1)')->shouldBeCalledOnce()->willReturn([]); + $connection->fetchAllAssociative('SELECT RELEASE_LOCK("133742")')->shouldBeCalledOnce()->willReturn([]); + + $connection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + )->shouldBeCalled(); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $this->expectException(RuntimeException::class); + + $store->transactional($callback(...)); + } + + public function testSaveWithOneEvent(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamHeader( + 'profile-1', + 1, + $recordedOn, + )); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->serialize($message->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->serialize([])->willReturn('[]'); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->getDatabasePlatform()->willReturn(new SQLitePlatform()); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + ); + + $mockedConnection->executeStatement( + "INSERT INTO event_store (stream, playhead, event, payload, recorded_on, new_stream_start, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?)", + ['profile-1', 1, 'profile_created', '', $recordedOn, false, false, '[]'], + [ + 4 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 5 => Type::getType(Types::BOOLEAN), + 6 => Type::getType(Types::BOOLEAN), + ], + )->shouldBeCalledOnce(); + + $singleTableStore = new StreamDoctrineDbalStore( + $mockedConnection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + $singleTableStore->save($message); + } + + public function testSaveWithoutStreamHeader(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->serialize($message->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->serialize([])->willReturn('[]'); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->getDatabasePlatform()->willReturn(new SQLitePlatform()); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + ); + + $mockedConnection->executeStatement( + "INSERT INTO event_store (stream, playhead, event, payload, recorded_on, new_stream_start, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?)", + ['profile-1', 1, 'profile_created', '', $recordedOn, false, false, []], + [ + 4 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 5 => Type::getType(Types::BOOLEAN), + 6 => Type::getType(Types::BOOLEAN), + ], + )->shouldNotBeCalled(); + + $singleTableStore = new StreamDoctrineDbalStore( + $mockedConnection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $this->expectException(MissingDataForStorage::class); + $singleTableStore->save($message); + } + + public function testSaveWithTwoEvents(): void + { + $recordedOn = new DateTimeImmutable(); + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamHeader( + 'profile-1', + 1, + $recordedOn, + )); + $message2 = Message::create(new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('d'))) + ->withHeader(new StreamHeader( + 'profile-1', + 2, + $recordedOn, + )); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->serialize($message1->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + $eventSerializer->serialize($message2->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent( + 'profile_email_changed', + '', + )); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->serialize([])->willReturn('[]'); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->getDatabasePlatform()->willReturn(new SQLitePlatform()); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + ); + + $mockedConnection->executeStatement( + "INSERT INTO event_store (stream, playhead, event, payload, recorded_on, new_stream_start, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?),\n(?, ?, ?, ?, ?, ?, ?, ?)", + [ + 'profile-1', + 1, + 'profile_created', + '', + $recordedOn, + false, + false, + '[]', + 'profile-1', + 2, + 'profile_email_changed', + '', + $recordedOn, + false, + false, + '[]', + ], + [ + 4 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 5 => Type::getType(Types::BOOLEAN), + 6 => Type::getType(Types::BOOLEAN), + 12 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 13 => Type::getType(Types::BOOLEAN), + 14 => Type::getType(Types::BOOLEAN), + ], + )->shouldBeCalledOnce(); + + $singleTableStore = new StreamDoctrineDbalStore( + $mockedConnection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + $singleTableStore->save($message1, $message2); + } + + public function testSaveWithUniqueConstraintViolation(): void + { + $recordedOn = new DateTimeImmutable(); + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamHeader( + 'profile-1', + 1, + $recordedOn, + )); + $message2 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamHeader( + 'profile-1', + 1, + $recordedOn, + )); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->serialize($message1->event())->shouldBeCalledTimes(2)->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->serialize([])->willReturn('[]'); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->getDatabasePlatform()->willReturn(new SQLitePlatform()); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + ); + + $mockedConnection->executeStatement( + "INSERT INTO event_store (stream, playhead, event, payload, recorded_on, new_stream_start, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?),\n(?, ?, ?, ?, ?, ?, ?, ?)", + [ + 'profile-1', + 1, + 'profile_created', + '', + $recordedOn, + false, + false, + '[]', + 'profile-1', + 1, + 'profile_created', + '', + $recordedOn, + false, + false, + '[]', + ], + [ + 4 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 5 => Type::getType(Types::BOOLEAN), + 6 => Type::getType(Types::BOOLEAN), + 12 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 13 => Type::getType(Types::BOOLEAN), + 14 => Type::getType(Types::BOOLEAN), + ], + )->shouldBeCalledOnce()->willThrow(UniqueConstraintViolationException::class); + + $singleTableStore = new StreamDoctrineDbalStore( + $mockedConnection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $this->expectException(UniqueConstraintViolation::class); + $singleTableStore->save($message1, $message2); + } + + public function testSaveWithThousandEvents(): void + { + $recordedOn = new DateTimeImmutable(); + + $messages = []; + for ($i = 1; $i <= 10000; $i++) { + $messages[] = Message::create(new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamHeader( + 'profile-1', + $i, + $recordedOn, + )); + } + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->serialize($messages[0]->event())->shouldBeCalledTimes(10000)->willReturn(new SerializedEvent( + 'profile_email_changed', + '', + )); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->serialize([])->willReturn('[]'); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->getDatabasePlatform()->willReturn(new SQLitePlatform()); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + ); + + $mockedConnection->executeStatement(Argument::any(), Argument::any(), Argument::any()) + ->shouldBeCalledTimes(2); + + $singleTableStore = new StreamDoctrineDbalStore( + $mockedConnection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + $singleTableStore->save(...$messages); + } + + public function testSaveWithCustomHeaders(): void + { + $customHeaders = [ + new FooHeader('foo'), + new BazHeader('baz'), + ]; + + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamHeader( + 'profile-1', + 1, + $recordedOn, + )) + ->withHeaders($customHeaders); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->serialize($message->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->serialize($customHeaders)->willReturn('{foo: "foo", baz: "baz"}'); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->getDatabasePlatform()->willReturn(new SQLitePlatform()); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + ); + + $mockedConnection->executeStatement( + "INSERT INTO event_store (stream, playhead, event, payload, recorded_on, new_stream_start, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?)", + ['profile-1', 1, 'profile_created', '', $recordedOn, false, false, '{foo: "foo", baz: "baz"}'], + [ + 4 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 5 => Type::getType(Types::BOOLEAN), + 6 => Type::getType(Types::BOOLEAN), + ], + )->shouldBeCalledOnce(); + + $singleTableStore = new StreamDoctrineDbalStore( + $mockedConnection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + $singleTableStore->save($message); + } + + public function testCount(): void + { + $connection = $this->prophesize(Connection::class); + $connection->fetchOne( + 'SELECT COUNT(*) FROM event_store WHERE (stream = :stream) AND (playhead > :playhead) AND (archived = :archived)', + [ + 'stream' => 'profile-1', + 'playhead' => 0, + 'archived' => false, + ], + Argument::type('array'), + )->willReturn('1'); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $count = $doctrineDbalStore->count( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(1, $count); + } + + public function testCountWrongResult(): void + { + $connection = $this->prophesize(Connection::class); + $connection->fetchOne( + 'SELECT COUNT(*) FROM event_store WHERE (stream = :stream) AND (playhead > :playhead) AND (archived = :archived)', + [ + 'stream' => 'profile-1', + 'playhead' => 0, + 'archived' => false, + ], + Argument::type('array'), + )->willReturn([]); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $this->expectException(WrongQueryResult::class); + $doctrineDbalStore->count( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + } + + public function testSetupSubscription(): void + { + $connection = $this->prophesize(Connection::class); + $connection->executeStatement( + <<<'SQL' + CREATE OR REPLACE FUNCTION notify_event_store() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify('event_store', 'update'); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + SQL, + )->shouldBeCalledOnce()->willReturn(1); + $connection->executeStatement('DROP TRIGGER IF EXISTS notify_trigger ON event_store;') + ->shouldBeCalledOnce()->willReturn(1); + $connection->executeStatement('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON event_store FOR EACH ROW EXECUTE PROCEDURE notify_event_store();') + ->shouldBeCalledOnce()->willReturn(1); + + $abstractPlatform = $this->prophesize(PostgreSQLPlatform::class); + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + $doctrineDbalStore->setupSubscription(); + } + + public function testSetupSubscriptionWithOtherStoreTableName(): void + { + $connection = $this->prophesize(Connection::class); + $connection->executeStatement( + <<<'SQL' + CREATE OR REPLACE FUNCTION new.notify_event_store() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify('new.event_store', 'update'); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + SQL, + )->shouldBeCalledOnce()->willReturn(1); + $connection->executeStatement('DROP TRIGGER IF EXISTS notify_trigger ON new.event_store;') + ->shouldBeCalledOnce()->willReturn(1); + $connection->executeStatement('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON new.event_store FOR EACH ROW EXECUTE PROCEDURE new.notify_event_store();') + ->shouldBeCalledOnce()->willReturn(1); + + $abstractPlatform = $this->prophesize(PostgreSQLPlatform::class); + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $clock = $this->prophesize(ClockInterface::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + $clock->reveal(), + ['table_name' => 'new.event_store'], + ); + $doctrineDbalStore->setupSubscription(); + } + + public function testSetupSubscriptionNotPostgres(): void + { + $connection = $this->prophesize(Connection::class); + $connection->executeStatement( + <<<'SQL' + CREATE OR REPLACE FUNCTION notify_event_store() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify('event_store'); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + SQL, + )->shouldNotBeCalled(); + $connection->executeStatement('DROP TRIGGER IF EXISTS notify_trigger ON event_store;') + ->shouldNotBeCalled(); + $connection->executeStatement('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON event_store FOR EACH ROW EXECUTE PROCEDURE notify_event_store();') + ->shouldNotBeCalled(); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + $doctrineDbalStore->setupSubscription(); + } + + public function testWait(): void + { + $nativeConnection = $this->getMockBuilder(PDO::class) + ->disableOriginalConstructor() + ->addMethods(['pgsqlGetNotify']) + ->getMock(); + $nativeConnection + ->expects($this->once()) + ->method('pgsqlGetNotify') + ->with(PDO::FETCH_ASSOC, 100) + ->willReturn([]); + + $connection = $this->prophesize(Connection::class); + $connection->executeStatement('LISTEN "event_store"') + ->shouldBeCalledOnce() + ->willReturn(1); + $connection->getNativeConnection() + ->shouldBeCalledOnce() + ->willReturn($nativeConnection); + + $abstractPlatform = $this->prophesize(PostgreSQLPlatform::class); + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + $doctrineDbalStore->wait(100); + } + + public function testConfigureSchemaWithDifferentConnections(): void + { + $connection = $this->prophesize(Connection::class); + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + $schema = new Schema(); + $doctrineDbalStore->configureSchema($schema, $this->prophesize(Connection::class)->reveal()); + + self::assertEquals(new Schema(), $schema); + } + + public function testConfigureSchema(): void + { + $connection = $this->prophesize(Connection::class); + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $expectedSchema = new Schema(); + $table = $expectedSchema->createTable('event_store'); + $table->addColumn('id', Types::BIGINT) + ->setAutoincrement(true) + ->setNotnull(true); + $table->addColumn('stream', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('playhead', Types::INTEGER) + ->setNotnull(false); + $table->addColumn('event', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('payload', Types::JSON) + ->setNotnull(true); + $table->addColumn('recorded_on', Types::DATETIMETZ_IMMUTABLE) + ->setNotnull(true); + $table->addColumn('new_stream_start', Types::BOOLEAN) + ->setNotnull(true) + ->setDefault(false); + $table->addColumn('archived', Types::BOOLEAN) + ->setNotnull(true) + ->setDefault(false); + $table->addColumn('custom_headers', Types::JSON) + ->setNotnull(true); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['stream', 'playhead']); + $table->addIndex(['stream', 'playhead', 'archived']); + + $schema = new Schema(); + $doctrineDbalStore->configureSchema($schema, $connection->reveal()); + + self::assertEquals($expectedSchema, $schema); + } + + #[RequiresPhp('>= 8.2')] + public function testArchiveMessagesDifferentAggregates(): void + { + $recordedOn = new DateTimeImmutable(); + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamHeader( + 'profile-1', + 5, + $recordedOn, + )) + ->withHeader(new StreamStartHeader()); + + $message2 = Message::create(new ProfileEmailChanged(ProfileId::fromString('2'), Email::fromString('d'))) + ->withHeader(new StreamHeader( + 'profile-2', + 42, + $recordedOn, + )) + ->withHeader(new StreamStartHeader()); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->serialize($message1->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + $eventSerializer->serialize($message2->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent( + 'profile_email_changed', + '', + )); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->serialize([])->willReturn('[]'); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->getDatabasePlatform()->willReturn(new SQLitePlatform()); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + ); + + $mockedConnection->executeStatement( + "INSERT INTO event_store (stream, playhead, event, payload, recorded_on, new_stream_start, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?),\n(?, ?, ?, ?, ?, ?, ?, ?)", + [ + 'profile-1', + 5, + 'profile_created', + '', + $recordedOn, + true, + false, + '[]', + 'profile-2', + 42, + 'profile_email_changed', + '', + $recordedOn, + true, + false, + '[]', + ], + [ + 4 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 5 => Type::getType(Types::BOOLEAN), + 6 => Type::getType(Types::BOOLEAN), + 12 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 13 => Type::getType(Types::BOOLEAN), + 14 => Type::getType(Types::BOOLEAN), + ], + )->shouldBeCalledOnce(); + + $mockedConnection->executeStatement( + <<<'SQL' + UPDATE event_store + SET archived = true + WHERE stream = :stream + AND playhead < :playhead + AND archived = false + SQL, + [ + 'stream' => 'profile-1', + 'playhead' => 5, + ], + )->shouldBeCalledOnce(); + + $mockedConnection->executeStatement( + <<<'SQL' + UPDATE event_store + SET archived = true + WHERE stream = :stream + AND playhead < :playhead + AND archived = false + SQL, + [ + 'stream' => 'profile-2', + 'playhead' => 42, + ], + )->shouldBeCalledOnce(); + + $singleTableStore = new StreamDoctrineDbalStore( + $mockedConnection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $singleTableStore->save($message1, $message2); + } + + #[RequiresPhp('>= 8.2')] + public function testArchiveMessagesSameAggregate(): void + { + $recordedOn = new DateTimeImmutable(); + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamHeader( + 'profile-1', + 5, + $recordedOn, + )) + ->withHeader(new StreamStartHeader()); + + $message2 = Message::create(new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('d'))) + ->withHeader(new StreamHeader( + 'profile-1', + 42, + $recordedOn, + )) + ->withHeader(new StreamStartHeader()); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->serialize($message1->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + $eventSerializer->serialize($message2->event())->shouldBeCalledOnce()->willReturn(new SerializedEvent( + 'profile_email_changed', + '', + )); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->serialize([])->willReturn('[]'); + + $mockedConnection = $this->prophesize(Connection::class); + $mockedConnection->getDatabasePlatform()->willReturn(new SQLitePlatform()); + $mockedConnection->transactional(Argument::any())->will( + /** @param array{0: callable} $args */ + static fn (array $args): mixed => $args[0](), + ); + + $mockedConnection->executeStatement( + "INSERT INTO event_store (stream, playhead, event, payload, recorded_on, new_stream_start, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?),\n(?, ?, ?, ?, ?, ?, ?, ?)", + [ + 'profile-1', + 5, + 'profile_created', + '', + $recordedOn, + true, + false, + '[]', + 'profile-1', + 42, + 'profile_email_changed', + '', + $recordedOn, + true, + false, + '[]', + ], + [ + 4 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 5 => Type::getType(Types::BOOLEAN), + 6 => Type::getType(Types::BOOLEAN), + 12 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 13 => Type::getType(Types::BOOLEAN), + 14 => Type::getType(Types::BOOLEAN), + ], + )->shouldBeCalledOnce(); + + $mockedConnection->executeStatement( + <<<'SQL' + UPDATE event_store + SET archived = true + WHERE stream = :stream + AND playhead < :playhead + AND archived = false + SQL, + [ + 'stream' => 'profile-1', + 'playhead' => 42, + ], + )->shouldBeCalledOnce(); + + $singleTableStore = new StreamDoctrineDbalStore( + $mockedConnection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $singleTableStore->save($message1, $message2); + } +} diff --git a/tests/Unit/Store/StreamDoctrineDbalStreamTest.php b/tests/Unit/Store/StreamDoctrineDbalStreamTest.php new file mode 100644 index 000000000..aea21cede --- /dev/null +++ b/tests/Unit/Store/StreamDoctrineDbalStreamTest.php @@ -0,0 +1,400 @@ +prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $platform = $this->prophesize(AbstractPlatform::class); + + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->shouldBeCalledOnce()->willReturn(new ArrayIterator()); + + $stream = new StreamDoctrineDbalStoreStream( + $result->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + $platform->reveal(), + ); + + self::assertSame(null, $stream->position()); + self::assertSame(null, $stream->current()); + self::assertSame(null, $stream->index()); + self::assertSame(true, $stream->end()); + + $this->expectException(Throwable::class); + iterator_to_array($stream); + } + + public function testOneMessage(): void + { + $messages = [ + [ + 'id' => 1, + 'event' => 'profile_created', + 'payload' => '{}', + 'stream' => 'profile-1', + 'playhead' => 1, + 'recorded_on' => '2022-10-10 10:10:10', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '{}', + ], + ]; + + $event = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + $message = Message::create($event) + ->withHeader(new StreamHeader('profile-1', 1, new DateTimeImmutable('2022-10-10 10:10:10'))); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->deserialize(new SerializedEvent('profile_created', '{}')) + ->shouldBeCalledOnce() + ->willReturn($event); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->deserialize('{}')->shouldBeCalledOnce()->willReturn([]); + + $platform = $this->prophesize(AbstractPlatform::class); + $platform->getDateTimeTzFormatString()->shouldBeCalledOnce()->willReturn('Y-m-d H:i:s'); + + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->shouldBeCalledOnce()->willReturn(new ArrayIterator($messages)); + + $stream = new StreamDoctrineDbalStoreStream( + $result->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + $platform->reveal(), + ); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + self::assertEquals($message, $stream->current()); + self::assertSame(false, $stream->end()); + + $stream->next(); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + self::assertSame(null, $stream->current()); + self::assertSame(true, $stream->end()); + } + + public function testMultipleMessages(): void + { + $messagesArray = [ + [ + 'id' => 1, + 'event' => 'profile_created', + 'payload' => '{}', + 'stream' => 'profile-1', + 'playhead' => 1, + 'recorded_on' => '2022-10-10 10:10:10', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '{}', + ], + [ + 'id' => 2, + 'event' => 'profile_created2', + 'payload' => '{}', + 'stream' => 'profile-2', + 'playhead' => null, + 'recorded_on' => '2022-10-10 10:10:10', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '{}', + ], + [ + 'id' => 3, + 'event' => 'profile_created3', + 'payload' => '{}', + 'stream' => 'profile-3', + 'playhead' => 1, + 'recorded_on' => '2022-10-10 10:10:10', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '{}', + ], + ]; + + $event = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $messages = [ + Message::create($event) + ->withHeader(new StreamHeader('profile-1', 1, new DateTimeImmutable('2022-10-10 10:10:10'))), + Message::create($event) + ->withHeader(new StreamHeader('profile-2', null, new DateTimeImmutable('2022-10-10 10:10:10'))), + Message::create($event) + ->withHeader(new StreamHeader('profile-3', 1, new DateTimeImmutable('2022-10-10 10:10:10'))), + ]; + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->deserialize(new SerializedEvent('profile_created', '{}')) + ->shouldBeCalledOnce() + ->willReturn($event); + $eventSerializer->deserialize(new SerializedEvent('profile_created2', '{}')) + ->shouldBeCalledOnce() + ->willReturn($event); + $eventSerializer->deserialize(new SerializedEvent('profile_created3', '{}')) + ->shouldBeCalledOnce() + ->willReturn($event); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->deserialize('{}')->shouldBeCalledTimes(3)->willReturn([]); + + $platform = $this->prophesize(AbstractPlatform::class); + $platform->getDateTimeTzFormatString()->shouldBeCalledTimes(3)->willReturn('Y-m-d H:i:s'); + + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->shouldBeCalledOnce()->willReturn(new ArrayIterator($messagesArray)); + + $stream = new StreamDoctrineDbalStoreStream( + $result->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + $platform->reveal(), + ); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + self::assertEquals($messages[0], $stream->current()); + self::assertSame(false, $stream->end()); + + $stream->next(); + + self::assertSame(2, $stream->index()); + self::assertSame(1, $stream->position()); + self::assertEquals($messages[1], $stream->current()); + self::assertSame(false, $stream->end()); + + $stream->next(); + + self::assertSame(3, $stream->index()); + self::assertSame(2, $stream->position()); + self::assertEquals($messages[2], $stream->current()); + self::assertSame(false, $stream->end()); + + $stream->next(); + + self::assertSame(3, $stream->index()); + self::assertSame(2, $stream->position()); + self::assertSame(null, $stream->current()); + self::assertSame(true, $stream->end()); + } + + public function testWithNoList(): void + { + $messages = [ + 5 => [ + 'id' => 5, + 'event' => 'profile_created', + 'payload' => '{}', + 'stream' => 'profile-1', + 'playhead' => 1, + 'recorded_on' => '2022-10-10 10:10:10', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '{}', + ], + ]; + + $event = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + $message = Message::create($event) + ->withHeader(new StreamHeader('profile-1', 1, new DateTimeImmutable('2022-10-10 10:10:10'))); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->deserialize(new SerializedEvent('profile_created', '{}')) + ->shouldBeCalledOnce() + ->willReturn($event); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->deserialize('{}')->shouldBeCalledOnce()->willReturn([]); + + $platform = $this->prophesize(AbstractPlatform::class); + $platform->getDateTimeTzFormatString()->shouldBeCalledOnce()->willReturn('Y-m-d H:i:s'); + + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->shouldBeCalledOnce()->willReturn(new ArrayIterator($messages)); + + $stream = new StreamDoctrineDbalStoreStream( + $result->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + $platform->reveal(), + ); + + self::assertSame(5, $stream->index()); + self::assertSame(0, $stream->position()); + self::assertEquals($message, $stream->current()); + self::assertSame(false, $stream->end()); + + $stream->next(); + + self::assertSame(5, $stream->index()); + self::assertSame(0, $stream->position()); + self::assertSame(null, $stream->current()); + self::assertSame(true, $stream->end()); + } + + public function testClose(): void + { + $messages = [ + [ + 'id' => 1, + 'event' => 'profile_created', + 'payload' => '{}', + 'stream' => 'profile-1', + 'playhead' => 1, + 'recorded_on' => '2022-10-10 10:10:10', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '{}', + ], + ]; + + $event = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + $message = Message::create($event) + ->withHeader(new StreamHeader('profile-1', 1, new DateTimeImmutable('2022-10-10 10:10:10'))); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->deserialize(new SerializedEvent('profile_created', '{}')) + ->shouldBeCalledOnce() + ->willReturn($event); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->deserialize('{}')->shouldBeCalledOnce()->willReturn([]); + + $platform = $this->prophesize(AbstractPlatform::class); + $platform->getDateTimeTzFormatString()->shouldBeCalledOnce()->willReturn('Y-m-d H:i:s'); + + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->shouldBeCalledOnce()->willReturn(new ArrayIterator($messages)); + $result->free()->shouldBeCalledOnce(); + + $stream = new StreamDoctrineDbalStoreStream( + $result->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + $platform->reveal(), + ); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + self::assertEquals($message, $stream->current()); + self::assertSame(false, $stream->end()); + + $stream->close(); + + $this->expectException(StreamClosed::class); + $stream->index(); + } + + public function testPositionEmpty(): void + { + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $platform = $this->prophesize(AbstractPlatform::class); + + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->shouldBeCalledOnce()->willReturn(new ArrayIterator()); + + $stream = new StreamDoctrineDbalStoreStream( + $result->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + $platform->reveal(), + ); + + $position = $stream->position(); + + self::assertNull($position); + } + + public function testPosition(): void + { + $messages = [ + [ + 'id' => 1, + 'event' => 'profile_created', + 'payload' => '{}', + 'stream' => 'profile-1', + 'playhead' => 1, + 'recorded_on' => '2022-10-10 10:10:10', + 'archived' => '0', + 'new_stream_start' => '0', + 'custom_headers' => '{}', + ], + ]; + + $event = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $eventSerializer->deserialize(new SerializedEvent('profile_created', '{}')) + ->shouldBeCalledOnce() + ->willReturn($event); + + $headersSerializer = $this->prophesize(HeadersSerializer::class); + $headersSerializer->deserialize('{}')->shouldBeCalledOnce()->willReturn([]); + + $platform = $this->prophesize(AbstractPlatform::class); + $platform->getDateTimeTzFormatString()->shouldBeCalledOnce()->willReturn('Y-m-d H:i:s'); + + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->shouldBeCalledOnce()->willReturn(new ArrayIterator($messages)); + + $stream = new StreamDoctrineDbalStoreStream( + $result->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + $platform->reveal(), + ); + + $position = $stream->position(); + + self::assertSame(0, $position); + } +} From d2f82b811f4e8ff0d7020baaa3a92d823715eed7 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 26 Jul 2024 10:19:25 +0200 Subject: [PATCH 12/15] add more tests --- .../Store/StreamDoctrineDbalStoreTest.php | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php index 7dddf2ec6..e2347005c 100644 --- a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php @@ -25,6 +25,7 @@ use Patchlevel\EventSourcing\Serializer\EventSerializer; use Patchlevel\EventSourcing\Serializer\SerializedEvent; use Patchlevel\EventSourcing\Store\Criteria\CriteriaBuilder; +use Patchlevel\EventSourcing\Store\InvalidStreamName; use Patchlevel\EventSourcing\Store\MissingDataForStorage; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Store\StreamHeader; @@ -253,6 +254,134 @@ public function testLoadWithIndex(): void self::assertSame(null, $stream->position()); } + public function testLoadWithLike(): void + { + $connection = $this->prophesize(Connection::class); + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->willReturn(new EmptyIterator()); + + $connection->executeQuery( + 'SELECT * FROM event_store WHERE (stream LIKE :stream) AND (playhead > :playhead) AND (archived = :archived) ORDER BY id ASC', + [ + 'stream' => 'profile-%', + 'playhead' => 0, + 'archived' => false, + ], + Argument::type('array'), + )->willReturn($result->reveal()); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-*') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithLikeAll(): void + { + $connection = $this->prophesize(Connection::class); + $result = $this->prophesize(Result::class); + $result->iterateAssociative()->willReturn(new EmptyIterator()); + + $connection->executeQuery( + 'SELECT * FROM event_store WHERE (playhead > :playhead) AND (archived = :archived) ORDER BY id ASC', + [ + 'playhead' => 0, + 'archived' => false, + ], + Argument::type('array'), + )->willReturn($result->reveal()); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + $abstractPlatform->createSelectSQLBuilder()->shouldBeCalledOnce()->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform->reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('*') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithLikeInvalid(): void + { + $connection = $this->prophesize(Connection::class); + + $abstractPlatform = $this->prophesize(AbstractPlatform::class); + + $connection->getDatabasePlatform()->willReturn($abstractPlatform->reveal()); + $queryBuilder = new QueryBuilder($connection->reveal()); + $connection->createQueryBuilder()->willReturn($queryBuilder); + + $eventSerializer = $this->prophesize(EventSerializer::class); + $headersSerializer = $this->prophesize(HeadersSerializer::class); + + $doctrineDbalStore = new StreamDoctrineDbalStore( + $connection->reveal(), + $eventSerializer->reveal(), + $headersSerializer->reveal(), + ); + + $this->expectException(InvalidStreamName::class); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('*-*') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + public function testLoadWithOneEvent(): void { $connection = $this->prophesize(Connection::class); From 362f1d9e67759263c1ffba3368fe86e4d771d861 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 26 Jul 2024 10:41:23 +0200 Subject: [PATCH 13/15] fix psalm --- baseline.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/baseline.xml b/baseline.xml index 45ab6dee9..dba7c91bd 100644 --- a/baseline.xml +++ b/baseline.xml @@ -378,6 +378,16 @@ 'FOR UPDATE', 'SKIP LOCKED', )]]> + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> + reveal(), + 'FOR UPDATE', + 'SKIP LOCKED', + )]]> From b3a467172358153e9d2b0125baf0d355bf3dedca Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 29 Jul 2024 10:12:15 +0200 Subject: [PATCH 14/15] add streamName method into aggregate header --- src/Aggregate/AggregateHeader.php | 5 +++++ src/Console/OutputStyle.php | 12 +----------- .../Translator/RecalculatePlayheadTranslator.php | 7 +------ 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/Aggregate/AggregateHeader.php b/src/Aggregate/AggregateHeader.php index d4e9b515d..9fe5f1ec3 100644 --- a/src/Aggregate/AggregateHeader.php +++ b/src/Aggregate/AggregateHeader.php @@ -19,4 +19,9 @@ public function __construct( public readonly DateTimeImmutable $recordedOn, ) { } + + public function streamName(): string + { + return StreamNameTranslator::streamName($this->aggregateName, $this->aggregateId); + } } diff --git a/src/Console/OutputStyle.php b/src/Console/OutputStyle.php index e00faf909..20b44dbee 100644 --- a/src/Console/OutputStyle.php +++ b/src/Console/OutputStyle.php @@ -5,7 +5,6 @@ namespace Patchlevel\EventSourcing\Console; use Patchlevel\EventSourcing\Aggregate\AggregateHeader; -use Patchlevel\EventSourcing\Aggregate\StreamNameTranslator; use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Serializer\HeadersSerializer; @@ -71,7 +70,7 @@ public function message( ], [ [ - $this->streamName($metaHeader), + $metaHeader instanceof AggregateHeader ? $metaHeader->streamName() : $metaHeader->streamName, $metaHeader->playhead, $metaHeader->recordedOn?->format('Y-m-d H:i:s'), $streamStart ? 'yes' : 'no', @@ -108,13 +107,4 @@ private function metaHeader(Message $message): AggregateHeader|StreamHeader return $message->header(StreamHeader::class); } } - - private function streamName(AggregateHeader|StreamHeader $header): string - { - if ($header instanceof AggregateHeader) { - return StreamNameTranslator::streamName($header->aggregateName, $header->aggregateId); - } - - return $header->streamName; - } } diff --git a/src/Message/Translator/RecalculatePlayheadTranslator.php b/src/Message/Translator/RecalculatePlayheadTranslator.php index b9084567f..27185baaa 100644 --- a/src/Message/Translator/RecalculatePlayheadTranslator.php +++ b/src/Message/Translator/RecalculatePlayheadTranslator.php @@ -5,7 +5,6 @@ namespace Patchlevel\EventSourcing\Message\Translator; use Patchlevel\EventSourcing\Aggregate\AggregateHeader; -use Patchlevel\EventSourcing\Aggregate\StreamNameTranslator; use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Store\StreamHeader; @@ -30,11 +29,7 @@ public function __invoke(Message $message): array } } - if ($header instanceof StreamHeader) { - $stream = $header->streamName; - } else { - $stream = StreamNameTranslator::streamName($header->aggregateName, $header->aggregateId); - } + $stream = $header instanceof StreamHeader ? $header->streamName : $header->streamName(); $playhead = $this->nextPlayhead($stream); From 21a31d7e5d6b14548903d6f24a800e8120fbabaf Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 29 Jul 2024 10:23:45 +0200 Subject: [PATCH 15/15] fix plsam --- src/Aggregate/StreamNameTranslator.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Aggregate/StreamNameTranslator.php b/src/Aggregate/StreamNameTranslator.php index e3030b341..00c74bf98 100644 --- a/src/Aggregate/StreamNameTranslator.php +++ b/src/Aggregate/StreamNameTranslator.php @@ -14,6 +14,7 @@ private function __construct() { } + /** @pure */ public static function streamName(string $aggregate, string $aggregateId): string { return $aggregate . '-' . $aggregateId;