From 6987cd00ad0bd344ab0b3d65472f6ce0fec2cb19 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 24 Jul 2025 08:39:13 +0200 Subject: [PATCH 01/22] add taggable doctrine store --- .../Message/MessageHeaderRegistry.php | 2 + src/Store/Criteria/TagCriterion.php | 26 + src/Store/Header/TagsHeader.php | 15 + src/Store/TaggableDoctrineDbalStore.php | 657 ++++++++++++++++++ src/Store/TaggableDoctrineDbalStoreStream.php | 179 +++++ .../Store/TaggableDoctrineDbalStoreTest.php | 594 ++++++++++++++++ 6 files changed, 1473 insertions(+) create mode 100644 src/Store/Criteria/TagCriterion.php create mode 100644 src/Store/Header/TagsHeader.php create mode 100644 src/Store/TaggableDoctrineDbalStore.php create mode 100644 src/Store/TaggableDoctrineDbalStoreStream.php create mode 100644 tests/Integration/Store/TaggableDoctrineDbalStoreTest.php diff --git a/src/Metadata/Message/MessageHeaderRegistry.php b/src/Metadata/Message/MessageHeaderRegistry.php index 75ceeba0..ed15f4d3 100644 --- a/src/Metadata/Message/MessageHeaderRegistry.php +++ b/src/Metadata/Message/MessageHeaderRegistry.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\Header\TagsHeader; use Patchlevel\EventSourcing\Store\StreamStartHeader; use function array_flip; @@ -83,6 +84,7 @@ public static function createWithInternalHeaders(array $headerNameToClassMap = [ 'newStreamStart' => StreamStartHeader::class, 'eventId' => EventIdHeader::class, 'index' => IndexHeader::class, + 'tags' => TagsHeader::class, ]; return new self($headerNameToClassMap + $internalHeaders); diff --git a/src/Store/Criteria/TagCriterion.php b/src/Store/Criteria/TagCriterion.php new file mode 100644 index 00000000..957a3f9c --- /dev/null +++ b/src/Store/Criteria/TagCriterion.php @@ -0,0 +1,26 @@ + */ + public readonly array $tags; + + public function __construct( + string ...$tags, + ) { + $this->tags = array_values(array_unique($tags)); + + if ($this->tags === []) { + throw new InvalidArgumentException('At least one tag must be provided.'); + } + } +} diff --git a/src/Store/Header/TagsHeader.php b/src/Store/Header/TagsHeader.php new file mode 100644 index 00000000..f52a6921 --- /dev/null +++ b/src/Store/Header/TagsHeader.php @@ -0,0 +1,15 @@ + $tags */ + public function __construct( + public readonly array $tags, + ) { + } +} diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php new file mode 100644 index 00000000..3605f4e6 --- /dev/null +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -0,0 +1,657 @@ +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, + 'keep_index' => false, + ], $config); + + $platform = $this->connection->getDatabasePlatform(); + + $this->isMysql = $platform instanceof MySQLPlatform; + $this->isMariaDb = $platform instanceof MariaDBPlatform; + $this->isPostgres = $platform instanceof PostgreSQLPlatform; + $this->isSQLite = $platform instanceof SQLitePlatform; + } + + public function load( + Criteria|null $criteria = null, + int|null $limit = null, + int|null $offset = null, + bool $backwards = false, + ): TaggableDoctrineDbalStoreStream { + $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 TaggableDoctrineDbalStoreStream( + $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: + if ($criterion->all()) { + break; + } + + $streamFilters = []; + + foreach ($criterion->streamName as $index => $streamName) { + if (str_contains($streamName, '*')) { + $streamFilters[] = 'stream LIKE :stream_' . $index; + $builder->setParameter('stream_' . $index, str_replace('*', '%', $streamName)); + } else { + $streamFilters[] = 'stream = :stream_' . $index; + $builder->setParameter('stream_' . $index, $streamName); + } + } + + if ($streamFilters === []) { + break; + } + + $builder->andWhere($builder->expr()->or(...$streamFilters)); + + break; + case FromPlayheadCriterion::class: + $builder->andWhere('playhead > :from_playhead'); + $builder->setParameter('from_playhead', $criterion->fromPlayhead, Types::INTEGER); + break; + case ToPlayheadCriterion::class: + $builder->andWhere('playhead < :to_playhead'); + $builder->setParameter('to_playhead', $criterion->toPlayhead, Types::INTEGER); + break; + case ArchivedCriterion::class: + $builder->andWhere('archived = :archived'); + $builder->setParameter('archived', $criterion->archived, Types::BOOLEAN); + break; + case FromIndexCriterion::class: + $builder->andWhere('id > :from_index'); + $builder->setParameter('from_index', $criterion->fromIndex, Types::INTEGER); + break; + case ToIndexCriterion::class: + $builder->andWhere('id < :to_index'); + $builder->setParameter('to_index', $criterion->toIndex, Types::INTEGER); + break; + case EventsCriterion::class: + $builder->andWhere('event_name IN (:events)'); + $builder->setParameter('events', $criterion->events, ArrayParameterType::STRING); + break; + case EventIdCriterion::class: + $builder->andWhere('event_id = :event_id'); + $builder->setParameter('event_id', $criterion->eventId, ArrayParameterType::STRING); + break; + case TagCriterion::class: + if ($this->isSQLite) { + $builder->andWhere('NOT EXISTS(SELECT value FROM JSON_EACH(:tags) WHERE value NOT IN (SELECT value FROM JSON_EACH(tags)))'); + } elseif ($this->isPostgres) { + $builder->andWhere('tags @> :tags::jsonb'); + } elseif ($this->isMysql || $this->isMariaDb) { + $builder->andWhere('JSON_CONTAINS(tags, :tags)'); + } else { + throw new UnsupportedCriterion($criterion::class); + } + + $builder->setParameter('tags', $criterion->tags, Types::JSON); + break; + default: + throw new UnsupportedCriterion($criterion::class); + } + } + } + + public function save(Message ...$messages): void + { + if ($messages === []) { + return; + } + + $this->transactional( + function () use ($messages): void { + $booleanType = Type::getType(Types::BOOLEAN); + $dateTimeType = Type::getType(Types::DATETIMETZ_IMMUTABLE); + $jsonType = Type::getType(Types::JSON); + + $columns = [ + 'stream', + 'playhead', + 'event_id', + 'event_name', + 'event_payload', + 'tags', + 'recorded_on', + 'archived', + 'custom_headers', + ]; + + if ($this->config['keep_index']) { + $columns[] = 'id'; + } + + $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 { + $streamName = $message->header(StreamNameHeader::class)->streamName; + $parameters[] = $streamName; + } catch (HeaderNotFound $e) { + throw new MissingDataForStorage($e->name, $e); + } + + if ($message->hasHeader(PlayheadHeader::class)) { + $parameters[] = $message->header(PlayheadHeader::class)->playhead; + } else { + $parameters[] = null; + } + + if ($message->hasHeader(EventIdHeader::class)) { + $eventId = $message->header(EventIdHeader::class)->eventId; + } else { + $eventId = Uuid::uuid7()->toString(); + } + + $parameters[] = $eventId; + $parameters[] = $data->name; + $parameters[] = $data->payload; + + if ($message->hasHeader(TagsHeader::class)) { + $parameters[] = $message->header(TagsHeader::class)->tags; + } else { + $parameters[] = []; + } + + $types[$offset + 5] = $jsonType; + + if ($message->hasHeader(RecordedOnHeader::class)) { + $parameters[] = $message->header(RecordedOnHeader::class)->recordedOn; + } else { + $parameters[] = $this->clock->now(); + } + + $types[$offset + 6] = $dateTimeType; + + $parameters[] = $message->hasHeader(ArchivedHeader::class); + $types[$offset + 7] = $booleanType; + + $parameters[] = $this->headersSerializer->serialize($this->getCustomHeaders($message)); + + if ($this->config['keep_index']) { + try { + $parameters[] = $message->header(IndexHeader::class)->index; + } catch (HeaderNotFound $e) { + throw new MissingDataForStorage($e->name, $e); + } + } + + $position++; + + if ($position !== $batchSize) { + continue; + } + + $this->executeSave($columns, $placeholders, $parameters, $types, $this->connection); + + $parameters = []; + $placeholders = []; + $types = []; + + $position = 0; + } + + if ($position === 0) { + return; + } + + $this->executeSave($columns, $placeholders, $parameters, $types, $this->connection); + + if (!$this->config['keep_index'] || !$this->isPostgres) { + return; + } + + $this->connection->executeStatement( + sprintf( + "SELECT setval('%s', (SELECT MAX(id) FROM %s));", + sprintf('%s_id_seq', $this->config['table_name']), + $this->config['table_name'], + ), + ); + }, + ); + } + + /** + * @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(); + } + }); + } + } + + /** @return list */ + public function streams(): array + { + $builder = $this->connection->createQueryBuilder() + ->select('stream') + ->distinct() + ->from($this->config['table_name']) + ->orderBy('stream'); + + /** @var list $streams */ + $streams = $builder->fetchFirstColumn(); + + return $streams; + } + + public function remove(Criteria|null $criteria = null): void + { + $builder = $this->connection->createQueryBuilder(); + + $builder->delete($this->config['table_name']); + $this->applyCriteria($builder, $criteria ?? new Criteria()); + + $builder->executeStatement(); + } + + public function archive(Criteria|null $criteria = null): void + { + $builder = $this->connection->createQueryBuilder(); + + $builder->update($this->config['table_name']); + + $this->applyCriteria($builder, $criteria ?? new Criteria()); + + $builder + ->set('archived', ':value') + ->setParameter('value', true, Types::BOOLEAN); + + $builder->executeStatement(); + } + + 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_id', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('event_name', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('event_payload', Types::JSON) + ->setNotnull(true); + $table->addColumn('recorded_on', Types::DATETIMETZ_IMMUTABLE) + ->setNotnull(true); + $table->addColumn('archived', Types::BOOLEAN) + ->setNotnull(true) + ->setDefault(false); + $table->addColumn('tags', Types::JSON) + ->setPlatformOptions($this->isPostgres ? ['jsonb' => true] : []) + ->setLength(255) + ->setNotnull(false); + + $table->addColumn('custom_headers', Types::JSON) + ->setNotnull(true); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['event_id']); + $table->addUniqueIndex(['stream', 'playhead']); + $table->addIndex(['stream', 'playhead', 'archived']); + } + + /** @return list */ + private function getCustomHeaders(Message $message): array + { + $filteredHeaders = [ + IndexHeader::class, + StreamNameHeader::class, + EventIdHeader::class, + PlayheadHeader::class, + RecordedOnHeader::class, + ArchivedHeader::class, + ]; + + return array_values( + array_filter( + $message->headers(), + static fn (object $header) => !in_array($header::class, $filteredHeaders, true), + ), + ); + } + + public function supportSubscription(): bool + { + return $this->isPostgres && 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', NEW.stream::text); + 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, + )); + } + + public function connection(): Connection + { + return $this->connection; + } + + 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; + + if ($this->isPostgres) { + $this->connection->executeStatement( + sprintf( + 'SELECT pg_advisory_xact_lock(%s)', + $this->config['lock_id'], + ), + ); + + return; + } + + if ($this->isMariaDb || $this->isMysql) { + $this->connection->fetchAllAssociative( + sprintf( + 'SELECT GET_LOCK("%s", %d)', + $this->config['lock_id'], + $this->config['lock_timeout'], + ), + ); + + return; + } + + if ($this->isSQLite) { + return; // sql locking is not needed because of file locking + } + + throw new LockingNotImplemented( + $this->connection->getDatabasePlatform()::class, + ); + } + + private function unlock(): void + { + $this->hasLock = false; + + if ($this->isPostgres) { + return; // lock is released automatically after transaction + } + + if ($this->isMariaDb || $this->isMysql) { + $this->connection->fetchAllAssociative( + sprintf( + 'SELECT RELEASE_LOCK("%s")', + $this->config['lock_id'], + ), + ); + + return; + } + + if ($this->isSQLite) { + return; // sql locking is not needed because of file locking + } + + throw new LockingNotImplemented( + $this->connection->getDatabasePlatform()::class, + ); + } +} diff --git a/src/Store/TaggableDoctrineDbalStoreStream.php b/src/Store/TaggableDoctrineDbalStoreStream.php new file mode 100644 index 00000000..fc9affb3 --- /dev/null +++ b/src/Store/TaggableDoctrineDbalStoreStream.php @@ -0,0 +1,179 @@ + + */ +final class TaggableDoctrineDbalStoreStream 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 { + /** @var DateTimeTzImmutableType $dateTimeType */ + $dateTimeType = Type::getType(Types::DATETIMETZ_IMMUTABLE); + + /** @var array{id: positive-int, stream: string, playhead: int|string|null, event_id: string, event_name: string, event_payload: string, tags: string, recorded_on: string, archived: 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_name'], $data['event_payload'])); + + $message = Message::create($event) + ->withHeader(new IndexHeader($data['id'])) + ->withHeader(new StreamNameHeader($data['stream'])) + ->withHeader(new RecordedOnHeader($dateTimeType->convertToPHPValue($data['recorded_on'], $platform))) + ->withHeader(new EventIdHeader($data['event_id'])); + + if ($data['playhead'] !== null) { + $message = $message->withHeader(new PlayheadHeader((int)$data['playhead'])); + } + + if ($data['archived']) { + $message = $message->withHeader(new ArchivedHeader()); + } + + if ($data['tags'] !== '[]') { + $message = $message->withHeader( + new TagsHeader( + json_decode($data['tags'], true, 512, JSON_THROW_ON_ERROR), + ), + ); + } + + $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/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php new file mode 100644 index 00000000..c8b50387 --- /dev/null +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -0,0 +1,594 @@ +connection = DbalManager::createConnection(); + + $this->clock = new FrozenClock(new DateTimeImmutable('2020-01-01 00:00:00')); + + $this->store = new TaggableDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + clock: $this->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 StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(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_name']); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result1['event_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_name']); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result2['event_payload'], true), + ); + } + + public function testSaveWithIndex(): void + { + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new IndexHeader(1)), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-02 00:00:00'))) + ->withHeader(new IndexHeader(42)), + ]; + + $store = new TaggableDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + clock: $this->clock, + config: ['keep_index' => true], + ); + + $store->save(...$messages); + + $store = new TaggableDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + clock: $this->clock, + ); + + $store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(3)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-02 00:00:00'))), + ); + + /** @var list> $result */ + $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store'); + + self::assertCount(3, $result); + + $result1 = $result[0]; + + self::assertEquals(1, $result1['id']); + 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_name']); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result1['event_payload'], true), + ); + + $result2 = $result[1]; + + self::assertEquals(42, $result2['id']); + 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_name']); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result2['event_payload'], true), + ); + + $result3 = $result[2]; + + self::assertEquals(43, $result3['id']); + self::assertEquals(sprintf('profile-%s', $profileId->toString()), $result3['stream']); + self::assertEquals('3', $result3['playhead']); + self::assertStringContainsString('2020-01-02 00:00:00', $result3['recorded_on']); + self::assertEquals('profile.created', $result3['event_name']); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result3['event_payload'], true), + ); + } + + public function testSaveWithOnlyStreamName(): void + { + $messages = [ + Message::create(new ExternEvent('test 1')) + ->withHeader(new StreamNameHeader('extern')), + Message::create(new ExternEvent('test 2')) + ->withHeader(new StreamNameHeader('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('extern', $result1['event_name']); + self::assertEquals( + ['message' => 'test 1'], + json_decode($result1['event_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('extern', $result2['event_name']); + self::assertEquals( + ['message' => 'test 2'], + json_decode($result2['event_payload'], true), + ); + } + + public function testSaveWithTransactional(): void + { + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(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_name']); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result1['event_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_name']); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result2['event_payload'], true), + ); + } + + public function testArchive(): void + { + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new EventIdHeader('2')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-02 00:00:00'))), + ]; + + $this->store->save(...$messages); + $this->store->archive( + new Criteria( + new StreamCriterion(sprintf('profile-%s', $profileId->toString())), + new ToPlayheadCriterion(2), + ), + ); + + /** @var list> $result */ + $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store ORDER BY id'); + + 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_name']); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result1['event_payload'], true), + ); + + self::assertEquals('1', $result1['archived']); + + $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_name']); + self::assertEquals( + ['profileId' => $profileId->toString(), 'name' => 'test'], + json_decode($result2['event_payload'], true), + ); + + self::assertEquals('0', $result2['archived']); + } + + public function testUniqueStreamNameAndPlayheadConstraint(): void + { + $this->expectException(UniqueConstraintViolation::class); + + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ]; + + $this->store->save(...$messages); + } + + public function testUniqueEventIdConstraint(): void + { + $this->expectException(UniqueConstraintViolation::class); + + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 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 StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader($i)) + ->withHeader(new RecordedOnHeader(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 StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(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(StreamNameHeader::class)->streamName, + $loadedMessage->header(StreamNameHeader::class)->streamName, + ); + self::assertEquals( + $message->header(PlayheadHeader::class)->playhead, + $loadedMessage->header(PlayheadHeader::class)->playhead, + ); + self::assertEquals( + $message->header(RecordedOnHeader::class)->recordedOn, + $loadedMessage->header(RecordedOnHeader::class)->recordedOn, + ); + } finally { + $stream?->close(); + } + } + + public function testLoadWithWildcard(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId1->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId2->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('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(); + } + + try { + $stream = $this->store->load(new Criteria(new StreamCriterion('*-*'))); + + $messages = iterator_to_array($stream); + + self::assertCount(2, $messages); + } finally { + $stream?->close(); + } + } + + public function testTags(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])), + Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])), + ]; + + $this->store->save(...$messages); + + $stream = null; + + try { + $stream = $this->store->load( + new Criteria( + new TagCriterion('profile:' . $profileId1->toString()), + ), + ); + + $messages = iterator_to_array($stream); + + self::assertCount(2, $messages); + + self::assertEquals( + ['profile:' . $profileId1->toString()], + $messages[0]->header(TagsHeader::class)->tags, + ); + + self::assertEquals( + ['profile:' . $profileId1->toString(), 'profile:' . $profileId2->toString()], + $messages[1]->header(TagsHeader::class)->tags, + ); + } finally { + $stream?->close(); + } + } + + public function testStreams(): void + { + $profileId = ProfileId::fromString('0190e47e-77e9-7b90-bf62-08bbf0ab9b4b'); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')), + ]; + + $this->store->save(...$messages); + + $streams = $this->store->streams(); + + self::assertEquals([ + 'foo', + 'profile-0190e47e-77e9-7b90-bf62-08bbf0ab9b4b', + ], $streams); + } + + public function testRemove(): void + { + $profileId = ProfileId::fromString('0190e47e-77e9-7b90-bf62-08bbf0ab9b4b'); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')), + ]; + + $this->store->save(...$messages); + + $streams = $this->store->streams(); + + self::assertEquals([ + 'foo', + 'profile-0190e47e-77e9-7b90-bf62-08bbf0ab9b4b', + ], $streams); + + $this->store->remove(new Criteria(new StreamCriterion('profile-*'))); + + $streams = $this->store->streams(); + + self::assertEquals(['foo'], $streams); + } +} From 919bdbbbfcf2d6f4b186291b2b0fccf293c4b263 Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 28 Jul 2025 12:53:54 +0200 Subject: [PATCH 02/22] dcb poc --- docs/pages/dynamic_consistency_boundary.md | 2 + src/Attribute/EventTag.php | 16 ++ src/DCB/AppendCondition.php | 16 ++ src/DCB/AttributeEventTagExtractor.php | 58 ++++ src/DCB/CompositeProjection.php | 95 +++++++ src/DCB/DecisionModel.php | 50 ++++ src/DCB/DecisionModelBuilder.php | 14 + src/DCB/EventAppender.php | 12 + src/DCB/EventTagExtractor.php | 12 + src/DCB/HighestSequenceNumber.php | 24 ++ src/DCB/Projection.php | 41 +++ src/DCB/StoreDecisionModelBuilder.php | 50 ++++ src/DCB/StoreEventAppender.php | 35 +++ .../MessageDecorator/EventTagDecorator.php | 25 ++ src/Store/Criteria/TagCriterion.php | 15 +- src/Store/Header/TagsHeader.php | 7 +- src/Store/TaggableDoctrineDbalStore.php | 249 +++++++++++++++--- .../Course/Command/ChangeCourseCapacity.php | 16 ++ .../Course/Command/DefineCourse.php | 16 ++ .../Command/SubscribeStudentToCourse.php | 17 ++ .../Course/CourseId.php | 13 + .../Course/Event/CourseCapacityChanged.php | 20 ++ .../Course/Event/CourseDefined.php | 20 ++ .../Event/StudentSubscribedToCourse.php | 22 ++ .../Handler/ChangeCourseCapacityHandler.php | 49 ++++ .../Course/Handler/DefineCourseHandler.php | 43 +++ .../SubscribeStudentToCourseHandler.php | 66 +++++ .../Projection/CourseCapacityProjection.php | 42 +++ .../Course/Projection/CourseExists.php | 36 +++ .../NumberOfCourseSubscriptionsProjection.php | 35 +++ ...NumberOfStudentSubscriptionsProjection.php | 35 +++ .../StudentAlreadySubscribedProjection.php | 40 +++ .../Course/StudentId.php | 13 + .../DynamicConsistencyBoundaryTest.php | 115 ++++++++ .../Invoice/Command/CreateInvoice.php | 13 + .../Invoice/Event/InvoiceCreated.php | 19 ++ .../Invoice/Handler/CreateInvoiceHandler.php | 38 +++ .../NextInvoiceNumberProjection.php | 27 ++ .../Store/TaggableDoctrineDbalStoreTest.php | 108 +++++++- 39 files changed, 1470 insertions(+), 54 deletions(-) create mode 100644 docs/pages/dynamic_consistency_boundary.md create mode 100644 src/Attribute/EventTag.php create mode 100644 src/DCB/AppendCondition.php create mode 100644 src/DCB/AttributeEventTagExtractor.php create mode 100644 src/DCB/CompositeProjection.php create mode 100644 src/DCB/DecisionModel.php create mode 100644 src/DCB/DecisionModelBuilder.php create mode 100644 src/DCB/EventAppender.php create mode 100644 src/DCB/EventTagExtractor.php create mode 100644 src/DCB/HighestSequenceNumber.php create mode 100644 src/DCB/Projection.php create mode 100644 src/DCB/StoreDecisionModelBuilder.php create mode 100644 src/DCB/StoreEventAppender.php create mode 100644 src/Repository/MessageDecorator/EventTagDecorator.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Command/ChangeCourseCapacity.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Command/DefineCourse.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Command/SubscribeStudentToCourse.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/CourseId.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Event/CourseCapacityChanged.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Event/CourseDefined.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Event/StudentSubscribedToCourse.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Handler/ChangeCourseCapacityHandler.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Handler/DefineCourseHandler.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Handler/SubscribeStudentToCourseHandler.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseCapacityProjection.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseExists.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/StudentId.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Invoice/Command/CreateInvoice.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Invoice/Event/InvoiceCreated.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Invoice/Handler/CreateInvoiceHandler.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php diff --git a/docs/pages/dynamic_consistency_boundary.md b/docs/pages/dynamic_consistency_boundary.md new file mode 100644 index 00000000..7064df33 --- /dev/null +++ b/docs/pages/dynamic_consistency_boundary.md @@ -0,0 +1,2 @@ +# Dynamic Consistency Boundary + diff --git a/src/Attribute/EventTag.php b/src/Attribute/EventTag.php new file mode 100644 index 00000000..e2506fcc --- /dev/null +++ b/src/Attribute/EventTag.php @@ -0,0 +1,16 @@ +> $tags */ + public function __construct( + public readonly array $tags, + public readonly HighestSequenceNumber|null $expectedHighestSequenceNumber = null, + ) { + } +} diff --git a/src/DCB/AttributeEventTagExtractor.php b/src/DCB/AttributeEventTagExtractor.php new file mode 100644 index 00000000..49618d4a --- /dev/null +++ b/src/DCB/AttributeEventTagExtractor.php @@ -0,0 +1,58 @@ + */ + public function extract(object $event): array + { + $reflectionClass = new ReflectionClass($event); + + $tags = []; + + foreach ($reflectionClass->getProperties() as $property) { + $attributes = $property->getAttributes(EventTag::class); + + if ($attributes === []) { + continue; + } + + $attribute = $attributes[0]->newInstance(); + + $value = $property->getValue($event); + + if ($value instanceof AggregateRootId) { + $value = $value->toString(); + } + + if (!is_string($value) && !is_int($value)) { + throw new RuntimeException( + sprintf('Event tag value must be a string or an int, %s given', get_debug_type($value)), + ); + } + + if ($attribute->prefix) { + $value = $attribute->prefix . ':' . $value; + } + + $tags[(string)$value] = true; + } + + return array_keys($tags); + } +} diff --git a/src/DCB/CompositeProjection.php b/src/DCB/CompositeProjection.php new file mode 100644 index 00000000..628c9726 --- /dev/null +++ b/src/DCB/CompositeProjection.php @@ -0,0 +1,95 @@ +> + */ +final class CompositeProjection extends Projection +{ + /** @param array $projections */ + public function __construct( + private readonly array $projections, + ) { + } + + /** @return list */ + public function tagFilter(): array + { + $tags = []; + + foreach ($this->projections as $projection) { + $tags = array_merge($tags, $projection->tagFilter()); + } + + return array_values(array_unique($tags)); + } + + /** @return list> */ + public function groupedTagFilter(): array + { + $result = []; + + foreach ($this->projections as $projection) { + $tags = $projection->tagFilter(); + + sort($tags); + + if (in_array($tags, $result, true)) { + continue; + } + + $result[] = $tags; + } + + return $result; + } + + /** @return array */ + public function initialState(): array + { + return array_map(static function (Projection $projection) { + return $projection->initialState(); + }, $this->projections); + } + + public function apply(mixed $state, Message $message): mixed + { + $tags = $message->header(TagsHeader::class)->tags; + + foreach ($this->projections as $name => $projection) { + $neededTags = $projection->tagFilter(); + + if (!$this->isSubset($neededTags, $tags)) { + continue; + } + + $state[$name] = $projection->apply($state[$name], $message); + } + + return $state; + } + + /** + * @param list $needle + * @param list $haystack + */ + private function isSubset(array $needle, array $haystack): bool + { + return empty(array_diff($needle, $haystack)); + } +} diff --git a/src/DCB/DecisionModel.php b/src/DCB/DecisionModel.php new file mode 100644 index 00000000..5de31669 --- /dev/null +++ b/src/DCB/DecisionModel.php @@ -0,0 +1,50 @@ + + */ +final class DecisionModel implements ArrayAccess +{ + /** @param array $state */ + public function __construct( + public readonly array $state, + public readonly AppendCondition $appendCondition, + ) { + } + + public function offsetExists(mixed $offset): bool + { + return array_key_exists($offset, $this->state); + } + + public function offsetGet(mixed $offset): mixed + { + if (!$this->offsetExists($offset)) { + throw new OutOfBoundsException("Offset '$offset' does not exist in the state."); + } + + return $this->state[$offset]; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new LogicException('State is immutable, cannot set value.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new LogicException('State is immutable, cannot unset value.'); + } +} diff --git a/src/DCB/DecisionModelBuilder.php b/src/DCB/DecisionModelBuilder.php new file mode 100644 index 00000000..90e64019 --- /dev/null +++ b/src/DCB/DecisionModelBuilder.php @@ -0,0 +1,14 @@ + $projections */ + public function build( + array $projections, + ): DecisionModel; +} diff --git a/src/DCB/EventAppender.php b/src/DCB/EventAppender.php new file mode 100644 index 00000000..3e3c19ec --- /dev/null +++ b/src/DCB/EventAppender.php @@ -0,0 +1,12 @@ + $events */ + public function append(iterable $events, AppendCondition|null $appendCondition = null): void; +} diff --git a/src/DCB/EventTagExtractor.php b/src/DCB/EventTagExtractor.php new file mode 100644 index 00000000..14906131 --- /dev/null +++ b/src/DCB/EventTagExtractor.php @@ -0,0 +1,12 @@ + */ + public function extract(object $event): array; +} diff --git a/src/DCB/HighestSequenceNumber.php b/src/DCB/HighestSequenceNumber.php new file mode 100644 index 00000000..9d863f65 --- /dev/null +++ b/src/DCB/HighestSequenceNumber.php @@ -0,0 +1,24 @@ +value === 0; + } +} diff --git a/src/DCB/Projection.php b/src/DCB/Projection.php new file mode 100644 index 00000000..6ab922da --- /dev/null +++ b/src/DCB/Projection.php @@ -0,0 +1,41 @@ + */ + abstract public function tagFilter(): array; + + /** @return S */ + abstract public function initialState(): mixed; + + /** + * @param S $state + * + * @return S + */ + public function apply(mixed $state, Message $message): mixed + { + $event = $message->event(); + + $method = 'apply' . (new ReflectionClass($event))->getShortName(); + + if (method_exists($this, $method)) { + $state = $this->{$method}($state, $event); + } + + return $state; + } +} diff --git a/src/DCB/StoreDecisionModelBuilder.php b/src/DCB/StoreDecisionModelBuilder.php new file mode 100644 index 00000000..f7d2b0b5 --- /dev/null +++ b/src/DCB/StoreDecisionModelBuilder.php @@ -0,0 +1,50 @@ + $projections */ + public function build( + array $projections, + ): DecisionModel { + $projection = new CompositeProjection($projections); + + $stream = $this->store->load( + new Criteria( + new StreamCriterion('main'), + new TagCriterion(...$projection->groupedTagFilter()), + ), + ); + + $state = $projection->initialState(); + + $highestId = 0; + + foreach ($stream as $message) { + $highestId = $stream->index(); + $state = $projection->apply($state, $message); + } + + return new DecisionModel( + $state, + new AppendCondition( + $projection->groupedTagFilter(), + new HighestSequenceNumber($highestId ?? 0), + ), + ); + } +} diff --git a/src/DCB/StoreEventAppender.php b/src/DCB/StoreEventAppender.php new file mode 100644 index 00000000..c36f516e --- /dev/null +++ b/src/DCB/StoreEventAppender.php @@ -0,0 +1,35 @@ + $events */ + public function append(iterable $events, AppendCondition|null $appendCondition = null): void + { + $messages = array_map( + fn (object $event) => Message::create($event) + ->withHeader(new StreamNameHeader('main')) + ->withHeader(new TagsHeader($this->eventTagExtractor->extract($event))), + $events, + ); + + $this->store->append($messages, $appendCondition); + } +} diff --git a/src/Repository/MessageDecorator/EventTagDecorator.php b/src/Repository/MessageDecorator/EventTagDecorator.php new file mode 100644 index 00000000..9b97b565 --- /dev/null +++ b/src/Repository/MessageDecorator/EventTagDecorator.php @@ -0,0 +1,25 @@ +eventTagExtractor->extract($message->event()); + + return $message->withHeader(new TagsHeader($tags)); + } +} diff --git a/src/Store/Criteria/TagCriterion.php b/src/Store/Criteria/TagCriterion.php index 957a3f9c..b4f3692c 100644 --- a/src/Store/Criteria/TagCriterion.php +++ b/src/Store/Criteria/TagCriterion.php @@ -4,23 +4,18 @@ namespace Patchlevel\EventSourcing\Store\Criteria; -use InvalidArgumentException; - -use function array_unique; use function array_values; +/** @experimental */ final class TagCriterion { - /** @var list */ + /** @var list> */ public readonly array $tags; + /** @param list ...$tags */ public function __construct( - string ...$tags, + array ...$tags, ) { - $this->tags = array_values(array_unique($tags)); - - if ($this->tags === []) { - throw new InvalidArgumentException('At least one tag must be provided.'); - } + $this->tags = array_values($tags); } } diff --git a/src/Store/Header/TagsHeader.php b/src/Store/Header/TagsHeader.php index f52a6921..9cd3b2a8 100644 --- a/src/Store/Header/TagsHeader.php +++ b/src/Store/Header/TagsHeader.php @@ -4,8 +4,11 @@ namespace Patchlevel\EventSourcing\Store\Header; -/** @psalm-immutable */ -class TagsHeader +/** + * @experimental + * @psalm-immutable + */ +final class TagsHeader { /** @param list $tags */ public function __construct( diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index 3605f4e6..a22125dd 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -13,10 +13,13 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Query\QueryBuilder; +use Doctrine\DBAL\Schema\Name\UnqualifiedName; +use Doctrine\DBAL\Schema\PrimaryKeyConstraint; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use Patchlevel\EventSourcing\Clock\SystemClock; +use Patchlevel\EventSourcing\DCB\AppendCondition; use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; @@ -42,9 +45,11 @@ use PDO; use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; +use RuntimeException; use function array_fill; use function array_filter; +use function array_map; use function array_merge; use function array_values; use function class_exists; @@ -55,6 +60,7 @@ use function in_array; use function is_int; use function is_string; +use function json_encode; use function sprintf; use function str_contains; use function str_replace; @@ -124,7 +130,7 @@ public function load( ): TaggableDoctrineDbalStoreStream { $builder = $this->connection->createQueryBuilder() ->select('*') - ->from($this->config['table_name']) + ->from($this->config['table_name'], 'events') ->orderBy('id', $backwards ? 'DESC' : 'ASC'); $this->applyCriteria($builder, $criteria ?? new Criteria()); @@ -224,17 +230,7 @@ private function applyCriteria(QueryBuilder $builder, Criteria $criteria): void $builder->setParameter('event_id', $criterion->eventId, ArrayParameterType::STRING); break; case TagCriterion::class: - if ($this->isSQLite) { - $builder->andWhere('NOT EXISTS(SELECT value FROM JSON_EACH(:tags) WHERE value NOT IN (SELECT value FROM JSON_EACH(tags)))'); - } elseif ($this->isPostgres) { - $builder->andWhere('tags @> :tags::jsonb'); - } elseif ($this->isMysql || $this->isMariaDb) { - $builder->andWhere('JSON_CONTAINS(tags, :tags)'); - } else { - throw new UnsupportedCriterion($criterion::class); - } - - $builder->setParameter('tags', $criterion->tags, Types::JSON); + $this->queryCondition($builder, $criterion->tags); break; default: throw new UnsupportedCriterion($criterion::class); @@ -286,42 +282,31 @@ function () use ($messages): void { $data = $this->eventSerializer->serialize($message->event()); - try { - $streamName = $message->header(StreamNameHeader::class)->streamName; - $parameters[] = $streamName; - } catch (HeaderNotFound $e) { - throw new MissingDataForStorage($e->name, $e); - } + $parameters[] = $message->hasHeader(StreamNameHeader::class) + ? $message->header(StreamNameHeader::class)->streamName + : 'default'; - if ($message->hasHeader(PlayheadHeader::class)) { - $parameters[] = $message->header(PlayheadHeader::class)->playhead; - } else { - $parameters[] = null; - } + $parameters[] = $message->hasHeader(PlayheadHeader::class) + ? $message->header(PlayheadHeader::class)->playhead + : null; - if ($message->hasHeader(EventIdHeader::class)) { - $eventId = $message->header(EventIdHeader::class)->eventId; - } else { - $eventId = Uuid::uuid7()->toString(); - } + $eventId = $message->hasHeader(EventIdHeader::class) + ? $message->header(EventIdHeader::class)->eventId + : Uuid::uuid7()->toString(); $parameters[] = $eventId; $parameters[] = $data->name; $parameters[] = $data->payload; - if ($message->hasHeader(TagsHeader::class)) { - $parameters[] = $message->header(TagsHeader::class)->tags; - } else { - $parameters[] = []; - } + $parameters[] = $message->hasHeader(TagsHeader::class) + ? $message->header(TagsHeader::class)->tags + : []; $types[$offset + 5] = $jsonType; - if ($message->hasHeader(RecordedOnHeader::class)) { - $parameters[] = $message->header(RecordedOnHeader::class)->recordedOn; - } else { - $parameters[] = $this->clock->now(); - } + $parameters[] = $message->hasHeader(RecordedOnHeader::class) + ? $message->header(RecordedOnHeader::class)->recordedOn + : $this->clock->now(); $types[$offset + 6] = $dateTimeType; @@ -374,6 +359,135 @@ function () use ($messages): void { ); } + /** @param iterable $messages */ + public function append(iterable $messages, AppendCondition|null $appendCondition = null): void + { + $this->transactional(function () use ($messages, $appendCondition): void { + $booleanType = Type::getType(Types::BOOLEAN); + $dateTimeType = Type::getType(Types::DATETIMETZ_IMMUTABLE); + $jsonType = Type::getType(Types::JSON); + + $columns = [ + 'stream', + 'playhead', + 'event_id', + 'event_name', + 'event_payload', + 'tags', + 'recorded_on', + 'archived', + 'custom_headers', + ]; + + $selects = []; + $parameters = []; + $types = []; + + $columnsLength = count($columns); + //$batchSize = (int)floor(self::MAX_UNSIGNED_SMALL_INT / $columnsLength); + $position = 0; + + foreach ($messages as $message) { + $selects[] = sprintf('SELECT %s', implode(', ', array_map( + static fn (string $column) => ':' . $column . $position, + $columns, + ))); + + /** @var int<0, max> $offset */ + $offset = $position * $columnsLength; + + $data = $this->eventSerializer->serialize($message->event()); + + $parameters['stream' . $position] = $message->hasHeader(StreamNameHeader::class) + ? $message->header(StreamNameHeader::class)->streamName + : 'default'; + + $parameters['playhead' . $position] = $message->hasHeader(PlayheadHeader::class) + ? $message->header(PlayheadHeader::class)->playhead + : null; + + $eventId = $message->hasHeader(EventIdHeader::class) + ? $message->header(EventIdHeader::class)->eventId + : Uuid::uuid7()->toString(); + + $parameters['event_id' . $position] = $eventId; + $parameters['event_name' . $position] = $data->name; + $parameters['event_payload' . $position] = $data->payload; + + $parameters['tags' . $position] = $message->hasHeader(TagsHeader::class) + ? $message->header(TagsHeader::class)->tags + : []; + + $types['tags' . $position] = $jsonType; + + $parameters['recorded_on' . $position] = $message->hasHeader(RecordedOnHeader::class) + ? $message->header(RecordedOnHeader::class)->recordedOn + : $this->clock->now(); + + $types['recorded_on' . $position] = $dateTimeType; + + $parameters['archived' . $position] = $message->hasHeader(ArchivedHeader::class); + $types['archived' . $position] = $booleanType; + + $parameters['custom_headers' . $position] = $this->headersSerializer->serialize($this->getCustomHeaders($message)); + + $position++; + + /* + if ($position !== $batchSize) { + continue; + } + + $this->executeSave($columns, $placeholders, $parameters, $types, $this->connection); + */ + } + + $query = sprintf( + 'INSERT INTO %s (%s) %s', + $this->config['table_name'], + implode(', ', $columns), + implode(' UNION ALL ', $selects), + ); + + if ($appendCondition instanceof AppendCondition && $appendCondition->tags !== []) { + $queryBuilder = $this->connection->createQueryBuilder() + ->select('events.id') + ->from($this->config['table_name'], 'events') + ->orderBy('events.id', 'DESC') + ->setMaxResults(1); + + $this->queryCondition($queryBuilder, $appendCondition->tags); + + if ($appendCondition->expectedHighestSequenceNumber->isNone()) { + $query .= ' WHERE NOT EXISTS (' . $queryBuilder->getSQL() . ')'; + } else { + $query .= ' WHERE (' . $queryBuilder->getSQL() . ') = :highestId'; + $parameters['highestId'] = $appendCondition->expectedHighestSequenceNumber->value; + } + + $parameters = array_merge( + $parameters, + $queryBuilder->getParameters(), + ); + + $types = array_merge( + $types, + $queryBuilder->getParameterTypes(), + ); + } + + try { + $affectedRows = $this->connection->executeStatement($query, $parameters, $types); + + if ($affectedRows === 0 && $appendCondition->expectedHighestSequenceNumber !== null) { + throw new UniqueConstraintViolation(); + } + } catch (UniqueConstraintViolationException $e) { + throw new UniqueConstraintViolation($e); + } + }); + } + /** * @param Closure():ClosureReturn $function * @@ -471,7 +585,11 @@ public function configureSchema(Schema $schema, Connection $connection): void $table->addColumn('custom_headers', Types::JSON) ->setNotnull(true); - $table->setPrimaryKey(['id']); + $table->addPrimaryKeyConstraint( + PrimaryKeyConstraint::editor()->setColumnNames( + UnqualifiedName::unquoted('id'), + )->create(), + ); $table->addUniqueIndex(['event_id']); $table->addUniqueIndex(['stream', 'playhead']); $table->addIndex(['stream', 'playhead', 'archived']); @@ -487,6 +605,7 @@ private function getCustomHeaders(Message $message): array PlayheadHeader::class, RecordedOnHeader::class, ArchivedHeader::class, + TagsHeader::class, ]; return array_values( @@ -654,4 +773,56 @@ private function unlock(): void $this->connection->getDatabasePlatform()::class, ); } + + /** @return Closure(): string */ + private function uniqueParameterGenerator(): Closure + { + return static function () { + static $counter = 0; + + return 'param' . ++$counter; + }; + } + + /** @param list> $tagGroups */ + private function queryCondition(QueryBuilder $builder, array $tagGroups): void + { + $subqueries = []; + + $uniqueParameterGenerator = $this->uniqueParameterGenerator(); + + foreach ($tagGroups as $tags) { + $subQueryBuilder = $this->connection->createQueryBuilder() + ->select('id') + ->from($this->config['table_name']); + + $parameterName = $uniqueParameterGenerator(); + + if ($this->isSQLite) { + $subQueryBuilder->andWhere("NOT EXISTS(SELECT value FROM JSON_EACH(:{$parameterName}) WHERE value NOT IN (SELECT value FROM JSON_EACH(tags)))"); + } elseif ($this->isPostgres) { + $subQueryBuilder->andWhere("tags @> :{$parameterName}::jsonb"); + } elseif ($this->isMysql || $this->isMariaDb) { + $subQueryBuilder->andWhere("JSON_CONTAINS(tags, :{$parameterName})"); + } else { + throw new RuntimeException('x'); + } + + $builder->setParameter($parameterName, json_encode($tags)); + + $subqueries[] = $subQueryBuilder->getSQL(); + } + + $joinQueryBuilder = $this->connection->createQueryBuilder() + ->select('id') + ->from('(' . implode(' UNION ALL ', $subqueries) . ')', 'j') + ->groupBy('j.id'); + + $builder->innerJoin( + 'events', + '(' . $joinQueryBuilder->getSQL() . ')', + 'ej', + 'ej.id = events.id', + ); + } } diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Command/ChangeCourseCapacity.php b/tests/Integration/DynamicConsistencyBoundary/Course/Command/ChangeCourseCapacity.php new file mode 100644 index 00000000..fea9991a --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Command/ChangeCourseCapacity.php @@ -0,0 +1,16 @@ +decisionModelBuilder->build( + [ + 'courseExists' => new CourseExists($command->courseId), + 'courseCapacity' => new CourseCapacityProjection($command->courseId), + ], + ); + + if (!$state['courseExists']) { + throw new RuntimeException('Course does not exist'); + } + + if ($state['courseCapacity'] === $command->capacity) { + return; + } + + $this->eventAppender->append([ + new CourseCapacityChanged( + $command->courseId, + $command->capacity, + ), + ], $state->appendCondition); + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Handler/DefineCourseHandler.php b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/DefineCourseHandler.php new file mode 100644 index 00000000..7284e728 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/DefineCourseHandler.php @@ -0,0 +1,43 @@ +decisionModelBuilder->build( + [ + 'courseExists' => new CourseExists($command->courseId), + ], + ); + + if ($state['courseExists']) { + throw new RuntimeException('Course already exists'); + } + + $this->eventAppender->append([ + new CourseDefined( + $command->courseId, + $command->capacity, + ), + ], $state->appendCondition); + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Handler/SubscribeStudentToCourseHandler.php b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/SubscribeStudentToCourseHandler.php new file mode 100644 index 00000000..f51db248 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/SubscribeStudentToCourseHandler.php @@ -0,0 +1,66 @@ +decisionModelBuilder->build( + projections: [ + 'courseExists' => new CourseExists($command->courseId), + 'courseCapacity' => new CourseCapacityProjection($command->courseId), + 'numberOfCourseSubscriptions' => new NumberOfCourseSubscriptionsProjection($command->courseId), + 'numberOfStudentSubscriptions' => new NumberOfStudentSubscriptionsProjection($command->studentId), + 'studentAlreadySubscribed' => new StudentAlreadySubscribedProjection( + $command->studentId, + $command->courseId, + ), + ], + ); + + if (!$state['courseExists']) { + throw new RuntimeException("Course {$command->courseId->toString()} does not exist"); + } + + if ($state['numberOfCourseSubscriptions'] >= $state['courseCapacity']) { + throw new RuntimeException("Course {$command->courseId->toString()} is not available"); + } + + if ($state['studentAlreadySubscribed']) { + throw new RuntimeException("Student {$command->studentId->toString()} is already subscribed to course {$command->courseId->toString()}"); + } + + if ($state['numberOfStudentSubscriptions'] >= 5) { + throw new RuntimeException("Student {$command->studentId->toString()} is already subscribed to 5 courses"); + } + + $this->eventAppender->append([ + new StudentSubscribedToCourse( + $command->studentId, + $command->courseId, + ), + ], $state->appendCondition); + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseCapacityProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseCapacityProjection.php new file mode 100644 index 00000000..d76f62a9 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseCapacityProjection.php @@ -0,0 +1,42 @@ + */ + public function tagFilter(): array + { + return ["course:{$this->courseId->toString()}"]; + } + + #[Apply] + public function applyCourseDefined(int $state, CourseDefined $event): int + { + return $event->capacity; + } + + #[Apply] + public function applyCourseCapacityChanged(int $state, CourseCapacityChanged $event): int + { + return $event->capacity; + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseExists.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseExists.php new file mode 100644 index 00000000..4854525e --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseExists.php @@ -0,0 +1,36 @@ + */ +final class CourseExists extends Projection +{ + public function __construct( + private readonly CourseId $courseId, + ) { + } + + #[Apply] + public function applyCourseDefined(bool $state, CourseDefined $event): bool + { + return true; + } + + /** @return list */ + public function tagFilter(): array + { + return ["course:{$this->courseId->toString()}"]; + } + + public function initialState(): bool + { + return false; + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php new file mode 100644 index 00000000..5b37cab1 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php @@ -0,0 +1,35 @@ + */ + public function tagFilter(): array + { + return ["course:{$this->courseId->toString()}"]; + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php new file mode 100644 index 00000000..79011691 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php @@ -0,0 +1,35 @@ + */ + public function tagFilter(): array + { + return ["student:{$this->studentId->toString()}"]; + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php new file mode 100644 index 00000000..6f652d46 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php @@ -0,0 +1,40 @@ + */ + public function tagFilter(): array + { + return [ + "student:{$this->studentId->toString()}", + "course:{$this->courseId->toString()}", + ]; + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/StudentId.php b/tests/Integration/DynamicConsistencyBoundary/Course/StudentId.php new file mode 100644 index 00000000..62d11f76 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/StudentId.php @@ -0,0 +1,13 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testCourse(): void + { + $store = new TaggableDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Course/Event']), + DefaultHeadersSerializer::createDefault(), + ); + + $decisionModelBuilder = new StoreDecisionModelBuilder($store); + $eventAppender = new StoreEventAppender($store); + + $commandBus = new SyncCommandBus( + new ServiceHandlerProvider([ + new DefineCourseHandler($decisionModelBuilder, $eventAppender), + new ChangeCourseCapacityHandler($decisionModelBuilder, $eventAppender), + new SubscribeStudentToCourseHandler($decisionModelBuilder, $eventAppender), + ]), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $courseId = CourseId::generate(); + $student1Id = StudentId::generate(); + $student2Id = StudentId::generate(); + + $commandBus->dispatch(new DefineCourse($courseId, 10)); + $commandBus->dispatch(new ChangeCourseCapacity($courseId, 2)); + $commandBus->dispatch(new SubscribeStudentToCourse($student1Id, $courseId)); + $commandBus->dispatch(new SubscribeStudentToCourse($student2Id, $courseId)); + + $this->assertTrue(true); + } + + public function testInvoice(): void + { + $store = new TaggableDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Invoice/Event']), + DefaultHeadersSerializer::createDefault(), + ); + + $decisionModelBuilder = new StoreDecisionModelBuilder($store); + $eventAppender = new StoreEventAppender($store); + + $commandBus = new SyncCommandBus( + new ServiceHandlerProvider([ + new CreateInvoiceHandler($decisionModelBuilder, $eventAppender), + ]), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $commandBus->dispatch(new CreateInvoice(10)); + $commandBus->dispatch(new CreateInvoice(10)); + $commandBus->dispatch(new CreateInvoice(10)); + $commandBus->dispatch(new CreateInvoice(10)); + $commandBus->dispatch(new CreateInvoice(10)); + + $this->assertTrue(true); + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Invoice/Command/CreateInvoice.php b/tests/Integration/DynamicConsistencyBoundary/Invoice/Command/CreateInvoice.php new file mode 100644 index 00000000..a18ee4c3 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Invoice/Command/CreateInvoice.php @@ -0,0 +1,13 @@ +decisionModelBuilder->build( + [ + 'nextInvoiceNumber' => new NextInvoiceNumberProjection(), + ], + ); + + $this->eventAppender->append([ + new InvoiceCreated( + $state['nextInvoiceNumber'], + $command->money, + ), + ], $state->appendCondition); + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php new file mode 100644 index 00000000..6b6d3914 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php @@ -0,0 +1,27 @@ + */ + public function tagFilter(): array + { + return []; + } + + public function initialState(): int + { + return 1; + } + + public function applyInvoiceCreated(int $state, InvoiceCreated $event): int + { + return $state + 1; + } +} diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index c8b50387..0712545c 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -7,6 +7,8 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Clock\FrozenClock; +use Patchlevel\EventSourcing\DCB\AppendCondition; +use Patchlevel\EventSourcing\DCB\HighestSequenceNumber; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; @@ -20,7 +22,6 @@ use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; -use Patchlevel\EventSourcing\Store\StreamStore; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; use Patchlevel\EventSourcing\Tests\DbalManager; @@ -38,7 +39,7 @@ final class TaggableDoctrineDbalStoreTest extends TestCase { private Connection $connection; - private StreamStore $store; + private TaggableDoctrineDbalStore $store; private ClockInterface $clock; @@ -510,7 +511,7 @@ public function testTags(): void try { $stream = $this->store->load( new Criteria( - new TagCriterion('profile:' . $profileId1->toString()), + new TagCriterion(['profile:' . $profileId1->toString()], ['test']), ), ); @@ -532,6 +533,107 @@ public function testTags(): void } } + public function testAppend(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])), + Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])), + ]; + + $this->store->append( + $messages, + new AppendCondition( + [], + HighestSequenceNumber::none(), + ), + ); + + $stream = null; + + try { + $stream = $this->store->load( + new Criteria( + new TagCriterion(['profile:' . $profileId1->toString()], ['test']), + ), + ); + + $messages = iterator_to_array($stream); + + self::assertCount(2, $messages); + + self::assertEquals( + ['profile:' . $profileId1->toString()], + $messages[0]->header(TagsHeader::class)->tags, + ); + + self::assertEquals( + ['profile:' . $profileId1->toString(), 'profile:' . $profileId2->toString()], + $messages[1]->header(TagsHeader::class)->tags, + ); + } finally { + $stream?->close(); + } + } + + public function testAppendRaceCondition(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])), + Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])), + ]; + + $this->store->append( + $messages, + new AppendCondition( + [], + HighestSequenceNumber::none(), + ), + ); + + $messages = [ + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])), + ]; + + $this->expectException(UniqueConstraintViolation::class); + + $this->store->append( + $messages, + new AppendCondition( + [['profile:' . $profileId1->toString()]], + HighestSequenceNumber::none(), + ), + ); + } + public function testStreams(): void { $profileId = ProfileId::fromString('0190e47e-77e9-7b90-bf62-08bbf0ab9b4b'); From 56ece437ac25e950ccbf7fd6178036ef3da1785a Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 28 Jul 2025 14:14:51 +0200 Subject: [PATCH 03/22] fix integration tests --- src/Store/TaggableDoctrineDbalStore.php | 29 +++++++++---------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index a22125dd..35b06cde 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -49,7 +49,6 @@ use function array_fill; use function array_filter; -use function array_map; use function array_merge; use function array_values; use function class_exists; @@ -131,7 +130,7 @@ public function load( $builder = $this->connection->createQueryBuilder() ->select('*') ->from($this->config['table_name'], 'events') - ->orderBy('id', $backwards ? 'DESC' : 'ASC'); + ->orderBy('events.id', $backwards ? 'DESC' : 'ASC'); $this->applyCriteria($builder, $criteria ?? new Criteria()); @@ -383,18 +382,18 @@ public function append(iterable $messages, AppendCondition|null $appendCondition $parameters = []; $types = []; - $columnsLength = count($columns); - //$batchSize = (int)floor(self::MAX_UNSIGNED_SMALL_INT / $columnsLength); $position = 0; foreach ($messages as $message) { - $selects[] = sprintf('SELECT %s', implode(', ', array_map( - static fn (string $column) => ':' . $column . $position, - $columns, - ))); - - /** @var int<0, max> $offset */ - $offset = $position * $columnsLength; + $selects[] = 'SELECT :stream' . $position + . ', :playhead' . $position . ($this->isPostgres ? '::int' : '') + . ', :event_id' . $position + . ', :event_name' . $position + . ', :event_payload' . $position . ($this->isPostgres ? '::jsonb' : '') + . ', :tags' . $position . ($this->isPostgres ? '::jsonb' : '') + . ', :recorded_on' . $position . ($this->isPostgres ? '::timestamptz' : '') + . ', :archived' . $position . ($this->isPostgres ? '::boolean' : '') + . ', :custom_headers' . $position . ($this->isPostgres ? '::jsonb' : ''); $data = $this->eventSerializer->serialize($message->event()); @@ -432,14 +431,6 @@ public function append(iterable $messages, AppendCondition|null $appendCondition $parameters['custom_headers' . $position] = $this->headersSerializer->serialize($this->getCustomHeaders($message)); $position++; - - /* - if ($position !== $batchSize) { - continue; - } - - $this->executeSave($columns, $placeholders, $parameters, $types, $this->connection); - */ } $query = sprintf( From 092ae15948aba2ad1cae64a8a366fcbc226dfe04 Mon Sep 17 00:00:00 2001 From: David Badura Date: Mon, 28 Jul 2025 15:19:29 +0200 Subject: [PATCH 04/22] add benchmark --- .../Events/ProfileCreated.php | 2 + .../SimpleSetupTaggableStoreBench.php | 145 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/Benchmark/SimpleSetupTaggableStoreBench.php diff --git a/tests/Benchmark/BasicImplementation/Events/ProfileCreated.php b/tests/Benchmark/BasicImplementation/Events/ProfileCreated.php index 7dcea07d..07adcaee 100644 --- a/tests/Benchmark/BasicImplementation/Events/ProfileCreated.php +++ b/tests/Benchmark/BasicImplementation/Events/ProfileCreated.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Events; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Attribute\EventTag; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\Hydrator\Attribute\DataSubjectId; use Patchlevel\Hydrator\Attribute\PersonalData; @@ -14,6 +15,7 @@ final class ProfileCreated { public function __construct( #[DataSubjectId] + #[EventTag(prefix: 'profile')] public ProfileId $profileId, public string $name, #[PersonalData] diff --git a/tests/Benchmark/SimpleSetupTaggableStoreBench.php b/tests/Benchmark/SimpleSetupTaggableStoreBench.php new file mode 100644 index 00000000..8c6da0e1 --- /dev/null +++ b/tests/Benchmark/SimpleSetupTaggableStoreBench.php @@ -0,0 +1,145 @@ +store = new TaggableDoctrineDbalStore( + $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); + } + }); + } + + #[Bench\Revs(10)] + public function benchAppend1Event(): void + { + $messages = [ + Message::create( + new ProfileCreated( + ProfileId::generate(), + 'Peter', + null, + ) + ) + ]; + + $this->store->append($messages); + } + + #[Bench\Revs(10)] + public function benchAppend100Events(): void + { + $messages = []; + + for ($i = 1; $i < 100; $i++) { + $messages[] = Message::create( + new ProfileCreated( + ProfileId::generate(), + 'Peter', + null, + ) + ); + } + + $this->store->append($messages); + } +} From 2906ccb03b6285f118036ce4c491eb05d040b4b0 Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 29 Jul 2025 16:33:36 +0200 Subject: [PATCH 05/22] refactoring --- Makefile | 2 +- docs/mkdocs.yml | 1 + src/Attribute/EventTag.php | 1 + src/DCB/AppendCondition.php | 2 +- src/DCB/AttributeEventTagExtractor.php | 10 +++- src/DCB/HighestSequenceNumber.php | 24 -------- src/DCB/Projection.php | 42 +++++++++++++ src/DCB/StoreDecisionModelBuilder.php | 2 +- .../Translator/ExtractEventTagTranslator.php | 32 ++++++++++ .../MessageDecorator/EventTagDecorator.php | 1 + src/Store/TaggableDoctrineDbalStore.php | 6 +- .../SimpleSetupTaggableStoreBench.php | 6 +- .../Store/TaggableDoctrineDbalStoreTest.php | 13 +--- .../DCB/AttributeEventTagExtractorTest.php | 60 +++++++++++++++++++ 14 files changed, 158 insertions(+), 44 deletions(-) delete mode 100644 src/DCB/HighestSequenceNumber.php create mode 100644 src/Message/Translator/ExtractEventTagTranslator.php create mode 100644 tests/Unit/DCB/AttributeEventTagExtractorTest.php diff --git a/Makefile b/Makefile index 4f9b56d6..859c0b31 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ test: phpunit .PHONY: benchmark benchmark: vendor ## run benchmarks - DB_URL=sqlite3:///:memory: php -d memory_limit=512M vendor/bin/phpbench run tests/Benchmark --report=default + DB_URL=sqlite3:///:memory: php -d memory_limit=512M vendor/bin/phpbench run tests/Benchmark --report=default --filter=benchAppend100Events .PHONY: benchmark-base benchmark-base: vendor ## run benchmarks diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 342d3913..0cbac3c1 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -101,6 +101,7 @@ nav: - Event Bus: event_bus.md - Query Bus: query_bus.md - Advanced: + - Dynamic Consistency Boundary: dynamic_consistency_boundary.md - Identifier: identifier.md - Normalizer: normalizer.md - Snapshots: snapshots.md diff --git a/src/Attribute/EventTag.php b/src/Attribute/EventTag.php index e2506fcc..9e05dce7 100644 --- a/src/Attribute/EventTag.php +++ b/src/Attribute/EventTag.php @@ -11,6 +11,7 @@ final class EventTag { public function __construct( public readonly string|null $prefix = null, + public readonly string|null $hash = null, ) { } } diff --git a/src/DCB/AppendCondition.php b/src/DCB/AppendCondition.php index 51f7cbfe..20c5a36f 100644 --- a/src/DCB/AppendCondition.php +++ b/src/DCB/AppendCondition.php @@ -10,7 +10,7 @@ final class AppendCondition /** @param list> $tags */ public function __construct( public readonly array $tags, - public readonly HighestSequenceNumber|null $expectedHighestSequenceNumber = null, + public readonly int|null $highestSequenceNumber = null, ) { } } diff --git a/src/DCB/AttributeEventTagExtractor.php b/src/DCB/AttributeEventTagExtractor.php index 49618d4a..b19d2cce 100644 --- a/src/DCB/AttributeEventTagExtractor.php +++ b/src/DCB/AttributeEventTagExtractor.php @@ -11,6 +11,7 @@ use function array_keys; use function get_debug_type; +use function hash; use function is_int; use function is_string; use function sprintf; @@ -32,6 +33,7 @@ public function extract(object $event): array continue; } + /** @var EventTag $attribute */ $attribute = $attributes[0]->newInstance(); $value = $property->getValue($event); @@ -46,11 +48,17 @@ public function extract(object $event): array ); } + $value = (string)$value; + + if ($attribute->hash) { + $value = hash($attribute->hash, $value); + } + if ($attribute->prefix) { $value = $attribute->prefix . ':' . $value; } - $tags[(string)$value] = true; + $tags[$value] = true; } return array_keys($tags); diff --git a/src/DCB/HighestSequenceNumber.php b/src/DCB/HighestSequenceNumber.php deleted file mode 100644 index 9d863f65..00000000 --- a/src/DCB/HighestSequenceNumber.php +++ /dev/null @@ -1,24 +0,0 @@ -value === 0; - } -} diff --git a/src/DCB/Projection.php b/src/DCB/Projection.php index 6ab922da..099c4dc1 100644 --- a/src/DCB/Projection.php +++ b/src/DCB/Projection.php @@ -4,10 +4,15 @@ namespace Patchlevel\EventSourcing\DCB; +use Closure; +use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Message\Message; use ReflectionClass; +use RuntimeException; +use function is_a; use function method_exists; +use function sprintf; /** * @experimental @@ -38,4 +43,41 @@ public function apply(mixed $state, Message $message): mixed return $state; } + + /** @return array */ + private static function applies(): array + { + $reflection = new ReflectionClass(static::class); + $methods = $reflection->getMethods(); + + $applies = []; + + foreach ($methods as $method) { + $attributes = $method->getAttributes(Apply::class); + + if ($attributes === []) { + continue; + } + + foreach ($attributes as $attribute) { + /** @var Apply $apply */ + $apply = $attribute->newInstance(); + + if ($apply->class === null) { + $applies[$method->getName()] = Closure::fromCallable([$reflection->getName(), $method->getName()]); + continue; + } + + if (!is_a($apply->class, static::class, true)) { + throw new RuntimeException( + sprintf('Apply class %s must be a subclass of %s', $apply->class, static::class), + ); + } + + $applies[$apply->class] = Closure::fromCallable([$reflection->getName(), $method->getName()]); + } + } + + return $applies; + } } diff --git a/src/DCB/StoreDecisionModelBuilder.php b/src/DCB/StoreDecisionModelBuilder.php index f7d2b0b5..c772cac8 100644 --- a/src/DCB/StoreDecisionModelBuilder.php +++ b/src/DCB/StoreDecisionModelBuilder.php @@ -43,7 +43,7 @@ public function build( $state, new AppendCondition( $projection->groupedTagFilter(), - new HighestSequenceNumber($highestId ?? 0), + $highestId ?? 0, ), ); } diff --git a/src/Message/Translator/ExtractEventTagTranslator.php b/src/Message/Translator/ExtractEventTagTranslator.php new file mode 100644 index 00000000..9d5c3f0d --- /dev/null +++ b/src/Message/Translator/ExtractEventTagTranslator.php @@ -0,0 +1,32 @@ + */ + public function __invoke(Message $message): array + { + if ($this->skipAlreadyTagged && $message->hasHeader(TagsHeader::class)) { + return [$message]; + } + + $tags = $this->eventTagExtractor->extract($message->event()); + + return [$message->withHeader(new TagsHeader($tags))]; + } +} diff --git a/src/Repository/MessageDecorator/EventTagDecorator.php b/src/Repository/MessageDecorator/EventTagDecorator.php index 9b97b565..40680d69 100644 --- a/src/Repository/MessageDecorator/EventTagDecorator.php +++ b/src/Repository/MessageDecorator/EventTagDecorator.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Store\Header\TagsHeader; +/** @experimental */ final class EventTagDecorator implements MessageDecorator { public function __construct( diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index 35b06cde..1ae0c509 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -449,11 +449,11 @@ public function append(iterable $messages, AppendCondition|null $appendCondition $this->queryCondition($queryBuilder, $appendCondition->tags); - if ($appendCondition->expectedHighestSequenceNumber->isNone()) { + if ($appendCondition->highestSequenceNumber === 0) { $query .= ' WHERE NOT EXISTS (' . $queryBuilder->getSQL() . ')'; } else { $query .= ' WHERE (' . $queryBuilder->getSQL() . ') = :highestId'; - $parameters['highestId'] = $appendCondition->expectedHighestSequenceNumber->value; + $parameters['highestId'] = $appendCondition->highestSequenceNumber; } $parameters = array_merge( @@ -470,7 +470,7 @@ public function append(iterable $messages, AppendCondition|null $appendCondition try { $affectedRows = $this->connection->executeStatement($query, $parameters, $types); - if ($affectedRows === 0 && $appendCondition->expectedHighestSequenceNumber !== null) { + if ($affectedRows === 0 && $appendCondition->highestSequenceNumber !== null) { throw new UniqueConstraintViolation(); } } catch (UniqueConstraintViolationException $e) { diff --git a/tests/Benchmark/SimpleSetupTaggableStoreBench.php b/tests/Benchmark/SimpleSetupTaggableStoreBench.php index 8c6da0e1..c5c929b7 100644 --- a/tests/Benchmark/SimpleSetupTaggableStoreBench.php +++ b/tests/Benchmark/SimpleSetupTaggableStoreBench.php @@ -118,8 +118,8 @@ public function benchAppend1Event(): void ProfileId::generate(), 'Peter', null, - ) - ) + ), + ), ]; $this->store->append($messages); @@ -136,7 +136,7 @@ public function benchAppend100Events(): void ProfileId::generate(), 'Peter', null, - ) + ), ); } diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index 0712545c..4a3f3e0e 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -8,7 +8,6 @@ use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\DCB\AppendCondition; -use Patchlevel\EventSourcing\DCB\HighestSequenceNumber; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; @@ -557,10 +556,7 @@ public function testAppend(): void $this->store->append( $messages, - new AppendCondition( - [], - HighestSequenceNumber::none(), - ), + new AppendCondition([], 0), ); $stream = null; @@ -608,10 +604,7 @@ public function testAppendRaceCondition(): void $this->store->append( $messages, - new AppendCondition( - [], - HighestSequenceNumber::none(), - ), + new AppendCondition([], 0), ); $messages = [ @@ -629,7 +622,7 @@ public function testAppendRaceCondition(): void $messages, new AppendCondition( [['profile:' . $profileId1->toString()]], - HighestSequenceNumber::none(), + 0, ), ); } diff --git a/tests/Unit/DCB/AttributeEventTagExtractorTest.php b/tests/Unit/DCB/AttributeEventTagExtractorTest.php new file mode 100644 index 00000000..ce94bb3b --- /dev/null +++ b/tests/Unit/DCB/AttributeEventTagExtractorTest.php @@ -0,0 +1,60 @@ +extract($event); + + self::assertSame([], $tags); + } + + public function testExtract(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class ('foo') { + public function __construct( + #[EventTag] + public string $id, + #[EventTag(prefix: 'bar')] + public string $name = 'baz', + ) { + } + }; + + $tags = $extractor->extract($event); + + self::assertSame(['foo', 'bar:baz'], $tags); + } + + public function testExtractWithHash(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class ('foo') { + public function __construct( + #[EventTag(prefix: 'name', hash: 'sha1')] + public string $name, + ) { + } + }; + + $tags = $extractor->extract($event); + + self::assertSame(['name:0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33'], $tags); + } +} From 82e8251a403369ec5ef58c2b5ba4b28cce120c87 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 30 Jul 2025 10:27:18 +0200 Subject: [PATCH 06/22] handle stringable in AttributeEventTagExtractor --- src/DCB/AttributeEventTagExtractor.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/DCB/AttributeEventTagExtractor.php b/src/DCB/AttributeEventTagExtractor.php index b19d2cce..4b54c063 100644 --- a/src/DCB/AttributeEventTagExtractor.php +++ b/src/DCB/AttributeEventTagExtractor.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Attribute\EventTag; use ReflectionClass; use RuntimeException; +use Stringable; use function array_keys; use function get_debug_type; @@ -42,14 +43,16 @@ public function extract(object $event): array $value = $value->toString(); } - if (!is_string($value) && !is_int($value)) { + if ($value instanceof Stringable || is_int($value)) { + $value = (string)$value; + } + + if (!is_string($value)) { throw new RuntimeException( - sprintf('Event tag value must be a string or an int, %s given', get_debug_type($value)), + sprintf('Event tag value must be stringable, %s given', get_debug_type($value)), ); } - $value = (string)$value; - if ($attribute->hash) { $value = hash($attribute->hash, $value); } From 776769041c59e48fcdc993d41aae7ab287126646 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 30 Jul 2025 16:02:44 +0200 Subject: [PATCH 07/22] add Query value object --- src/DCB/CompositeProjection.php | 2 +- src/DCB/DecisionModel.php | 1 + src/DCB/EventAppender.php | 2 + src/DCB/StoreDecisionModelBuilder.php | 26 ++++---- src/DCB/StoreEventAppender.php | 5 +- src/{DCB => Store}/AppendCondition.php | 5 +- src/Store/AppendConditionNotMet.php | 14 +++++ src/Store/AppendStore.php | 18 ++++++ src/Store/Criteria/TagCriterion.php | 10 +-- src/Store/Query.php | 17 +++++ src/Store/QueryComponent.php | 14 +++++ src/Store/TaggableDoctrineDbalStore.php | 63 ++++++++++++++----- .../Store/TaggableDoctrineDbalStoreTest.php | 31 ++++----- 13 files changed, 149 insertions(+), 59 deletions(-) rename src/{DCB => Store}/AppendCondition.php (62%) create mode 100644 src/Store/AppendConditionNotMet.php create mode 100644 src/Store/AppendStore.php create mode 100644 src/Store/Query.php create mode 100644 src/Store/QueryComponent.php diff --git a/src/DCB/CompositeProjection.php b/src/DCB/CompositeProjection.php index 628c9726..82e3c926 100644 --- a/src/DCB/CompositeProjection.php +++ b/src/DCB/CompositeProjection.php @@ -19,7 +19,7 @@ * @experimental * @extends Projection> */ -final class CompositeProjection extends Projection +final class CompositeProjection { /** @param array $projections */ public function __construct( diff --git a/src/DCB/DecisionModel.php b/src/DCB/DecisionModel.php index 5de31669..54fb942a 100644 --- a/src/DCB/DecisionModel.php +++ b/src/DCB/DecisionModel.php @@ -7,6 +7,7 @@ use ArrayAccess; use LogicException; use OutOfBoundsException; +use Patchlevel\EventSourcing\Store\AppendCondition; use function array_key_exists; diff --git a/src/DCB/EventAppender.php b/src/DCB/EventAppender.php index 3e3c19ec..b0bc4cad 100644 --- a/src/DCB/EventAppender.php +++ b/src/DCB/EventAppender.php @@ -4,6 +4,8 @@ namespace Patchlevel\EventSourcing\DCB; +use Patchlevel\EventSourcing\Store\AppendCondition; + /** @experimental */ interface EventAppender { diff --git a/src/DCB/StoreDecisionModelBuilder.php b/src/DCB/StoreDecisionModelBuilder.php index c772cac8..49f824de 100644 --- a/src/DCB/StoreDecisionModelBuilder.php +++ b/src/DCB/StoreDecisionModelBuilder.php @@ -4,16 +4,18 @@ namespace Patchlevel\EventSourcing\DCB; -use Patchlevel\EventSourcing\Store\Criteria\Criteria; -use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; -use Patchlevel\EventSourcing\Store\Criteria\TagCriterion; -use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; +use Patchlevel\EventSourcing\Store\AppendCondition; +use Patchlevel\EventSourcing\Store\AppendStore; +use Patchlevel\EventSourcing\Store\Query; +use Patchlevel\EventSourcing\Store\QueryComponent; + +use function array_map; /** @experimental */ final class StoreDecisionModelBuilder implements DecisionModelBuilder { public function __construct( - private TaggableDoctrineDbalStore $store, + private AppendStore $store, ) { } @@ -23,12 +25,12 @@ public function build( ): DecisionModel { $projection = new CompositeProjection($projections); - $stream = $this->store->load( - new Criteria( - new StreamCriterion('main'), - new TagCriterion(...$projection->groupedTagFilter()), - ), - ); + $query = new Query(...array_map( + static fn (array $tags) => new QueryComponent($tags), + $projection->groupedTagFilter(), + )); + + $stream = $this->store->query($query); $state = $projection->initialState(); @@ -42,7 +44,7 @@ public function build( return new DecisionModel( $state, new AppendCondition( - $projection->groupedTagFilter(), + $query, $highestId ?? 0, ), ); diff --git a/src/DCB/StoreEventAppender.php b/src/DCB/StoreEventAppender.php index c36f516e..cf7b1b0c 100644 --- a/src/DCB/StoreEventAppender.php +++ b/src/DCB/StoreEventAppender.php @@ -5,9 +5,10 @@ namespace Patchlevel\EventSourcing\DCB; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Store\AppendCondition; +use Patchlevel\EventSourcing\Store\AppendStore; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; -use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; use function array_map; @@ -15,7 +16,7 @@ final class StoreEventAppender implements EventAppender { public function __construct( - private readonly TaggableDoctrineDbalStore $store, + private readonly AppendStore $store, private readonly EventTagExtractor $eventTagExtractor = new AttributeEventTagExtractor(), ) { } diff --git a/src/DCB/AppendCondition.php b/src/Store/AppendCondition.php similarity index 62% rename from src/DCB/AppendCondition.php rename to src/Store/AppendCondition.php index 20c5a36f..53797ae4 100644 --- a/src/DCB/AppendCondition.php +++ b/src/Store/AppendCondition.php @@ -2,14 +2,13 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\DCB; +namespace Patchlevel\EventSourcing\Store; /** @experimental */ final class AppendCondition { - /** @param list> $tags */ public function __construct( - public readonly array $tags, + public readonly Query $query, public readonly int|null $highestSequenceNumber = null, ) { } diff --git a/src/Store/AppendConditionNotMet.php b/src/Store/AppendConditionNotMet.php new file mode 100644 index 00000000..fd040264 --- /dev/null +++ b/src/Store/AppendConditionNotMet.php @@ -0,0 +1,14 @@ + $messages */ + public function append( + iterable $messages, + AppendCondition|null $appendCondition = null, + ): void; + + public function query(Query $query): Stream; +} diff --git a/src/Store/Criteria/TagCriterion.php b/src/Store/Criteria/TagCriterion.php index b4f3692c..b973745f 100644 --- a/src/Store/Criteria/TagCriterion.php +++ b/src/Store/Criteria/TagCriterion.php @@ -4,18 +4,12 @@ namespace Patchlevel\EventSourcing\Store\Criteria; -use function array_values; - /** @experimental */ final class TagCriterion { - /** @var list> */ - public readonly array $tags; - - /** @param list ...$tags */ + /** @param list $tags */ public function __construct( - array ...$tags, + public readonly array $tags, ) { - $this->tags = array_values($tags); } } diff --git a/src/Store/Query.php b/src/Store/Query.php new file mode 100644 index 00000000..07ce1d68 --- /dev/null +++ b/src/Store/Query.php @@ -0,0 +1,17 @@ + */ + public readonly array $components; + + public function __construct( + QueryComponent ...$components, + ) { + $this->components = $components; + } +} diff --git a/src/Store/QueryComponent.php b/src/Store/QueryComponent.php new file mode 100644 index 00000000..b8713582 --- /dev/null +++ b/src/Store/QueryComponent.php @@ -0,0 +1,14 @@ + $tags */ + public function __construct( + public readonly array $tags, + ) { + } +} diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index 1ae0c509..bd44c3b0 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -19,7 +19,6 @@ use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use Patchlevel\EventSourcing\Clock\SystemClock; -use Patchlevel\EventSourcing\DCB\AppendCondition; use Patchlevel\EventSourcing\Message\HeaderNotFound; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; @@ -65,7 +64,7 @@ use function str_replace; /** @experimental */ -final class TaggableDoctrineDbalStore implements StreamStore, SubscriptionStore, DoctrineSchemaConfigurator +final class TaggableDoctrineDbalStore implements StreamStore, AppendStore, SubscriptionStore, DoctrineSchemaConfigurator { /** * PostgreSQL has a limit of 65535 parameters in a single query. @@ -129,8 +128,8 @@ public function load( ): TaggableDoctrineDbalStoreStream { $builder = $this->connection->createQueryBuilder() ->select('*') - ->from($this->config['table_name'], 'events') - ->orderBy('events.id', $backwards ? 'DESC' : 'ASC'); + ->from($this->config['table_name']) + ->orderBy('id', $backwards ? 'DESC' : 'ASC'); $this->applyCriteria($builder, $criteria ?? new Criteria()); @@ -229,7 +228,17 @@ private function applyCriteria(QueryBuilder $builder, Criteria $criteria): void $builder->setParameter('event_id', $criterion->eventId, ArrayParameterType::STRING); break; case TagCriterion::class: - $this->queryCondition($builder, $criterion->tags); + if ($this->isSQLite) { + $builder->andWhere('NOT EXISTS(SELECT value FROM JSON_EACH(:tags) WHERE value NOT IN (SELECT value FROM JSON_EACH(tags)))'); + } elseif ($this->isPostgres) { + $builder->andWhere('tags @> :tags::jsonb'); + } elseif ($this->isMysql || $this->isMariaDb) { + $builder->andWhere('JSON_CONTAINS(tags, :tags)'); + } else { + throw new RuntimeException('x'); + } + + $builder->setParameter('tags', json_encode($criterion->tags)); break; default: throw new UnsupportedCriterion($criterion::class); @@ -440,14 +449,14 @@ public function append(iterable $messages, AppendCondition|null $appendCondition implode(' UNION ALL ', $selects), ); - if ($appendCondition instanceof AppendCondition && $appendCondition->tags !== []) { + if ($appendCondition instanceof AppendCondition && $appendCondition->query->components !== []) { $queryBuilder = $this->connection->createQueryBuilder() ->select('events.id') ->from($this->config['table_name'], 'events') ->orderBy('events.id', 'DESC') ->setMaxResults(1); - $this->queryCondition($queryBuilder, $appendCondition->tags); + $this->queryCondition($queryBuilder, $appendCondition->query); if ($appendCondition->highestSequenceNumber === 0) { $query .= ' WHERE NOT EXISTS (' . $queryBuilder->getSQL() . ')'; @@ -469,16 +478,37 @@ public function append(iterable $messages, AppendCondition|null $appendCondition try { $affectedRows = $this->connection->executeStatement($query, $parameters, $types); - - if ($affectedRows === 0 && $appendCondition->highestSequenceNumber !== null) { - throw new UniqueConstraintViolation(); - } } catch (UniqueConstraintViolationException $e) { throw new UniqueConstraintViolation($e); } + + if ($affectedRows === 0 && $appendCondition->highestSequenceNumber !== null) { + throw new AppendConditionNotMet($appendCondition); + } }); } + public function query(Query $query): Stream + { + $builder = $this->connection->createQueryBuilder() + ->select('*') + ->from($this->config['table_name'], 'events') + ->orderBy('events.id', 'ASC'); + + $this->queryCondition($builder, $query); + + return new TaggableDoctrineDbalStoreStream( + $this->connection->executeQuery( + $builder->getSQL(), + $builder->getParameters(), + $builder->getParameterTypes(), + ), + $this->eventSerializer, + $this->headersSerializer, + $this->connection->getDatabasePlatform(), + ); + } + /** * @param Closure():ClosureReturn $function * @@ -775,14 +805,17 @@ private function uniqueParameterGenerator(): Closure }; } - /** @param list> $tagGroups */ - private function queryCondition(QueryBuilder $builder, array $tagGroups): void + private function queryCondition(QueryBuilder $builder, Query $query): void { + if ($query->components === []) { + return; + } + $subqueries = []; $uniqueParameterGenerator = $this->uniqueParameterGenerator(); - foreach ($tagGroups as $tags) { + foreach ($query->components as $component) { $subQueryBuilder = $this->connection->createQueryBuilder() ->select('id') ->from($this->config['table_name']); @@ -799,7 +832,7 @@ private function queryCondition(QueryBuilder $builder, array $tagGroups): void throw new RuntimeException('x'); } - $builder->setParameter($parameterName, json_encode($tags)); + $builder->setParameter($parameterName, json_encode($component->tags)); $subqueries[] = $subQueryBuilder->getSQL(); } diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index 4a3f3e0e..1cd7c6fe 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -7,10 +7,11 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Clock\FrozenClock; -use Patchlevel\EventSourcing\DCB\AppendCondition; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Store\AppendCondition; +use Patchlevel\EventSourcing\Store\AppendConditionNotMet; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Criteria\TagCriterion; @@ -21,6 +22,8 @@ use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; +use Patchlevel\EventSourcing\Store\Query; +use Patchlevel\EventSourcing\Store\QueryComponent; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; use Patchlevel\EventSourcing\Tests\DbalManager; @@ -510,7 +513,7 @@ public function testTags(): void try { $stream = $this->store->load( new Criteria( - new TagCriterion(['profile:' . $profileId1->toString()], ['test']), + new TagCriterion(['profile:' . $profileId1->toString()]), ), ); @@ -532,7 +535,7 @@ public function testTags(): void } } - public function testAppend(): void + public function testAppendAndQuery(): void { $profileId1 = ProfileId::generate(); $profileId2 = ProfileId::generate(); @@ -554,19 +557,14 @@ public function testAppend(): void ])), ]; - $this->store->append( - $messages, - new AppendCondition([], 0), - ); + $this->store->append($messages); $stream = null; try { - $stream = $this->store->load( - new Criteria( - new TagCriterion(['profile:' . $profileId1->toString()], ['test']), - ), - ); + $stream = $this->store->query(new Query( + new QueryComponent(['profile:' . $profileId1->toString()]), + )); $messages = iterator_to_array($stream); @@ -602,10 +600,7 @@ public function testAppendRaceCondition(): void ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])), ]; - $this->store->append( - $messages, - new AppendCondition([], 0), - ); + $this->store->append($messages); $messages = [ Message::create(new ExternEvent('test message')) @@ -616,12 +611,12 @@ public function testAppendRaceCondition(): void ])), ]; - $this->expectException(UniqueConstraintViolation::class); + $this->expectException(AppendConditionNotMet::class); $this->store->append( $messages, new AppendCondition( - [['profile:' . $profileId1->toString()]], + new Query(new QueryComponent(['profile:' . $profileId1->toString()])), 0, ), ); From 213c6c529586734f485b5404aeaeca72fa0d1a2f Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 1 Aug 2025 13:33:36 +0200 Subject: [PATCH 08/22] refactor projection & query api --- src/DCB/CompositeProjection.php | 52 ++++----------------------- src/DCB/Projection.php | 6 ++++ src/DCB/ProjectionBuilder.php | 18 ++++++++++ src/DCB/StoreDecisionModelBuilder.php | 10 +----- src/DCB/StoreProjectionBuilder.php | 38 ++++++++++++++++++++ src/Store/Query.php | 13 +++++++ src/Store/QueryComponent.php | 34 ++++++++++++++++++ 7 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 src/DCB/ProjectionBuilder.php create mode 100644 src/DCB/StoreProjectionBuilder.php diff --git a/src/DCB/CompositeProjection.php b/src/DCB/CompositeProjection.php index 82e3c926..a6d14554 100644 --- a/src/DCB/CompositeProjection.php +++ b/src/DCB/CompositeProjection.php @@ -5,15 +5,9 @@ namespace Patchlevel\EventSourcing\DCB; use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\TagsHeader; +use Patchlevel\EventSourcing\Store\Query; -use function array_diff; use function array_map; -use function array_merge; -use function array_unique; -use function array_values; -use function in_array; -use function sort; /** * @experimental @@ -27,36 +21,15 @@ public function __construct( ) { } - /** @return list */ - public function tagFilter(): array + public function query(): Query { - $tags = []; + $query = new Query(); foreach ($this->projections as $projection) { - $tags = array_merge($tags, $projection->tagFilter()); + $query = $query->add($projection->queryComponent()); } - return array_values(array_unique($tags)); - } - - /** @return list> */ - public function groupedTagFilter(): array - { - $result = []; - - foreach ($this->projections as $projection) { - $tags = $projection->tagFilter(); - - sort($tags); - - if (in_array($tags, $result, true)) { - continue; - } - - $result[] = $tags; - } - - return $result; + return $query; } /** @return array */ @@ -69,12 +42,8 @@ public function initialState(): array public function apply(mixed $state, Message $message): mixed { - $tags = $message->header(TagsHeader::class)->tags; - foreach ($this->projections as $name => $projection) { - $neededTags = $projection->tagFilter(); - - if (!$this->isSubset($neededTags, $tags)) { + if (!$projection->queryComponent()->match($message)) { continue; } @@ -83,13 +52,4 @@ public function apply(mixed $state, Message $message): mixed return $state; } - - /** - * @param list $needle - * @param list $haystack - */ - private function isSubset(array $needle, array $haystack): bool - { - return empty(array_diff($needle, $haystack)); - } } diff --git a/src/DCB/Projection.php b/src/DCB/Projection.php index 099c4dc1..d1c6c507 100644 --- a/src/DCB/Projection.php +++ b/src/DCB/Projection.php @@ -7,6 +7,7 @@ use Closure; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Store\QueryComponent; use ReflectionClass; use RuntimeException; @@ -26,6 +27,11 @@ abstract public function tagFilter(): array; /** @return S */ abstract public function initialState(): mixed; + public function queryComponent(): QueryComponent + { + return new QueryComponent($this->tagFilter()); + } + /** * @param S $state * diff --git a/src/DCB/ProjectionBuilder.php b/src/DCB/ProjectionBuilder.php new file mode 100644 index 00000000..a67e72ad --- /dev/null +++ b/src/DCB/ProjectionBuilder.php @@ -0,0 +1,18 @@ + $projections + * + * @return array + */ + public function build( + array $projections, + ): array; +} diff --git a/src/DCB/StoreDecisionModelBuilder.php b/src/DCB/StoreDecisionModelBuilder.php index 49f824de..763d4f10 100644 --- a/src/DCB/StoreDecisionModelBuilder.php +++ b/src/DCB/StoreDecisionModelBuilder.php @@ -6,10 +6,6 @@ use Patchlevel\EventSourcing\Store\AppendCondition; use Patchlevel\EventSourcing\Store\AppendStore; -use Patchlevel\EventSourcing\Store\Query; -use Patchlevel\EventSourcing\Store\QueryComponent; - -use function array_map; /** @experimental */ final class StoreDecisionModelBuilder implements DecisionModelBuilder @@ -25,11 +21,7 @@ public function build( ): DecisionModel { $projection = new CompositeProjection($projections); - $query = new Query(...array_map( - static fn (array $tags) => new QueryComponent($tags), - $projection->groupedTagFilter(), - )); - + $query = $projection->query(); $stream = $this->store->query($query); $state = $projection->initialState(); diff --git a/src/DCB/StoreProjectionBuilder.php b/src/DCB/StoreProjectionBuilder.php new file mode 100644 index 00000000..53824e9f --- /dev/null +++ b/src/DCB/StoreProjectionBuilder.php @@ -0,0 +1,38 @@ + $projections + * + * @return array + */ + public function build( + array $projections, + ): array { + $projection = new CompositeProjection($projections); + + $query = $projection->query(); + $stream = $this->store->query($query); + + $state = $projection->initialState(); + + foreach ($stream as $message) { + $state = $projection->apply($state, $message); + } + + return $state; + } +} diff --git a/src/Store/Query.php b/src/Store/Query.php index 07ce1d68..3de4a9f0 100644 --- a/src/Store/Query.php +++ b/src/Store/Query.php @@ -14,4 +14,17 @@ public function __construct( ) { $this->components = $components; } + + public function add(QueryComponent $component): self + { + foreach ($this->components as $c) { + if ($c->equals($component)) { + return $this; + } + } + + $components = [...$this->components, $component]; + + return new self(...$components); + } } diff --git a/src/Store/QueryComponent.php b/src/Store/QueryComponent.php index b8713582..cf54c704 100644 --- a/src/Store/QueryComponent.php +++ b/src/Store/QueryComponent.php @@ -4,11 +4,45 @@ namespace Patchlevel\EventSourcing\Store; +use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Store\Header\TagsHeader; + +use function array_diff; +use function sort; + final class QueryComponent { /** @param list $tags */ public function __construct( public readonly array $tags, ) { + sort($tags); + } + + public function match(Message $message): bool + { + if ($this->tags === []) { + return true; + } + + if (!$message->hasHeader(TagsHeader::class)) { + return false; + } + + return $this->isSubset($this->tags, $message->header(TagsHeader::class)->tags); + } + + public function equals(self $queryComponent): bool + { + return $this->tags === $queryComponent->tags; + } + + /** + * @param list $needle + * @param list $haystack + */ + private function isSubset(array $needle, array $haystack): bool + { + return empty(array_diff($needle, $haystack)); } } From d4f47738be0bb1f6d96a95614f140cd44c5e63fb Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 1 Aug 2025 13:36:26 +0200 Subject: [PATCH 09/22] add missing experimental annotations --- src/Store/AppendConditionNotMet.php | 1 + src/Store/AppendStore.php | 1 + src/Store/Query.php | 1 + src/Store/QueryComponent.php | 1 + 4 files changed, 4 insertions(+) diff --git a/src/Store/AppendConditionNotMet.php b/src/Store/AppendConditionNotMet.php index fd040264..f0ad8d61 100644 --- a/src/Store/AppendConditionNotMet.php +++ b/src/Store/AppendConditionNotMet.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Store; +/** @experimental */ class AppendConditionNotMet extends StoreException { public function __construct( diff --git a/src/Store/AppendStore.php b/src/Store/AppendStore.php index cfa31e53..479080e1 100644 --- a/src/Store/AppendStore.php +++ b/src/Store/AppendStore.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Message\Message; +/** @experimental */ interface AppendStore { /** @param iterable $messages */ diff --git a/src/Store/Query.php b/src/Store/Query.php index 3de4a9f0..05664e9c 100644 --- a/src/Store/Query.php +++ b/src/Store/Query.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Store; +/** @experimental */ final class Query { /** @var list */ diff --git a/src/Store/QueryComponent.php b/src/Store/QueryComponent.php index cf54c704..a96905b4 100644 --- a/src/Store/QueryComponent.php +++ b/src/Store/QueryComponent.php @@ -10,6 +10,7 @@ use function array_diff; use function sort; +/** @experimental */ final class QueryComponent { /** @param list $tags */ From 528e9cbf69f723871be0918e91d948085a18185f Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 9 Aug 2025 13:32:09 +0200 Subject: [PATCH 10/22] add Stringable Interface & refactor some code --- src/DCB/ApplyTrait.php | 166 ++++++++++++++++++ src/DCB/AttributeEventTagExtractor.php | 8 +- src/DCB/CompositeProjection.php | 7 +- src/DCB/Projection.php | 72 +------- src/Metadata/PropertyType.php | 53 ++++++ .../Normalizer/StringableNormalizer.php | 79 +++++++++ src/Store/Query.php | 18 +- .../{QueryComponent.php => SubQuery.php} | 2 +- src/Store/TaggableDoctrineDbalStore.php | 8 +- src/Stringable.php | 13 ++ .../Course/CourseId.php | 4 +- .../Projection/CourseCapacityProjection.php | 5 +- .../Course/Projection/CourseExists.php | 8 +- .../NumberOfCourseSubscriptionsProjection.php | 5 +- ...NumberOfStudentSubscriptionsProjection.php | 5 +- .../StudentAlreadySubscribedProjection.php | 5 +- .../Course/StudentId.php | 4 +- .../NextInvoiceNumberProjection.php | 5 +- .../Store/TaggableDoctrineDbalStoreTest.php | 6 +- 19 files changed, 367 insertions(+), 106 deletions(-) create mode 100644 src/DCB/ApplyTrait.php create mode 100644 src/Metadata/PropertyType.php create mode 100644 src/Serializer/Normalizer/StringableNormalizer.php rename src/Store/{QueryComponent.php => SubQuery.php} (97%) create mode 100644 src/Stringable.php diff --git a/src/DCB/ApplyTrait.php b/src/DCB/ApplyTrait.php new file mode 100644 index 00000000..f6d6d66f --- /dev/null +++ b/src/DCB/ApplyTrait.php @@ -0,0 +1,166 @@ +|null $applyMethods */ + private array|null $applyMethods = null; + + public function apply(mixed $state, Message $message): mixed + { + if (!$this->subQuery()->match($message)) { + return $state; + } + + $event = $message->event(); + $applyMethods = $this->applyMethods(); + + if (array_key_exists($event::class, $applyMethods)) { + return $this->{$applyMethods[$event::class]}($state, $event); + } + + return $state; + } + + public function subQuery(): SubQuery + { + return new SubQuery( + $this->tagFilter(), + // $this->eventTypeFilter(), + ); + } + + public function eventTypeFilter(): array + { + return array_keys($this->applyMethods()); + } + + /** @return array */ + private function applyMethods(): array + { + if ($this->applyMethods !== null) { + return $this->applyMethods; + } + + $reflector = new ReflectionClass($this); + + $this->applyMethods = []; + + foreach ($reflector->getMethods() as $method) { + $attributes = $method->getAttributes(Apply::class); + + if ($attributes === []) { + continue; + } + + $eventClasses = []; + $hasOneEmptyApply = false; + $hasOneNonEmptyApply = false; + + foreach ($attributes as $attribute) { + $applyAttribute = $attribute->newInstance(); + $eventClass = $applyAttribute->eventClass; + + if ($eventClass !== null) { + $hasOneNonEmptyApply = true; + $eventClasses[] = $eventClass; + + continue; + } + + if ($hasOneEmptyApply) { + throw new DuplicateEmptyApplyAttribute($method->getName()); + } + + $hasOneEmptyApply = true; + $eventClasses = array_merge($eventClasses, $this->getEventClassesByPropertyTypes($method)); + } + + if ($hasOneEmptyApply && $hasOneNonEmptyApply) { + throw new MixedApplyAttributeUsage($method->getName()); + } + + foreach ($eventClasses as $eventClass) { + if (!class_exists($eventClass)) { + throw new ArgumentTypeIsNotAClass($method->getName(), $eventClass); + } + + if (array_key_exists($eventClass, $this->applyMethods)) { + throw new DuplicateApplyMethod( + $eventClass, + $this->applyMethods[$eventClass], + $method->getName(), + ); + } + + $this->applyMethods[$eventClass] = $method->getName(); + } + } + + return $this->applyMethods; + } + + /** @return array */ + private function getEventClassesByPropertyTypes(ReflectionMethod $method): array + { + $parameters = $method->getParameters(); + + if (array_key_exists(1, $parameters) === false) { + throw new ParameterIsMissing($method->getName(), 1); + } + + $propertyType = $parameters[1]->getType(); + $methodName = $method->getName(); + + if ($propertyType === null) { + throw new ArgumentTypeIsMissing($methodName); + } + + if ($propertyType instanceof ReflectionIntersectionType) { + throw new ArgumentTypeIsMissing($methodName); + } + + if ($propertyType instanceof ReflectionNamedType) { + return [$propertyType->getName()]; + } + + if ($propertyType instanceof ReflectionUnionType) { + return array_map( + static function (ReflectionNamedType|ReflectionIntersectionType $reflectionType) use ($methodName, + ): string { + if ($reflectionType instanceof ReflectionIntersectionType) { + throw new ArgumentTypeIsMissing($methodName); + } + + return $reflectionType->getName(); + }, + $propertyType->getTypes(), + ); + } + + return []; + } + + /** @return list */ + public abstract function tagFilter(): array; +} diff --git a/src/DCB/AttributeEventTagExtractor.php b/src/DCB/AttributeEventTagExtractor.php index 4b54c063..566c3010 100644 --- a/src/DCB/AttributeEventTagExtractor.php +++ b/src/DCB/AttributeEventTagExtractor.php @@ -4,11 +4,11 @@ namespace Patchlevel\EventSourcing\DCB; -use Patchlevel\EventSourcing\Aggregate\AggregateRootId; use Patchlevel\EventSourcing\Attribute\EventTag; +use Patchlevel\EventSourcing\Stringable; use ReflectionClass; use RuntimeException; -use Stringable; +use Stringable as NativeStringable; use function array_keys; use function get_debug_type; @@ -39,11 +39,11 @@ public function extract(object $event): array $value = $property->getValue($event); - if ($value instanceof AggregateRootId) { + if ($value instanceof Stringable) { $value = $value->toString(); } - if ($value instanceof Stringable || is_int($value)) { + if ($value instanceof NativeStringable || is_int($value)) { $value = (string)$value; } diff --git a/src/DCB/CompositeProjection.php b/src/DCB/CompositeProjection.php index a6d14554..5742fe65 100644 --- a/src/DCB/CompositeProjection.php +++ b/src/DCB/CompositeProjection.php @@ -6,7 +6,6 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Store\Query; - use function array_map; /** @@ -26,7 +25,7 @@ public function query(): Query $query = new Query(); foreach ($this->projections as $projection) { - $query = $query->add($projection->queryComponent()); + $query = $query->add($projection->subQuery()); } return $query; @@ -43,10 +42,6 @@ public function initialState(): array public function apply(mixed $state, Message $message): mixed { foreach ($this->projections as $name => $projection) { - if (!$projection->queryComponent()->match($message)) { - continue; - } - $state[$name] = $projection->apply($state[$name], $message); } diff --git a/src/DCB/Projection.php b/src/DCB/Projection.php index d1c6c507..f519c6cd 100644 --- a/src/DCB/Projection.php +++ b/src/DCB/Projection.php @@ -4,86 +4,24 @@ namespace Patchlevel\EventSourcing\DCB; -use Closure; -use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\QueryComponent; -use ReflectionClass; -use RuntimeException; - -use function is_a; -use function method_exists; -use function sprintf; +use Patchlevel\EventSourcing\Store\SubQuery; /** * @experimental * @template S as mixed */ -abstract class Projection +interface Projection { - /** @return list */ - abstract public function tagFilter(): array; - /** @return S */ - abstract public function initialState(): mixed; - - public function queryComponent(): QueryComponent - { - return new QueryComponent($this->tagFilter()); - } + public function initialState(): mixed; /** * @param S $state * * @return S */ - public function apply(mixed $state, Message $message): mixed - { - $event = $message->event(); - - $method = 'apply' . (new ReflectionClass($event))->getShortName(); - - if (method_exists($this, $method)) { - $state = $this->{$method}($state, $event); - } - - return $state; - } - - /** @return array */ - private static function applies(): array - { - $reflection = new ReflectionClass(static::class); - $methods = $reflection->getMethods(); - - $applies = []; - - foreach ($methods as $method) { - $attributes = $method->getAttributes(Apply::class); - - if ($attributes === []) { - continue; - } - - foreach ($attributes as $attribute) { - /** @var Apply $apply */ - $apply = $attribute->newInstance(); - - if ($apply->class === null) { - $applies[$method->getName()] = Closure::fromCallable([$reflection->getName(), $method->getName()]); - continue; - } - - if (!is_a($apply->class, static::class, true)) { - throw new RuntimeException( - sprintf('Apply class %s must be a subclass of %s', $apply->class, static::class), - ); - } - - $applies[$apply->class] = Closure::fromCallable([$reflection->getName(), $method->getName()]); - } - } + public function apply(mixed $state, Message $message): mixed; - return $applies; - } + public function subQuery(): SubQuery; } diff --git a/src/Metadata/PropertyType.php b/src/Metadata/PropertyType.php new file mode 100644 index 00000000..fbfda150 --- /dev/null +++ b/src/Metadata/PropertyType.php @@ -0,0 +1,53 @@ +typeResolver = TypeResolver::create(); + } + + /** + * @param class-string $class + * @return array + */ + public function getEventClassesByPropertyTypes(ReflectionParameter $parameter): array + { + $propertyType = $parameter->getType(); + + if ($propertyType === null) { + throw new ArgumentTypeIsMissing($methodName); + } + + $type = $this->typeResolver->resolve($propertyType); + + if ($type instanceof ObjectType) { + return [$type->getClassName()]; + } + + if ($type instanceof UnionType) { + return array_map( + static function (Type $type): string { + if ($type instanceof ObjectType) { + return $type->getClassName(); + } + + throw new ArgumentTypeIsMissing($methodName); + }, + $type->getTypes(), + ); + } + + throw new ArgumentTypeIsMissing($methodName); + } +} \ No newline at end of file diff --git a/src/Serializer/Normalizer/StringableNormalizer.php b/src/Serializer/Normalizer/StringableNormalizer.php new file mode 100644 index 00000000..a7314882 --- /dev/null +++ b/src/Serializer/Normalizer/StringableNormalizer.php @@ -0,0 +1,79 @@ +|null */ + private string|null $stringableClass = null, + ) { + } + + public function normalize(mixed $value): string|null + { + if ($value === null) { + return null; + } + + $class = $this->stringableClass(); + + if (!$value instanceof Stringable) { + throw InvalidArgument::withWrongType($class, $value); + } + + return $value->toString(); + } + + public function denormalize(mixed $value): Stringable|null + { + if ($value === null) { + return null; + } + + if (!is_string($value)) { + throw InvalidArgument::withWrongType('string', $value); + } + + $class = $this->stringableClass(); + + return $class::fromString($value); + } + + /** @return class-string */ + public function stringableClass(): string + { + if ($this->stringableClass === null) { + throw InvalidType::missingType(); + } + + return $this->stringableClass; + } + + public function handleType(Type|null $type): void + { + if ($type === null || $this->stringableClass !== null) { + return; + } + + if (!$type instanceof ObjectType) { + return; + } + + $this->stringableClass = $type->getClassName(); + } +} diff --git a/src/Store/Query.php b/src/Store/Query.php index 05664e9c..9099eccb 100644 --- a/src/Store/Query.php +++ b/src/Store/Query.php @@ -7,25 +7,23 @@ /** @experimental */ final class Query { - /** @var list */ - public readonly array $components; + /** @var list */ + public readonly array $subQueries; public function __construct( - QueryComponent ...$components, + SubQuery ...$subQueries, ) { - $this->components = $components; + $this->subQueries = $subQueries; } - public function add(QueryComponent $component): self + public function add(SubQuery $subQuery): self { - foreach ($this->components as $c) { - if ($c->equals($component)) { + foreach ($this->subQueries as $query) { + if ($query->equals($subQuery)) { return $this; } } - $components = [...$this->components, $component]; - - return new self(...$components); + return new self($subQuery, ...$this->subQueries); } } diff --git a/src/Store/QueryComponent.php b/src/Store/SubQuery.php similarity index 97% rename from src/Store/QueryComponent.php rename to src/Store/SubQuery.php index a96905b4..4e95d248 100644 --- a/src/Store/QueryComponent.php +++ b/src/Store/SubQuery.php @@ -11,7 +11,7 @@ use function sort; /** @experimental */ -final class QueryComponent +final class SubQuery { /** @param list $tags */ public function __construct( diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index bd44c3b0..d3fd7e86 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -449,7 +449,7 @@ public function append(iterable $messages, AppendCondition|null $appendCondition implode(' UNION ALL ', $selects), ); - if ($appendCondition instanceof AppendCondition && $appendCondition->query->components !== []) { + if ($appendCondition instanceof AppendCondition && $appendCondition->query->subQueries !== []) { $queryBuilder = $this->connection->createQueryBuilder() ->select('events.id') ->from($this->config['table_name'], 'events') @@ -807,7 +807,7 @@ private function uniqueParameterGenerator(): Closure private function queryCondition(QueryBuilder $builder, Query $query): void { - if ($query->components === []) { + if ($query->subQueries === []) { return; } @@ -815,7 +815,7 @@ private function queryCondition(QueryBuilder $builder, Query $query): void $uniqueParameterGenerator = $this->uniqueParameterGenerator(); - foreach ($query->components as $component) { + foreach ($query->subQueries as $subQuery) { $subQueryBuilder = $this->connection->createQueryBuilder() ->select('id') ->from($this->config['table_name']); @@ -832,7 +832,7 @@ private function queryCondition(QueryBuilder $builder, Query $query): void throw new RuntimeException('x'); } - $builder->setParameter($parameterName, json_encode($component->tags)); + $builder->setParameter($parameterName, json_encode($subQuery->tags)); $subqueries[] = $subQueryBuilder->getSQL(); } diff --git a/src/Stringable.php b/src/Stringable.php new file mode 100644 index 00000000..ff4abdaf --- /dev/null +++ b/src/Stringable.php @@ -0,0 +1,13 @@ + */ -final class CourseExists extends Projection +/** @implements Projection */ +final class CourseExists implements Projection { + use ApplyTrait; + public function __construct( private readonly CourseId $courseId, ) { diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php index 5b37cab1..01655362 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php @@ -5,12 +5,15 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection; use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\DCB\ApplyTrait; use Patchlevel\EventSourcing\DCB\Projection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\CourseId; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; -final class NumberOfCourseSubscriptionsProjection extends Projection +final class NumberOfCourseSubscriptionsProjection implements Projection { + use ApplyTrait; + public function __construct( private readonly CourseId $courseId, ) { diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php index 79011691..87a214d6 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php @@ -5,12 +5,15 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection; use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\DCB\ApplyTrait; use Patchlevel\EventSourcing\DCB\Projection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\StudentId; -final class NumberOfStudentSubscriptionsProjection extends Projection +final class NumberOfStudentSubscriptionsProjection implements Projection { + use ApplyTrait; + public function __construct( private readonly StudentId $studentId, ) { diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php index 6f652d46..887748c8 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php @@ -5,13 +5,16 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection; use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\DCB\ApplyTrait; use Patchlevel\EventSourcing\DCB\Projection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\CourseId; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\StudentId; -final class StudentAlreadySubscribedProjection extends Projection +final class StudentAlreadySubscribedProjection implements Projection { + use ApplyTrait; + public function __construct( private readonly StudentId $studentId, private readonly CourseId $courseId, diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/StudentId.php b/tests/Integration/DynamicConsistencyBoundary/Course/StudentId.php index 62d11f76..49b5209e 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/StudentId.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/StudentId.php @@ -4,10 +4,10 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course; -use Patchlevel\EventSourcing\Aggregate\AggregateRootId; use Patchlevel\EventSourcing\Aggregate\RamseyUuidV7Behaviour; +use Patchlevel\EventSourcing\Stringable; -final class StudentId implements AggregateRootId +final class StudentId implements Stringable { use RamseyUuidV7Behaviour; } diff --git a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php index 6b6d3914..6f9ee822 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php @@ -4,11 +4,14 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Projection; +use Patchlevel\EventSourcing\DCB\ApplyTrait; use Patchlevel\EventSourcing\DCB\Projection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Event\InvoiceCreated; -final class NextInvoiceNumberProjection extends Projection +final class NextInvoiceNumberProjection implements Projection { + use ApplyTrait; + /** @return list */ public function tagFilter(): array { diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index 1cd7c6fe..58bac97c 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -23,7 +23,7 @@ use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; use Patchlevel\EventSourcing\Store\Query; -use Patchlevel\EventSourcing\Store\QueryComponent; +use Patchlevel\EventSourcing\Store\SubQuery; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; use Patchlevel\EventSourcing\Tests\DbalManager; @@ -563,7 +563,7 @@ public function testAppendAndQuery(): void try { $stream = $this->store->query(new Query( - new QueryComponent(['profile:' . $profileId1->toString()]), + new SubQuery(['profile:' . $profileId1->toString()]), )); $messages = iterator_to_array($stream); @@ -616,7 +616,7 @@ public function testAppendRaceCondition(): void $this->store->append( $messages, new AppendCondition( - new Query(new QueryComponent(['profile:' . $profileId1->toString()])), + new Query(new SubQuery(['profile:' . $profileId1->toString()])), 0, ), ); From dd07a3f94102e9df06defcfe14d15d8e846238cf Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 12 Aug 2025 12:57:53 +0200 Subject: [PATCH 11/22] add event type filter --- src/DCB/CompositeProjection.php | 1 + src/DCB/{ApplyTrait.php => EventRouter.php} | 12 +- src/Metadata/PropertyType.php | 7 +- .../Normalizer/StringableNormalizer.php | 2 +- src/Store/SubQuery.php | 19 +- src/Store/TaggableDoctrineDbalStore.php | 50 +++- src/Stringable.php | 4 +- .../Projection/CourseCapacityProjection.php | 4 +- .../Course/Projection/CourseExists.php | 5 +- .../NumberOfCourseSubscriptionsProjection.php | 4 +- ...NumberOfStudentSubscriptionsProjection.php | 4 +- .../StudentAlreadySubscribedProjection.php | 4 +- .../DynamicConsistencyBoundaryTest.php | 6 +- .../NextInvoiceNumberProjection.php | 4 +- .../Store/TaggableDoctrineDbalStoreTest.php | 247 ++++++++++++++++-- 15 files changed, 314 insertions(+), 59 deletions(-) rename src/DCB/{ApplyTrait.php => EventRouter.php} (96%) diff --git a/src/DCB/CompositeProjection.php b/src/DCB/CompositeProjection.php index 5742fe65..1880a6f4 100644 --- a/src/DCB/CompositeProjection.php +++ b/src/DCB/CompositeProjection.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Store\Query; + use function array_map; /** diff --git a/src/DCB/ApplyTrait.php b/src/DCB/EventRouter.php similarity index 96% rename from src/DCB/ApplyTrait.php rename to src/DCB/EventRouter.php index f6d6d66f..2483fa83 100644 --- a/src/DCB/ApplyTrait.php +++ b/src/DCB/EventRouter.php @@ -12,16 +12,15 @@ use ReflectionMethod; use ReflectionNamedType; use ReflectionUnionType; + use function array_key_exists; use function array_keys; use function array_map; use function array_merge; use function class_exists; -/** - * @experimental - */ -trait ApplyTrait +/** @experimental */ +trait EventRouter { /** @param array|null $applyMethods */ private array|null $applyMethods = null; @@ -46,10 +45,11 @@ public function subQuery(): SubQuery { return new SubQuery( $this->tagFilter(), - // $this->eventTypeFilter(), + $this->eventTypeFilter(), ); } + /** @return list */ public function eventTypeFilter(): array { return array_keys($this->applyMethods()); @@ -162,5 +162,5 @@ static function (ReflectionNamedType|ReflectionIntersectionType $reflectionType) } /** @return list */ - public abstract function tagFilter(): array; + abstract public function tagFilter(): array; } diff --git a/src/Metadata/PropertyType.php b/src/Metadata/PropertyType.php index fbfda150..ccbf5513 100644 --- a/src/Metadata/PropertyType.php +++ b/src/Metadata/PropertyType.php @@ -1,5 +1,7 @@ */ public function getEventClassesByPropertyTypes(ReflectionParameter $parameter): array @@ -50,4 +55,4 @@ static function (Type $type): string { throw new ArgumentTypeIsMissing($methodName); } -} \ No newline at end of file +} diff --git a/src/Serializer/Normalizer/StringableNormalizer.php b/src/Serializer/Normalizer/StringableNormalizer.php index a7314882..13c51854 100644 --- a/src/Serializer/Normalizer/StringableNormalizer.php +++ b/src/Serializer/Normalizer/StringableNormalizer.php @@ -11,8 +11,8 @@ use Patchlevel\Hydrator\Normalizer\Normalizer; use Patchlevel\Hydrator\Normalizer\TypeAwareNormalizer; use Symfony\Component\TypeInfo\Type; - use Symfony\Component\TypeInfo\Type\ObjectType; + use function is_string; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] diff --git a/src/Store/SubQuery.php b/src/Store/SubQuery.php index 4e95d248..2bd3e1fd 100644 --- a/src/Store/SubQuery.php +++ b/src/Store/SubQuery.php @@ -8,21 +8,27 @@ use Patchlevel\EventSourcing\Store\Header\TagsHeader; use function array_diff; +use function in_array; use function sort; /** @experimental */ final class SubQuery { - /** @param list $tags */ + /** + * @param list $tags + * @param list $events + */ public function __construct( - public readonly array $tags, + public readonly array $tags = [], + public readonly array $events = [], ) { sort($tags); + sort($events); } public function match(Message $message): bool { - if ($this->tags === []) { + if ($this->tags === [] && $this->events === []) { return true; } @@ -30,12 +36,17 @@ public function match(Message $message): bool return false; } + if ($this->events !== [] && !in_array($message->event()::class, $this->events, true)) { + return false; + } + return $this->isSubset($this->tags, $message->header(TagsHeader::class)->tags); } public function equals(self $queryComponent): bool { - return $this->tags === $queryComponent->tags; + return $this->tags === $queryComponent->tags + && $this->events === $queryComponent->events; } /** diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index d3fd7e86..f2dde8ea 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -23,6 +23,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Message\Serializer\HeadersSerializer; +use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; use Patchlevel\EventSourcing\Schema\DoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Serializer\EventSerializer; use Patchlevel\EventSourcing\Store\Criteria\ArchivedCriterion; @@ -48,6 +49,7 @@ use function array_fill; use function array_filter; +use function array_map; use function array_merge; use function array_values; use function class_exists; @@ -97,6 +99,7 @@ final class TaggableDoctrineDbalStore implements StreamStore, AppendStore, Subsc public function __construct( private readonly Connection $connection, private readonly EventSerializer $eventSerializer, + private readonly EventRegistry $eventRegistry, HeadersSerializer|null $headersSerializer = null, ClockInterface|null $clock = null, array $config = [], @@ -449,7 +452,7 @@ public function append(iterable $messages, AppendCondition|null $appendCondition implode(' UNION ALL ', $selects), ); - if ($appendCondition instanceof AppendCondition && $appendCondition->query->subQueries !== []) { + if ($appendCondition instanceof AppendCondition) { $queryBuilder = $this->connection->createQueryBuilder() ->select('events.id') ->from($this->config['table_name'], 'events') @@ -816,27 +819,52 @@ private function queryCondition(QueryBuilder $builder, Query $query): void $uniqueParameterGenerator = $this->uniqueParameterGenerator(); foreach ($query->subQueries as $subQuery) { + if ($subQuery->tags === [] && $subQuery->events === []) { + continue; + } + $subQueryBuilder = $this->connection->createQueryBuilder() ->select('id') ->from($this->config['table_name']); - $parameterName = $uniqueParameterGenerator(); + if ($subQuery->tags !== []) { + $tagParameterName = $uniqueParameterGenerator(); - if ($this->isSQLite) { - $subQueryBuilder->andWhere("NOT EXISTS(SELECT value FROM JSON_EACH(:{$parameterName}) WHERE value NOT IN (SELECT value FROM JSON_EACH(tags)))"); - } elseif ($this->isPostgres) { - $subQueryBuilder->andWhere("tags @> :{$parameterName}::jsonb"); - } elseif ($this->isMysql || $this->isMariaDb) { - $subQueryBuilder->andWhere("JSON_CONTAINS(tags, :{$parameterName})"); - } else { - throw new RuntimeException('x'); + if ($this->isSQLite) { + $subQueryBuilder->andWhere("NOT EXISTS(SELECT value FROM JSON_EACH(:{$tagParameterName}) WHERE value NOT IN (SELECT value FROM JSON_EACH(tags)))"); + } elseif ($this->isPostgres) { + $subQueryBuilder->andWhere("tags @> :{$tagParameterName}::jsonb"); + } elseif ($this->isMysql || $this->isMariaDb) { + $subQueryBuilder->andWhere("JSON_CONTAINS(tags, :{$tagParameterName})"); + } else { + throw new RuntimeException('x'); + } + + $builder->setParameter($tagParameterName, json_encode($subQuery->tags)); } - $builder->setParameter($parameterName, json_encode($subQuery->tags)); + if ($subQuery->events !== []) { + $eventParameterName = $uniqueParameterGenerator(); + + $subQueryBuilder->andWhere("event_name IN (:{$eventParameterName})"); + + $builder->setParameter( + $eventParameterName, + array_map( + fn (string $event) => $this->eventRegistry->eventName($event), + $subQuery->events, + ), + ArrayParameterType::STRING, + ); + } $subqueries[] = $subQueryBuilder->getSQL(); } + if ($subqueries === []) { + return; + } + $joinQueryBuilder = $this->connection->createQueryBuilder() ->select('id') ->from('(' . implode(' UNION ALL ', $subqueries) . ')', 'j') diff --git a/src/Stringable.php b/src/Stringable.php index ff4abdaf..ec6e4679 100644 --- a/src/Stringable.php +++ b/src/Stringable.php @@ -1,5 +1,7 @@ */ final class CourseExists implements Projection { - use ApplyTrait; + use EventRouter; public function __construct( private readonly CourseId $courseId, diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php index 01655362..88c48c82 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php @@ -5,14 +5,14 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\ApplyTrait; +use Patchlevel\EventSourcing\DCB\EventRouter; use Patchlevel\EventSourcing\DCB\Projection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\CourseId; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; final class NumberOfCourseSubscriptionsProjection implements Projection { - use ApplyTrait; + use EventRouter; public function __construct( private readonly CourseId $courseId, diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php index 87a214d6..a3045d09 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php @@ -5,14 +5,14 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\ApplyTrait; +use Patchlevel\EventSourcing\DCB\EventRouter; use Patchlevel\EventSourcing\DCB\Projection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\StudentId; final class NumberOfStudentSubscriptionsProjection implements Projection { - use ApplyTrait; + use EventRouter; public function __construct( private readonly StudentId $studentId, diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php index 887748c8..83673d00 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php @@ -5,7 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\ApplyTrait; +use Patchlevel\EventSourcing\DCB\EventRouter; use Patchlevel\EventSourcing\DCB\Projection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\CourseId; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; @@ -13,7 +13,7 @@ final class StudentAlreadySubscribedProjection implements Projection { - use ApplyTrait; + use EventRouter; public function __construct( private readonly StudentId $studentId, diff --git a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php index 2e1820ce..608d8af5 100644 --- a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php +++ b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php @@ -9,7 +9,7 @@ use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; use Patchlevel\EventSourcing\DCB\StoreDecisionModelBuilder; use Patchlevel\EventSourcing\DCB\StoreEventAppender; -use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; @@ -47,7 +47,7 @@ public function testCourse(): void $store = new TaggableDoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Course/Event']), - DefaultHeadersSerializer::createDefault(), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Course/Event']), ); $decisionModelBuilder = new StoreDecisionModelBuilder($store); @@ -85,7 +85,7 @@ public function testInvoice(): void $store = new TaggableDoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Invoice/Event']), - DefaultHeadersSerializer::createDefault(), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Invoice/Event']), ); $decisionModelBuilder = new StoreDecisionModelBuilder($store); diff --git a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php index 6f9ee822..b474a916 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php @@ -4,13 +4,13 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Projection; -use Patchlevel\EventSourcing\DCB\ApplyTrait; +use Patchlevel\EventSourcing\DCB\EventRouter; use Patchlevel\EventSourcing\DCB\Projection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Event\InvoiceCreated; final class NextInvoiceNumberProjection implements Projection { - use ApplyTrait; + use EventRouter; /** @return list */ public function tagFilter(): array diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index 58bac97c..094ecede 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -8,6 +8,7 @@ use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\AppendCondition; @@ -23,6 +24,7 @@ use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; use Patchlevel\EventSourcing\Store\Query; +use Patchlevel\EventSourcing\Store\Stream; use Patchlevel\EventSourcing\Store\SubQuery; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; @@ -32,7 +34,9 @@ use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; +use RuntimeException; +use function count; use function iterator_to_array; use function json_decode; use function sprintf; @@ -54,6 +58,7 @@ public function setUp(): void $this->store = new TaggableDoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), clock: $this->clock, ); @@ -135,6 +140,7 @@ public function testSaveWithIndex(): void $store = new TaggableDoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), clock: $this->clock, config: ['keep_index' => true], ); @@ -144,6 +150,7 @@ public function testSaveWithIndex(): void $store = new TaggableDoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), clock: $this->clock, ); @@ -535,7 +542,7 @@ public function testTags(): void } } - public function testAppendAndQuery(): void + public function testAppendWithoutAppendCondition(): void { $profileId1 = ProfileId::generate(); $profileId2 = ProfileId::generate(); @@ -558,30 +565,125 @@ public function testAppendAndQuery(): void ]; $this->store->append($messages); + $this->expectedStream($messages); + } - $stream = null; + public function testQueryTags(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); - try { - $stream = $this->store->query(new Query( - new SubQuery(['profile:' . $profileId1->toString()]), - )); + $message1 = Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])); + $message2 = Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])); + $message3 = Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])); + + $this->store->append([$message1, $message2, $message3]); + + $stream = $this->store->query(new Query( + new SubQuery(['profile:' . $profileId1->toString()]), + )); + + $this->expectedStreamEquals([$message1, $message3], $stream); + } - $messages = iterator_to_array($stream); + public function testQueryEvents(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); - self::assertCount(2, $messages); + $message1 = Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])); + $message2 = Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])); + $message3 = Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])); + + $this->store->append([$message1, $message2, $message3]); + + $stream = $this->store->query(new Query( + new SubQuery(events: [ProfileCreated::class]), + )); + + $this->expectedStreamEquals([$message1, $message2], $stream); + } - self::assertEquals( - ['profile:' . $profileId1->toString()], - $messages[0]->header(TagsHeader::class)->tags, - ); + public function testQueryTagAndEvent(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); - self::assertEquals( - ['profile:' . $profileId1->toString(), 'profile:' . $profileId2->toString()], - $messages[1]->header(TagsHeader::class)->tags, - ); - } finally { - $stream?->close(); - } + $message1 = Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])); + $message2 = Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])); + $message3 = Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])); + + $this->store->append([$message1, $message2, $message3]); + + $stream = $this->store->query(new Query( + new SubQuery(['profile:' . $profileId1->toString()], [ProfileCreated::class]), + )); + + $this->expectedStreamEquals([$message1], $stream); + } + + public function testComplexQuery(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $message1 = Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])); + $message2 = Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])); + $message3 = Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])); + + $this->store->append([$message1, $message2, $message3]); + + $stream = $this->store->query(new Query( + new SubQuery(['profile:' . $profileId1->toString()], [ProfileCreated::class]), + new SubQuery(['profile:' . $profileId2->toString()]), + new SubQuery(events: [ExternEvent::class]), + )); + + $this->expectedStreamEquals([$message1, $message2, $message3], $stream); } public function testAppendRaceCondition(): void @@ -622,6 +724,80 @@ public function testAppendRaceCondition(): void ); } + public function testAppendRaceConditionEmptyQuery(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])), + Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])), + ]; + + $this->store->append($messages); + + $messages = [ + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])), + ]; + + $this->expectException(AppendConditionNotMet::class); + + $this->store->append( + $messages, + new AppendCondition( + new Query(), + 0, + ), + ); + } + + public function testAppendWithEmptyQuery(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])), + Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])), + ]; + + $this->store->append($messages); + + $message = Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])); + + $this->store->append( + [$message], + new AppendCondition( + new Query(), + 2, + ), + ); + + $this->expectedStream([...$messages, $message]); + } + public function testStreams(): void { $profileId = ProfileId::fromString('0190e47e-77e9-7b90-bf62-08bbf0ab9b4b'); @@ -681,4 +857,37 @@ public function testRemove(): void self::assertEquals(['foo'], $streams); } + + /** @param list $messages */ + private function expectedStream(array $messages): void + { + $this->expectedStreamEquals($messages, $this->store->load()); + } + + /** @param list $expectedMessages */ + private function expectedStreamEquals(array $expectedMessages, Stream $stream): void + { + $index = 0; + + $messages = iterator_to_array($stream); + + self::assertEquals(count($expectedMessages), count($messages), 'Expected and actual message count do not match'); + + foreach ($messages as $message) { + $expectedMessage = $expectedMessages[$index] ?? null; + + if ($expectedMessage === null) { + throw new RuntimeException(sprintf('Expected message at index %d not found', $index)); + } + + $this->assertEquals( + $expectedMessage->header(StreamNameHeader::class)->streamName, + $message->header(StreamNameHeader::class)->streamName, + ); + + $this->assertEquals($expectedMessage->event(), $message->event()); + + ++$index; + } + } } From a7da910ef57432421c3d0a2b9268be4356e31614 Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 12 Aug 2025 13:48:31 +0200 Subject: [PATCH 12/22] handle stream name query --- src/DCB/EventAppender.php | 6 ++- src/DCB/EventRouter.php | 6 +++ src/DCB/StoreEventAppender.php | 10 +++-- src/Store/SubQuery.php | 14 ++++++- src/Store/TaggableDoctrineDbalStore.php | 14 +++++-- .../Store/TaggableDoctrineDbalStoreTest.php | 40 +++++++++++++++++-- 6 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/DCB/EventAppender.php b/src/DCB/EventAppender.php index b0bc4cad..9284bafa 100644 --- a/src/DCB/EventAppender.php +++ b/src/DCB/EventAppender.php @@ -10,5 +10,9 @@ interface EventAppender { /** @param iterable $events */ - public function append(iterable $events, AppendCondition|null $appendCondition = null): void; + public function append( + iterable $events, + AppendCondition|null $appendCondition = null, + string|null $streamName = null, + ): void; } diff --git a/src/DCB/EventRouter.php b/src/DCB/EventRouter.php index 2483fa83..89668d25 100644 --- a/src/DCB/EventRouter.php +++ b/src/DCB/EventRouter.php @@ -46,6 +46,7 @@ public function subQuery(): SubQuery return new SubQuery( $this->tagFilter(), $this->eventTypeFilter(), + $this->streamName(), ); } @@ -163,4 +164,9 @@ static function (ReflectionNamedType|ReflectionIntersectionType $reflectionType) /** @return list */ abstract public function tagFilter(): array; + + public function streamName(): string|null + { + return null; + } } diff --git a/src/DCB/StoreEventAppender.php b/src/DCB/StoreEventAppender.php index cf7b1b0c..dd9343db 100644 --- a/src/DCB/StoreEventAppender.php +++ b/src/DCB/StoreEventAppender.php @@ -18,15 +18,19 @@ final class StoreEventAppender implements EventAppender public function __construct( private readonly AppendStore $store, private readonly EventTagExtractor $eventTagExtractor = new AttributeEventTagExtractor(), + private readonly string $defaultStreamName = 'main', ) { } /** @param iterable $events */ - public function append(iterable $events, AppendCondition|null $appendCondition = null): void - { + public function append( + iterable $events, + AppendCondition|null $appendCondition = null, + string|null $streamName = null, + ): void { $messages = array_map( fn (object $event) => Message::create($event) - ->withHeader(new StreamNameHeader('main')) + ->withHeader(new StreamNameHeader($streamName ?? $this->defaultStreamName)) ->withHeader(new TagsHeader($this->eventTagExtractor->extract($event))), $events, ); diff --git a/src/Store/SubQuery.php b/src/Store/SubQuery.php index 2bd3e1fd..cbe8bb34 100644 --- a/src/Store/SubQuery.php +++ b/src/Store/SubQuery.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Store; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; use function array_diff; @@ -21,6 +22,7 @@ final class SubQuery public function __construct( public readonly array $tags = [], public readonly array $events = [], + public readonly string|null $streamName = null, ) { sort($tags); sort($events); @@ -36,6 +38,10 @@ public function match(Message $message): bool return false; } + if ($this->streamName !== null && $message->header(StreamNameHeader::class)->streamName !== $this->streamName) { + return false; + } + if ($this->events !== [] && !in_array($message->event()::class, $this->events, true)) { return false; } @@ -45,7 +51,8 @@ public function match(Message $message): bool public function equals(self $queryComponent): bool { - return $this->tags === $queryComponent->tags + return $this->streamName === $queryComponent->streamName + && $this->tags === $queryComponent->tags && $this->events === $queryComponent->events; } @@ -57,4 +64,9 @@ private function isSubset(array $needle, array $haystack): bool { return empty(array_diff($needle, $haystack)); } + + public function empty(): bool + { + return $this->streamName === null && $this->tags === [] && $this->events === []; + } } diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index f2dde8ea..a9e82e31 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -113,6 +113,7 @@ public function __construct( 'lock_id' => self::DEFAULT_LOCK_ID, 'lock_timeout' => -1, 'keep_index' => false, + 'default_stream_name' => 'main', ], $config); $platform = $this->connection->getDatabasePlatform(); @@ -295,7 +296,7 @@ function () use ($messages): void { $parameters[] = $message->hasHeader(StreamNameHeader::class) ? $message->header(StreamNameHeader::class)->streamName - : 'default'; + : $this->config['default_stream_name']; $parameters[] = $message->hasHeader(PlayheadHeader::class) ? $message->header(PlayheadHeader::class)->playhead @@ -411,7 +412,7 @@ public function append(iterable $messages, AppendCondition|null $appendCondition $parameters['stream' . $position] = $message->hasHeader(StreamNameHeader::class) ? $message->header(StreamNameHeader::class)->streamName - : 'default'; + : $this->config['default_stream_name']; $parameters['playhead' . $position] = $message->hasHeader(PlayheadHeader::class) ? $message->header(PlayheadHeader::class)->playhead @@ -819,7 +820,7 @@ private function queryCondition(QueryBuilder $builder, Query $query): void $uniqueParameterGenerator = $this->uniqueParameterGenerator(); foreach ($query->subQueries as $subQuery) { - if ($subQuery->tags === [] && $subQuery->events === []) { + if ($subQuery->empty()) { continue; } @@ -827,6 +828,13 @@ private function queryCondition(QueryBuilder $builder, Query $query): void ->select('id') ->from($this->config['table_name']); + if ($subQuery->streamName !== null) { + $streamNameParameterName = $uniqueParameterGenerator(); + + $subQueryBuilder->andWhere("stream = :{$streamNameParameterName}"); + $builder->setParameter($streamNameParameterName, $subQuery->streamName); + } + if ($subQuery->tags !== []) { $tagParameterName = $uniqueParameterGenerator(); diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index 094ecede..45c75db8 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -626,6 +626,36 @@ public function testQueryEvents(): void $this->expectedStreamEquals([$message1, $message2], $stream); } + public function testQueryStream(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $message1 = Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])); + $message2 = Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('bar')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])); + $message3 = Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('baz')) + ->withHeader(new TagsHeader([ + 'profile:' . $profileId1->toString(), + 'profile:' . $profileId2->toString(), + ])); + + $this->store->append([$message1, $message2, $message3]); + + $stream = $this->store->query(new Query( + new SubQuery(streamName: 'foo'), + new SubQuery(streamName: 'baz'), + )); + + $this->expectedStreamEquals([$message1, $message3], $stream); + } + public function testQueryTagAndEvent(): void { $profileId1 = ProfileId::generate(); @@ -669,21 +699,25 @@ public function testComplexQuery(): void ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])); $message3 = Message::create(new ExternEvent('test message')) - ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new StreamNameHeader('main')) ->withHeader(new TagsHeader([ 'profile:' . $profileId1->toString(), 'profile:' . $profileId2->toString(), ])); + $message4 = Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new TagsHeader([])); - $this->store->append([$message1, $message2, $message3]); + $this->store->append([$message1, $message2, $message3, $message4]); $stream = $this->store->query(new Query( new SubQuery(['profile:' . $profileId1->toString()], [ProfileCreated::class]), new SubQuery(['profile:' . $profileId2->toString()]), new SubQuery(events: [ExternEvent::class]), + new SubQuery(streamName: 'foo'), )); - $this->expectedStreamEquals([$message1, $message2, $message3], $stream); + $this->expectedStreamEquals([$message1, $message2, $message3, $message4], $stream); } public function testAppendRaceCondition(): void From 72ee0a57598134c96f6443ede8f3a29fcf10ed47 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 13 Aug 2025 09:48:16 +0200 Subject: [PATCH 13/22] fix phpstan errors --- Makefile | 2 +- src/DCB/ApplyMethodDetectionError.php | 83 +++++++++++++++++++ src/DCB/CompositeProjection.php | 10 ++- src/DCB/DecisionModel.php | 12 ++- src/DCB/EventAppender.php | 4 +- src/DCB/EventRouter.php | 33 +++++--- src/DCB/Projection.php | 2 +- src/DCB/StoreEventAppender.php | 4 +- src/Metadata/PropertyType.php | 58 ------------- .../Normalizer/StringableNormalizer.php | 6 ++ src/Store/AppendConditionNotMet.php | 2 +- src/Store/Query.php | 4 +- src/Store/TaggableDoctrineDbalStore.php | 10 ++- src/Stringable.php | 1 + .../SimpleSetupTaggableStoreBench.php | 2 + .../DynamicConsistencyBoundaryTest.php | 2 + .../Store/TaggableDoctrineDbalStoreTest.php | 2 + .../DCB/AttributeEventTagExtractorTest.php | 2 +- 18 files changed, 152 insertions(+), 87 deletions(-) create mode 100644 src/DCB/ApplyMethodDetectionError.php delete mode 100644 src/Metadata/PropertyType.php diff --git a/Makefile b/Makefile index 859c0b31..4d0a6568 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ phpstan-baseline: vendor .PHONY: psalm psalm: vendor ## run psalm static code analyser - vendor/bin/psalm + php -d memory_limit=312M vendor/bin/psalm .PHONY: psalm-baseline psalm-baseline: vendor ## run psalm static code analyser diff --git a/src/DCB/ApplyMethodDetectionError.php b/src/DCB/ApplyMethodDetectionError.php new file mode 100644 index 00000000..dd0a4827 --- /dev/null +++ b/src/DCB/ApplyMethodDetectionError.php @@ -0,0 +1,83 @@ +> - */ +/** @experimental */ final class CompositeProjection { /** @param array $projections */ @@ -40,6 +37,11 @@ public function initialState(): array }, $this->projections); } + /** + * @param array $state + * + * @return array + */ public function apply(mixed $state, Message $message): mixed { foreach ($this->projections as $name => $projection) { diff --git a/src/DCB/DecisionModel.php b/src/DCB/DecisionModel.php index 54fb942a..80d7d0cf 100644 --- a/src/DCB/DecisionModel.php +++ b/src/DCB/DecisionModel.php @@ -14,11 +14,12 @@ /** * @experimental * @psalm-immutable - * @implements ArrayAccess + * @implements ArrayAccess, value-of> + * @template T as array */ final class DecisionModel implements ArrayAccess { - /** @param array $state */ + /** @param T $state */ public function __construct( public readonly array $state, public readonly AppendCondition $appendCondition, @@ -30,6 +31,13 @@ public function offsetExists(mixed $offset): bool return array_key_exists($offset, $this->state); } + /** + * @param TKey $offset + * + * @return T[TKey] + * + * @template TKey of key-of + */ public function offsetGet(mixed $offset): mixed { if (!$this->offsetExists($offset)) { diff --git a/src/DCB/EventAppender.php b/src/DCB/EventAppender.php index 9284bafa..9c05e44a 100644 --- a/src/DCB/EventAppender.php +++ b/src/DCB/EventAppender.php @@ -9,9 +9,9 @@ /** @experimental */ interface EventAppender { - /** @param iterable $events */ + /** @param list $events */ public function append( - iterable $events, + array $events, AppendCondition|null $appendCondition = null, string|null $streamName = null, ): void; diff --git a/src/DCB/EventRouter.php b/src/DCB/EventRouter.php index 89668d25..782dd50e 100644 --- a/src/DCB/EventRouter.php +++ b/src/DCB/EventRouter.php @@ -19,10 +19,14 @@ use function array_merge; use function class_exists; -/** @experimental */ +/** + * @experimental + * @require-implements Projection + * @template S of mixed + */ trait EventRouter { - /** @param array|null $applyMethods */ + /** @var array|null $applyMethods */ private array|null $applyMethods = null; public function apply(mixed $state, Message $message): mixed @@ -35,6 +39,7 @@ public function apply(mixed $state, Message $message): mixed $applyMethods = $this->applyMethods(); if (array_key_exists($event::class, $applyMethods)) { + /* @phpstan-ignore return.type */ return $this->{$applyMethods[$event::class]}($state, $event); } @@ -90,7 +95,9 @@ private function applyMethods(): array } if ($hasOneEmptyApply) { - throw new DuplicateEmptyApplyAttribute($method->getName()); + throw ApplyMethodDetectionError::duplicateEmptyApplyAttribute( + $method->getName(), + ); } $hasOneEmptyApply = true; @@ -98,16 +105,22 @@ private function applyMethods(): array } if ($hasOneEmptyApply && $hasOneNonEmptyApply) { - throw new MixedApplyAttributeUsage($method->getName()); + throw ApplyMethodDetectionError::mixedApplyAttributeUsage( + $method->getName(), + ); } foreach ($eventClasses as $eventClass) { if (!class_exists($eventClass)) { - throw new ArgumentTypeIsNotAClass($method->getName(), $eventClass); + throw ApplyMethodDetectionError::argumentTypeIsNotAClass( + $method->getName(), + $eventClass, + ); } if (array_key_exists($eventClass, $this->applyMethods)) { - throw new DuplicateApplyMethod( + throw ApplyMethodDetectionError::duplicateApplyMethod( + static::class, $eventClass, $this->applyMethods[$eventClass], $method->getName(), @@ -127,18 +140,18 @@ private function getEventClassesByPropertyTypes(ReflectionMethod $method): array $parameters = $method->getParameters(); if (array_key_exists(1, $parameters) === false) { - throw new ParameterIsMissing($method->getName(), 1); + throw ApplyMethodDetectionError::parameterIsMissing($method->getName(), 1); } $propertyType = $parameters[1]->getType(); $methodName = $method->getName(); if ($propertyType === null) { - throw new ArgumentTypeIsMissing($methodName); + throw ApplyMethodDetectionError::argumentTypeIsMissing($methodName); } if ($propertyType instanceof ReflectionIntersectionType) { - throw new ArgumentTypeIsMissing($methodName); + throw ApplyMethodDetectionError::argumentTypeIsMissing($methodName); } if ($propertyType instanceof ReflectionNamedType) { @@ -150,7 +163,7 @@ private function getEventClassesByPropertyTypes(ReflectionMethod $method): array static function (ReflectionNamedType|ReflectionIntersectionType $reflectionType) use ($methodName, ): string { if ($reflectionType instanceof ReflectionIntersectionType) { - throw new ArgumentTypeIsMissing($methodName); + throw ApplyMethodDetectionError::argumentTypeIsMissing($methodName); } return $reflectionType->getName(); diff --git a/src/DCB/Projection.php b/src/DCB/Projection.php index f519c6cd..67aeb3c4 100644 --- a/src/DCB/Projection.php +++ b/src/DCB/Projection.php @@ -9,7 +9,7 @@ /** * @experimental - * @template S as mixed + * @template S of mixed */ interface Projection { diff --git a/src/DCB/StoreEventAppender.php b/src/DCB/StoreEventAppender.php index dd9343db..91a3fa7a 100644 --- a/src/DCB/StoreEventAppender.php +++ b/src/DCB/StoreEventAppender.php @@ -22,9 +22,9 @@ public function __construct( ) { } - /** @param iterable $events */ + /** @param list $events */ public function append( - iterable $events, + array $events, AppendCondition|null $appendCondition = null, string|null $streamName = null, ): void { diff --git a/src/Metadata/PropertyType.php b/src/Metadata/PropertyType.php deleted file mode 100644 index ccbf5513..00000000 --- a/src/Metadata/PropertyType.php +++ /dev/null @@ -1,58 +0,0 @@ -typeResolver = TypeResolver::create(); - } - - /** - * @param class-string $class - * - * @return array - */ - public function getEventClassesByPropertyTypes(ReflectionParameter $parameter): array - { - $propertyType = $parameter->getType(); - - if ($propertyType === null) { - throw new ArgumentTypeIsMissing($methodName); - } - - $type = $this->typeResolver->resolve($propertyType); - - if ($type instanceof ObjectType) { - return [$type->getClassName()]; - } - - if ($type instanceof UnionType) { - return array_map( - static function (Type $type): string { - if ($type instanceof ObjectType) { - return $type->getClassName(); - } - - throw new ArgumentTypeIsMissing($methodName); - }, - $type->getTypes(), - ); - } - - throw new ArgumentTypeIsMissing($methodName); - } -} diff --git a/src/Serializer/Normalizer/StringableNormalizer.php b/src/Serializer/Normalizer/StringableNormalizer.php index 13c51854..537bdbaf 100644 --- a/src/Serializer/Normalizer/StringableNormalizer.php +++ b/src/Serializer/Normalizer/StringableNormalizer.php @@ -13,8 +13,10 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ObjectType; +use function is_a; use function is_string; +/** @experimental */ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] final class StringableNormalizer implements Normalizer, TypeAwareNormalizer { @@ -74,6 +76,10 @@ public function handleType(Type|null $type): void return; } + if (is_a($type->getClassName(), Stringable::class, true) === false) { + throw InvalidType::unsupportedType(Stringable::class, $type->getClassName()); + } + $this->stringableClass = $type->getClassName(); } } diff --git a/src/Store/AppendConditionNotMet.php b/src/Store/AppendConditionNotMet.php index f0ad8d61..21a60818 100644 --- a/src/Store/AppendConditionNotMet.php +++ b/src/Store/AppendConditionNotMet.php @@ -5,7 +5,7 @@ namespace Patchlevel\EventSourcing\Store; /** @experimental */ -class AppendConditionNotMet extends StoreException +final class AppendConditionNotMet extends StoreException { public function __construct( public readonly AppendCondition $appendCondition, diff --git a/src/Store/Query.php b/src/Store/Query.php index 9099eccb..c40bd0f4 100644 --- a/src/Store/Query.php +++ b/src/Store/Query.php @@ -4,6 +4,8 @@ namespace Patchlevel\EventSourcing\Store; +use function array_values; + /** @experimental */ final class Query { @@ -13,7 +15,7 @@ final class Query public function __construct( SubQuery ...$subQueries, ) { - $this->subQueries = $subQueries; + $this->subQueries = array_values($subQueries); } public function add(SubQuery $subQuery): self diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index a9e82e31..bd4ff42b 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -82,7 +82,7 @@ final class TaggableDoctrineDbalStore implements StreamStore, AppendStore, Subsc private readonly ClockInterface $clock; - /** @var array{table_name: string, locking: bool, lock_id: int, lock_timeout: int, keep_index: bool} */ + /** @var array{table_name: string, locking: bool, lock_id: int, lock_timeout: int, keep_index: bool, default_stream_name: string} */ private readonly array $config; private bool $hasLock = false; @@ -95,7 +95,7 @@ final class TaggableDoctrineDbalStore implements StreamStore, AppendStore, Subsc private readonly bool $isSQLite; - /** @param array{table_name?: string, locking?: bool, lock_id?: int, lock_timeout?: int, keep_index?: bool} $config */ + /** @param array{table_name?: string, locking?: bool, lock_id?: int, lock_timeout?: int, keep_index?: bool, default_stream_name?: string} $config */ public function __construct( private readonly Connection $connection, private readonly EventSerializer $eventSerializer, @@ -486,7 +486,7 @@ public function append(iterable $messages, AppendCondition|null $appendCondition throw new UniqueConstraintViolation($e); } - if ($affectedRows === 0 && $appendCondition->highestSequenceNumber !== null) { + if ($affectedRows === 0 && $appendCondition && $appendCondition->highestSequenceNumber !== null) { throw new AppendConditionNotMet($appendCondition); } }); @@ -805,7 +805,9 @@ private function uniqueParameterGenerator(): Closure return static function () { static $counter = 0; - return 'param' . ++$counter; + ++$counter; + + return 'param' . $counter; }; } diff --git a/src/Stringable.php b/src/Stringable.php index ec6e4679..eeb96725 100644 --- a/src/Stringable.php +++ b/src/Stringable.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Serializer\Normalizer\StringableNormalizer; +/** @experimental */ #[StringableNormalizer] interface Stringable { diff --git a/tests/Benchmark/SimpleSetupTaggableStoreBench.php b/tests/Benchmark/SimpleSetupTaggableStoreBench.php index c5c929b7..c70a889a 100644 --- a/tests/Benchmark/SimpleSetupTaggableStoreBench.php +++ b/tests/Benchmark/SimpleSetupTaggableStoreBench.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Aggregate\AggregateRootId; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Repository\DefaultRepository; use Patchlevel\EventSourcing\Repository\Repository; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; @@ -33,6 +34,7 @@ public function setUp(): void $this->store = new TaggableDoctrineDbalStore( $connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/BasicImplementation/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/BasicImplementation/Events']), ); $this->repository = new DefaultRepository($this->store, Profile::metadata()); diff --git a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php index 608d8af5..ceecea11 100644 --- a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php +++ b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php @@ -77,6 +77,8 @@ public function testCourse(): void $commandBus->dispatch(new SubscribeStudentToCourse($student1Id, $courseId)); $commandBus->dispatch(new SubscribeStudentToCourse($student2Id, $courseId)); + $stream = $store->load(); + $this->assertTrue(true); } diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index 45c75db8..4372e1ba 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -480,6 +480,8 @@ public function testLoadWithWildcard(): void $stream?->close(); } + $stream = null; + try { $stream = $this->store->load(new Criteria(new StreamCriterion('*-*'))); diff --git a/tests/Unit/DCB/AttributeEventTagExtractorTest.php b/tests/Unit/DCB/AttributeEventTagExtractorTest.php index ce94bb3b..c6fe4f37 100644 --- a/tests/Unit/DCB/AttributeEventTagExtractorTest.php +++ b/tests/Unit/DCB/AttributeEventTagExtractorTest.php @@ -8,7 +8,7 @@ use Patchlevel\EventSourcing\DCB\AttributeEventTagExtractor; use PHPUnit\Framework\TestCase; -class AttributeEventTagExtractorTest extends TestCase +final class AttributeEventTagExtractorTest extends TestCase { public function testExtractEmpty(): void { From 41404df948b5214b65301269a3adaa064bd4c666 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 13 Aug 2025 09:56:42 +0200 Subject: [PATCH 14/22] add default for generic type in event router --- src/DCB/EventRouter.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DCB/EventRouter.php b/src/DCB/EventRouter.php index 782dd50e..d7037cce 100644 --- a/src/DCB/EventRouter.php +++ b/src/DCB/EventRouter.php @@ -21,8 +21,8 @@ /** * @experimental - * @require-implements Projection - * @template S of mixed + * @require-implements Projection + * @template S = mixed */ trait EventRouter { From f6f4b09d88598a2c1940f065785a6f36184eb2c0 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 13 Aug 2025 10:20:57 +0200 Subject: [PATCH 15/22] add event sourcing phpstan extension --- composer.json | 1 + composer.lock | 59 ++++++++++++++++++++++++++++++++++++++++++++++- phpstan.neon.dist | 1 + 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bab3d8c0..d758b950 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,7 @@ "infection/infection": "^0.29.12", "league/commonmark": "^2.6.1", "patchlevel/coding-standard": "^1.3.0", + "patchlevel/event-sourcing-phpstan-extension": "^1.0", "patchlevel/event-sourcing-psalm-plugin": "^3.1.0", "phpat/phpat": "^0.11.3", "phpbench/phpbench": "^1.4.1", diff --git a/composer.lock b/composer.lock index d2d175e4..63a7b235 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e47b04b41df0b48268165ffc8e103455", + "content-hash": "1595ca16451b55640850494b54772098", "packages": [ { "name": "brick/math", @@ -5627,6 +5627,63 @@ }, "time": "2023-06-02T10:05:08+00:00" }, + { + "name": "patchlevel/event-sourcing-phpstan-extension", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/patchlevel/event-sourcing-phpstan-extension.git", + "reference": "fb72d87091a5ed55f8079c8b2bacdec2f547143c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/patchlevel/event-sourcing-phpstan-extension/zipball/fb72d87091a5ed55f8079c8b2bacdec2f547143c", + "reference": "fb72d87091a5ed55f8079c8b2bacdec2f547143c", + "shasum": "" + }, + "require": { + "php": "~8.1 || ~8.2 || ~8.3 || 8.4", + "phpstan/phpstan": "^2.1.20" + }, + "require-dev": { + "patchlevel/coding-standard": "^1.3.0", + "patchlevel/event-sourcing": "dev-DCB", + "phpstan/phpstan-strict-rules": "^2.0.4", + "roave/security-advisories": "dev-master", + "symfony/var-dumper": "^7.0.0" + }, + "type": "phpstan-extension", + "autoload": { + "psr-4": { + "Patchlevel\\EventSourcingPHPStanExtension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Badura", + "email": "daniel.badura@patchlevel.de" + }, + { + "name": "David Badura", + "email": "david.badura@patchlevel.de" + } + ], + "description": "phpstan extension for patchlevel/event-sourcing", + "homepage": "https://github.com/patchlevel/event-sourcing-phpstan-extension", + "keywords": [ + "PHPStan", + "event-sourcing" + ], + "support": { + "issues": "https://github.com/patchlevel/event-sourcing-phpstan-extension/issues", + "source": "https://github.com/patchlevel/event-sourcing-phpstan-extension/tree/1.0.0" + }, + "time": "2025-08-13T08:14:47+00:00" + }, { "name": "patchlevel/event-sourcing-psalm-plugin", "version": "3.1.0", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 689e71a1..c5c79651 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,6 +2,7 @@ includes: - phpstan-baseline.neon - vendor/phpstan/phpstan-phpunit/extension.neon - vendor/phpat/phpat/extension.neon + - vendor/patchlevel/event-sourcing-phpstan-extension/extension.neon parameters: level: max From d4d51c6837fa9509a607d03bde802d5998c51d79 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 13 Aug 2025 11:24:03 +0200 Subject: [PATCH 16/22] fix phpstan errors --- baseline.xml | 52 ++++++++++++++++++ phpstan-baseline.neon | 30 +++++++++++ .../DynamicConsistencyBoundaryTest.php | 35 ++++++++++-- .../NextInvoiceNumberProjection.php | 2 + .../Store/TaggableDoctrineDbalStoreTest.php | 53 ++++--------------- tests/PhpunitHelper.php | 49 +++++++++++++++++ 6 files changed, 174 insertions(+), 47 deletions(-) create mode 100644 tests/PhpunitHelper.php diff --git a/baseline.xml b/baseline.xml index 7a797cba..f8cb9485 100644 --- a/baseline.xml +++ b/baseline.xml @@ -33,6 +33,28 @@ + + + hash]]> + prefix]]> + + + + + + + + + + + + + + + + + + getName()]]> @@ -109,6 +131,11 @@ getClassName()]]> + + + getClassName()]]> + + @@ -152,6 +179,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + ]]> diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e656e2c6..3aea54d9 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -108,6 +108,36 @@ parameters: count: 1 path: src/Store/StreamDoctrineDbalStoreStream.php + - + message: '#^Cannot use \+\+ on mixed\.$#' + identifier: preInc.type + count: 1 + path: src/Store/TaggableDoctrineDbalStore.php + + - + message: '#^Method Patchlevel\\EventSourcing\\Store\\TaggableDoctrineDbalStoreStream\:\:current\(\) never returns null so it can be removed from the return type\.$#' + identifier: return.unusedType + count: 1 + path: src/Store/TaggableDoctrineDbalStoreStream.php + + - + message: '#^Parameter \#1 \$playhead of class Patchlevel\\EventSourcing\\Store\\Header\\PlayheadHeader constructor expects int\<1, max\>, int given\.$#' + identifier: argument.type + count: 1 + path: src/Store/TaggableDoctrineDbalStoreStream.php + + - + message: '#^Parameter \#1 \$tags of class Patchlevel\\EventSourcing\\Store\\Header\\TagsHeader constructor expects list\, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Store/TaggableDoctrineDbalStoreStream.php + + - + message: '#^Ternary operator condition is always true\.$#' + identifier: ternary.alwaysTrue + count: 1 + path: src/Store/TaggableDoctrineDbalStoreStream.php + - message: '#^Generator expects key type int, int\<1, max\>\|null given\.$#' identifier: generator.keyType diff --git a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php index ceecea11..93286f98 100644 --- a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php +++ b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php @@ -9,27 +9,36 @@ use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; use Patchlevel\EventSourcing\DCB\StoreDecisionModelBuilder; use Patchlevel\EventSourcing\DCB\StoreEventAppender; +use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Command\ChangeCourseCapacity; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Command\DefineCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Command\SubscribeStudentToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\CourseId; +use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\CourseCapacityChanged; +use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\CourseDefined; +use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Handler\ChangeCourseCapacityHandler; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Handler\DefineCourseHandler; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Handler\SubscribeStudentToCourseHandler; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\StudentId; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Command\CreateInvoice; +use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Event\InvoiceCreated; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Handler\CreateInvoiceHandler; +use Patchlevel\EventSourcing\Tests\PhpunitHelper; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; #[CoversNothing] final class DynamicConsistencyBoundaryTest extends TestCase { + use PhpunitHelper; + private Connection $connection; public function setUp(): void @@ -77,9 +86,16 @@ public function testCourse(): void $commandBus->dispatch(new SubscribeStudentToCourse($student1Id, $courseId)); $commandBus->dispatch(new SubscribeStudentToCourse($student2Id, $courseId)); - $stream = $store->load(); - - $this->assertTrue(true); + self::assertStreamEquals([ + Message::create(new CourseDefined($courseId, 10)) + ->withHeader(new StreamNameHeader('main')), + Message::create(new CourseCapacityChanged($courseId, 2)) + ->withHeader(new StreamNameHeader('main')), + Message::create(new StudentSubscribedToCourse($student1Id, $courseId)) + ->withHeader(new StreamNameHeader('main')), + Message::create(new StudentSubscribedToCourse($student2Id, $courseId)) + ->withHeader(new StreamNameHeader('main')), + ], $store->load()); } public function testInvoice(): void @@ -112,6 +128,17 @@ public function testInvoice(): void $commandBus->dispatch(new CreateInvoice(10)); $commandBus->dispatch(new CreateInvoice(10)); - $this->assertTrue(true); + self::assertStreamEquals([ + Message::create(new InvoiceCreated(1, 10)) + ->withHeader(new StreamNameHeader('main')), + Message::create(new InvoiceCreated(2, 10)) + ->withHeader(new StreamNameHeader('main')), + Message::create(new InvoiceCreated(3, 10)) + ->withHeader(new StreamNameHeader('main')), + Message::create(new InvoiceCreated(4, 10)) + ->withHeader(new StreamNameHeader('main')), + Message::create(new InvoiceCreated(5, 10)) + ->withHeader(new StreamNameHeader('main')), + ], $store->load()); } } diff --git a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php index b474a916..b282cee9 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Projection; +use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\EventRouter; use Patchlevel\EventSourcing\DCB\Projection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Event\InvoiceCreated; @@ -23,6 +24,7 @@ public function initialState(): int return 1; } + #[Apply] public function applyInvoiceCreated(int $state, InvoiceCreated $event): int { return $state + 1; diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index 4372e1ba..f0e9c0a5 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -24,19 +24,17 @@ use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; use Patchlevel\EventSourcing\Store\Query; -use Patchlevel\EventSourcing\Store\Stream; use Patchlevel\EventSourcing\Store\SubQuery; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\Store\Events\ExternEvent; use Patchlevel\EventSourcing\Tests\Integration\Store\Events\ProfileCreated; +use Patchlevel\EventSourcing\Tests\PhpunitHelper; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; -use RuntimeException; -use function count; use function iterator_to_array; use function json_decode; use function sprintf; @@ -44,6 +42,8 @@ #[CoversNothing] final class TaggableDoctrineDbalStoreTest extends TestCase { + use PhpunitHelper; + private Connection $connection; private TaggableDoctrineDbalStore $store; @@ -567,7 +567,7 @@ public function testAppendWithoutAppendCondition(): void ]; $this->store->append($messages); - $this->expectedStream($messages); + self::assertStreamEquals($messages, $this->store->load()); } public function testQueryTags(): void @@ -596,7 +596,7 @@ public function testQueryTags(): void new SubQuery(['profile:' . $profileId1->toString()]), )); - $this->expectedStreamEquals([$message1, $message3], $stream); + self::assertStreamEquals([$message1, $message3], $stream); } public function testQueryEvents(): void @@ -625,7 +625,7 @@ public function testQueryEvents(): void new SubQuery(events: [ProfileCreated::class]), )); - $this->expectedStreamEquals([$message1, $message2], $stream); + self::assertStreamEquals([$message1, $message2], $stream); } public function testQueryStream(): void @@ -655,7 +655,7 @@ public function testQueryStream(): void new SubQuery(streamName: 'baz'), )); - $this->expectedStreamEquals([$message1, $message3], $stream); + self::assertStreamEquals([$message1, $message3], $stream); } public function testQueryTagAndEvent(): void @@ -684,7 +684,7 @@ public function testQueryTagAndEvent(): void new SubQuery(['profile:' . $profileId1->toString()], [ProfileCreated::class]), )); - $this->expectedStreamEquals([$message1], $stream); + self::assertStreamEquals([$message1], $stream); } public function testComplexQuery(): void @@ -719,7 +719,7 @@ public function testComplexQuery(): void new SubQuery(streamName: 'foo'), )); - $this->expectedStreamEquals([$message1, $message2, $message3, $message4], $stream); + self::assertStreamEquals([$message1, $message2, $message3, $message4], $stream); } public function testAppendRaceCondition(): void @@ -831,7 +831,7 @@ public function testAppendWithEmptyQuery(): void ), ); - $this->expectedStream([...$messages, $message]); + self::assertStreamEquals([...$messages, $message], $this->store->load()); } public function testStreams(): void @@ -893,37 +893,4 @@ public function testRemove(): void self::assertEquals(['foo'], $streams); } - - /** @param list $messages */ - private function expectedStream(array $messages): void - { - $this->expectedStreamEquals($messages, $this->store->load()); - } - - /** @param list $expectedMessages */ - private function expectedStreamEquals(array $expectedMessages, Stream $stream): void - { - $index = 0; - - $messages = iterator_to_array($stream); - - self::assertEquals(count($expectedMessages), count($messages), 'Expected and actual message count do not match'); - - foreach ($messages as $message) { - $expectedMessage = $expectedMessages[$index] ?? null; - - if ($expectedMessage === null) { - throw new RuntimeException(sprintf('Expected message at index %d not found', $index)); - } - - $this->assertEquals( - $expectedMessage->header(StreamNameHeader::class)->streamName, - $message->header(StreamNameHeader::class)->streamName, - ); - - $this->assertEquals($expectedMessage->event(), $message->event()); - - ++$index; - } - } } diff --git a/tests/PhpunitHelper.php b/tests/PhpunitHelper.php new file mode 100644 index 00000000..acbbb84d --- /dev/null +++ b/tests/PhpunitHelper.php @@ -0,0 +1,49 @@ + $expectedMessages */ + public static function assertStreamEquals(array $expectedMessages, Stream $stream): void + { + $index = 0; + + $messages = iterator_to_array($stream); + + self::assertEquals(count($expectedMessages), count($messages), 'Expected and actual message count do not match'); + + foreach ($messages as $message) { + $expectedMessage = $expectedMessages[$index] ?? null; + + if ($expectedMessage === null) { + throw new RuntimeException(sprintf('Expected message at index %d not found', $index)); + } + + self::assertEquals( + $expectedMessage->header(StreamNameHeader::class)->streamName, + $message->header(StreamNameHeader::class)->streamName, + ); + + self::assertEquals( + $expectedMessage->event(), + $message->event(), + sprintf('Event at index %d does not match expected event', $index), + ); + + ++$index; + } + } +} From fc7f16d20a9979ce2ad606556a9ba846223b257d Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 13 Aug 2025 16:13:20 +0200 Subject: [PATCH 17/22] add only last event filter --- src/CommandBus/InstantRetryCommandBus.php | 3 +- src/DCB/EventRouter.php | 6 ++++ src/Store/SubQuery.php | 8 ++++-- src/Store/TaggableDoctrineDbalStore.php | 9 ++++-- .../NextInvoiceNumberProjection.php | 7 ++++- .../Store/TaggableDoctrineDbalStoreTest.php | 28 +++++++++++++++++++ 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/CommandBus/InstantRetryCommandBus.php b/src/CommandBus/InstantRetryCommandBus.php index 41ff60b4..e245c847 100644 --- a/src/CommandBus/InstantRetryCommandBus.php +++ b/src/CommandBus/InstantRetryCommandBus.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Attribute\InstantRetry; use Patchlevel\EventSourcing\Repository\AggregateOutdated; +use Patchlevel\EventSourcing\Store\AppendConditionNotMet; use ReflectionClass; use Throwable; @@ -20,7 +21,7 @@ final class InstantRetryCommandBus implements CommandBus public function __construct( private readonly CommandBus $commandBus, private readonly int $defaultMaxRetries = 3, - private readonly array $defaultExceptions = [AggregateOutdated::class], + private readonly array $defaultExceptions = [AggregateOutdated::class, AppendConditionNotMet::class], ) { } diff --git a/src/DCB/EventRouter.php b/src/DCB/EventRouter.php index d7037cce..46d198d5 100644 --- a/src/DCB/EventRouter.php +++ b/src/DCB/EventRouter.php @@ -52,6 +52,7 @@ public function subQuery(): SubQuery $this->tagFilter(), $this->eventTypeFilter(), $this->streamName(), + $this->lastEventIsEnough(), ); } @@ -182,4 +183,9 @@ public function streamName(): string|null { return null; } + + public function lastEventIsEnough(): bool + { + return false; + } } diff --git a/src/Store/SubQuery.php b/src/Store/SubQuery.php index cbe8bb34..1e7f2c00 100644 --- a/src/Store/SubQuery.php +++ b/src/Store/SubQuery.php @@ -23,6 +23,7 @@ public function __construct( public readonly array $tags = [], public readonly array $events = [], public readonly string|null $streamName = null, + public readonly bool $onlyLastEvent = false, ) { sort($tags); sort($events); @@ -53,7 +54,8 @@ public function equals(self $queryComponent): bool { return $this->streamName === $queryComponent->streamName && $this->tags === $queryComponent->tags - && $this->events === $queryComponent->events; + && $this->events === $queryComponent->events + && $this->onlyLastEvent === $queryComponent->onlyLastEvent; } /** @@ -67,6 +69,8 @@ private function isSubset(array $needle, array $haystack): bool public function empty(): bool { - return $this->streamName === null && $this->tags === [] && $this->events === []; + return $this->streamName === null + && $this->tags === [] + && $this->events === []; } } diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index bd4ff42b..7742214f 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -803,11 +803,10 @@ private function unlock(): void private function uniqueParameterGenerator(): Closure { return static function () { + /** @var int $counter */ static $counter = 0; - ++$counter; - - return 'param' . $counter; + return 'param' . ++$counter; }; } @@ -868,6 +867,10 @@ private function queryCondition(QueryBuilder $builder, Query $query): void ); } + if ($subQuery->onlyLastEvent) { + $subQueryBuilder->select('MAX(id) AS id'); + } + $subqueries[] = $subQueryBuilder->getSQL(); } diff --git a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php index b282cee9..5cfb7c88 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php @@ -27,6 +27,11 @@ public function initialState(): int #[Apply] public function applyInvoiceCreated(int $state, InvoiceCreated $event): int { - return $state + 1; + return $event->invoiceNumber + 1; + } + + public function lastEventIsEnough(): bool + { + return true; } } diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index f0e9c0a5..f61a0df3 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -687,6 +687,34 @@ public function testQueryTagAndEvent(): void self::assertStreamEquals([$message1], $stream); } + public function testOnlyLastEvent(): void + { + $profileId1 = ProfileId::generate(); + $profileId2 = ProfileId::generate(); + + $message1 = Message::create(new ProfileCreated($profileId1, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId1->toString()])); + $message2 = Message::create(new ProfileCreated($profileId2, 'test')) + ->withHeader(new StreamNameHeader('foo')) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new TagsHeader(['profile:' . $profileId2->toString()])); + + $this->store->append([$message1, $message2]); + + $stream = $this->store->query( + new Query( + new SubQuery( + events: [ProfileCreated::class], + onlyLastEvent: true, + ), + ), + ); + + self::assertStreamEquals([$message2], $stream); + } + public function testComplexQuery(): void { $profileId1 = ProfileId::generate(); From b28303d954c086bbfd1ad4636d97cb35424bb129 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 21 Aug 2025 20:46:20 +0200 Subject: [PATCH 18/22] refactor query optimization & add tests --- baseline.xml | 9 - docs/pages/dynamic_consistency_boundary.md | 527 ++++++++++++++++++ src/DCB/AttributeEventTagExtractor.php | 9 +- src/DCB/CompositeProjection.php | 15 +- src/DCB/EventTagExtractorError.php | 26 + src/Store/Query.php | 30 +- src/Store/SubQuery.php | 58 +- .../DCB/AttributeEventTagExtractorTest.php | 34 ++ tests/Unit/DCB/CompositeProjectionTest.php | 101 ++++ tests/Unit/DCB/DecisionModelTest.php | 74 +++ tests/Unit/Fixture/IncrementProjection.php | 44 ++ tests/Unit/Store/QueryTest.php | 210 +++++++ tests/Unit/Store/SubQueryTest.php | 242 ++++++++ 13 files changed, 1327 insertions(+), 52 deletions(-) create mode 100644 src/DCB/EventTagExtractorError.php create mode 100644 tests/Unit/DCB/CompositeProjectionTest.php create mode 100644 tests/Unit/DCB/DecisionModelTest.php create mode 100644 tests/Unit/Fixture/IncrementProjection.php create mode 100644 tests/Unit/Store/QueryTest.php create mode 100644 tests/Unit/Store/SubQueryTest.php diff --git a/baseline.xml b/baseline.xml index f8cb9485..7759cbfc 100644 --- a/baseline.xml +++ b/baseline.xml @@ -179,15 +179,6 @@ - - - - - - - - - diff --git a/docs/pages/dynamic_consistency_boundary.md b/docs/pages/dynamic_consistency_boundary.md index 7064df33..24194b96 100644 --- a/docs/pages/dynamic_consistency_boundary.md +++ b/docs/pages/dynamic_consistency_boundary.md @@ -1,2 +1,529 @@ # Dynamic Consistency Boundary +In our little DCB getting-started example, we decide things atomically across multiple event streams without loading aggregates. +We keep the example small and show how to validate a course subscription and how to generate invoice numbers using the DCB API. + +!!! note + + DCB is marked as experimental. APIs may change. + +## Define some events + +First we define the events that happen in our system. + +A course can be defined with a capacity: + +```php +use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Attribute\EventTag; + +#[Event('course.defined')] +final class CourseDefined +{ + public function __construct( + #[EventTag(prefix: 'course')] + public CourseId $courseId, + public int $capacity, + ) { + } +} +``` +A course capacity can change later: + +```php +use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Attribute\EventTag; + +#[Event('course.capacity_changed')] +final class CourseCapacityChanged +{ + public function __construct( + #[EventTag(prefix: 'course')] + public CourseId $courseId, + public int $capacity, + ) { + } +} +``` +A student can subscribe to a course. We tag the event with both student and course, so that DCB projections can select the exact subset of events: + +```php +use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Attribute\EventTag; + +#[Event('course.student_subscribed')] +final class StudentSubscribedToCourse +{ + public function __construct( + #[EventTag(prefix: 'student')] + public readonly StudentId $studentId, + #[EventTag(prefix: 'course')] + public readonly CourseId $courseId, + ) { + } +} +``` +And finally, an invoice can be created with a monotonically increasing number: + +```php +use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Attribute\EventTag; + +#[Event('invoice.created')] +final class InvoiceCreated +{ + public function __construct( + #[EventTag(prefix: 'invoice')] + public readonly int $invoiceNumber, + public readonly int $money, + ) { + } +} +``` + +## Define projections + +DCB builds a tiny, purpose-built state just for the current decision using projections. +A projection selects events via tags, processes those events and yields a small value. + +We use the EventRouter trait which wires `#[Apply]` methods and builds a SubQuery from filters. + +Course exists: + +```php +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\Projection; + +/** @implements Projection */ +final class CourseExists implements Projection +{ + use EventRouter; + + public function __construct(private readonly CourseId $courseId) {} + + public function initialState(): bool + { + return false; + } + + /** @return list */ + public function tagFilter(): array + { + return ["course:{$this->courseId->toString()}"]; + } + + #[Apply] + public function applyCourseDefined(bool $state, CourseDefined $event): bool + { + return true; + } +} +``` + +Current capacity of a course: + +```php +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\Projection; + +final class CourseCapacityProjection implements Projection +{ + use EventRouter; + + public function __construct(private readonly CourseId $courseId) {} + + public function initialState(): int + { + return 0; + } + + /** @return list */ + public function tagFilter(): array + { + return ["course:{$this->courseId->toString()}"]; + } + + #[Apply] + public function applyCourseDefined(int $state, CourseDefined $event): int + { + return $event->capacity; + } + + #[Apply] + public function applyCourseCapacityChanged(int $state, CourseCapacityChanged $event): int + { + return $event->capacity; + } +} +``` + +Count subscriptions of a course: + +```php +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\Projection; + +final class NumberOfCourseSubscriptionsProjection implements Projection +{ + use EventRouter; + + public function __construct(private readonly CourseId $courseId) {} + + public function initialState(): int + { + return 0; + } + + /** @return list */ + public function tagFilter(): array + { + return ["course:{$this->courseId->toString()}"]; + } + + #[Apply] + public function applyStudentSubscribedToCourse(int $state, StudentSubscribedToCourse $event): int + { + return $state + 1; + } +} +``` + +Count subscriptions of a student: + +```php +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\Projection; + +final class NumberOfStudentSubscriptionsProjection implements Projection +{ + use EventRouter; + + public function __construct(private readonly StudentId $studentId) {} + + public function initialState(): int + { + return 0; + } + + /** @return list */ + public function tagFilter(): array + { + return ["student:{$this->studentId->toString()}"]; + } + + #[Apply] + public function applyStudentSubscribedToCourse(int $state, StudentSubscribedToCourse $event): int + { + return $state + 1; + } +} +``` + +Has the student already subscribed to this course: + +```php +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\Projection; + +/** @implements Projection */ +final class StudentAlreadySubscribedProjection implements Projection +{ + use EventRouter; + + public function __construct( + private readonly StudentId $studentId, + private readonly CourseId $courseId, + ) {} + + public function initialState(): bool + { + return false; + } + + /** @return list */ + public function tagFilter(): array + { + return [ + "student:{$this->studentId->toString()}", + "course:{$this->courseId->toString()}", + ]; + } + + #[Apply] + public function applyStudentSubscribedToCourse(bool $state, StudentSubscribedToCourse $event): bool + { + return true; + } +} +``` + +Next invoice number from the last event only: + +```php +use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\Projection; + +final class NextInvoiceNumberProjection implements Projection +{ + use EventRouter; + + public function initialState(): int + { + return 1; + } + + #[Apply] + public function applyInvoiceCreated(int $state, InvoiceCreated $event): int + { + return $event->invoiceNumber + 1; + } + + public function lastEventIsEnough(): bool + { + return true; // optimize: only the last matching event is needed + } +} +``` + +!!! tip + + `#[Apply]` methods can be named freely. The second parameter’s type determines which event is routed. + +## Write handlers + +Now we can build decisions and append new events atomically by using the DecisionModelBuilder and EventAppender. + +Define a course only if it does not exist yet: + +```php +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\DCB\DecisionModelBuilder; +use Patchlevel\EventSourcing\DCB\EventAppender; + +final class DefineCourseHandler +{ + public function __construct( + private readonly DecisionModelBuilder $decisionModelBuilder, + private readonly EventAppender $eventAppender, + ) {} + + #[Handle] + public function __invoke(DefineCourse $command): void + { + $state = $this->decisionModelBuilder->build([ + 'courseExists' => new CourseExists($command->courseId), + ]); + + if ($state['courseExists']) { + throw new RuntimeException('Course already exists'); + } + + $this->eventAppender->append([ + new CourseDefined($command->courseId, $command->capacity), + ], $state->appendCondition); + } +} +``` + +Change capacity if different: + +```php +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\DCB\DecisionModelBuilder; +use Patchlevel\EventSourcing\DCB\EventAppender; + +final class ChangeCourseCapacityHandler +{ + public function __construct( + private readonly DecisionModelBuilder $decisionModelBuilder, + private readonly EventAppender $eventAppender, + ) {} + + #[Handle] + public function __invoke(ChangeCourseCapacity $command): void + { + $state = $this->decisionModelBuilder->build([ + 'courseExists' => new CourseExists($command->courseId), + 'courseCapacity' => new CourseCapacityProjection($command->courseId), + ]); + + if (!$state['courseExists']) { + throw new RuntimeException('Course does not exist'); + } + + if ($state['courseCapacity'] === $command->capacity) { + return; + } + + $this->eventAppender->append([ + new CourseCapacityChanged($command->courseId, $command->capacity), + ], $state->appendCondition); + } +} +``` + +Subscribe a student with multiple checks atomically: + +```php +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\DCB\DecisionModelBuilder; +use Patchlevel\EventSourcing\DCB\EventAppender; + +final class SubscribeStudentToCourseHandler +{ + public function __construct( + private readonly DecisionModelBuilder $decisionModelBuilder, + private readonly EventAppender $eventAppender, + ) {} + + #[Handle] + public function __invoke(SubscribeStudentToCourse $command): void + { + $state = $this->decisionModelBuilder->build([ + 'courseExists' => new CourseExists($command->courseId), + 'courseCapacity' => new CourseCapacityProjection($command->courseId), + 'numberOfCourseSubscriptions' => new NumberOfCourseSubscriptionsProjection($command->courseId), + 'numberOfStudentSubscriptions' => new NumberOfStudentSubscriptionsProjection($command->studentId), + 'studentAlreadySubscribed' => new StudentAlreadySubscribedProjection($command->studentId, $command->courseId), + ]); + + if (!$state['courseExists']) { + throw new RuntimeException("Course {$command->courseId->toString()} does not exist"); + } + + if ($state['numberOfCourseSubscriptions'] >= $state['courseCapacity']) { + throw new RuntimeException("Course {$command->courseId->toString()} is not available"); + } + + if ($state['studentAlreadySubscribed']) { + throw new RuntimeException("Student {$command->studentId->toString()} is already subscribed to course {$command->courseId->toString()}"); + } + + if ($state['numberOfStudentSubscriptions'] >= 5) { + throw new RuntimeException("Student {$command->studentId->toString()} is already subscribed to 5 courses"); + } + + $this->eventAppender->append([ + new StudentSubscribedToCourse($command->studentId, $command->courseId), + ], $state->appendCondition); + } +} +``` + +Create invoices while safely generating the next number from the last event only: + +```php +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\DCB\DecisionModelBuilder; +use Patchlevel\EventSourcing\DCB\EventAppender; + +final class CreateInvoiceHandler +{ + public function __construct( + private readonly DecisionModelBuilder $decisionModelBuilder, + private readonly EventAppender $eventAppender, + ) {} + + #[Handle] + public function __invoke(CreateInvoice $command): void + { + $state = $this->decisionModelBuilder->build([ + 'nextInvoiceNumber' => new NextInvoiceNumberProjection(), + ]); + + $this->eventAppender->append([ + new InvoiceCreated($state['nextInvoiceNumber'], $command->money), + ], $state->appendCondition); + } +} +``` + +!!! tip + + If any concurrent writer changes the queried subset in between build() and append(), the store will reject the write (optimistic concurrency). You can retry if appropriate. + +## Configuration + +Now we plug the whole thing together. We use the TaggableDoctrineDbalStore plus the DCB builder and appender. + +```php +use Patchlevel\EventSourcing\CommandBus\ServiceHandlerProvider; +use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; +use Patchlevel\EventSourcing\DCB\StoreDecisionModelBuilder; +use Patchlevel\EventSourcing\DCB\StoreEventAppender; +use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; +use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; + +$store = new TaggableDoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Event']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Event']), +); + +$decisionModelBuilder = new StoreDecisionModelBuilder($store); +$eventAppender = new StoreEventAppender($store); + +$commandBus = new SyncCommandBus( + new ServiceHandlerProvider([ + new DefineCourseHandler($decisionModelBuilder, $eventAppender), + new ChangeCourseCapacityHandler($decisionModelBuilder, $eventAppender), + new SubscribeStudentToCourseHandler($decisionModelBuilder, $eventAppender), + new CreateInvoiceHandler($decisionModelBuilder, $eventAppender), + ]), +); +``` + +## Database setup + +To actually write data to the database we need to create the event table. + +```php +use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; + +$schemaDirector = new DoctrineSchemaDirector($connection, $store); +$schemaDirector->create(); +``` + +!!! note + + You can also use the predefined CLI commands to create and drop the schema. See the CLI documentation. + +## Usage + +We are now ready to use DCB. We can dispatch commands and DCB will keep each decision consistent. + +```php +$courseId = CourseId::generate(); +$student1 = StudentId::generate(); +$student2 = StudentId::generate(); + +$commandBus->dispatch(new DefineCourse($courseId, 10)); +$commandBus->dispatch(new ChangeCourseCapacity($courseId, 2)); +$commandBus->dispatch(new SubscribeStudentToCourse($student1, $courseId)); +$commandBus->dispatch(new SubscribeStudentToCourse($student2, $courseId)); + +$commandBus->dispatch(new CreateInvoice(10)); +$commandBus->dispatch(new CreateInvoice(10)); +``` + +## Result + +!!! success + + We have successfully implemented and used DCB to make consistent decisions across multiple streams without loading aggregates. + Feel free to browse further in the documentation for more detailed information. + +## Learn more + +* [How to use command bus](command_bus.md) +* [How to use aggregates](aggregate.md) +* [How to use aggregate id](aggregate_id.md) +* [How to use clock](clock.md) + diff --git a/src/DCB/AttributeEventTagExtractor.php b/src/DCB/AttributeEventTagExtractor.php index 566c3010..30e869e8 100644 --- a/src/DCB/AttributeEventTagExtractor.php +++ b/src/DCB/AttributeEventTagExtractor.php @@ -7,15 +7,12 @@ use Patchlevel\EventSourcing\Attribute\EventTag; use Patchlevel\EventSourcing\Stringable; use ReflectionClass; -use RuntimeException; use Stringable as NativeStringable; use function array_keys; -use function get_debug_type; use function hash; use function is_int; use function is_string; -use function sprintf; /** @experimental */ final class AttributeEventTagExtractor implements EventTagExtractor @@ -48,8 +45,10 @@ public function extract(object $event): array } if (!is_string($value)) { - throw new RuntimeException( - sprintf('Event tag value must be stringable, %s given', get_debug_type($value)), + throw EventTagExtractorError::invalidValueType( + $event::class, + $property->getName(), + $value, ); } diff --git a/src/DCB/CompositeProjection.php b/src/DCB/CompositeProjection.php index e9d736a3..5976c390 100644 --- a/src/DCB/CompositeProjection.php +++ b/src/DCB/CompositeProjection.php @@ -20,13 +20,14 @@ public function __construct( public function query(): Query { - $query = new Query(); - - foreach ($this->projections as $projection) { - $query = $query->add($projection->subQuery()); - } - - return $query; + $query = new Query( + ...array_map( + static fn (Projection $projection) => $projection->subQuery(), + $this->projections, + ), + ); + + return $query->optimize(); } /** @return array */ diff --git a/src/DCB/EventTagExtractorError.php b/src/DCB/EventTagExtractorError.php new file mode 100644 index 00000000..96185fc6 --- /dev/null +++ b/src/DCB/EventTagExtractorError.php @@ -0,0 +1,26 @@ +subQueries as $query) { - if ($query->equals($subQuery)) { - return $this; + return new self(...array_merge($this->subQueries, [$subQuery])); + } + + /** + * Optimize the query by removing sub-queries that are included in other sub-queries. + */ + public function optimize(): Query + { + $queries = $this->subQueries; + + foreach ($queries as $key => $a) { + foreach ($queries as $b) { + if ($a === $b) { + continue; + } + + if ($b->empty() && !$b->onlyLastEvent) { + return new self(); + } + + if ($b->includes($a)) { + unset($queries[$key]); + continue 2; + } } } - return new self($subQuery, ...$this->subQueries); + return new self(...$queries); } } diff --git a/src/Store/SubQuery.php b/src/Store/SubQuery.php index 1e7f2c00..d4513191 100644 --- a/src/Store/SubQuery.php +++ b/src/Store/SubQuery.php @@ -10,7 +10,6 @@ use function array_diff; use function in_array; -use function sort; /** @experimental */ final class SubQuery @@ -25,52 +24,57 @@ public function __construct( public readonly string|null $streamName = null, public readonly bool $onlyLastEvent = false, ) { - sort($tags); - sort($events); } public function match(Message $message): bool { - if ($this->tags === [] && $this->events === []) { - return true; + if ( + $this->streamName !== null + && (!$message->hasHeader(StreamNameHeader::class) + || $message->header(StreamNameHeader::class)->streamName !== $this->streamName) + ) { + return false; } - if (!$message->hasHeader(TagsHeader::class)) { + if ( + $this->tags !== [] + && (!$message->hasHeader(TagsHeader::class) + || !self::isSubset($this->tags, $message->header(TagsHeader::class)->tags)) + ) { return false; } - if ($this->streamName !== null && $message->header(StreamNameHeader::class)->streamName !== $this->streamName) { + return $this->events === [] || in_array($message->event()::class, $this->events, true); + } + + public function empty(): bool + { + return $this->streamName === null && $this->tags === [] && $this->events === []; + } + + public function includes(SubQuery $other): bool + { + if ($this->streamName !== null && $this->streamName !== $other->streamName) { return false; } - if ($this->events !== [] && !in_array($message->event()::class, $this->events, true)) { + if (!self::isSubset($this->tags, $other->tags)) { return false; } - return $this->isSubset($this->tags, $message->header(TagsHeader::class)->tags); - } + if (!self::isSubset($this->events, $other->events)) { + return false; + } - public function equals(self $queryComponent): bool - { - return $this->streamName === $queryComponent->streamName - && $this->tags === $queryComponent->tags - && $this->events === $queryComponent->events - && $this->onlyLastEvent === $queryComponent->onlyLastEvent; + return !$this->onlyLastEvent || $other->onlyLastEvent; } /** - * @param list $needle - * @param list $haystack + * @param list $subject + * @param list $off */ - private function isSubset(array $needle, array $haystack): bool - { - return empty(array_diff($needle, $haystack)); - } - - public function empty(): bool + private static function isSubset(array $subject, array $off): bool { - return $this->streamName === null - && $this->tags === [] - && $this->events === []; + return empty(array_diff($subject, $off)); } } diff --git a/tests/Unit/DCB/AttributeEventTagExtractorTest.php b/tests/Unit/DCB/AttributeEventTagExtractorTest.php index c6fe4f37..84afd8c6 100644 --- a/tests/Unit/DCB/AttributeEventTagExtractorTest.php +++ b/tests/Unit/DCB/AttributeEventTagExtractorTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\DCB; +use Patchlevel\EventSourcing\Aggregate\CustomId; use Patchlevel\EventSourcing\Attribute\EventTag; use Patchlevel\EventSourcing\DCB\AttributeEventTagExtractor; use PHPUnit\Framework\TestCase; @@ -22,6 +23,22 @@ public function testExtractEmpty(): void self::assertSame([], $tags); } + public function testExtractClassWithoutAttributes(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class { + public function __construct( + public string $name = 'baz', + ) { + } + }; + + $tags = $extractor->extract($event); + + self::assertSame([], $tags); + } + public function testExtract(): void { $extractor = new AttributeEventTagExtractor(); @@ -41,6 +58,23 @@ public function __construct( self::assertSame(['foo', 'bar:baz'], $tags); } + public function testExtractStringable(): void + { + $extractor = new AttributeEventTagExtractor(); + + $event = new class (new CustomId('foo')) { + public function __construct( + #[EventTag] + public CustomId $id, + ) { + } + }; + + $tags = $extractor->extract($event); + + self::assertSame(['foo'], $tags); + } + public function testExtractWithHash(): void { $extractor = new AttributeEventTagExtractor(); diff --git a/tests/Unit/DCB/CompositeProjectionTest.php b/tests/Unit/DCB/CompositeProjectionTest.php new file mode 100644 index 00000000..ed5b9724 --- /dev/null +++ b/tests/Unit/DCB/CompositeProjectionTest.php @@ -0,0 +1,101 @@ + $p1, + 'b' => $p2, + ]); + + self::assertEquals(new Query( + new SubQuery( + ['tag:a'], + [ProfileCreated::class], + ), + new SubQuery( + ['tag:b'], + [ProfileCreated::class], + 'main', + ), + ), $composite->query()); + } + + public function testInitialStateBuildsMap(): void + { + $composite = new CompositeProjection([ + 'alpha' => new IncrementProjection(1), + 'beta' => new IncrementProjection(5), + ]); + + $state = $composite->initialState(); + + self::assertSame(['alpha' => 1, 'beta' => 5], $state); + } + + public function testApplyDelegatesToEachProjection(): void + { + $composite = new CompositeProjection([ + 'x' => new IncrementProjection(0, ['match']), + 'y' => new IncrementProjection(10, ['match']), + ]); + + $state = $composite->initialState(); + + $message = Message::create(new ProfileCreated( + ProfileId::fromString('test'), + Email::fromString('foo@example.com'), + )) + ->withHeader(new StreamNameHeader('main')) + ->withHeader(new TagsHeader(['match'])); + + $newState = $composite->apply($state, $message); + + // both should have been incremented by 1 + self::assertSame(1, $newState['x']); + self::assertSame(11, $newState['y']); + } + + public function testApplySkipsNonMatchingProjection(): void + { + $composite = new CompositeProjection([ + 'm' => new IncrementProjection(2, ['match']), + 'n' => new IncrementProjection(3, ['other']), + ]); + + $state = $composite->initialState(); + + $message = Message::create(new ProfileCreated( + ProfileId::fromString('test'), + Email::fromString('foo@example.com'), + )) + ->withHeader(new StreamNameHeader('main')) + ->withHeader(new TagsHeader(['match'])); + + $newState = $composite->apply($state, $message); + + // 'm' matches and increments; 'n' does not match and stays the same + self::assertSame(3, $newState['m']); + self::assertSame(3, $newState['n']); + } +} diff --git a/tests/Unit/DCB/DecisionModelTest.php b/tests/Unit/DCB/DecisionModelTest.php new file mode 100644 index 00000000..fb8718b6 --- /dev/null +++ b/tests/Unit/DCB/DecisionModelTest.php @@ -0,0 +1,74 @@ +createAppendCondition(); + + $model = new DecisionModel([ + 'foo' => 123, + 'bar' => 'baz', + ], $appendCondition); + + self::assertSame(123, $model['foo']); + self::assertSame('baz', $model['bar']); + self::assertSame($appendCondition, $model->appendCondition); + } + + public function testOffsetExists(): void + { + $model = new DecisionModel(['a' => 1], $this->createAppendCondition()); + + self::assertTrue(isset($model['a'])); + /** @phpstan-ignore-next-line */ + self::assertFalse(isset($model['b'])); + } + + public function testUnknownKeyThrowsOutOfBounds(): void + { + $model = new DecisionModel(['a' => 1], $this->createAppendCondition()); + + $this->expectException(OutOfBoundsException::class); + // access non-existing key + /** @phpstan-ignore-next-line */ + $unused = $model['b']; + } + + public function testImmutableSetThrows(): void + { + $model = new DecisionModel(['a' => 1], $this->createAppendCondition()); + + $this->expectException(LogicException::class); + /** @phpstan-ignore-next-line */ + $model['a'] = 2; + } + + public function testImmutableUnsetThrows(): void + { + $model = new DecisionModel(['a' => 1], $this->createAppendCondition()); + + $this->expectException(LogicException::class); + /** @phpstan-ignore-next-line */ + unset($model['a']); + } +} diff --git a/tests/Unit/Fixture/IncrementProjection.php b/tests/Unit/Fixture/IncrementProjection.php new file mode 100644 index 00000000..ced2180d --- /dev/null +++ b/tests/Unit/Fixture/IncrementProjection.php @@ -0,0 +1,44 @@ + $tags */ + public function __construct( + private readonly int $initial, + private readonly array $tags = [], + private readonly string|null $streamName = null, + ) { + } + + #[Apply] + public function applyProfileCreated(int $state, ProfileCreated $event): int + { + return $state + 1; + } + + public function initialState(): int + { + return $this->initial; + } + + /** @return list */ + public function tagFilter(): array + { + return $this->tags; + } + + public function streamName(): string|null + { + return $this->streamName; + } +} diff --git a/tests/Unit/Store/QueryTest.php b/tests/Unit/Store/QueryTest.php new file mode 100644 index 00000000..f8cd4a9c --- /dev/null +++ b/tests/Unit/Store/QueryTest.php @@ -0,0 +1,210 @@ +subQueries); + } + + public function testConstructWithSubqueriesKeepsOrder(): void + { + $sq1 = new SubQuery(['a'], [ProfileCreated::class], 's1'); + $sq2 = new SubQuery(['b'], [ProfileVisited::class], 's2', true); + + $query = new Query($sq1, $sq2); + + self::assertEquals([$sq1, $sq2], $query->subQueries); + } + + public function testAdd(): void + { + $sq1 = new SubQuery(['a'], [ProfileCreated::class], 's1'); + $sq2 = new SubQuery(['b'], [ProfileVisited::class], 's2'); + + $q1 = new Query($sq1); + $q2 = $q1->add($sq2); + + self::assertNotSame($q1, $q2); + self::assertEquals([$sq1, $sq2], $q2->subQueries); + } + + #[DataProvider('providerForOptimize')] + public function testOptimize(Query $before, Query $after): void + { + self::assertEquals($after, $before->optimize()); + } + + /** @return Generator */ + public static function providerForOptimize(): Generator + { + yield 'no subqueries' => [ + new Query(), + new Query(), + ]; + + yield 'single subquery' => [ + new Query( + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + ), + new Query( + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + ), + ]; + + yield 'equal subqueries' => [ + new Query( + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + ), + new Query( + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + ), + ]; + + yield 'equal subqueries 3 times' => [ + new Query( + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + ), + new Query( + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + ), + ]; + + yield 'empty sub query includes all' => [ + new Query( + new SubQuery( + ['a'], + [ProfileCreated::class], + 's1', + ), + new SubQuery( + ['b'], + [ProfileVisited::class], + 's2', + true, + ), + new SubQuery(), + ), + new Query(), + ]; + + yield 'partial overlap subqueries' => [ + new Query( + new SubQuery( + ['a'], + [ProfileCreated::class], + ), + new SubQuery( + ['a'], + ), + new SubQuery( + [], + [ProfileCreated::class], + ), + ), + new Query( + new SubQuery( + ['a'], + ), + new SubQuery( + [], + [ProfileCreated::class], + ), + ), + ]; + + yield 'empty sub query' => [ + new Query( + new SubQuery(), + new SubQuery( + ['a'], + ), + new SubQuery( + [], + [ProfileCreated::class], + ), + ), + new Query(), + ]; + + yield 'empty sub query with only last event' => [ + new Query( + new SubQuery( + onlyLastEvent: true, + ), + new SubQuery( + ['a'], + ), + new SubQuery( + [], + [ProfileCreated::class], + ), + ), + new Query( + new SubQuery( + onlyLastEvent: true, + ), + new SubQuery( + ['a'], + ), + new SubQuery( + [], + [ProfileCreated::class], + ), + ), + ]; + } +} diff --git a/tests/Unit/Store/SubQueryTest.php b/tests/Unit/Store/SubQueryTest.php new file mode 100644 index 00000000..e0db8398 --- /dev/null +++ b/tests/Unit/Store/SubQueryTest.php @@ -0,0 +1,242 @@ +tags); + self::assertSame([], $query->events); + self::assertSame(null, $query->streamName); + self::assertFalse($query->onlyLastEvent); + + self::assertTrue($query->empty()); + } + + public function testNotEmpty(): void + { + $query = new SubQuery(['tag'], [ProfileCreated::class], 'foo', true); + + self::assertFalse($query->empty()); + } + + #[DataProvider('providerForMatch')] + public function testMatch(SubQuery $subQuery, Message $message, bool $result): void + { + self::assertEquals($result, $subQuery->match($message)); + } + + /** @return Generator */ + public static function providerForMatch(): Generator + { + yield 'match always with empty subquery' => [ + new SubQuery(), + self::messageFactory(ProfileCreated::class, ['test'], 'foo'), + true, + ]; + + yield 'match always with empty subquery and only last event' => [ + new SubQuery(onlyLastEvent: true), + self::messageFactory(ProfileCreated::class, ['test'], 'foo'), + true, + ]; + + yield 'match with perfect message' => [ + new SubQuery(['test'], [ProfileCreated::class], 'foo'), + self::messageFactory(ProfileCreated::class, ['test'], 'foo'), + true, + ]; + + yield 'match with subset events' => [ + new SubQuery(['test'], [ProfileCreated::class, ProfileVisited::class], 'foo'), + self::messageFactory(ProfileCreated::class, ['test'], 'foo'), + true, + ]; + + yield 'match with more tags on message' => [ + new SubQuery(['test'], [ProfileCreated::class], 'foo'), + self::messageFactory(ProfileCreated::class, ['test', 'other'], 'foo'), + true, + ]; + + yield 'not match with wrong tag' => [ + new SubQuery(['test'], [ProfileCreated::class], 'foo'), + self::messageFactory(ProfileCreated::class, ['other'], 'foo'), + false, + ]; + + yield 'not match with less tags on message' => [ + new SubQuery(['test', 'other'], [ProfileCreated::class], 'foo'), + self::messageFactory(ProfileCreated::class, ['test'], 'foo'), + false, + ]; + + yield 'not match with wrong event' => [ + new SubQuery(['test'], [ProfileCreated::class], 'foo'), + self::messageFactory(ProfileVisited::class, ['test'], 'foo'), + false, + ]; + + yield 'not match with wrong stream' => [ + new SubQuery(['test'], [ProfileCreated::class], 'foo'), + self::messageFactory(ProfileCreated::class, ['test'], 'bar'), + false, + ]; + + yield 'not match with missing tag header' => [ + new SubQuery(['test'], [ProfileCreated::class], 'foo'), + self::messageFactory(ProfileCreated::class, null, 'bar'), + false, + ]; + + yield 'not match with missing steam header' => [ + new SubQuery(['test'], [ProfileCreated::class], 'foo'), + self::messageFactory(ProfileCreated::class, ['test']), + false, + ]; + } + + #[DataProvider('providerForIncludes')] + public function testIncludes(SubQuery $a, SubQuery $b, bool $result): void + { + self::assertEquals($result, $a->includes($b)); + } + + /** @return Generator */ + public static function providerForIncludes(): Generator + { + yield 'empty includes empty' => [ + new SubQuery(), + new SubQuery(), + true, + ]; + + yield 'empty includes non-empty' => [ + new SubQuery(), + new SubQuery(['tag']), + true, + ]; + + yield 'non-empty does not include empty' => [ + new SubQuery(['tag']), + new SubQuery(), + false, + ]; + + yield 'same tags and events includes' => [ + new SubQuery(['tag'], [ProfileCreated::class]), + new SubQuery(['tag'], [ProfileCreated::class]), + true, + ]; + + yield 'different tags does not include' => [ + new SubQuery(['tag1'], [ProfileCreated::class]), + new SubQuery(['tag2'], [ProfileCreated::class]), + false, + ]; + + yield 'different events does not include' => [ + new SubQuery(['tag'], [ProfileCreated::class]), + new SubQuery(['tag'], [ProfileVisited::class]), + false, + ]; + + yield 'subset includes' => [ + new SubQuery(['tag1'], [ProfileCreated::class]), + new SubQuery(['tag1', 'tag2'], [ProfileCreated::class, ProfileVisited::class]), + true, + ]; + + yield 'non-subset not includes' => [ + new SubQuery(['tag1', 'tag2'], [ProfileCreated::class, ProfileVisited::class]), + new SubQuery(['tag1'], [ProfileCreated::class]), + false, + ]; + + yield 'stream name equals includes' => [ + new SubQuery(streamName: 'foo'), + new SubQuery(streamName: 'foo'), + true, + ]; + + yield 'stream name not equals does not include' => [ + new SubQuery(streamName: 'foo'), + new SubQuery(streamName: 'bar'), + false, + ]; + + yield 'stream name does not include stream name not set' => [ + new SubQuery(streamName: 'foo'), + new SubQuery(), + false, + ]; + + yield 'no stream name does include stream name set' => [ + new SubQuery(), + new SubQuery(streamName: 'foo'), + true, + ]; + + yield 'only last event includes only last event' => [ + new SubQuery(onlyLastEvent: true), + new SubQuery(onlyLastEvent: true), + true, + ]; + + yield 'only last event does not include only last event false' => [ + new SubQuery(onlyLastEvent: true), + new SubQuery(onlyLastEvent: false), + false, + ]; + + yield 'only last event false does not include only last event true' => [ + new SubQuery(onlyLastEvent: false), + new SubQuery(onlyLastEvent: true), + true, + ]; + } + + /** + * @param class-string $class + * @param list|null $tags + */ + private static function messageFactory(string $class, array|null $tags = null, string|null $stream = null): Message + { + $message = match ($class) { + ProfileCreated::class => Message::create(new ProfileCreated( + ProfileId::fromString('1'), + Email::fromString('s'), + )), + ProfileVisited::class => Message::create(new ProfileVisited(ProfileId::fromString('1'))), + default => throw new InvalidArgumentException('unknown class'), + }; + + if ($tags !== null) { + $message = $message->withHeader(new TagsHeader($tags)); + } + + if ($stream !== null) { + $message = $message->withHeader(new StreamNameHeader($stream)); + } + + return $message; + } +} From e69b9b8ff76ac8039b09cf3c27f631bf5a362e4e Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 22 Aug 2025 09:19:03 +0200 Subject: [PATCH 19/22] replace trait with an abstract class --- docs/pages/dynamic_consistency_boundary.md | 24 +++++++++---------- .../{EventRouter.php => BasicProjection.php} | 17 ++++++++----- src/DCB/CompositeProjection.php | 20 +++++++++++----- src/DCB/Projection.php | 3 --- src/DCB/SubQueryProvider.php | 13 ++++++++++ .../Projection/CourseCapacityProjection.php | 7 ++---- .../Course/Projection/CourseExists.php | 9 +++---- .../NumberOfCourseSubscriptionsProjection.php | 7 ++---- ...NumberOfStudentSubscriptionsProjection.php | 7 ++---- .../StudentAlreadySubscribedProjection.php | 7 ++---- .../NextInvoiceNumberProjection.php | 7 ++---- tests/Unit/Fixture/IncrementProjection.php | 7 ++---- 12 files changed, 65 insertions(+), 63 deletions(-) rename src/DCB/{EventRouter.php => BasicProjection.php} (93%) create mode 100644 src/DCB/SubQueryProvider.php diff --git a/docs/pages/dynamic_consistency_boundary.md b/docs/pages/dynamic_consistency_boundary.md index 24194b96..bb0f94df 100644 --- a/docs/pages/dynamic_consistency_boundary.md +++ b/docs/pages/dynamic_consistency_boundary.md @@ -92,13 +92,13 @@ Course exists: ```php use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\DCB\Projection; /** @implements Projection */ final class CourseExists implements Projection { - use EventRouter; + use BasicProjection; public function __construct(private readonly CourseId $courseId) {} @@ -125,12 +125,12 @@ Current capacity of a course: ```php use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\DCB\Projection; final class CourseCapacityProjection implements Projection { - use EventRouter; + use BasicProjection; public function __construct(private readonly CourseId $courseId) {} @@ -163,12 +163,12 @@ Count subscriptions of a course: ```php use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\DCB\Projection; final class NumberOfCourseSubscriptionsProjection implements Projection { - use EventRouter; + use BasicProjection; public function __construct(private readonly CourseId $courseId) {} @@ -195,12 +195,12 @@ Count subscriptions of a student: ```php use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\DCB\Projection; final class NumberOfStudentSubscriptionsProjection implements Projection { - use EventRouter; + use BasicProjection; public function __construct(private readonly StudentId $studentId) {} @@ -227,13 +227,13 @@ Has the student already subscribed to this course: ```php use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\DCB\Projection; /** @implements Projection */ final class StudentAlreadySubscribedProjection implements Projection { - use EventRouter; + use BasicProjection; public function __construct( private readonly StudentId $studentId, @@ -266,12 +266,12 @@ Next invoice number from the last event only: ```php use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\DCB\Projection; final class NextInvoiceNumberProjection implements Projection { - use EventRouter; + use BasicProjection; public function initialState(): int { diff --git a/src/DCB/EventRouter.php b/src/DCB/BasicProjection.php similarity index 93% rename from src/DCB/EventRouter.php rename to src/DCB/BasicProjection.php index 46d198d5..cfec0db0 100644 --- a/src/DCB/EventRouter.php +++ b/src/DCB/BasicProjection.php @@ -21,14 +21,19 @@ /** * @experimental - * @require-implements Projection * @template S = mixed + * @implements Projection */ -trait EventRouter +abstract class BasicProjection implements Projection, SubQueryProvider { /** @var array|null $applyMethods */ private array|null $applyMethods = null; + /** + * @param S $state + * + * @return S + */ public function apply(mixed $state, Message $message): mixed { if (!$this->subQuery()->match($message)) { @@ -57,7 +62,7 @@ public function subQuery(): SubQuery } /** @return list */ - public function eventTypeFilter(): array + protected function eventTypeFilter(): array { return array_keys($this->applyMethods()); } @@ -177,14 +182,14 @@ static function (ReflectionNamedType|ReflectionIntersectionType $reflectionType) } /** @return list */ - abstract public function tagFilter(): array; + abstract protected function tagFilter(): array; - public function streamName(): string|null + protected function streamName(): string|null { return null; } - public function lastEventIsEnough(): bool + protected function lastEventIsEnough(): bool { return false; } diff --git a/src/DCB/CompositeProjection.php b/src/DCB/CompositeProjection.php index 5976c390..cabb0ea0 100644 --- a/src/DCB/CompositeProjection.php +++ b/src/DCB/CompositeProjection.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Store\Query; +use Patchlevel\EventSourcing\Store\SubQuery; use function array_map; @@ -20,12 +21,19 @@ public function __construct( public function query(): Query { - $query = new Query( - ...array_map( - static fn (Projection $projection) => $projection->subQuery(), - $this->projections, - ), - ); + $subQueries = []; + + foreach ($this->projections as $projection) { + if ($projection instanceof SubQueryProvider) { + $subQueries[] = $projection->subQuery(); + + continue; + } + + $subQueries[] = new SubQuery(); + } + + $query = new Query(...$subQueries); return $query->optimize(); } diff --git a/src/DCB/Projection.php b/src/DCB/Projection.php index 67aeb3c4..47c709be 100644 --- a/src/DCB/Projection.php +++ b/src/DCB/Projection.php @@ -5,7 +5,6 @@ namespace Patchlevel\EventSourcing\DCB; use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\SubQuery; /** * @experimental @@ -22,6 +21,4 @@ public function initialState(): mixed; * @return S */ public function apply(mixed $state, Message $message): mixed; - - public function subQuery(): SubQuery; } diff --git a/src/DCB/SubQueryProvider.php b/src/DCB/SubQueryProvider.php new file mode 100644 index 00000000..0fbcd596 --- /dev/null +++ b/src/DCB/SubQueryProvider.php @@ -0,0 +1,13 @@ + */ -final class CourseExists implements Projection +/** @extends BasicProjection */ +final class CourseExists extends BasicProjection { - use EventRouter; - public function __construct( private readonly CourseId $courseId, ) { diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php index 88c48c82..3022448f 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php @@ -5,15 +5,12 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; -use Patchlevel\EventSourcing\DCB\Projection; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\CourseId; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; -final class NumberOfCourseSubscriptionsProjection implements Projection +final class NumberOfCourseSubscriptionsProjection extends BasicProjection { - use EventRouter; - public function __construct( private readonly CourseId $courseId, ) { diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php index a3045d09..c6a2a41e 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php @@ -5,15 +5,12 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; -use Patchlevel\EventSourcing\DCB\Projection; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\StudentId; -final class NumberOfStudentSubscriptionsProjection implements Projection +final class NumberOfStudentSubscriptionsProjection extends BasicProjection { - use EventRouter; - public function __construct( private readonly StudentId $studentId, ) { diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php index 83673d00..c642fbfc 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php @@ -5,16 +5,13 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; -use Patchlevel\EventSourcing\DCB\Projection; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\CourseId; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\StudentId; -final class StudentAlreadySubscribedProjection implements Projection +final class StudentAlreadySubscribedProjection extends BasicProjection { - use EventRouter; - public function __construct( private readonly StudentId $studentId, private readonly CourseId $courseId, diff --git a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php index 5cfb7c88..1daf3396 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php @@ -5,14 +5,11 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Projection; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; -use Patchlevel\EventSourcing\DCB\Projection; +use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Event\InvoiceCreated; -final class NextInvoiceNumberProjection implements Projection +final class NextInvoiceNumberProjection extends BasicProjection { - use EventRouter; - /** @return list */ public function tagFilter(): array { diff --git a/tests/Unit/Fixture/IncrementProjection.php b/tests/Unit/Fixture/IncrementProjection.php index ced2180d..afdc80c0 100644 --- a/tests/Unit/Fixture/IncrementProjection.php +++ b/tests/Unit/Fixture/IncrementProjection.php @@ -5,13 +5,10 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Fixture; use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\EventRouter; -use Patchlevel\EventSourcing\DCB\Projection; +use Patchlevel\EventSourcing\DCB\BasicProjection; -final class IncrementProjection implements Projection +final class IncrementProjection extends BasicProjection { - use EventRouter; - /** @param list $tags */ public function __construct( private readonly int $initial, From 1c067cfaf880242d672d60195e1beee135baf035 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 22 Aug 2025 12:33:39 +0200 Subject: [PATCH 20/22] update documentation --- docs/pages/dynamic_consistency_boundary.md | 109 +++++++++------------ 1 file changed, 45 insertions(+), 64 deletions(-) diff --git a/docs/pages/dynamic_consistency_boundary.md b/docs/pages/dynamic_consistency_boundary.md index bb0f94df..d03fb3d6 100644 --- a/docs/pages/dynamic_consistency_boundary.md +++ b/docs/pages/dynamic_consistency_boundary.md @@ -1,85 +1,83 @@ # Dynamic Consistency Boundary -In our little DCB getting-started example, we decide things atomically across multiple event streams without loading aggregates. -We keep the example small and show how to validate a course subscription and how to generate invoice numbers using the DCB API. +??? example "Experimental" + + This feature is still experimental and may change in the future. + Use it with caution. + +Dynamic Consistency Boundary (DCB) is an event‑sourcing approach for making consistent, +cross‑stream decisions without loading full aggregates. +For each decision, it builds a minimal, purpose‑built state from a targeted subset of events selected via tags. +Lightweight projections compute just the values needed to validate commands and derive new events. +The decision evaluation and event append are coupled by an optimistic append condition to prevent race conditions; +if the queried subset changes concurrently, the write is rejected and can be retried. +This makes handlers simple, fast, and scalable, since only relevant events are processed. +DCB is a great fit when business rules span multiple streams. !!! note - DCB is marked as experimental. APIs may change. + You can read more about Dynamic Consistency Boundary on page [dcb.events](https://dcb.events/). + +Since this approach differs slightly from the standard "aggregate" event sourcing principle, +we will use the [Getting Started](./getting_started.md) example and build it as a DCB variant. + +In our litt le getting started example, we manage hotels. +We keep the example small, so we can only create hotels and let guests check in and check out. ## Define some events First we define the events that happen in our system. -A course can be defined with a capacity: +A hotel can be created with a `name` and a `id`: ```php +use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcing\Attribute\EventTag; -#[Event('course.defined')] -final class CourseDefined +#[Event('hotel.created')] +final class HotelCreated { public function __construct( - #[EventTag(prefix: 'course')] - public CourseId $courseId, - public int $capacity, + #[EventTag(prefix: 'hotel')] + public readonly Uuid $hotelId, + public readonly string $hotelName, ) { } } ``` -A course capacity can change later: +A guest can check in by `name`: ```php use Patchlevel\EventSourcing\Attribute\Event; -use Patchlevel\EventSourcing\Attribute\EventTag; -#[Event('course.capacity_changed')] -final class CourseCapacityChanged +#[Event('hotel.guest_checked_in')] +final class GuestIsCheckedIn { public function __construct( - #[EventTag(prefix: 'course')] - public CourseId $courseId, - public int $capacity, + public readonly string $guestName, ) { } } ``` -A student can subscribe to a course. We tag the event with both student and course, so that DCB projections can select the exact subset of events: +And also check out again: ```php use Patchlevel\EventSourcing\Attribute\Event; -use Patchlevel\EventSourcing\Attribute\EventTag; -#[Event('course.student_subscribed')] -final class StudentSubscribedToCourse +#[Event('hotel.guest_checked_out')] +final class GuestIsCheckedOut { public function __construct( - #[EventTag(prefix: 'student')] - public readonly StudentId $studentId, - #[EventTag(prefix: 'course')] - public readonly CourseId $courseId, + public readonly string $guestName, ) { } } ``` -And finally, an invoice can be created with a monotonically increasing number: +!!! note -```php -use Patchlevel\EventSourcing\Attribute\Event; -use Patchlevel\EventSourcing\Attribute\EventTag; + You can find out more about events [here](events.md). -#[Event('invoice.created')] -final class InvoiceCreated -{ - public function __construct( - #[EventTag(prefix: 'invoice')] - public readonly int $invoiceNumber, - public readonly int $money, - ) { - } -} -``` ## Define projections @@ -93,13 +91,10 @@ Course exists: ```php use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\BasicProjection; -use Patchlevel\EventSourcing\DCB\Projection; -/** @implements Projection */ -final class CourseExists implements Projection +/** @extends BasicProjection */ +final class CourseExists extends BasicProjection { - use BasicProjection; - public function __construct(private readonly CourseId $courseId) {} public function initialState(): bool @@ -126,12 +121,9 @@ Current capacity of a course: ```php use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\BasicProjection; -use Patchlevel\EventSourcing\DCB\Projection; -final class CourseCapacityProjection implements Projection +final class CourseCapacityProjection extends BasicProjection { - use BasicProjection; - public function __construct(private readonly CourseId $courseId) {} public function initialState(): int @@ -164,12 +156,9 @@ Count subscriptions of a course: ```php use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\BasicProjection; -use Patchlevel\EventSourcing\DCB\Projection; -final class NumberOfCourseSubscriptionsProjection implements Projection +final class NumberOfCourseSubscriptionsProjection extends BasicProjection { - use BasicProjection; - public function __construct(private readonly CourseId $courseId) {} public function initialState(): int @@ -198,10 +187,8 @@ use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\BasicProjection; use Patchlevel\EventSourcing\DCB\Projection; -final class NumberOfStudentSubscriptionsProjection implements Projection +final class NumberOfStudentSubscriptionsProjection extends BasicProjection { - use BasicProjection; - public function __construct(private readonly StudentId $studentId) {} public function initialState(): int @@ -228,13 +215,10 @@ Has the student already subscribed to this course: ```php use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\BasicProjection; -use Patchlevel\EventSourcing\DCB\Projection; -/** @implements Projection */ -final class StudentAlreadySubscribedProjection implements Projection +/** @extends BasicProjection */ +final class StudentAlreadySubscribedProjection extends BasicProjection { - use BasicProjection; - public function __construct( private readonly StudentId $studentId, private readonly CourseId $courseId, @@ -267,12 +251,9 @@ Next invoice number from the last event only: ```php use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\BasicProjection; -use Patchlevel\EventSourcing\DCB\Projection; -final class NextInvoiceNumberProjection implements Projection +final class NextInvoiceNumberProjection extends BasicProjection { - use BasicProjection; - public function initialState(): int { return 1; From 666e2bbe6518a2e4fc6453c2d7855b5b4eb0d13d Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 22 Aug 2025 15:50:36 +0200 Subject: [PATCH 21/22] update docs --- docs/pages/dynamic_consistency_boundary.md | 410 +++++++++------------ phpstan-baseline.neon | 6 - 2 files changed, 175 insertions(+), 241 deletions(-) diff --git a/docs/pages/dynamic_consistency_boundary.md b/docs/pages/dynamic_consistency_boundary.md index d03fb3d6..78d0d282 100644 --- a/docs/pages/dynamic_consistency_boundary.md +++ b/docs/pages/dynamic_consistency_boundary.md @@ -21,14 +21,14 @@ DCB is a great fit when business rules span multiple streams. Since this approach differs slightly from the standard "aggregate" event sourcing principle, we will use the [Getting Started](./getting_started.md) example and build it as a DCB variant. -In our litt le getting started example, we manage hotels. +In our little getting started example, we manage hotels. We keep the example small, so we can only create hotels and let guests check in and check out. ## Define some events First we define the events that happen in our system. -A hotel can be created with a `name` and a `id`: +A hotel can be created with an ID and a name. In DCB we also tag the hotelId so projections can filter by this hotel. ```php use Patchlevel\EventSourcing\Aggregate\Uuid; @@ -46,248 +46,226 @@ final class HotelCreated } } ``` -A guest can check in by `name`: + +A guest can check in. +We tag the hotelId and the guest name so projections can filter by this combination. ```php +use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Attribute\EventTag; #[Event('hotel.guest_checked_in')] final class GuestIsCheckedIn { public function __construct( + #[EventTag(prefix: 'hotel')] + public readonly Uuid $hotelId, + #[EventTag(prefix: 'guest')] public readonly string $guestName, ) { } } ``` -And also check out again: + +A guest can check out again. Here we tag the hotelId and the guest name again. ```php +use Patchlevel\EventSourcing\Aggregate\Uuid; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Attribute\EventTag; #[Event('hotel.guest_checked_out')] final class GuestIsCheckedOut { public function __construct( + #[EventTag(prefix: 'hotel')] + public readonly Uuid $hotelId, + #[EventTag(prefix: 'guest')] public readonly string $guestName, ) { } } ``` + !!! note You can find out more about events [here](events.md). +## Define Commands -## Define projections - -DCB builds a tiny, purpose-built state just for the current decision using projections. -A projection selects events via tags, processes those events and yields a small value. - -We use the EventRouter trait which wires `#[Apply]` methods and builds a SubQuery from filters. +Unlike in the [Getting Started](./getting_started.md) section, we're working with the [Command Bus](./command_bus.md) here. +This allows us to express our interaction with the system using commands. We can do the following with our system: -Course exists: +The following command creates a new hotel. It carries the hotel ID and name. ```php -use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\BasicProjection; - -/** @extends BasicProjection */ -final class CourseExists extends BasicProjection -{ - public function __construct(private readonly CourseId $courseId) {} +class CreateHotel { + public function __construct( + public HotelId $hotelId, + public readonly string $hotelName, + ) { + } +} +``` - public function initialState(): bool - { - return false; - } +Next, this command checks a guest in to a specific hotel. +It contains the hotel ID and the guest name. - /** @return list */ - public function tagFilter(): array - { - return ["course:{$this->courseId->toString()}"]; - } - - #[Apply] - public function applyCourseDefined(bool $state, CourseDefined $event): bool - { - return true; - } +```php +class CheckIn { + public function __construct( + public HotelId $hotelId, + public readonly string $guestName, + ) { + } } ``` -Current capacity of a course: +Last but not least, this command checks a guest out of a specific hotel. +It also provides the hotel ID and guest name. ```php -use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\BasicProjection; +class CheckOut { + public function __construct( + public HotelId $hotelId, + public readonly string $guestName, + ) { + } +} +``` -final class CourseCapacityProjection extends BasicProjection -{ - public function __construct(private readonly CourseId $courseId) {} +## Define projections - public function initialState(): int - { - return 0; - } +With DCB we don’t load an aggregate. +Instead, we assemble the minimal state for a single decision from lightweight projections. - /** @return list */ - public function tagFilter(): array - { - return ["course:{$this->courseId->toString()}"]; - } +Each projection: - #[Apply] - public function applyCourseDefined(int $state, CourseDefined $event): int - { - return $event->capacity; - } +* Declares an initial state +* Applies only the few events relevant to compute the decision value +* Optionally filters events by tags - #[Apply] - public function applyCourseCapacityChanged(int $state, CourseCapacityChanged $event): int - { - return $event->capacity; - } -} -``` - -Count subscriptions of a course: +The first projection answers only whether the hotel already exists. ```php use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\BasicProjection; -final class NumberOfCourseSubscriptionsProjection extends BasicProjection +final class HotelExists extends BasicProjection { - public function __construct(private readonly CourseId $courseId) {} + public function __construct( + private readonly Uuid $hotelId + ) { + } - public function initialState(): int - { - return 0; + public function initialState(): bool + { + return false; } /** @return list */ - public function tagFilter(): array + protected function tagFilter(): array { - return ["course:{$this->courseId->toString()}"]; + return ["hotel:{$this->hotelId->toString()}"]; } #[Apply] - public function applyStudentSubscribedToCourse(int $state, StudentSubscribedToCourse $event): int + public function applyHotelCreated(bool $state, HotelCreated $event): bool { - return $state + 1; + return true; } } ``` -Count subscriptions of a student: +The second projection counts the guests currently checked in. ```php use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\BasicProjection; -use Patchlevel\EventSourcing\DCB\Projection; -final class NumberOfStudentSubscriptionsProjection extends BasicProjection +final class NumberOfGuestsInHotel extends BasicProjection { - public function __construct(private readonly StudentId $studentId) {} + public function __construct( + private readonly Uuid $hotelId + ) { + } - public function initialState(): int - { - return 0; + public function initialState(): int + { + return 0; } /** @return list */ - public function tagFilter(): array + protected function tagFilter(): array { - return ["student:{$this->studentId->toString()}"]; + return ["hotel:{$this->hotelId->toString()}"]; // same tag as above } #[Apply] - public function applyStudentSubscribedToCourse(int $state, StudentSubscribedToCourse $event): int + public function applyGuestIsCheckedIn(int $state, GuestIsCheckedIn $event): int { return $state + 1; } + + #[Apply] + public function applyGuestIsCheckedOut(int $state, GuestIsCheckedOut $event): int + { + return $state - 1; + } } ``` -Has the student already subscribed to this course: +The third projection answers whether the given guest is already checked into this hotel. ```php use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\DCB\BasicProjection; -/** @extends BasicProjection */ -final class StudentAlreadySubscribedProjection extends BasicProjection +final class GuestAlreadyCheckedIn extends BasicProjection { public function __construct( - private readonly StudentId $studentId, - private readonly CourseId $courseId, + private readonly Uuid $hotelId, + private readonly string $guestName, ) {} - public function initialState(): bool - { - return false; - } + public function initialState(): bool { return false; } /** @return list */ - public function tagFilter(): array + protected function tagFilter(): array { return [ - "student:{$this->studentId->toString()}", - "course:{$this->courseId->toString()}", + "hotel:{$this->hotelId->toString()}", + "guest:{$this->guestName}", ]; } #[Apply] - public function applyStudentSubscribedToCourse(bool $state, StudentSubscribedToCourse $event): bool + public function applyGuestIsCheckedIn(bool $state, GuestIsCheckedIn $event): bool { return true; } -} -``` - -Next invoice number from the last event only: - -```php -use Patchlevel\EventSourcing\Attribute\Apply; -use Patchlevel\EventSourcing\DCB\BasicProjection; - -final class NextInvoiceNumberProjection extends BasicProjection -{ - public function initialState(): int - { - return 1; - } #[Apply] - public function applyInvoiceCreated(int $state, InvoiceCreated $event): int + public function applyGuestIsCheckedOut(bool $state, GuestIsCheckedOut $event): bool { - return $event->invoiceNumber + 1; - } - - public function lastEventIsEnough(): bool - { - return true; // optimize: only the last matching event is needed + return false; } } ``` -!!! tip - - `#[Apply]` methods can be named freely. The second parameter’s type determines which event is routed. +## Define handlers -## Write handlers +We’ll implement three command handlers corresponding to our commands. -Now we can build decisions and append new events atomically by using the DecisionModelBuilder and EventAppender. - -Define a course only if it does not exist yet: +First, we implement the handler for the `CreateHotel` command. ```php use Patchlevel\EventSourcing\Attribute\Handle; use Patchlevel\EventSourcing\DCB\DecisionModelBuilder; use Patchlevel\EventSourcing\DCB\EventAppender; -final class DefineCourseHandler +final class CreateHotelHandler { public function __construct( private readonly DecisionModelBuilder $decisionModelBuilder, @@ -295,68 +273,36 @@ final class DefineCourseHandler ) {} #[Handle] - public function __invoke(DefineCourse $command): void + public function __invoke(CreateHotel $command): void { $state = $this->decisionModelBuilder->build([ - 'courseExists' => new CourseExists($command->courseId), + 'hotelExists' => new HotelExists($command->hotelId), ]); - if ($state['courseExists']) { - throw new RuntimeException('Course already exists'); + if ($state['hotelExists']) { + throw new RuntimeException('Hotel already exists'); } $this->eventAppender->append([ - new CourseDefined($command->courseId, $command->capacity), + new HotelCreated($command->hotelId, $command->hotelName), ], $state->appendCondition); } } ``` -Change capacity if different: - -```php -use Patchlevel\EventSourcing\Attribute\Handle; -use Patchlevel\EventSourcing\DCB\DecisionModelBuilder; -use Patchlevel\EventSourcing\DCB\EventAppender; - -final class ChangeCourseCapacityHandler -{ - public function __construct( - private readonly DecisionModelBuilder $decisionModelBuilder, - private readonly EventAppender $eventAppender, - ) {} - - #[Handle] - public function __invoke(ChangeCourseCapacity $command): void - { - $state = $this->decisionModelBuilder->build([ - 'courseExists' => new CourseExists($command->courseId), - 'courseCapacity' => new CourseCapacityProjection($command->courseId), - ]); +!!! note - if (!$state['courseExists']) { - throw new RuntimeException('Course does not exist'); - } + Handlers build a Decision Model from the projections and then append events with an optimistic AppendCondition. + If any relevant event arrives between read and write, the append fails and you can retry. - if ($state['courseCapacity'] === $command->capacity) { - return; - } - - $this->eventAppender->append([ - new CourseCapacityChanged($command->courseId, $command->capacity), - ], $state->appendCondition); - } -} -``` - -Subscribe a student with multiple checks atomically: +The next handler implements the `CheckIn` command. ```php use Patchlevel\EventSourcing\Attribute\Handle; use Patchlevel\EventSourcing\DCB\DecisionModelBuilder; use Patchlevel\EventSourcing\DCB\EventAppender; -final class SubscribeStudentToCourseHandler +final class CheckInHandler { public function __construct( private readonly DecisionModelBuilder $decisionModelBuilder, @@ -364,47 +310,42 @@ final class SubscribeStudentToCourseHandler ) {} #[Handle] - public function __invoke(SubscribeStudentToCourse $command): void + public function __invoke(CheckIn $command): void { $state = $this->decisionModelBuilder->build([ - 'courseExists' => new CourseExists($command->courseId), - 'courseCapacity' => new CourseCapacityProjection($command->courseId), - 'numberOfCourseSubscriptions' => new NumberOfCourseSubscriptionsProjection($command->courseId), - 'numberOfStudentSubscriptions' => new NumberOfStudentSubscriptionsProjection($command->studentId), - 'studentAlreadySubscribed' => new StudentAlreadySubscribedProjection($command->studentId, $command->courseId), + 'hotelExists' => new HotelExists($command->hotelId), + 'guestCount' => new NumberOfGuestsInHotel($command->hotelId), + 'alreadyIn' => new GuestAlreadyCheckedIn($command->hotelId, $command->guestName), ]); - if (!$state['courseExists']) { - throw new RuntimeException("Course {$command->courseId->toString()} does not exist"); + if (!$state['hotelExists']) { + throw new RuntimeException('Hotel does not exist'); } - if ($state['numberOfCourseSubscriptions'] >= $state['courseCapacity']) { - throw new RuntimeException("Course {$command->courseId->toString()} is not available"); + if ($state['alreadyIn']) { + throw new RuntimeException(sprintf('Guest "%s" already checked in', $command->guestName)); } - if ($state['studentAlreadySubscribed']) { - throw new RuntimeException("Student {$command->studentId->toString()} is already subscribed to course {$command->courseId->toString()}"); - } - - if ($state['numberOfStudentSubscriptions'] >= 5) { - throw new RuntimeException("Student {$command->studentId->toString()} is already subscribed to 5 courses"); + // Optional policy example: max 5 guests + if ($state['guestCount'] >= 5) { + throw new RuntimeException('Hotel is full'); } $this->eventAppender->append([ - new StudentSubscribedToCourse($command->studentId, $command->courseId), + new GuestIsCheckedIn($command->hotelId, $command->guestName), ], $state->appendCondition); } } ``` -Create invoices while safely generating the next number from the last event only: +And the last handler implements the `CheckOut` command. ```php use Patchlevel\EventSourcing\Attribute\Handle; use Patchlevel\EventSourcing\DCB\DecisionModelBuilder; use Patchlevel\EventSourcing\DCB\EventAppender; -final class CreateInvoiceHandler +final class CheckOutHandler { public function __construct( private readonly DecisionModelBuilder $decisionModelBuilder, @@ -412,99 +353,98 @@ final class CreateInvoiceHandler ) {} #[Handle] - public function __invoke(CreateInvoice $command): void + public function __invoke(CheckOut $command): void { $state = $this->decisionModelBuilder->build([ - 'nextInvoiceNumber' => new NextInvoiceNumberProjection(), + 'hotelExists' => new HotelExists($command->hotelId), + 'alreadyIn' => new GuestAlreadyCheckedIn($command->hotelId, $command->guestName), ]); + if (!$state['hotelExists']) { + throw new RuntimeException('Hotel does not exist'); + } + + if (!$state['alreadyIn']) { + throw new RuntimeException(sprintf('Guest "%s" is not checked in', $command->guestName)); + } + $this->eventAppender->append([ - new InvoiceCreated($state['nextInvoiceNumber'], $command->money), + new GuestIsCheckedOut($command->hotelId, $command->guestName), ], $state->appendCondition); } } ``` -!!! tip - - If any concurrent writer changes the queried subset in between build() and append(), the store will reject the write (optimistic concurrency). You can retry if appropriate. - ## Configuration -Now we plug the whole thing together. We use the TaggableDoctrineDbalStore plus the DCB builder and appender. +Now we can wire everything together. ```php +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Tools\DsnParser; use Patchlevel\EventSourcing\CommandBus\ServiceHandlerProvider; use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; use Patchlevel\EventSourcing\DCB\StoreDecisionModelBuilder; use Patchlevel\EventSourcing\DCB\StoreEventAppender; -use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; -$store = new TaggableDoctrineDbalStore( - $connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Event']), - (new AttributeEventRegistryFactory())->create([__DIR__ . '/Event']), -); +$connection = DriverManager::getConnection((new DsnParser())->parse('pdo-pgsql://user:secret@localhost/app')); +$serializer = DefaultEventSerializer::createFromPaths(['src/Domain/Hotel/Event']); -$decisionModelBuilder = new StoreDecisionModelBuilder($store); -$eventAppender = new StoreEventAppender($store); +$eventStore = new TaggableDoctrineDbalStore($connection, $serializer, $eventRegistry); -$commandBus = new SyncCommandBus( - new ServiceHandlerProvider([ - new DefineCourseHandler($decisionModelBuilder, $eventAppender), - new ChangeCourseCapacityHandler($decisionModelBuilder, $eventAppender), - new SubscribeStudentToCourseHandler($decisionModelBuilder, $eventAppender), - new CreateInvoiceHandler($decisionModelBuilder, $eventAppender), - ]), -); +$decisionModelBuilder = new StoreDecisionModelBuilder($eventStore); +$eventAppender = new StoreEventAppender($eventStore); + +$provider = new ServiceHandlerProvider([ + new CreateHotelHandler($decisionModelBuilder, $eventAppender), + new CheckInHandler($decisionModelBuilder, $eventAppender), + new CheckOutHandler($decisionModelBuilder, $eventAppender), +]); + +$commandBus = new SyncCommandBus($provider); ``` ## Database setup -To actually write data to the database we need to create the event table. +The last step is to create the database schema. ```php use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; +use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; +use Patchlevel\EventSourcing\Store\Store; -$schemaDirector = new DoctrineSchemaDirector($connection, $store); +$schemaDirector = new DoctrineSchemaDirector( + $connection, + new ChainDoctrineSchemaConfigurator([$eventStore]), +); $schemaDirector->create(); ``` -!!! note - - You can also use the predefined CLI commands to create and drop the schema. See the CLI documentation. - ## Usage -We are now ready to use DCB. We can dispatch commands and DCB will keep each decision consistent. +Now we can use our command bus to execute our commands. ```php -$courseId = CourseId::generate(); -$student1 = StudentId::generate(); -$student2 = StudentId::generate(); - -$commandBus->dispatch(new DefineCourse($courseId, 10)); -$commandBus->dispatch(new ChangeCourseCapacity($courseId, 2)); -$commandBus->dispatch(new SubscribeStudentToCourse($student1, $courseId)); -$commandBus->dispatch(new SubscribeStudentToCourse($student2, $courseId)); +use Patchlevel\EventSourcing\Aggregate\Uuid; -$commandBus->dispatch(new CreateInvoice(10)); -$commandBus->dispatch(new CreateInvoice(10)); +$hotelId = Uuid::generate(); +$commandBus->dispatch(new CreateHotel($hotelId, 'HOTEL')); +$commandBus->dispatch(new CheckIn($hotelId, 'David')); +$commandBus->dispatch(new CheckIn($hotelId, 'Daniel')); +$commandBus->dispatch(new CheckOut($hotelId, 'David')); ``` -## Result - -!!! success +## Conclusion - We have successfully implemented and used DCB to make consistent decisions across multiple streams without loading aggregates. - Feel free to browse further in the documentation for more detailed information. +We've seen how to use DCB to make decisions consistently. +In this example we skipped the subscription part, +but you can add it by following the [Getting Started](./getting_started.md) section. ## Learn more -* [How to use command bus](command_bus.md) -* [How to use aggregates](aggregate.md) -* [How to use aggregate id](aggregate_id.md) -* [How to use clock](clock.md) +* [Events](./events.md) +* [Command Bus](./command_bus.md) +* [Store](./store.md) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3aea54d9..83635f14 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -108,12 +108,6 @@ parameters: count: 1 path: src/Store/StreamDoctrineDbalStoreStream.php - - - message: '#^Cannot use \+\+ on mixed\.$#' - identifier: preInc.type - count: 1 - path: src/Store/TaggableDoctrineDbalStore.php - - message: '#^Method Patchlevel\\EventSourcing\\Store\\TaggableDoctrineDbalStoreStream\:\:current\(\) never returns null so it can be removed from the return type\.$#' identifier: return.unusedType From e589cf208c23690554bd4a78bb262a689ac3d7a0 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 4 Sep 2025 18:32:46 +0200 Subject: [PATCH 22/22] use identifier component --- src/DCB/AttributeEventTagExtractor.php | 12 +-- .../Normalizer/StringableNormalizer.php | 85 ------------------- src/Stringable.php | 16 ---- .../SimpleSetupTaggableStoreBench.php | 6 +- .../Course/CourseId.php | 6 +- .../Course/StudentId.php | 6 +- .../DCB/AttributeEventTagExtractorTest.php | 2 +- 7 files changed, 16 insertions(+), 117 deletions(-) delete mode 100644 src/Serializer/Normalizer/StringableNormalizer.php delete mode 100644 src/Stringable.php diff --git a/src/DCB/AttributeEventTagExtractor.php b/src/DCB/AttributeEventTagExtractor.php index 30e869e8..f9254902 100644 --- a/src/DCB/AttributeEventTagExtractor.php +++ b/src/DCB/AttributeEventTagExtractor.php @@ -5,9 +5,9 @@ namespace Patchlevel\EventSourcing\DCB; use Patchlevel\EventSourcing\Attribute\EventTag; -use Patchlevel\EventSourcing\Stringable; +use Patchlevel\EventSourcing\Identifier\Identifier; use ReflectionClass; -use Stringable as NativeStringable; +use Stringable; use function array_keys; use function hash; @@ -36,12 +36,12 @@ public function extract(object $event): array $value = $property->getValue($event); - if ($value instanceof Stringable) { - $value = $value->toString(); + if ($value instanceof Stringable || is_int($value)) { + $value = (string)$value; } - if ($value instanceof NativeStringable || is_int($value)) { - $value = (string)$value; + if ($value instanceof Identifier) { + $value = $value->toString(); } if (!is_string($value)) { diff --git a/src/Serializer/Normalizer/StringableNormalizer.php b/src/Serializer/Normalizer/StringableNormalizer.php deleted file mode 100644 index 537bdbaf..00000000 --- a/src/Serializer/Normalizer/StringableNormalizer.php +++ /dev/null @@ -1,85 +0,0 @@ -|null */ - private string|null $stringableClass = null, - ) { - } - - public function normalize(mixed $value): string|null - { - if ($value === null) { - return null; - } - - $class = $this->stringableClass(); - - if (!$value instanceof Stringable) { - throw InvalidArgument::withWrongType($class, $value); - } - - return $value->toString(); - } - - public function denormalize(mixed $value): Stringable|null - { - if ($value === null) { - return null; - } - - if (!is_string($value)) { - throw InvalidArgument::withWrongType('string', $value); - } - - $class = $this->stringableClass(); - - return $class::fromString($value); - } - - /** @return class-string */ - public function stringableClass(): string - { - if ($this->stringableClass === null) { - throw InvalidType::missingType(); - } - - return $this->stringableClass; - } - - public function handleType(Type|null $type): void - { - if ($type === null || $this->stringableClass !== null) { - return; - } - - if (!$type instanceof ObjectType) { - return; - } - - if (is_a($type->getClassName(), Stringable::class, true) === false) { - throw InvalidType::unsupportedType(Stringable::class, $type->getClassName()); - } - - $this->stringableClass = $type->getClassName(); - } -} diff --git a/src/Stringable.php b/src/Stringable.php deleted file mode 100644 index eeb96725..00000000 --- a/src/Stringable.php +++ /dev/null @@ -1,16 +0,0 @@ -