diff --git a/README.md b/README.md index 3ecc785..557460e 100644 --- a/README.md +++ b/README.md @@ -10,52 +10,38 @@ The purpose of this package is to explore the idea, find potential pitfalls and Dynamic Consistency Boundary (aka DCB) allow to enforce hard constraints in Event-Sourced systems without having to rely on individual Event Streams. This facilitates focussing on the _behavior_ of the Domain Model rather than on its rigid structure. It also allows for simpler architecture and potential -performance improvements as multiple aggregates can act on the same events without requiring synchronization. +performance improvements as multiple projections can act on the same events without requiring synchronization. Read all about this interesting approach in the blog post mentioned above or watch Saras talk on [YouTube](https://www.youtube.com/watch?v=DhhxKoOpJe0&t=150s) (Italian with English subtitles). This package models the example of this presentation (with a few deviations) using the [wwwision/dcb-eventstore](https://github.com/bwaidelich/dcb-eventstore) package and the [wwwision/dcb-eventstore-doctrine](https://github.com/bwaidelich/dcb-eventstore-doctrine) database adapter. ### Important Classes / Concepts -* [Commands](src/Command) are just a concept of this example package. They implement the [Command Marker Interface](src/Command/Command.php) +* [Commands](src%2FCommands) are just a concept of this example package. They implement the [Command Marker Interface](src%2FCommands%2FCommand.php) * The [CommandHandler](src/CommandHandler.php) is the central authority, handling and verifying incoming Commands -* ...it uses the [AggregateLoader](https://github.com/bwaidelich/dcb-eventstore/blob/main/src/Aggregate/AggregateLoader.php) to interact with all involved Aggregates¹ -* The [Aggregates](src/Model/Aggregate) are surprisingly small because they focus on a single responsibility (e.g. instead of a "CourseAggregate" there are three aggregates [CourseExistenceAggregate](src/Model/Aggregate/CourseExistenceAggregate.php), [CourseTitleAggregate](src/Model/Aggregate/CourseTitleAggregate.php) and [CourseCapacityAggregate](src/Model/Aggregate/CourseCapacityAggregate)) -* ...Aggregates record [Events](src/Event) that are serialized with the [EventNormalizer](src/Event/Normalizer/EventNormalizer.php) -* This package contains no Read Model (e.g. projections) yet +* ...it uses in-memory [Projections](src%2FProjections%2FProjection.php) to enforce hard constraints +* The [Projections](src%2FProjections%2FProjection.php) are surprisingly small because they focus on a single responsibility (e.g. instead of a "CourseAggregate" there are three projections [CourseExistenceProjection.php](src%2FProjections%2FCourseExistenceProjection.php), [CourseTitleProjection](src%2FProjections%2FCourseTitleProjection.php) and [CourseCapacityProjection.php](src%2FProjections%2FCourseCapacityProjection.php)) +* The [EventAppender](src%2FEventAppender.php) allows for easy publishing of [Events](src%2FEvents) that are serialized with the [EventNormalizer](src%2FEventNormalizer.php) +* This package contains no Read Model (i.e. classic projections) yet ### Considerations / Findings I always had the feeling, that the focus on Event Streams is a distraction to Domain-driven design. So I was very happy to come across this concept. So far I didn't have the chance to test it in a real world scenario, but it makes a lot of sense to me and IMO this example shows, that the approach -really works out in practice (in spite of some minor caveats in the current implementation¹). - -#### Some further thoughts - -* The signature of the [EventStore::append()](https://github.com/bwaidelich/dcb-eventstore/blob/main/src/EventStore.php#L36) method, is still a bit cumbersome and implicit - * A `$lastEventId` parameter of `null` has a special meaning (Maybe a union type would be better suited here) - * Instead of working with Event *IDs* it might make sense to use the global "sequence number" instead (in this implementation I have to expose that anyways) as that's easier to work with and to debug (as it gives expected vs actual value a clear order) -* The [StreamQuery](https://github.com/bwaidelich/dcb-eventstore/blob/main/src/Model/StreamQuery.php) has too many states because Domain Ids and Event Types can either be: - * a) `null` => fallback matching all events - * b) A set of values, matching events with at least one overlap - * c) "none" => an empty set matching no events (required for the initial event and for tests) -* I don't like that Aggregates have to specify the event types explicitly ([example](https://github.com/bwaidelich/dcb-example/blob/main/src/Model/Aggregate/StudentSubscriptionsAggregate.php#L73)) – This is a potential source of bugs - * Maybe the "projection" logic can be reworked such that affected event types can be extracted from it -* A lot of complexity comes from having to reconstitute multiple Aggregates at once¹ - * Maybe it makes more sense to have nested Aggregates, i.e. the [StudentSubscriptionsAggregate](https://github.com/bwaidelich/dcb-example/blob/main/src/Model/Aggregate/StudentSubscriptionsAggregate.php) could create the depending aggregates ([CourseExistenceAggregate](https://github.com/bwaidelich/dcb-example/blob/main/src/Model/Aggregate/CourseExistenceAggregate.php), [StudentExistenceAggregate](https://github.com/bwaidelich/dcb-example/blob/main/src/Model/Aggregate/StudentExistenceAggregate.php) and [CourseCapacityAggregate](https://github.com/bwaidelich/dcb-example/blob/main/src/Model/Aggregate/CourseCapacityAggregate.php) and do the composition that the [AggregateLoader](https://github.com/bwaidelich/dcb-eventstore/blob/main/src/Aggregate/AggregateLoader.php) does currently... +really works out in practice (in spite of some minor caveats in the current implementation). ## Usage Install via [composer](https://getcomposer.org): ```shell -composer create-project wwwision/dcb-example +composer create-project wwwision/dcb-example-courses ``` Now you should be able to run the [example script](index.php) via ```shell -php dcb-example/index.php +php dcb-example-courses/index.php ``` And you should get ...no output at all. That's because the example script currently satisfy all constraints. @@ -89,11 +75,4 @@ Most of the implementation of these packages are based on the great groundwork d ## Contributions I'm really curious to get feedback on this one. -Feel free to start/join a [discussion](https://github.com/bwaidelich/dcb-example/discussions), [issues](https://github.com/bwaidelich/dcb-example/issues) or [Pull requests](https://github.com/bwaidelich/dcb-example/pulls). - ------ - -¹ The purpose of the [AggregateLoader](https://github.com/bwaidelich/dcb-eventstore/blob/main/src/Aggregate/AggregateLoader.php) -is to allow interaction with multiple Aggregates without having to fetch multiple Event Streams. -It is currently one of the weakest links in this implementation because it adds some hidden complexity – I hope, that I -can rework this at some point +Feel free to start/join a [discussion](https://github.com/bwaidelich/dcb-example/discussions), [issues](https://github.com/bwaidelich/dcb-example/issues) or [Pull requests](https://github.com/bwaidelich/dcb-example/pulls). \ No newline at end of file diff --git a/behat.yml b/behat.yml index 7308fcc..113f516 100644 --- a/behat.yml +++ b/behat.yml @@ -4,7 +4,9 @@ default: suites: default: paths: ['%paths.base%/tests/Behat'] - contexts: [Wwwision\DCBExample\Tests\Behat\Bootstrap\FeatureContext] + contexts: + - Wwwision\DCBExample\Tests\Behat\Bootstrap\FeatureContext: + eventStoreDsn: 'pdo-pgsql://bwaidelich@127.0.0.1:5432/dcb' formatters: pretty: false progress: true \ No newline at end of file diff --git a/bench.php.bkp b/bench.php.bkp new file mode 100644 index 0000000..c70b225 --- /dev/null +++ b/bench.php.bkp @@ -0,0 +1,215 @@ + $dsn]); +$eventStore = DoctrineEventStore::create($connection, 'dcb_events'); +$eventStore->setup(); +echo 'resetting db...' . PHP_EOL; +$connection->executeStatement('TRUNCATE TABLE dcb_events'); +//$connection->executeStatement('TRUNCATE TABLE dcb_events RESTART IDENTITY'); +//$connection->executeStatement('DELETE FROM dcb_events'); +echo 'done' . PHP_EOL; + +//$eventStore->append(Events::single(EventId::create(), EventType::fromString('SomeEvent'), EventData::fromString('test'), DomainIds::single('foo', 'bar')), StreamQuery::matchingIdsAndTypes(DomainIds::single('foo', 'bar'), EventTypes::single('SomeType')), null); +////exit; +//// +//foreach ($eventStore->stream(StreamQuery::matchingAny()) as $eventEnvelope) { +// var_dump($eventEnvelope); +//} +//echo PHP_EOL . 'done' . PHP_EOL; +//exit; +// + +echo 'appending events...' . PHP_EOL; + +$process = function () use ($dsn) { + + $rand = static fn (int $percentage) => random_int(1, 100) < $percentage; + $connection = DriverManager::getConnection(['url' => $dsn]); + $eventStore = DoctrineEventStore::create($connection, 'dcb_events'); + /** @var {@see CommandHandler} is the central authority to handle {@see Command}s */ + $commandHandler = new CommandHandler($eventStore); + $constraintViolations = 0; + + $handle = function (Command $command) use ($commandHandler, &$constraintViolations) { + //echo $command::class . PHP_EOL; + try { + $commandHandler->handle($command); + } catch (ConditionalAppendFailed|ConstraintException $e) { + $constraintViolations ++; + //echo 'CONSTRAINT EXCEPTION: ' . $e->getMessage() . PHP_EOL; + } + }; + + for ($i = 0; $i < 100; $i++) { + + $courseId = CourseId::fromString('c' . random_int(1, 5)); + $studentId = StudentId::fromString('s' . random_int(1, 5)); + $capacity = CourseCapacity::fromInteger(random_int(1, 10)); + + + if ($rand(80)) { + $handle(new CreateCourse($courseId, $capacity, CourseTitle::fromString((string)getmypid()))); + } + if ($rand(40)) { + $handle(new RenameCourse($courseId, CourseTitle::fromString('Course renamed ' . md5(random_bytes(5))))); + } + if ($rand(80)) { + $handle(new RegisterStudent($studentId)); + } + + if ($rand(50)) { + $handle(new SubscribeStudentToCourse($courseId, $studentId)); + } + + if ($rand(20)) { + $handle(new UpdateCourseCapacity($courseId, $capacity)); + } + + if ($rand(10)) { + $handle(new UnsubscribeStudentFromCourse($courseId, $studentId)); + } + + //usleep(random_int(100, 10000)); + } + return ['constraintViolations' => $constraintViolations]; +}; +// +//$process(); +//die('DONE'); + +$pool = Pool::create(); +for ($i = 0; $i < 20; $i ++) { + $pool->add($process)->then(function ($output) { + //echo 'OUTPUT:' . PHP_EOL; + //var_dump($output); + })->catch(function ($exception) { + echo 'EXCEPTION:' . PHP_EOL; + var_dump($exception); + })->timeout(function () { + echo 'TIMEOUT' . PHP_EOL; + }); +} +$pool->wait(); + +echo 'done' . PHP_EOL; +echo 'checking inconsistencies...' . PHP_EOL; + +$courses = []; +$students = []; + +function fail(EventEnvelope $eventEnvelope, string $message, ...$args) { + throw new RuntimeException(sprintf('ERROR at sequence number ' . $eventEnvelope->sequenceNumber->value . ': ' . $message, ...$args)); +} + +$numberOfEvents = 0; + +foreach ($eventStore->streamAll() as $eventEnvelope) { + $payload = json_decode($eventEnvelope->event->data->value, true, 512, JSON_THROW_ON_ERROR); + $courseId = $payload['courseId'] ?? null; + $studentId = $payload['studentId'] ?? null; + $numberOfEvents ++; + + switch ($eventEnvelope->event->type->value) { + case 'CourseCreated': + if (isset($courses[$courseId])) { + fail($eventEnvelope, 'Course "%s" already exists', $courseId); + } + $courses[$courseId] = [ + 'title' => $payload['courseTitle'], + 'capacity' => $payload['initialCapacity'], + 'subscriptions' => 0, + ]; + break; + case 'CourseRenamed': + if (!isset($courses[$courseId])) { + fail($eventEnvelope, 'Course "%s" does not exist', $courseId); + } + if ($courses[$courseId]['title'] === $payload['newCourseTitle']) { + fail($eventEnvelope, 'Course title of "%s" did not change', $courseId); + } + $courses[$courseId]['title'] = $payload['newCourseTitle']; + break; + case 'StudentRegistered': + if (isset($students[$studentId])) { + fail($eventEnvelope, 'Student "%s" already exists', $studentId); + } + $students[$studentId] = [ + 'courses' => [] + ]; + break; + case 'StudentSubscribedToCourse': + if (!isset($courses[$courseId])) { + fail($eventEnvelope, 'Course "%s" does not exist', $courseId); + } + if (!isset($students[$studentId])) { + fail($eventEnvelope, 'Student "%s" does not exist', $studentId); + } + if (in_array($courseId, $students[$studentId]['courses'], true)) { + fail($eventEnvelope, 'Student "%s" already subscribed to course "%s"', $studentId, $courseId); + } + if ($courses[$courseId]['subscriptions'] >= $courses[$courseId]['capacity']) { + fail($eventEnvelope, 'Course "%s" capacity exceeded', $courseId); + } + $courses[$courseId]['subscriptions'] ++; + $students[$studentId]['courses'] = [...$students[$studentId]['courses'], $courseId]; + break; + case 'CourseCapacityChanged': + if (!isset($courses[$courseId])) { + fail($eventEnvelope, 'Course "%s" does not exist', $courseId); + } + if ($courses[$courseId]['subscriptions'] > $payload['newCapacity']) { + fail($eventEnvelope, 'Course "%s" capacity cannot be changed because it already has more subscriptions', $courseId); + } + $courses[$courseId]['capacity'] = $payload['newCapacity']; + break; + + case 'StudentUnsubscribedFromCourse': + if (!isset($courses[$courseId])) { + fail($eventEnvelope, 'Course "%s" does not exist', $courseId); + } + if (!isset($students[$studentId])) { + fail($eventEnvelope, 'Student "%s" does not exist', $studentId); + } + if (!in_array($courseId, $students[$studentId]['courses'], true)) { + fail($eventEnvelope, 'Student "%s" is not subscribed to course "%s"', $studentId, $courseId); + } + $courses[$courseId]['subscriptions'] --; + $students[$studentId]['courses'] = array_filter($students[$studentId]['courses'], static fn($c) => $c !== $courseId); + break; + default: + fail($eventEnvelope, 'Unexpected event type "%s"', $eventEnvelope->event->type->value); + } +} +printf('Checked %d events', $numberOfEvents); \ No newline at end of file diff --git a/composer.json b/composer.json index ae44f18..56761ab 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "wwwision/dcb-example", + "name": "wwwision/dcb-example-courses", "description": "Simple example for the Dynamic Consistency Boundary pattern described by Sara Pellegrini", "type": "project", "license": "MIT", @@ -28,15 +28,14 @@ "php": ">=8.2", "ramsey/uuid": "^4.7", "webmozart/assert": "^1.11", - "wwwision/dcb-eventstore": "^2.0", - "wwwision/dcb-eventstore-doctrine": "^2.0", - "spatie/async": "^1.5" + "wwwision/dcb-eventstore": "^2", + "wwwision/dcb-eventstore-doctrine": "^2" }, "require-dev": { "roave/security-advisories": "dev-latest", "phpstan/phpstan": "^1.10", "squizlabs/php_codesniffer": "^4.0.x-dev", - "phpunit/phpunit": "^10.1", + "phpunit/phpunit": "^10.2", "behat/behat": "^3.13" }, "autoload": { @@ -53,12 +52,10 @@ "test-phpstan": "phpstan", "test-cs": "phpcs --colors --standard=PSR12 --exclude=Generic.Files.LineLength src", "test-cs:fix": "phpcbf --colors --standard=PSR12 --exclude=Generic.Files.LineLength src", - "test-unit": "phpunit tests", "test-behat": "behat", "test": [ "@test-phpstan", "@test-cs", - "@test-unit", "@test-behat" ] } diff --git a/index.php b/index.php index 8b34121..bb149b8 100644 --- a/index.php +++ b/index.php @@ -2,20 +2,20 @@ declare(strict_types=1); use Doctrine\DBAL\DriverManager; +use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; -use Wwwision\DCBExample\Command\Command; -use Wwwision\DCBExample\Command\CreateCourse; -use Wwwision\DCBExample\Command\RegisterStudent; -use Wwwision\DCBExample\Command\RenameCourse; -use Wwwision\DCBExample\Command\SubscribeStudentToCourse; -use Wwwision\DCBExample\Command\UnsubscribeStudentFromCourse; -use Wwwision\DCBExample\Command\UpdateCourseCapacity; use Wwwision\DCBExample\CommandHandler; -use Wwwision\DCBExample\Model\CourseCapacity; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\CourseTitle; -use Wwwision\DCBExample\Model\StudentId; -use Wwwision\DCBEventStore\EventStore; +use Wwwision\DCBExample\Commands\Command; +use Wwwision\DCBExample\Commands\CreateCourse; +use Wwwision\DCBExample\Commands\RegisterStudent; +use Wwwision\DCBExample\Commands\RenameCourse; +use Wwwision\DCBExample\Commands\SubscribeStudentToCourse; +use Wwwision\DCBExample\Commands\UnsubscribeStudentFromCourse; +use Wwwision\DCBExample\Commands\UpdateCourseCapacity; +use Wwwision\DCBExample\Types\CourseCapacity; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\CourseTitle; +use Wwwision\DCBExample\Types\StudentId; require __DIR__ . '/vendor/autoload.php'; @@ -25,7 +25,7 @@ /** The second parameter is the table name to store the events in **/ $eventStore = DoctrineEventStore::create($connection, 'dcb_events'); -/** The {@see EventStore::setup() method is used to make sure that the Event Store backend is set up (i.e. required tables are created and their schema up-to-date) **/ +/** The {@see EventStore::setup()} method is used to make sure that the Events Store backend is set up (i.e. required tables are created and their schema up-to-date) **/ $eventStore->setup(); /** @var {@see CommandHandler} is the central authority to handle {@see Command}s */ diff --git a/src/CommandHandler.php b/src/CommandHandler.php index 6c6df08..4597356 100644 --- a/src/CommandHandler.php +++ b/src/CommandHandler.php @@ -4,37 +4,41 @@ namespace Wwwision\DCBExample; +use Closure; use RuntimeException; +use stdClass; use Wwwision\DCBEventStore\EventStore; -use Wwwision\DCBEventStore\Model\DomainId; -use Wwwision\DCBEventStore\Model\DomainIds; -use Wwwision\DCBEventStore\Model\EventTypes; -use Wwwision\DCBEventStore\Model\ExpectedLastEventId; -use Wwwision\DCBEventStore\Model\StreamQuery; -use Wwwision\DCBExample\Command\Command; -use Wwwision\DCBExample\Command\CreateCourse; -use Wwwision\DCBExample\Command\RegisterStudent; -use Wwwision\DCBExample\Command\RenameCourse; -use Wwwision\DCBExample\Command\SubscribeStudentToCourse; -use Wwwision\DCBExample\Command\UnsubscribeStudentFromCourse; -use Wwwision\DCBExample\Command\UpdateCourseCapacity; -use Wwwision\DCBExample\Event\Appender\EventAppender; -use Wwwision\DCBExample\Event\CourseCapacityChanged; -use Wwwision\DCBExample\Event\CourseCreated; -use Wwwision\DCBExample\Event\CourseRenamed; -use Wwwision\DCBExample\Event\Normalizer\EventNormalizer; -use Wwwision\DCBExample\Event\StudentRegistered; -use Wwwision\DCBExample\Event\StudentSubscribedToCourse; -use Wwwision\DCBExample\Event\StudentUnsubscribedFromCourse; +use Wwwision\DCBEventStore\Types\AppendCondition; +use Wwwision\DCBEventStore\Types\Events; +use Wwwision\DCBEventStore\Types\ExpectedHighestSequenceNumber; +use Wwwision\DCBEventStore\Types\StreamQuery\Criteria; +use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion; +use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; +use Wwwision\DCBEventStore\Types\Tags; +use Wwwision\DCBExample\Commands\Command; +use Wwwision\DCBExample\Commands\CreateCourse; +use Wwwision\DCBExample\Commands\RegisterStudent; +use Wwwision\DCBExample\Commands\RenameCourse; +use Wwwision\DCBExample\Commands\SubscribeStudentToCourse; +use Wwwision\DCBExample\Commands\UnsubscribeStudentFromCourse; +use Wwwision\DCBExample\Commands\UpdateCourseCapacity; +use Wwwision\DCBExample\Events\CourseCapacityChanged; +use Wwwision\DCBExample\Events\CourseCreated; +use Wwwision\DCBExample\Events\CourseRenamed; +use Wwwision\DCBExample\Events\StudentRegistered; +use Wwwision\DCBExample\Events\StudentSubscribedToCourse; +use Wwwision\DCBExample\Events\StudentUnsubscribedFromCourse; use Wwwision\DCBExample\Exception\ConstraintException; -use Wwwision\DCBExample\Model\Aggregate\Aggregate; -use Wwwision\DCBExample\Model\Aggregate\CourseCapacityAggregate; -use Wwwision\DCBExample\Model\Aggregate\CourseExistenceAggregate; -use Wwwision\DCBExample\Model\Aggregate\CourseTitleAggregate; -use Wwwision\DCBExample\Model\Aggregate\StudentExistenceAggregate; -use Wwwision\DCBExample\Model\Aggregate\StudentSubscriptionsAggregate; - -use function array_unique; +use Wwwision\DCBExample\Projections\GenericProjection; +use Wwwision\DCBExample\Projections\Projection; +use Wwwision\DCBExample\Projections\ProjectionLogic; +use Wwwision\DCBExample\Types\CourseCapacity; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\CourseIds; +use Wwwision\DCBExample\Types\CourseTitle; +use Wwwision\DCBExample\Types\StudentId; +use function array_map; +use function is_array; use function sprintf; /** @@ -65,132 +69,222 @@ public function handle(Command $command): void private function handleCreateCourse(CreateCourse $command): void { - $courseExistenceAggregate = new CourseExistenceAggregate($command->courseId); - $appender = $this->reconstituteAggregateStates($courseExistenceAggregate); - - if ($courseExistenceAggregate->courseExists()) { - throw new ConstraintException(sprintf('Failed to create course with id "%s" because a course with that id already exists', $command->courseId->value), 1684593925); - } - $appender->append(new CourseCreated($command->courseId, $command->initialCapacity, $command->courseTitle)); + $this->transactional([ + 'courseExists' => self::courseExists($command->courseId), + ], function ($state) use ($command) { + if ($state->courseExists) { + throw new ConstraintException(sprintf('Failed to create course with id "%s" because a course with that id already exists', $command->courseId->value), 1684593925); + } + return new CourseCreated($command->courseId, $command->initialCapacity, $command->courseTitle); + }); } private function handleRenameCourse(RenameCourse $command): void { - $courseExistenceAggregate = new CourseExistenceAggregate($command->courseId); - $courseTitleAggregate = new CourseTitleAggregate($command->courseId); - $appender = $this->reconstituteAggregateStates($courseExistenceAggregate, $courseTitleAggregate); - - if (!$courseExistenceAggregate->courseExists()) { - throw new ConstraintException(sprintf('Failed to rename course with id "%s" because a course with that id does not exist', $command->courseId->value), 1684509782); - } - if ($courseTitleAggregate->courseTitle !== null && $courseTitleAggregate->courseTitle->equals($command->newCourseTitle)) { - throw new ConstraintException(sprintf('Failed to rename course with id "%s" to "%s" because this is already the title of this course', $command->courseId->value, $command->newCourseTitle->value), 1684509837); - } - $appender->append(new CourseRenamed($command->courseId, $command->newCourseTitle)); + $this->transactional([ + 'courseExists' => self::courseExists($command->courseId), + 'courseTitle' => self::courseTitle($command->courseId), + ], function ($state) use ($command) { + if (!$state->courseExists) { + throw new ConstraintException(sprintf('Failed to rename course with id "%s" because a course with that id does not exist', $command->courseId->value), 1684509782); + } + if ($state->courseTitle !== null && $state->courseTitle->equals($command->newCourseTitle)) { + throw new ConstraintException(sprintf('Failed to rename course with id "%s" to "%s" because this is already the title of this course', $command->courseId->value, $command->newCourseTitle->value), 1684509837); + } + return new CourseRenamed($command->courseId, $command->newCourseTitle); + }); } private function handleRegisterStudent(RegisterStudent $command): void { - $studentExistenceAggregate = new StudentExistenceAggregate($command->studentId); - $appender = $this->reconstituteAggregateStates($studentExistenceAggregate); - if ($studentExistenceAggregate->studentExists()) { - throw new ConstraintException(sprintf('Failed to register student with id "%s" because a student with that id already exists', $command->studentId->value), 1684579300); - } - $appender->append(new StudentRegistered($command->studentId)); + $this->transactional([ + 'studentRegistered' => self::studentRegistered($command->studentId), + ], function ($state) use ($command) { + if ($state->studentRegistered) { + throw new ConstraintException(sprintf('Failed to register student with id "%s" because a student with that id already exists', $command->studentId->value), 1684579300); + } + return new StudentRegistered($command->studentId); + }); } private function handleSubscribeStudentToCourse(SubscribeStudentToCourse $command): void { - $courseExistenceAggregate = new CourseExistenceAggregate($command->courseId); - $courseCapacityAggregate = new CourseCapacityAggregate($command->courseId); - $studentExistenceAggregate = new StudentExistenceAggregate($command->studentId); - $studentSubscriptionsAggregate = new StudentSubscriptionsAggregate($command->studentId); - $appender = $this->reconstituteAggregateStates($courseExistenceAggregate, $courseCapacityAggregate, $studentExistenceAggregate, $studentSubscriptionsAggregate); - - if (!$studentExistenceAggregate->studentExists()) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a student with that id does not exist', $command->studentId->value, $command->courseId->value), 1686914105); - } - if (!$courseExistenceAggregate->courseExists()) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1685266122); - } - if ($courseCapacityAggregate->courseCapacityReached()) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because the course\'s capacity of %d is reached', $command->studentId->value, $command->courseId->value, $courseCapacityAggregate->courseCapacity()->value), 1684603201); - } - if ($studentSubscriptionsAggregate->subscribedToCourse($command->courseId)) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because that student is already subscribed to this course', $command->studentId->value, $command->courseId->value), 1684510963); - } - $maximumSubscriptionsPerStudent = 10; - if ($studentSubscriptionsAggregate->numberOfSubscriptions() === $maximumSubscriptionsPerStudent) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because that student is already subscribed the maximum of %d courses', $command->studentId->value, $command->courseId->value, $maximumSubscriptionsPerStudent), 1684605232); - } - $appender->append(new StudentSubscribedToCourse($command->courseId, $command->studentId)); + $this->transactional([ + 'studentRegistered' => self::studentRegistered($command->studentId), + 'courseExists' => self::courseExists($command->courseId), + 'courseCapacity' => self::courseCapacity($command->courseId), + 'numberOfCourseSubscriptions' => self::numberOfCourseSubscriptions($command->courseId), + 'studentSubscriptions' => self::studentSubscriptions($command->studentId), + ], function ($state) use ($command) { + if (!$state->studentRegistered) { + throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a student with that id does not exist', $command->studentId->value, $command->courseId->value), 1686914105); + } + if (!$state->courseExists) { + throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1685266122); + } + if ($state->courseCapacity->value === $state->numberOfCourseSubscriptions) { + throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because the course\'s capacity of %d is reached', $command->studentId->value, $command->courseId->value, $state->courseCapacity->value), 1684603201); + } + if ($state->studentSubscriptions->contains($command->courseId)) { + throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because that student is already subscribed to this course', $command->studentId->value, $command->courseId->value), 1684510963); + } + $maximumSubscriptionsPerStudent = 10; + if ($state->studentSubscriptions->count() === $maximumSubscriptionsPerStudent) { + throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because that student is already subscribed the maximum of %d courses', $command->studentId->value, $command->courseId->value, $maximumSubscriptionsPerStudent), 1684605232); + } + return new StudentSubscribedToCourse($command->courseId, $command->studentId); + }); } private function handleUnsubscribeStudentFromCourse(UnsubscribeStudentFromCourse $command): void { - $courseExistenceAggregate = new CourseExistenceAggregate($command->courseId); - $studentExistenceAggregate = new StudentExistenceAggregate($command->studentId); - $studentSubscriptionsAggregate = new StudentSubscriptionsAggregate($command->studentId); - $appender = $this->reconstituteAggregateStates($courseExistenceAggregate, $studentExistenceAggregate, $studentSubscriptionsAggregate); - - if (!$courseExistenceAggregate->courseExists()) { - throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1684579448); - } - if (!$studentExistenceAggregate->studentExists()) { - throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because a student with that id does not exist', $command->studentId->value, $command->courseId->value), 1684579463); - } - if (!$studentSubscriptionsAggregate->subscribedToCourse($command->courseId)) { - throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because that student is not subscribed to this course', $command->studentId->value, $command->courseId->value), 1684579464); - } - $appender->append(new StudentUnsubscribedFromCourse($command->studentId, $command->courseId)); + $this->transactional([ + 'courseExists' => self::courseExists($command->courseId), + 'studentRegistered' => self::studentRegistered($command->studentId), + 'studentSubscriptions' => self::studentSubscriptions($command->studentId), + ], function ($state) use ($command) { + if (!$state->courseExists) { + throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1684579448); + } + if (!$state->studentRegistered) { + throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because a student with that id does not exist', $command->studentId->value, $command->courseId->value), 1684579463); + } + if (!$state->studentSubscriptions->contains($command->courseId)) { + throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because that student is not subscribed to this course', $command->studentId->value, $command->courseId->value), 1684579464); + } + return new StudentUnsubscribedFromCourse($command->studentId, $command->courseId); + }); } private function handleUpdateCourseCapacity(UpdateCourseCapacity $command): void { - $courseExistenceAggregate = new CourseExistenceAggregate($command->courseId); - $courseCapacityAggregate = new CourseCapacityAggregate($command->courseId); - $appender = $this->reconstituteAggregateStates($courseExistenceAggregate, $courseCapacityAggregate); - - if (!$courseExistenceAggregate->courseExists()) { - throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because a course with that id does not exist', $command->courseId->value, $command->newCapacity->value), 1684604283); - } - if ($command->newCapacity->equals($courseCapacityAggregate->courseCapacity())) { - throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because that is already the courses capacity', $command->courseId->value, $command->newCapacity->value), 1686819073); - } - if ($courseCapacityAggregate->numberOfSubscriptions() > $command->newCapacity->value) { - throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because it already has %d active subscriptions', $command->courseId->value, $command->newCapacity->value, $courseCapacityAggregate->numberOfSubscriptions()), 1684604361); - } - $appender->append(new CourseCapacityChanged($command->courseId, $command->newCapacity)); + $this->transactional([ + 'courseExists' => self::courseExists($command->courseId), + 'courseCapacity' => self::courseCapacity($command->courseId), + 'numberOfCourseSubscriptions' => self::numberOfCourseSubscriptions($command->courseId), + ], function ($state) use ($command) { + if (!$state->courseExists) { + throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because a course with that id does not exist', $command->courseId->value, $command->newCapacity->value), 1684604283); + } + if ($state->courseCapacity->equals($command->newCapacity)) { + throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because that is already the courses capacity', $command->courseId->value, $command->newCapacity->value), 1686819073); + } + if ($state->numberOfCourseSubscriptions > $command->newCapacity->value) { + throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because it already has %d active subscriptions', $command->courseId->value, $command->newCapacity->value, $state->numberOfCourseSubscriptions), 1684604361); + } + return new CourseCapacityChanged($command->courseId, $command->newCapacity); + }); } // ----------------------------- - private function reconstituteAggregateStates(Aggregate ...$aggregates): EventAppender + /** + * @return Projection + */ + private static function courseExists(CourseId $courseId): Projection { - $domainIds = []; - $eventTypes = []; - foreach ($aggregates as $aggregate) { - $aggregateDomainIds = $aggregate->domainIds(); - if ($aggregateDomainIds instanceof DomainId) { - $aggregateDomainIds = DomainIds::create($aggregateDomainIds); - } - $domainIds[] = $aggregateDomainIds->toArray(); - $eventTypes[] = $aggregate->eventTypes()->toStringArray(); - } - $query = StreamQuery::matchingIdsAndTypes( - DomainIds::fromArray(array_merge(...$domainIds)), - EventTypes::fromStrings(...array_unique(array_merge(...$eventTypes))), + return new GenericProjection( + Tags::create($courseId->toTag()), + (new ProjectionLogic(false)) + ->when(CourseCreated::class, static fn() => true) + ); + } + + /** + * @param CourseId $courseId + * @return Projection + */ + private static function courseCapacity(CourseId $courseId): Projection + { + return new GenericProjection( + Tags::create($courseId->toTag()), + (new ProjectionLogic(CourseCapacity::fromInteger(0))) + ->when(CourseCreated::class, static fn($_, CourseCreated $event) => $event->initialCapacity) + ->when(CourseCapacityChanged::class, static fn($_, CourseCapacityChanged $event) => $event->newCapacity) + ); + } + + /** + * @param CourseId $courseId + * @return Projection + */ + private static function numberOfCourseSubscriptions(CourseId $courseId): Projection + { + return new GenericProjection( + Tags::create($courseId->toTag()), + (new ProjectionLogic(0)) + ->when(StudentSubscribedToCourse::class, static fn(int $state) => $state + 1) + ->when(StudentUnsubscribedFromCourse::class, static fn(int $state) => $state - 1) + ); + } + + /** + * @param CourseId $courseId + * @return Projection + */ + private static function courseTitle(CourseId $courseId): Projection + { + /** @var ProjectionLogic $logic */ + $logic = (new ProjectionLogic(CourseTitle::fromString(''))) + ->when(CourseCreated::class, static fn ($_, CourseCreated $event) => $event->courseTitle) + ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newCourseTitle); + return new GenericProjection(Tags::create($courseId->toTag()), $logic); + } + + private static function studentRegistered(StudentId $studentId): Projection + { + return new GenericProjection( + Tags::create($studentId->toTag()), + (new ProjectionLogic(false)) + ->when(StudentRegistered::class, static fn() => true) ); - $expectedLastEventId = ExpectedLastEventId::none(); - foreach ($this->eventStore->stream($query) as $eventEnvelope) { + } + + /** + * @param StudentId $studentId + * @return Projection + */ + private static function studentSubscriptions(StudentId $studentId): Projection + { + /** @var ProjectionLogic $logic */ + $logic = (new ProjectionLogic(CourseIds::none())) + ->when(StudentSubscribedToCourse::class, static fn (CourseIds $state, StudentSubscribedToCourse $event) => $state->with($event->courseId)) + ->when(StudentUnsubscribedFromCourse::class, static fn (CourseIds $state, StudentUnsubscribedFromCourse $event) => $state->without($event->courseId)); + + return new GenericProjection(Tags::create($studentId->toTag()), $logic); + } + + /** + * @param array $projections + */ + private function transactional(array $projections, Closure $closure): void + { + $criteria = []; + foreach ($projections as $projection) { + $criteria[] = new EventTypesAndTagsCriterion($projection->eventTypes(), $projection->tags()); + } + $query = StreamQuery::create(Criteria::fromArray($criteria)); + $expectedHighestSequenceNumber = ExpectedHighestSequenceNumber::none(); + foreach ($this->eventStore->read($query) as $eventEnvelope) { $domainEvent = $this->eventNormalizer->convertEvent($eventEnvelope); - foreach ($aggregates as $aggregate) { - if ($domainEvent->domainIds()->intersects($aggregate->domainIds())) { - $aggregate->apply($domainEvent); + foreach ($projections as $projection) { + if (!$projection->eventTypes()->contain($eventEnvelope->event->type)) { + continue; } + if (!$domainEvent->tags()->containEvery($projection->tags())) { + continue; + } + $projection->apply($domainEvent); } - $expectedLastEventId = ExpectedLastEventId::fromEventId($eventEnvelope->event->id); + $expectedHighestSequenceNumber = ExpectedHighestSequenceNumber::fromSequenceNumber($eventEnvelope->sequenceNumber); + } + $state = new stdClass(); + foreach ($projections as $key => $projection) { + $state->$key = $projection->getState(); } - return new EventAppender($this->eventStore, $query, $expectedLastEventId); + $domainEvents = $closure($state); + $events = Events::fromArray(array_map($this->eventNormalizer->convertDomainEvent(...), is_array($domainEvents) ? $domainEvents : [$domainEvents])); + $this->eventStore->append($events, new AppendCondition($query, $expectedHighestSequenceNumber)); + } } diff --git a/src/Command/Command.php b/src/Commands/Command.php similarity index 71% rename from src/Command/Command.php rename to src/Commands/Command.php index 0e63966..ff0de0e 100644 --- a/src/Command/Command.php +++ b/src/Commands/Command.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Command; +namespace Wwwision\DCBExample\Commands; /** * Marker interface for all commands diff --git a/src/Command/CreateCourse.php b/src/Commands/CreateCourse.php similarity index 57% rename from src/Command/CreateCourse.php rename to src/Commands/CreateCourse.php index 3ab0300..9903ca6 100644 --- a/src/Command/CreateCourse.php +++ b/src/Commands/CreateCourse.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Command; +namespace Wwwision\DCBExample\Commands; -use Wwwision\DCBExample\Model\CourseCapacity; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\CourseTitle; +use Wwwision\DCBExample\Types\CourseCapacity; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\CourseTitle; /** - * Command to create a new course + * Commands to create a new course */ final readonly class CreateCourse implements Command { diff --git a/src/Command/RegisterStudent.php b/src/Commands/RegisterStudent.php similarity index 58% rename from src/Command/RegisterStudent.php rename to src/Commands/RegisterStudent.php index cb7bc36..0008c13 100644 --- a/src/Command/RegisterStudent.php +++ b/src/Commands/RegisterStudent.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Command; +namespace Wwwision\DCBExample\Commands; -use Wwwision\DCBExample\Model\StudentId; +use Wwwision\DCBExample\Types\StudentId; /** - * Command to register a new student in the system + * Commands to register a new student in the system */ final readonly class RegisterStudent implements Command { diff --git a/src/Command/RenameCourse.php b/src/Commands/RenameCourse.php similarity index 57% rename from src/Command/RenameCourse.php rename to src/Commands/RenameCourse.php index 2bb54a8..7f823cc 100644 --- a/src/Command/RenameCourse.php +++ b/src/Commands/RenameCourse.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Command; +namespace Wwwision\DCBExample\Commands; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\CourseTitle; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\CourseTitle; /** - * Command to change the title of a course + * Commands to change the title of a course */ final readonly class RenameCourse implements Command { diff --git a/src/Command/SubscribeStudentToCourse.php b/src/Commands/SubscribeStudentToCourse.php similarity index 57% rename from src/Command/SubscribeStudentToCourse.php rename to src/Commands/SubscribeStudentToCourse.php index 801d080..600fd07 100644 --- a/src/Command/SubscribeStudentToCourse.php +++ b/src/Commands/SubscribeStudentToCourse.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Command; +namespace Wwwision\DCBExample\Commands; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\StudentId; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\StudentId; /** - * Command to subscribe a student to a course + * Commands to subscribe a student to a course */ final readonly class SubscribeStudentToCourse implements Command { diff --git a/src/Command/UnsubscribeStudentFromCourse.php b/src/Commands/UnsubscribeStudentFromCourse.php similarity index 57% rename from src/Command/UnsubscribeStudentFromCourse.php rename to src/Commands/UnsubscribeStudentFromCourse.php index f5d9dfd..883899e 100644 --- a/src/Command/UnsubscribeStudentFromCourse.php +++ b/src/Commands/UnsubscribeStudentFromCourse.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Command; +namespace Wwwision\DCBExample\Commands; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\StudentId; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\StudentId; /** - * Command to unsubscribe a student from a course + * Commands to unsubscribe a student from a course */ final readonly class UnsubscribeStudentFromCourse implements Command { diff --git a/src/Command/UpdateCourseCapacity.php b/src/Commands/UpdateCourseCapacity.php similarity index 56% rename from src/Command/UpdateCourseCapacity.php rename to src/Commands/UpdateCourseCapacity.php index 0022132..ade622e 100644 --- a/src/Command/UpdateCourseCapacity.php +++ b/src/Commands/UpdateCourseCapacity.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Command; +namespace Wwwision\DCBExample\Commands; -use Wwwision\DCBExample\Model\CourseCapacity; -use Wwwision\DCBExample\Model\CourseId; +use Wwwision\DCBExample\Types\CourseCapacity; +use Wwwision\DCBExample\Types\CourseId; /** - * Command to change the total capacity of a course + * Commands to change the total capacity of a course */ final readonly class UpdateCourseCapacity implements Command { diff --git a/src/Event/Appender/EventAppender.php b/src/Event/Appender/EventAppender.php deleted file mode 100644 index 470c567..0000000 --- a/src/Event/Appender/EventAppender.php +++ /dev/null @@ -1,39 +0,0 @@ -eventNormalizer = new EventNormalizer(); - } - - /** - * @throws ConditionalAppendFailed - */ - public function append(DomainEvents|DomainEvent $domainEvents): void - { - $convertedEvents = Events::fromArray(array_map($this->eventNormalizer->convertDomainEvent(...), $domainEvents instanceof DomainEvents ? iterator_to_array($domainEvents) : [$domainEvents])); - $this->eventStore->conditionalAppend($convertedEvents, $this->query, $this->expectedLastEventId); - } -} diff --git a/src/Event/Normalizer/EventNormalizer.php b/src/EventNormalizer.php similarity index 76% rename from src/Event/Normalizer/EventNormalizer.php rename to src/EventNormalizer.php index 08cbee3..2023e3c 100644 --- a/src/Event/Normalizer/EventNormalizer.php +++ b/src/EventNormalizer.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event\Normalizer; +namespace Wwwision\DCBExample; use JsonException; use RuntimeException; use Webmozart\Assert\Assert; -use Wwwision\DCBEventStore\Model\DomainEvent; -use Wwwision\DCBEventStore\Model\Event; -use Wwwision\DCBEventStore\Model\EventData; -use Wwwision\DCBEventStore\Model\EventEnvelope; -use Wwwision\DCBEventStore\Model\EventId; -use Wwwision\DCBEventStore\Model\EventType; +use Wwwision\DCBEventStore\Types\Event; +use Wwwision\DCBEventStore\Types\EventData; +use Wwwision\DCBEventStore\Types\EventEnvelope; +use Wwwision\DCBEventStore\Types\EventId; +use Wwwision\DCBEventStore\Types\EventType; +use Wwwision\DCBExample\Events\DomainEvent; use function get_debug_type; use function json_decode; @@ -24,7 +24,7 @@ use const JSON_THROW_ON_ERROR; /** - * Simple converter that expects Domain Events to implement the {@see FromArraySupport} interface + * Simple converter that expects Domain Events to implement the {@see DomainEvent} interface */ final readonly class EventNormalizer { @@ -39,10 +39,10 @@ public function convertEvent(Event|EventEnvelope $event): DomainEvent throw new RuntimeException(sprintf('Failed to decode JSON: %s', $e->getMessage()), 1684510536, $e); } Assert::isArray($payload); - /** @var class-string $eventClassName + /** @var class-string $eventClassName * @noinspection PhpRedundantVariableDocTypeInspection */ - $eventClassName = '\\Wwwision\\DCBExample\\Event\\' . $event->type->value; + $eventClassName = '\\Wwwision\\DCBExample\\Events\\' . $event->type->value; $domainEvent = $eventClassName::fromArray($payload); Assert::isInstanceOf($domainEvent, DomainEvent::class); return $domainEvent; @@ -59,7 +59,7 @@ public function convertDomainEvent(DomainEvent $domainEvent): Event EventId::create(), EventType::fromString(substr($domainEvent::class, strrpos($domainEvent::class, '\\') + 1)), EventData::fromString($eventData), - $domainEvent->domainIds(), + $domainEvent->tags(), ); } } diff --git a/src/Event/CourseCapacityChanged.php b/src/Events/CourseCapacityChanged.php similarity index 61% rename from src/Event/CourseCapacityChanged.php rename to src/Events/CourseCapacityChanged.php index 5a20c97..312c363 100644 --- a/src/Event/CourseCapacityChanged.php +++ b/src/Events/CourseCapacityChanged.php @@ -2,19 +2,17 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Events; -use Wwwision\DCBEventStore\Model\DomainEvent; -use Wwwision\DCBExample\Event\Normalizer\FromArraySupport; -use Wwwision\DCBExample\Model\CourseCapacity; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBEventStore\Model\DomainIds; use Webmozart\Assert\Assert; +use Wwwision\DCBEventStore\Types\Tags; +use Wwwision\DCBExample\Types\CourseCapacity; +use Wwwision\DCBExample\Types\CourseId; /** - * Domain Event that occurs when the total capacity of a course has changed + * Domain Events that occurs when the total capacity of a course has changed */ -final readonly class CourseCapacityChanged implements DomainEvent, FromArraySupport +final readonly class CourseCapacityChanged implements DomainEvent { public function __construct( public CourseId $courseId, @@ -37,8 +35,8 @@ public static function fromArray(array $data): self ); } - public function domainIds(): DomainIds + public function tags(): Tags { - return DomainIds::create($this->courseId); + return Tags::create($this->courseId->toTag()); } } diff --git a/src/Event/CourseCreated.php b/src/Events/CourseCreated.php similarity index 62% rename from src/Event/CourseCreated.php rename to src/Events/CourseCreated.php index 6b81a6c..6a6219c 100644 --- a/src/Event/CourseCreated.php +++ b/src/Events/CourseCreated.php @@ -2,20 +2,18 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Events; -use Wwwision\DCBExample\Event\Normalizer\FromArraySupport; -use Wwwision\DCBExample\Model\CourseCapacity; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\CourseTitle; -use Wwwision\DCBEventStore\Model\DomainEvent; -use Wwwision\DCBEventStore\Model\DomainIds; use Webmozart\Assert\Assert; +use Wwwision\DCBEventStore\Types\Tags; +use Wwwision\DCBExample\Types\CourseCapacity; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\CourseTitle; /** - * Domain Event that occurs when a new course was created + * Domain Events that occurs when a new course was created */ -final readonly class CourseCreated implements DomainEvent, FromArraySupport +final readonly class CourseCreated implements DomainEvent { public function __construct( public CourseId $courseId, @@ -42,8 +40,8 @@ public static function fromArray(array $data): self ); } - public function domainIds(): DomainIds + public function tags(): Tags { - return DomainIds::create($this->courseId); + return Tags::create($this->courseId->toTag()); } } diff --git a/src/Event/CourseRenamed.php b/src/Events/CourseRenamed.php similarity index 57% rename from src/Event/CourseRenamed.php rename to src/Events/CourseRenamed.php index 9697c10..e81196e 100644 --- a/src/Event/CourseRenamed.php +++ b/src/Events/CourseRenamed.php @@ -2,19 +2,17 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Events; -use Wwwision\DCBExample\Event\Normalizer\FromArraySupport; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\CourseTitle; -use Wwwision\DCBEventStore\Model\DomainEvent; -use Wwwision\DCBEventStore\Model\DomainIds; use Webmozart\Assert\Assert; +use Wwwision\DCBEventStore\Types\Tags; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\CourseTitle; /** - * Domain Event that occurs when the title of a course has changed + * Domain Events that occurs when the title of a course has changed */ -final readonly class CourseRenamed implements DomainEvent, FromArraySupport +final readonly class CourseRenamed implements DomainEvent { public function __construct( public CourseId $courseId, @@ -37,8 +35,8 @@ public static function fromArray(array $data): self ); } - public function domainIds(): DomainIds + public function tags(): Tags { - return DomainIds::create($this->courseId); + return Tags::create($this->courseId->toTag()); } } diff --git a/src/Event/Normalizer/FromArraySupport.php b/src/Events/DomainEvent.php similarity index 56% rename from src/Event/Normalizer/FromArraySupport.php rename to src/Events/DomainEvent.php index eb448f2..5ff9ab3 100644 --- a/src/Event/Normalizer/FromArraySupport.php +++ b/src/Events/DomainEvent.php @@ -4,15 +4,19 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event\Normalizer; +namespace Wwwision\DCBExample\Events; + +use Wwwision\DCBEventStore\Types\Tags; /** - * Contract for classes (usually Domain Events) with a static fromArray() constructor + * Contract for Domain Events classes */ -interface FromArraySupport +interface DomainEvent { /** * @param array $data */ public static function fromArray(array $data): self; + + public function tags(): Tags; } diff --git a/src/Event/StudentRegistered.php b/src/Events/StudentRegistered.php similarity index 50% rename from src/Event/StudentRegistered.php rename to src/Events/StudentRegistered.php index 95b2730..f121682 100644 --- a/src/Event/StudentRegistered.php +++ b/src/Events/StudentRegistered.php @@ -2,18 +2,16 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Events; -use Wwwision\DCBExample\Event\Normalizer\FromArraySupport; -use Wwwision\DCBExample\Model\StudentId; -use Wwwision\DCBEventStore\Model\DomainEvent; -use Wwwision\DCBEventStore\Model\DomainIds; use Webmozart\Assert\Assert; +use Wwwision\DCBEventStore\Types\Tags; +use Wwwision\DCBExample\Types\StudentId; /** - * Domain Event that occurs when a new student was registered in the system + * Domain Events that occurs when a new student was registered in the system */ -final readonly class StudentRegistered implements DomainEvent, FromArraySupport +final readonly class StudentRegistered implements DomainEvent { public function __construct( public StudentId $studentId, @@ -32,8 +30,8 @@ public static function fromArray(array $data): self ); } - public function domainIds(): DomainIds + public function tags(): Tags { - return DomainIds::create($this->studentId); + return Tags::create($this->studentId->toTag()); } } diff --git a/src/Event/StudentSubscribedToCourse.php b/src/Events/StudentSubscribedToCourse.php similarity index 62% rename from src/Event/StudentSubscribedToCourse.php rename to src/Events/StudentSubscribedToCourse.php index 5d3967c..79f46f8 100644 --- a/src/Event/StudentSubscribedToCourse.php +++ b/src/Events/StudentSubscribedToCourse.php @@ -2,21 +2,19 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Events; -use Wwwision\DCBExample\Event\Normalizer\FromArraySupport; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\StudentId; -use Wwwision\DCBEventStore\Model\DomainEvent; -use Wwwision\DCBEventStore\Model\DomainIds; use Webmozart\Assert\Assert; +use Wwwision\DCBEventStore\Types\Tags; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\StudentId; /** - * Domain Event that occurs when a student was subscribed to a course + * Domain Events that occurs when a student was subscribed to a course * * Note: This event affects two entities (course and student)! */ -final readonly class StudentSubscribedToCourse implements DomainEvent, FromArraySupport +final readonly class StudentSubscribedToCourse implements DomainEvent { public function __construct( public CourseId $courseId, @@ -39,8 +37,8 @@ public static function fromArray(array $data): self ); } - public function domainIds(): DomainIds + public function tags(): Tags { - return DomainIds::create($this->courseId, $this->studentId); + return Tags::create($this->courseId->toTag(), $this->studentId->toTag()); } } diff --git a/src/Event/StudentUnsubscribedFromCourse.php b/src/Events/StudentUnsubscribedFromCourse.php similarity index 61% rename from src/Event/StudentUnsubscribedFromCourse.php rename to src/Events/StudentUnsubscribedFromCourse.php index 57029c9..bc5eabc 100644 --- a/src/Event/StudentUnsubscribedFromCourse.php +++ b/src/Events/StudentUnsubscribedFromCourse.php @@ -2,21 +2,19 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Event; +namespace Wwwision\DCBExample\Events; -use Wwwision\DCBExample\Event\Normalizer\FromArraySupport; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\StudentId; -use Wwwision\DCBEventStore\Model\DomainEvent; -use Wwwision\DCBEventStore\Model\DomainIds; use Webmozart\Assert\Assert; +use Wwwision\DCBEventStore\Types\Tags; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\StudentId; /** - * Domain Event that occurs when a student was unsubscribed from a course + * Domain Events that occurs when a student was unsubscribed from a course * * Note: This event affects two entities (course and student)! */ -final readonly class StudentUnsubscribedFromCourse implements DomainEvent, FromArraySupport +final readonly class StudentUnsubscribedFromCourse implements DomainEvent { public function __construct( public StudentId $studentId, @@ -36,8 +34,8 @@ public static function fromArray(array $data): self return new self(StudentId::fromString($data['studentId']), CourseId::fromString($data['courseId']),); } - public function domainIds(): DomainIds + public function tags(): Tags { - return DomainIds::create($this->courseId, $this->studentId); + return Tags::create($this->courseId->toTag(), $this->studentId->toTag()); } } diff --git a/src/Exception/ConstraintException.php b/src/Exception/ConstraintException.php index 5e67f5c..39204c6 100644 --- a/src/Exception/ConstraintException.php +++ b/src/Exception/ConstraintException.php @@ -7,7 +7,7 @@ use InvalidArgumentException; /** - * An exception that is thrown when the hard constraint checks of an aggregate are not satisfied at write time + * An exception that is thrown when the hard constraint checks are not satisfied at write time */ final class ConstraintException extends InvalidArgumentException { diff --git a/src/Model/Aggregate/Aggregate.php b/src/Model/Aggregate/Aggregate.php deleted file mode 100644 index e61f2b4..0000000 --- a/src/Model/Aggregate/Aggregate.php +++ /dev/null @@ -1,22 +0,0 @@ -state = new CourseSubscriptionsState(CourseCapacity::fromInteger(0), 0); - } - - public function apply(DomainEvent $domainEvent): void - { - $this->state = match ($domainEvent::class) { - CourseCreated::class => $this->state->withCourseCapacity($domainEvent->initialCapacity), - CourseCapacityChanged::class => $this->state->withCourseCapacity($domainEvent->newCapacity), - StudentSubscribedToCourse::class => $this->state->withAddedSubscription(), - StudentUnsubscribedFromCourse::class => $this->state->withRemovedSubscription(), - default => $this->state, - }; - } - - public function courseCapacityReached(): bool - { - return $this->state->numberOfSubscriptions >= $this->state->courseCapacity->value; - } - - public function courseCapacity(): CourseCapacity - { - return $this->state->courseCapacity; - } - - public function numberOfSubscriptions(): int - { - return $this->state->numberOfSubscriptions; - } - - public function domainIds(): DomainId - { - return $this->courseId; - } - - public function eventTypes(): EventTypes - { - return EventTypes::fromStrings('CourseCreated', 'CourseCapacityChanged', 'StudentSubscribedToCourse', 'StudentUnsubscribedFromCourse'); - } -} diff --git a/src/Model/Aggregate/CourseExistenceAggregate.php b/src/Model/Aggregate/CourseExistenceAggregate.php deleted file mode 100644 index 66b6790..0000000 --- a/src/Model/Aggregate/CourseExistenceAggregate.php +++ /dev/null @@ -1,48 +0,0 @@ -courseExists = match ($domainEvent::class) { - CourseCreated::class => true, - default => $this->courseExists, - }; - } - - public function courseExists(): bool - { - return $this->courseExists; - } - - public function eventTypes(): EventTypes - { - return EventTypes::fromStrings('CourseCreated'); - } - - public function domainIds(): DomainId - { - return $this->courseId; - } -} diff --git a/src/Model/Aggregate/CourseTitleAggregate.php b/src/Model/Aggregate/CourseTitleAggregate.php deleted file mode 100644 index cba4462..0000000 --- a/src/Model/Aggregate/CourseTitleAggregate.php +++ /dev/null @@ -1,44 +0,0 @@ -courseTitle = match ($domainEvent::class) { - CourseCreated::class => $domainEvent->courseTitle, - CourseRenamed::class => $domainEvent->newCourseTitle, - default => $this->courseTitle, - }; - } - - public function domainIds(): DomainId - { - return $this->courseId; - } - - public function eventTypes(): EventTypes - { - return EventTypes::fromStrings('CourseCreated', 'CourseRenamed'); - } -} diff --git a/src/Model/Aggregate/State/CourseSubscriptionsState.php b/src/Model/Aggregate/State/CourseSubscriptionsState.php deleted file mode 100644 index 1bb12d4..0000000 --- a/src/Model/Aggregate/State/CourseSubscriptionsState.php +++ /dev/null @@ -1,35 +0,0 @@ -numberOfSubscriptions); - } - - public function withAddedSubscription(): self - { - return new self($this->courseCapacity, $this->numberOfSubscriptions + 1); - } - - public function withRemovedSubscription(): self - { - return new self($this->courseCapacity, $this->numberOfSubscriptions - 1); - } -} diff --git a/src/Model/Aggregate/StudentExistenceAggregate.php b/src/Model/Aggregate/StudentExistenceAggregate.php deleted file mode 100644 index 91860e5..0000000 --- a/src/Model/Aggregate/StudentExistenceAggregate.php +++ /dev/null @@ -1,47 +0,0 @@ -studentExists = match ($domainEvent::class) { - StudentRegistered::class => true, - default => $this->studentExists, - }; - } - - public function studentExists(): bool - { - return $this->studentExists; - } - - public function domainIds(): DomainIds - { - return DomainIds::create($this->studentId); - } - - public function eventTypes(): EventTypes - { - return EventTypes::fromStrings('StudentRegistered'); - } -} diff --git a/src/Model/Aggregate/StudentSubscriptionsAggregate.php b/src/Model/Aggregate/StudentSubscriptionsAggregate.php deleted file mode 100644 index 6598b87..0000000 --- a/src/Model/Aggregate/StudentSubscriptionsAggregate.php +++ /dev/null @@ -1,57 +0,0 @@ -subscribedCourseIds = CourseIds::none(); - } - - public function apply(DomainEvent $domainEvent): void - { - $this->subscribedCourseIds = match ($domainEvent::class) { - StudentSubscribedToCourse::class => $this->subscribedCourseIds->with($domainEvent->courseId), - StudentUnsubscribedFromCourse::class => $this->subscribedCourseIds->without($domainEvent->courseId), - default => $this->subscribedCourseIds, - }; - } - - public function subscribedToCourse(CourseId $courseId): bool - { - return $this->subscribedCourseIds->contains($courseId); - } - - public function numberOfSubscriptions(): int - { - return $this->subscribedCourseIds->count(); - } - - public function domainIds(): DomainIds - { - return DomainIds::create($this->studentId); - } - - public function eventTypes(): EventTypes - { - return EventTypes::fromStrings('StudentSubscribedToCourse', 'StudentUnsubscribedFromCourse'); - } -} diff --git a/src/Projections/GenericProjection.php b/src/Projections/GenericProjection.php new file mode 100644 index 0000000..0964597 --- /dev/null +++ b/src/Projections/GenericProjection.php @@ -0,0 +1,55 @@ +> + */ +final readonly class GenericProjection implements Projection +{ + /** + * @param P $logic + */ + public function __construct( + private Tags $tags, + private ProjectionLogic $logic, + ) + { + } + + public function apply(DomainEvent $domainEvent): void + { + $this->logic->apply($domainEvent); + } + + public function eventTypes(): EventTypes + { + return $this->logic->eventTypes(); + } + + /** + * @return S + */ + + public function initialState(): mixed + { + return $this->logic->initialState(); + } + + public function getState(): mixed + { + return $this->logic->getState(); + } + + public function tags(): Tags + { + return $this->tags; + } +} \ No newline at end of file diff --git a/src/Projections/Projection.php b/src/Projections/Projection.php new file mode 100644 index 0000000..d67a11f --- /dev/null +++ b/src/Projections/Projection.php @@ -0,0 +1,33 @@ +, Closure(S $state, DomainEvent $event): S> $mappers + */ + public function __construct( + private mixed $initialState, + private array $mappers = [], + ) + { + $this->state = $this->initialState(); + } + + public function initialState(): mixed + { + return $this->initialState; + } + + /** + * @template T of DomainEvent + * @param class-string $eventType + * @param Closure(S $state, T $event): S $closure + * @return self + */ + public function when(string $eventType, Closure $closure): self + { + if (array_key_exists($eventType, $this->mappers)) { + throw new InvalidArgumentException(sprintf('Event type "%s" is already handled by this projection logic', $eventType), 1690974513); + } + $mappers = $this->mappers; + /** @var array, Closure(S $state, DomainEvent $event): S> $mappers */ + $mappers[$eventType] = $closure; + return new self($this->initialState, $mappers); + } + + /** + * @param DomainEvent $event + */ + public function apply(DomainEvent $event): void + { + $this->state = isset($this->mappers[$event::class]) ? $this->mappers[$event::class]($this->state, $event) : $this->state; + } + + public function eventTypes(): EventTypes + { + return EventTypes::fromStrings(...array_map(static fn($domainEventClassName) => substr($domainEventClassName, strrpos($domainEventClassName, '\\') + 1), array_keys($this->mappers))); + } + + public function getState(): mixed + { + return $this->state; + } +} diff --git a/src/Model/CourseCapacity.php b/src/Types/CourseCapacity.php similarity index 94% rename from src/Model/CourseCapacity.php rename to src/Types/CourseCapacity.php index 8fe1538..816f460 100644 --- a/src/Model/CourseCapacity.php +++ b/src/Types/CourseCapacity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Model; +namespace Wwwision\DCBExample\Types; use JsonSerializable; use Webmozart\Assert\Assert; diff --git a/src/Model/CourseId.php b/src/Types/CourseId.php similarity index 64% rename from src/Model/CourseId.php rename to src/Types/CourseId.php index 77a40e5..2f0d124 100644 --- a/src/Model/CourseId.php +++ b/src/Types/CourseId.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Model; +namespace Wwwision\DCBExample\Types; use JsonSerializable; -use Wwwision\DCBEventStore\Model\DomainId; +use Wwwision\DCBEventStore\Types\Tag; /** * Globally unique identifier of a course (usually represented as a UUID v4) */ -final readonly class CourseId implements DomainId, JsonSerializable +final readonly class CourseId implements JsonSerializable { private function __construct(public string $value) { @@ -31,13 +31,8 @@ public function equals(self $other): bool return $other->value === $this->value; } - public function key(): string + public function toTag(): Tag { - return 'course'; - } - - public function value(): string - { - return $this->value; + return Tag::create('course', $this->value); } } diff --git a/src/Model/CourseIds.php b/src/Types/CourseIds.php similarity index 97% rename from src/Model/CourseIds.php rename to src/Types/CourseIds.php index 286bde4..f0502ff 100644 --- a/src/Model/CourseIds.php +++ b/src/Types/CourseIds.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Model; +namespace Wwwision\DCBExample\Types; use ArrayIterator; use Countable; diff --git a/src/Model/CourseTitle.php b/src/Types/CourseTitle.php similarity index 93% rename from src/Model/CourseTitle.php rename to src/Types/CourseTitle.php index 82bcf28..1d83332 100644 --- a/src/Model/CourseTitle.php +++ b/src/Types/CourseTitle.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Model; +namespace Wwwision\DCBExample\Types; use JsonSerializable; diff --git a/src/Model/StudentId.php b/src/Types/StudentId.php similarity index 59% rename from src/Model/StudentId.php rename to src/Types/StudentId.php index 7413b65..b71fb23 100644 --- a/src/Model/StudentId.php +++ b/src/Types/StudentId.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Model; +namespace Wwwision\DCBExample\Types; use JsonSerializable; -use Wwwision\DCBEventStore\Model\DomainId; +use Wwwision\DCBEventStore\Types\Tag; /** * Globally unique identifier of a student (usually represented as a UUID v4) */ -final readonly class StudentId implements DomainId, JsonSerializable +final readonly class StudentId implements JsonSerializable { private function __construct(public string $value) { @@ -26,14 +26,8 @@ public function jsonSerialize(): string return $this->value; } - - public function key(): string + public function toTag(): Tag { - return 'student'; - } - - public function value(): string - { - return $this->value; + return Tag::create('student', $this->value); } } diff --git a/tests/Behat/Bootstrap/FeatureContext.php b/tests/Behat/Bootstrap/FeatureContext.php index 0ee6fa3..2bcc063 100644 --- a/tests/Behat/Bootstrap/FeatureContext.php +++ b/tests/Behat/Bootstrap/FeatureContext.php @@ -6,48 +6,58 @@ use Behat\Behat\Context\Context; use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; use InvalidArgumentException; +use PHPUnit\Framework\Assert; +use RuntimeException; +use Throwable; +use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStore\EventStream; -use Wwwision\DCBExample\Exception\ConstraintException; -use Wwwision\DCBEventStore\Model\DomainEvent; -use Wwwision\DCBEventStore\Model\Event; -use Wwwision\DCBEventStore\Model\EventEnvelope; -use Wwwision\DCBEventStore\Model\EventId; -use Wwwision\DCBEventStore\Model\Events; -use Wwwision\DCBEventStore\Model\ExpectedLastEventId; -use Wwwision\DCBEventStore\Model\StreamQuery; -use Wwwision\DCBExample\Command\Command; -use Wwwision\DCBExample\Command\CreateCourse; -use Wwwision\DCBExample\Command\RegisterStudent; -use Wwwision\DCBExample\Command\RenameCourse; -use Wwwision\DCBExample\Command\SubscribeStudentToCourse; -use Wwwision\DCBExample\Command\UnsubscribeStudentFromCourse; -use Wwwision\DCBExample\Command\UpdateCourseCapacity; +use Wwwision\DCBEventStore\Helpers\InMemoryEventStore; +use Wwwision\DCBEventStore\Helpers\InMemoryEventStream; +use Wwwision\DCBEventStore\Types\AppendCondition; +use Wwwision\DCBEventStore\Types\Event; +use Wwwision\DCBEventStore\Types\Events; +use Wwwision\DCBEventStore\Types\SequenceNumber; +use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; +use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; use Wwwision\DCBExample\CommandHandler; -use Wwwision\DCBExample\Event\CourseCreated; -use Wwwision\DCBExample\Event\Normalizer\EventNormalizer; -use Wwwision\DCBExample\Event\StudentRegistered; -use Wwwision\DCBExample\Event\StudentSubscribedToCourse; -use Wwwision\DCBExample\Event\StudentUnsubscribedFromCourse; -use Wwwision\DCBExample\Model\CourseCapacity; -use Wwwision\DCBExample\Model\CourseId; -use Wwwision\DCBExample\Model\CourseTitle; -use Wwwision\DCBExample\Model\StudentId; -use Wwwision\DCBEventStore\EventStore; -use Wwwision\DCBEventStore\Helper\InMemoryEventStore; -use PHPUnit\Framework\Assert; +use Wwwision\DCBExample\Commands\Command; +use Wwwision\DCBExample\Commands\CreateCourse; +use Wwwision\DCBExample\Commands\RegisterStudent; +use Wwwision\DCBExample\Commands\RenameCourse; +use Wwwision\DCBExample\Commands\SubscribeStudentToCourse; +use Wwwision\DCBExample\Commands\UnsubscribeStudentFromCourse; +use Wwwision\DCBExample\Commands\UpdateCourseCapacity; +use Wwwision\DCBExample\Events\CourseCreated; +use Wwwision\DCBExample\Events\DomainEvent; +use Wwwision\DCBExample\EventNormalizer; +use Wwwision\DCBExample\Events\StudentRegistered; +use Wwwision\DCBExample\Events\StudentSubscribedToCourse; +use Wwwision\DCBExample\Events\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\Exception\ConstraintException; +use Wwwision\DCBExample\Types\CourseCapacity; +use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBExample\Types\CourseTitle; +use Wwwision\DCBExample\Types\StudentId; use function array_diff; use function array_keys; use function array_map; use function explode; +use function func_get_args; +use function get_debug_type; use function implode; use function json_decode; +use function reset; use function sprintf; -use function var_dump; use const JSON_THROW_ON_ERROR; final class FeatureContext implements Context { + private Connection $eventStoreConnection; private EventStore $eventStore; private CommandHandler $commandHandler; @@ -55,10 +65,14 @@ final class FeatureContext implements Context private ?ConstraintException $lastConstraintException = null; - public function __construct() + public function __construct(string $eventStoreDsn = null, private string $eventTableName = 'dcb_events_test') { - $innerEventStore = InMemoryEventStore::create(); + $this->eventStoreConnection = DriverManager::getConnection(['url' => $eventStoreDsn ?? 'pdo-sqlite://:memory:']); + /** The second parameter is the table name to store the events in **/ + $innerEventStore = DoctrineEventStore::create($this->eventStoreConnection, $eventTableName); + $innerEventStore->setup(); + $this->resetEventStore(); $this->eventStore = new class ($innerEventStore) implements EventStore { public Events $appendedEvents; @@ -74,34 +88,32 @@ public function setup(): void $this->inner->setup(); } - public function streamAll(): EventStream + public function read(StreamQuery $query, ?SequenceNumber $from = null): EventStream { - $innerStream = $this->inner->streamAll(); + $innerStream = $this->inner->read($query, $from); + $eventEnvelopes = []; foreach ($innerStream as $eventEnvelope) { $this->readEvents = $this->readEvents->append($eventEnvelope->event); + $eventEnvelopes[] = $eventEnvelope; } - return $innerStream; + return InMemoryEventStream::create(...$eventEnvelopes); + } - public function stream(StreamQuery $query): EventStream + public function readBackwards(StreamQuery $query, ?SequenceNumber $from = null): EventStream { - $innerStream = $this->inner->stream($query); + $innerStream = $this->inner->readBackwards($query, $from); + $eventEnvelopes = []; foreach ($innerStream as $eventEnvelope) { $this->readEvents = $this->readEvents->append($eventEnvelope->event); + $eventEnvelopes[] = $eventEnvelope; } - return $innerStream; + return InMemoryEventStream::create(...$eventEnvelopes); } - public function append(Events $events): void + public function append(Events $events, AppendCondition $condition): void { - $this->appendedEvents = $events; - $this->inner->append($events); - } - - - public function conditionalAppend(Events $events, StreamQuery $query, ExpectedLastEventId $expectedLastEventId): void - { - $this->inner->conditionalAppend($events, $query, $expectedLastEventId); + $this->inner->append($events, $condition); $this->appendedEvents = $events; } }; @@ -119,6 +131,22 @@ public function throwConstraintException(): void } } + /** + * @AfterScenario + */ + public function resetEventStore(): void + { + if ($this->eventStoreConnection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->eventStoreConnection->executeStatement('TRUNCATE TABLE ' . $this->eventTableName . ' RESTART IDENTITY'); + } elseif ($this->eventStoreConnection->getDatabasePlatform() instanceof SqlitePlatform) { + /** @noinspection SqlWithoutWhere */ + $this->eventStoreConnection->executeStatement('DELETE FROM ' . $this->eventTableName); + $this->eventStoreConnection->executeStatement('DELETE FROM sqlite_sequence WHERE name =\'' . $this->eventTableName . '\''); + } else { + $this->eventStoreConnection->executeStatement('TRUNCATE TABLE ' . $this->eventTableName); + } + } + // -------------- EVENTS ---------------------- /** @@ -317,7 +345,7 @@ private static function asserEvents(TableNode $expectedEventsTable, Events $even $actualEvents = []; $index = 0; foreach ($events as $event) { - $actualEvents[] = self::eventToArray(isset($expectedEvents[$index]) ? array_keys($expectedEvents[$index]) : ['Id', 'Type', 'Data', 'Domain Ids'], $event); + $actualEvents[] = self::eventToArray(isset($expectedEvents[$index]) ? array_keys($expectedEvents[$index]) : ['Id', 'Type', 'Data', 'Tags'], $event); $index ++; } Assert::assertEquals($expectedEvents, $actualEvents); @@ -325,7 +353,7 @@ private static function asserEvents(TableNode $expectedEventsTable, Events $even private static function eventToArray(array $keys, Event $event): array { - $supportedKeys = ['Id', 'Type', 'Data', 'Domain Ids']; + $supportedKeys = ['Id', 'Type', 'Data', 'Tags']; $unsupportedKeys = array_diff($keys, $supportedKeys); if ($unsupportedKeys !== []) { throw new InvalidArgumentException(sprintf('Invalid key(s) "%s" for expected event. Allowed keys are: "%s"', implode('", "', $unsupportedKeys), implode('", "', $supportedKeys)), 1686128517); @@ -334,7 +362,7 @@ private static function eventToArray(array $keys, Event $event): array 'Id' => $event->id->value, 'Type' => $event->type->value, 'Data' => json_decode($event->data->value, true, 512, JSON_THROW_ON_ERROR), - 'Domain Ids' => $event->domainIds->toArray(), + 'Tags' => $event->tags->toSimpleArray(), ]; foreach (array_diff($supportedKeys, $keys) as $unusedKey) { unset($actualAsArray[$unusedKey]); @@ -357,7 +385,7 @@ private function handleCommandAndCatchException(Command $command): void private function appendEvents(DomainEvent ...$domainEvents): void { - $this->eventStore->append(Events::fromArray(array_map($this->eventNormalizer->convertDomainEvent(...), $domainEvents))); + $this->eventStore->append(Events::fromArray(array_map($this->eventNormalizer->convertDomainEvent(...), $domainEvents)), AppendCondition::noConstraints()); } diff --git a/tests/Behat/CreateCourse.feature b/tests/Behat/CreateCourse.feature index 1710732..d5fe24f 100644 --- a/tests/Behat/CreateCourse.feature +++ b/tests/Behat/CreateCourse.feature @@ -15,5 +15,5 @@ Feature: Creating courses Then no events should be read And the command should pass without errors And the following event should be appended: - | Type | Data | Domain Ids | - | "CourseCreated" | {"courseId": "c2", "initialCapacity": "10", "courseTitle": "course 02"} | [{"course": "c2"}] | \ No newline at end of file + | Type | Data | Tags | + | "CourseCreated" | {"courseId": "c2", "initialCapacity": "10", "courseTitle": "course 02"} | ["course:c2"] | \ No newline at end of file diff --git a/tests/Behat/RegisterStudent.feature b/tests/Behat/RegisterStudent.feature index 3139f16..46fd0a8 100644 --- a/tests/Behat/RegisterStudent.feature +++ b/tests/Behat/RegisterStudent.feature @@ -14,5 +14,5 @@ Feature: Registering students Then no events should be read And the command should pass without errors And the following event should be appended: - | Type | Data | Domain Ids | - | "StudentRegistered" | {"studentId": "s2"} | [{"student": "s2"}] | \ No newline at end of file + | Type | Data | Tags | + | "StudentRegistered" | {"studentId": "s2"} | ["student:s2"] | \ No newline at end of file diff --git a/tests/Behat/RenameCourse.feature b/tests/Behat/RenameCourse.feature index 7c43215..0a4edd2 100644 --- a/tests/Behat/RenameCourse.feature +++ b/tests/Behat/RenameCourse.feature @@ -22,9 +22,9 @@ Feature: Renaming courses Given course "c1" exists with the title "course 01" When course "c1" is renamed to "course 01 renamed" Then the following events should be read: - | Type | Domain Ids | - | "CourseCreated" | [{"course": "c1"}] | + | Type | Tags | + | "CourseCreated" | ["course:c1"] | And the command should pass without errors And the following event should be appended: - | Type | Data | Domain Ids | - | "CourseRenamed" | {"courseId": "c1", "newCourseTitle": "course 01 renamed"} | [{"course": "c1"}] | \ No newline at end of file + | Type | Data | Tags | + | "CourseRenamed" | {"courseId": "c1", "newCourseTitle": "course 01 renamed"} | ["course:c1"] | \ No newline at end of file diff --git a/tests/Behat/SubscribeStudentToCourse.feature b/tests/Behat/SubscribeStudentToCourse.feature index eb4f9a8..4c2b737 100644 --- a/tests/Behat/SubscribeStudentToCourse.feature +++ b/tests/Behat/SubscribeStudentToCourse.feature @@ -60,19 +60,19 @@ Feature: Subscribing students to courses And student "s1" is subscribed to courses "c1,c2,c3,c4,c5,c6,c7,c8,c9" When student "s1" subscribes to course "c10" Then the following events should be read: - | Type | Domain Ids | - | "CourseCreated" | [{"course": "c10"}] | - | "StudentRegistered" | [{"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c1"}, {"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c2"}, {"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c3"}, {"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c4"}, {"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c5"}, {"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c6"}, {"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c7"}, {"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c8"}, {"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c9"}, {"student": "s1"}] | + | Type | Tags | + | "CourseCreated" | ["course:c10"] | + | "StudentRegistered" | ["student:s1"] | + | "StudentSubscribedToCourse" | ["course:c1", "student:s1"] | + | "StudentSubscribedToCourse" | ["course:c2", "student:s1"] | + | "StudentSubscribedToCourse" | ["course:c3", "student:s1"] | + | "StudentSubscribedToCourse" | ["course:c4", "student:s1"] | + | "StudentSubscribedToCourse" | ["course:c5", "student:s1"] | + | "StudentSubscribedToCourse" | ["course:c6", "student:s1"] | + | "StudentSubscribedToCourse" | ["course:c7", "student:s1"] | + | "StudentSubscribedToCourse" | ["course:c8", "student:s1"] | + | "StudentSubscribedToCourse" | ["course:c9", "student:s1"] | Then the command should pass without errors And the following event should be appended: - | Type | Data | Domain Ids | - | "StudentSubscribedToCourse" | {"courseId": "c10", "studentId": "s1"} | [{"course": "c10"}, {"student": "s1"}] | \ No newline at end of file + | Type | Data | Tags | + | "StudentSubscribedToCourse" | {"courseId": "c10", "studentId": "s1"} | ["course:c10", "student:s1"] | \ No newline at end of file diff --git a/tests/Behat/UnsubscribeStudentFromCourse.feature b/tests/Behat/UnsubscribeStudentFromCourse.feature index 9240178..c6f24b8 100644 --- a/tests/Behat/UnsubscribeStudentFromCourse.feature +++ b/tests/Behat/UnsubscribeStudentFromCourse.feature @@ -37,12 +37,11 @@ Feature: Unsubscribing students from courses And student "s2" is subscribed to course "c1" And student "s2" unsubscribes from course "c1" Then the following events should be read: - | Type | Domain Ids | - | "CourseCreated" | [{"course": "c1"}] | - | "StudentRegistered" | [{"student": "s2"}] | - | "StudentSubscribedToCourse" | [{"course": "c1"}, {"student": "s1"}] | - | "StudentSubscribedToCourse" | [{"course": "c1"}, {"student": "s2"}] | + | Type | Tags | + | "CourseCreated" | ["course:c1"] | + | "StudentRegistered" | ["student:s2"] | + | "StudentSubscribedToCourse" | ["course:c1", "student:s2"] | And the command should pass without errors And the following event should be appended: - | Type | Data | Domain Ids | - | "StudentUnsubscribedFromCourse" | {"courseId": "c1", "studentId": "s2"} | [{"course": "c1"}, {"student": "s2"}] | + | Type | Data | Tags | + | "StudentUnsubscribedFromCourse" | {"courseId": "c1", "studentId": "s2"} | ["course:c1", "student:s2"] | diff --git a/tests/Behat/UpdateCourseCapacity.feature b/tests/Behat/UpdateCourseCapacity.feature index b9f1e6e..271350e 100644 --- a/tests/Behat/UpdateCourseCapacity.feature +++ b/tests/Behat/UpdateCourseCapacity.feature @@ -38,20 +38,20 @@ Feature: Updating the capacity of a course Given course "c1" exists with a capacity of 3 When course "c1" capacity is changed to 4 Then the following events should be read: - | Type | Domain Ids | - | "CourseCreated" | [{"course": "c1"}] | + | Type | Tags | + | "CourseCreated" | ["course:c1"] | And the command should pass without errors And the following event should be appended: - | Type | Data | Domain Ids | - | "CourseCapacityChanged" | {"courseId": "c1", "newCapacity": 4} | [{"course": "c1"}] | + | Type | Data | Tags | + | "CourseCapacityChanged" | {"courseId": "c1", "newCapacity": 4} | ["course:c1"] | Scenario: Changing capacity of a course to a lower value Given course "c1" exists with a capacity of 4 When course "c1" capacity is changed to 3 Then the following events should be read: - | Type | Domain Ids | - | "CourseCreated" | [{"course": "c1"}] | + | Type | Tags | + | "CourseCreated" | ["course:c1"] | And the command should pass without errors And the following event should be appended: - | Type | Data | Domain Ids | - | "CourseCapacityChanged" | {"courseId": "c1", "newCapacity": 3} | [{"course": "c1"}] | \ No newline at end of file + | Type | Data | Tags | + | "CourseCapacityChanged" | {"courseId": "c1", "newCapacity": 3} | ["course:c1"] | \ No newline at end of file