diff --git a/LICENSE b/LICENSE index 333c3fb..d3bab70 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Bastian Waidelich +Copyright (c) 2026 Bastian Waidelich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 5c80089..9132b30 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dynamic Consistency Boundary Example -Simple example for the Dynamic Consistency Boundary pattern [described by Sara Pellegrini](https://sara.event-thinking.io/2023/04/kill-aggregate-chapter-1-I-am-here-to-kill-the-aggregate.html). +Simple example for the [Dynamic Consistency Boundary pattern](https://dcb.events). The purpose of this package is to explore the idea, find potential pitfalls and to spread the word. @@ -12,28 +12,16 @@ Dynamic Consistency Boundary (aka DCB) allow to enforce hard constraints in Even 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 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. +Read all about this interesting approach at https://dcb.events. -### Important Classes / Concepts - -* [Commands](src%2FCommand) are just a concept of this example package. They implement the [Command Marker Interface](src%2FCommand%2FCommand.php) -* The [CommandHandler](src/CommandHandler.php) is the central authority, handling and verifying incoming Command -* It uses in-memory [Projections](src%2FProjection%2FProjection.php) to enforce hard constraints -* For each command handler a [DecisionModel](src%2FDecisionModel%2FDecisionModel.php) instance is built that contains the state of those in-memory projections and the [AppendCondition](https://github.com/bwaidelich/dcb-eventstore/blob/main/Specification.md#AppendCondition) for new events -* The [EventSerializer](src%2FEventSerializer.php) can convert [DomainEvent](src%2FEvent%2DDomainEvent.php) instances to writable events, vice versa -* *Note:* This package contains no Read Model (i.e. classic projections) yet +This package models the example of this presentation (with a few deviations) introducing some higher level concepts like "Decision Models" and "Atomic Projections" (see below) and 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 for persistence. -### 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. -In the meantime I have had the chance to test it in multiple real world scenarios, and it works really well for me and simplifies things (in spite of some minor caveats in the current implementation): +### Important Classes / Concepts -* It becomes trivial to enforce constraints involving multiple entities (like in this example). -* Global uniqueness (aka "the unique username problem") can easily be achieved with DCB -* Consecutive sequences (e.g. invoice number) can be done without reservation patterns and by only reading a single event per constraint check -* When using composition like in this example, phe in-memory projections are surprisingly small because they focus on a single responsibility -* ...and more +* The [App](src/Domain/App.php) is the central authority, handling and verifying incoming Commands (Note: Commands are simple method calls right now, but obviously command classes could be used as well) +* It uses in-memory [DecisionModels](src/Domain/DecisionModel) to enforce hard constraints via in-memory [Projections](src/Domain/Projection) +* The [EventSerializer](src/Infrastructure/EventSerializer.php) can convert [DomainEvent](src/Infrastructure/DomainEvent.php) instances to [Sequenced Events](https://dcb.events/specification/#sequenced-event), vice versa +* *Note:* This package contains no Read Model (i.e. classic persisted projections) yet ## Usage @@ -53,13 +41,13 @@ And you should get ...no output at all. That's because the example script curren Try changing the script to test, that the business rules are actually enforced, for example you could add the line: ```php -$commandHandler->handle(SubscribeStudentToCourse::create(courseId: 'c1', studentId: 's2')); +$app->subscribeStudentToCourse(StudentId::fromString('s2'), CourseId::fromString('c1')); ``` to the end of the file, which should lead to the following exception: ``` -Failed to subscribe student with id "s2" to course with id "c1" because a student with that id does not exist +Constraint "studentIsRegistered" failed ``` Alternatively, you could have a look at the [Behat Tests](tests/Behat): diff --git a/composer.json b/composer.json index 9d30f1e..ea56d57 100644 --- a/composer.json +++ b/composer.json @@ -20,17 +20,17 @@ } ], "require": { - "php": ">=8.4", + "php": ">=8.5", "webmozart/assert": "^1.11", - "wwwision/dcb-eventstore": "^4", - "wwwision/dcb-eventstore-doctrine": "^4" + "wwwision/dcb-eventstore": "^5", + "wwwision/dcb-eventstore-doctrine": "^6", + "wwwision/dcb-tools": "@dev" }, "require-dev": { "roave/security-advisories": "dev-latest", "phpstan/phpstan": "^2", "squizlabs/php_codesniffer": "^4.0.x-dev", - "phpunit/phpunit": "^11", - "behat/behat": "^3" + "phpunit/phpunit": "^12" }, "autoload": { "psr-4": { @@ -46,11 +46,11 @@ "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:behat": "behat", + "test:phpunit": "phpunit", "test": [ "@test:phpstan", "@test:cs", - "@test:behat" + "@test:phpunit" ] } } diff --git a/index.php b/index.php index ca6c6c9..fccbca5 100644 --- a/index.php +++ b/index.php @@ -2,16 +2,30 @@ declare(strict_types=1); use Doctrine\DBAL\DriverManager; +use Wwwision\DCBEventStore\AppendCondition\AppendCondition; +use Wwwision\DCBEventStore\Event\Event; +use Wwwision\DCBEventStore\Event\Events; use Wwwision\DCBEventStore\EventStore; +use Wwwision\DCBEventStore\Query\Query; +use Wwwision\DCBEventStore\ReadOptions; +use Wwwision\DCBEventStore\SequencedEvent\SequencedEvents; use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; -use Wwwision\DCBExample\CommandHandler; -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\App; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentSubscribedToCourse; +use Wwwision\DCBExample\Features\CourseSubscription\Events\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseCapacityChanged; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseDefined; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRenamed; +use Wwwision\DCBExample\Features\DefineCourse\Events\CourseRescheduled; +use Wwwision\DCBExample\Features\RegisterStudent\Events\StudentRegistered; +use Wwwision\DCBExample\Model\Course\Dto\CourseCapacity; +use Wwwision\DCBExample\Model\Course\Dto\CourseId; +use Wwwision\DCBExample\Model\Course\Dto\CourseSchedule; +use Wwwision\DCBExample\Model\Course\Dto\CourseTitle; +use Wwwision\DCBExample\Model\Student\Dto\StudentId; +use Wwwision\DCBTools\DomainEventAppender; +use Wwwision\DCBTools\Serialization\SimpleEventSerializer; +use Wwwision\DCBTools\StateProjector; require __DIR__ . '/vendor/autoload.php'; @@ -27,27 +41,91 @@ /** The second parameter is the table name to store the events in **/ $eventStore = DoctrineEventStore::create($connection, 'dcb_events'); +$eventStoreWithTracer = new class ($eventStore) implements EventStore { + + private bool $active = false; + + private array $messages = []; + + public function __construct(private EventStore $inner) {} + + public function start(string $message): void + { + $this->messages[] = $message; + $this->active = true; + } + + public function end(): string + { + $this->active = false; + $result = implode(PHP_EOL, $this->messages); + $this->messages = []; + return $result; + } + + public function read(Query $query, ?ReadOptions $options = null): SequencedEvents + { + return $this->inner->read($query, $options); + } + + public function append(Event|Events $events, ?AppendCondition $condition = null): void + { + if ($this->active) { + $this->messages[] = json_encode($condition->failIfEventsMatch, JSON_PRETTY_PRINT); + } + $this->inner->append($events, $condition); + } +}; + /** 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 */ -$commandHandler = new CommandHandler($eventStore); +$eventSerializer = new SimpleEventSerializer([ + CourseCapacityChanged::class, + CourseDefined::class, + CourseRenamed::class, + CourseRescheduled::class, + StudentRegistered::class, + StudentSubscribedToCourse::class, + StudentUnsubscribedFromCourse::class, +]); + +$domainEventAppender = new DomainEventAppender($eventStoreWithTracer, $eventSerializer); +$stateProjector = new StateProjector($eventStoreWithTracer, $eventSerializer); + +/** @var {@see App} is the central authority to handle {@see Command}s */ +$app = new App($domainEventAppender, $stateProjector); // Example: -// 1. Create a course (c1) -$commandHandler->handle(CreateCourse::create(courseId: 'c1', initialCapacity: 10, courseTitle: 'Course 02')); +// 1. Define a course (c1) +$app->defineCourse('c1', 10, 'Course 01', CourseSchedule::fromArray(['start' => '2026-08-01 15:30:00', 'end' => '2026-08-01 17:30:00'])); +$app->defineCourse('c2', 8, 'Course 02', CourseSchedule::fromArray(['start' => '2026-08-01 17:00:00', 'end' => '2026-08-01 18:30:00'])); +$app->defineCourse('c3', 7, 'Course 03', CourseSchedule::fromArray(['start' => '2026-08-01 17:30:00', 'end' => '2026-08-01 18:45:00'])); -// 2. rename it, register a student (s1) and subscribe it to the course, change the course capacity, unregister the student -$commandHandler->handle(RenameCourse::create(courseId: 'c1', newCourseTitle: 'Course 01 renamed')); +// 2. rename it +$app->renameCourse('c1', 'Course 01 renamed'); // 3. register a student (s1) in the system -$commandHandler->handle(RegisterStudent::create(studentId: 's1')); +$app->registerStudent('s1'); +$app->registerStudent('s2'); // 4. subscribe student (s1) to course (s1) -$commandHandler->handle(SubscribeStudentToCourse::create(courseId: 'c1', studentId: 's1')); +#$eventStoreWithTracer->start('subscribeStudentToCourse s1 -> c1'); +$app->subscribeStudentToCourse('s1', 'c1'); +#echo $eventStoreWithTracer->end(); +$app->subscribeStudentToCourse('s1', 'c3'); +#$app->subscribeStudentToCourse('s2', 'c2'); + +$app->rescheduleCourse('c3', CourseSchedule::fromArray(['start' => '2026-08-01 14:00:00', 'end' => '2026-08-01 15:30:00'])); +#$app->rescheduleCourse('c3', CourseSchedule::fromArray(['start' => '2026-08-01 17:30:00', 'end' => '2026-08-01 19:45:00'])); // 5. change capacity of course (c1) to 5 -$commandHandler->handle(UpdateCourseCapacity::create(courseId: 'c1', newCapacity: 5)); +$app->changeCourseCapacity('c1', 5); // 6. unsubscribe student (s1) from course (c1) -$commandHandler->handle(UnsubscribeStudentFromCourse::create(courseId: 'c1', studentId: 's1')); \ No newline at end of file +$app->unsubscribeStudentFromCourse('s1', 'c1'); + + +//foreach ($eventStore->read(Query::all()) as $eventEnvelope) { +// echo $eventEnvelope->event->type . ': ' . implode(', ', $eventEnvelope->event->tags->toStrings()). PHP_EOL; +//} \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 81d4af7..478362b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,9 +1,4 @@ parameters: - level: 9 + level: max paths: - - src -services: - - - class: Wwwision\DCBExample\Tests\PHPStan\DecisionModelPhpStanExtension - tags: - - phpstan.broker.dynamicMethodReturnTypeExtension \ No newline at end of file + - src \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index 4d35423..7f470ac 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,11 +7,12 @@ requireCoverageMetadata="true" beStrictAboutCoverageMetadata="true" beStrictAboutOutputDuringTests="true" + beStrictAboutTestsThatDoNotTestAnything="false" failOnRisky="true" failOnWarning="true"> - - tests/unit + + tests/Consistency diff --git a/src/App.php b/src/App.php new file mode 100644 index 0000000..fcfb7ac --- /dev/null +++ b/src/App.php @@ -0,0 +1,76 @@ +domainEventAppender); + $handler(new DefineCourse($courseId, $initialCapacity, $courseTitle, $schedule)); + } + + public function renameCourse(string $courseId, string $newTitle): void + { + $handler = new RenameCourseCommandHandler($this->domainEventAppender); + $handler(new RenameCourse($courseId, $newTitle)); + } + + public function changeCourseCapacity(string $courseId, int $newCapacity): void + { + $handler = new ChangeCourseCapacityCommandHandler($this->domainEventAppender); + $handler(new ChangeCourseCapacity($courseId, $newCapacity)); + } + + public function rescheduleCourse(string $courseId, CourseSchedule $newSchedule): void + { + $handler = new RescheduleCourseCommandHandler($this->domainEventAppender); + $handler(new RescheduleCourse($courseId, $newSchedule)); + } + + public function registerStudent(string $studentId): void + { + $handler = new RegisterStudentCommandHandler($this->domainEventAppender); + $handler(new RegisterStudent($studentId)); + } + + public function subscribeStudentToCourse(string $studentId, string $courseId): void + { + $handler = new SubscribeStudentToCourseCommandHandler($this->domainEventAppender); + $handler(new SubscribeStudentToCourse($studentId, $courseId)); + } + + public function unsubscribeStudentFromCourse(string $studentId, string $courseId): void + { + $handler = new UnsubscribeStudentFromCourseCommandHandler($this->domainEventAppender); + $handler(new UnsubscribeStudentFromCourse($studentId, $courseId)); + } +} diff --git a/src/Command/Command.php b/src/Command/Command.php deleted file mode 100644 index 0e63966..0000000 --- a/src/Command/Command.php +++ /dev/null @@ -1,12 +0,0 @@ -eventSerializer = new EventSerializer(); - } - - public function handle(Command $command): void - { - match ($command::class) { - CreateCourse::class => $this->handleCreateCourse($command), - RenameCourse::class => $this->handleRenameCourse($command), - RegisterStudent::class => $this->handleRegisterStudent($command), - SubscribeStudentToCourse::class => $this->handleSubscribeStudentToCourse($command), - UnsubscribeStudentFromCourse::class => $this->handleUnsubscribeStudentFromCourse($command), - UpdateCourseCapacity::class => $this->handleUpdateCourseCapacity($command), - default => throw new RuntimeException(sprintf('Unsupported command %s', $command::class), 1684579212), - }; - } - - private function handleCreateCourse(CreateCourse $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - ); - if ($decisionModel->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); - } - $domainEvent = new CourseCreated($command->courseId, $command->initialCapacity, $command->courseTitle); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleRenameCourse(RenameCourse $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - courseTitle: self::courseTitle($command->courseId), - ); - if (!$decisionModel->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 ($decisionModel->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); - } - $domainEvent = new CourseRenamed($command->courseId, $command->newCourseTitle); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleRegisterStudent(RegisterStudent $command): void - { - $decisionModel = $this->buildDecisionModel( - studentRegistered: self::studentRegistered($command->studentId), - ); - if ($decisionModel->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); - } - $domainEvent = new StudentRegistered($command->studentId); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleSubscribeStudentToCourse(SubscribeStudentToCourse $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - studentRegistered: self::studentRegistered($command->studentId), - courseCapacity: self::courseCapacity($command->courseId), - numberOfCourseSubscriptions: self::numberOfCourseSubscriptions($command->courseId), - studentSubscriptions: self::studentSubscriptions($command->studentId), - ); - if (!$decisionModel->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 (!$decisionModel->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 ($decisionModel->state->courseCapacity->value === $decisionModel->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, $decisionModel->state->courseCapacity->value), 1684603201); - } - if ($decisionModel->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 ($decisionModel->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); - } - $domainEvent = new StudentSubscribedToCourse($command->courseId, $command->studentId); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleUnsubscribeStudentFromCourse(UnsubscribeStudentFromCourse $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - studentRegistered: self::studentRegistered($command->studentId), - studentSubscriptions: self::studentSubscriptions($command->studentId), - ); - if (!$decisionModel->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 (!$decisionModel->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 (!$decisionModel->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); - } - $domainEvent = new StudentUnsubscribedFromCourse($command->studentId, $command->courseId); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - private function handleUpdateCourseCapacity(UpdateCourseCapacity $command): void - { - $decisionModel = $this->buildDecisionModel( - courseExists: self::courseExists($command->courseId), - courseCapacity: self::courseCapacity($command->courseId), - numberOfCourseSubscriptions: self::numberOfCourseSubscriptions($command->courseId), - ); - if (!$decisionModel->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 ($decisionModel->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 ($decisionModel->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, $decisionModel->state->numberOfCourseSubscriptions), 1684604361); - } - $domainEvent = new CourseCapacityChanged($command->courseId, $command->newCapacity); - $this->eventStore->append($this->eventSerializer->convertDomainEvent($domainEvent), $decisionModel->appendCondition); - } - - // ----------------------------- - - /** - * @return Projection - */ - private static function courseExists(CourseId $courseId): Projection - { - return TaggedProjection::create( - $courseId->toTag(), - ClosureProjection::create( - initialState: false, - onlyLastEvent: true, - ) - ->when(CourseCreated::class, fn() => true) - ); - } - - /** - * @return Projection - */ - private static function courseCapacity(CourseId $courseId): Projection - { - return TaggedProjection::create( - $courseId->toTag(), - ClosureProjection::create( - initialState: CourseCapacity::fromInteger(0), - ) - ->when(CourseCreated::class, fn($_, CourseCreated $event) => $event->initialCapacity) - ->when(CourseCapacityChanged::class, fn($_, CourseCapacityChanged $event) => $event->newCapacity) - ); - } - - /** - * @return Projection - */ - private static function numberOfCourseSubscriptions(CourseId $courseId): Projection - { - return TaggedProjection::create( - $courseId->toTag(), - ClosureProjection::create( - initialState: 0, - ) - ->when(StudentSubscribedToCourse::class, fn(int $state) => $state + 1) - ->when(StudentUnsubscribedFromCourse::class, fn(int $state) => $state - 1) - ); - } - - /** - * @return Projection - */ - private static function courseTitle(CourseId $courseId): Projection - { - return TaggedProjection::create( - $courseId->toTag(), - ClosureProjection::create( - initialState: CourseTitle::fromString(''), - ) - ->when(CourseCreated::class, static fn ($_, CourseCreated $event) => $event->courseTitle) - ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newCourseTitle) - ); - } - - /** - * @return Projection - */ - private static function studentRegistered(StudentId $studentId): Projection - { - return TaggedProjection::create( - $studentId->toTag(), - ClosureProjection::create( - initialState: false, - onlyLastEvent: true, - ) - ->when(StudentRegistered::class, fn() => true) - ); - } - - /** - * @return Projection - */ - private static function studentSubscriptions(StudentId $studentId): Projection - { - return TaggedProjection::create( - $studentId->toTag(), - ClosureProjection::create( - initialState: 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)) - ); - } - - - // ------------------------------------ - - /** - * @phpstan-ignore-next-line - */ - private function buildDecisionModel(Projection ...$projections): DecisionModel - { - $query = StreamQuery::wildcard(); - Assert::isMap($projections); - $compositeProjection = CompositeProjection::create($projections); - $query = $query->withCriteria($compositeProjection->getCriteria()); - $expectedHighestSequenceNumber = ExpectedHighestSequenceNumber::none(); - $state = $compositeProjection->initialState(); - foreach ($this->eventStore->read($query) as $eventEnvelope) { - $domainEvent = $this->eventSerializer->convertEvent($eventEnvelope->event); - $state = $compositeProjection->apply($state, $domainEvent, $eventEnvelope); - $expectedHighestSequenceNumber = ExpectedHighestSequenceNumber::fromSequenceNumber($eventEnvelope->sequenceNumber); - } - return new DecisionModel($state, new AppendCondition($query, $expectedHighestSequenceNumber)); - } -} diff --git a/src/DecisionModel/DecisionModel.php b/src/DecisionModel/DecisionModel.php deleted file mode 100644 index 264ebe8..0000000 --- a/src/DecisionModel/DecisionModel.php +++ /dev/null @@ -1,22 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'newCapacity'); - Assert::numeric($data['newCapacity']); - return new self( - CourseId::fromString($data['courseId']), - CourseCapacity::fromInteger((int)$data['newCapacity']), - ); - } -} diff --git a/src/Event/CourseCreated.php b/src/Event/CourseCreated.php deleted file mode 100644 index 1ba6bb7..0000000 --- a/src/Event/CourseCreated.php +++ /dev/null @@ -1,41 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'initialCapacity'); - Assert::numeric($data['initialCapacity']); - Assert::keyExists($data, 'courseTitle'); - Assert::string($data['courseTitle']); - return new self( - CourseId::fromString($data['courseId']), - CourseCapacity::fromInteger((int)$data['initialCapacity']), - CourseTitle::fromString($data['courseTitle']), - ); - } -} diff --git a/src/Event/CourseEvent.php b/src/Event/CourseEvent.php deleted file mode 100644 index 7893495..0000000 --- a/src/Event/CourseEvent.php +++ /dev/null @@ -1,17 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'newCourseTitle'); - Assert::string($data['newCourseTitle']); - return new self( - CourseId::fromString($data['courseId']), - CourseTitle::fromString($data['newCourseTitle']), - ); - } -} diff --git a/src/Event/DomainEvent.php b/src/Event/DomainEvent.php deleted file mode 100644 index c7e8d73..0000000 --- a/src/Event/DomainEvent.php +++ /dev/null @@ -1,18 +0,0 @@ - $data - */ - public static function fromArray(array $data): self; -} diff --git a/src/Event/DomainEvents.php b/src/Event/DomainEvents.php deleted file mode 100644 index e10d2b6..0000000 --- a/src/Event/DomainEvents.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ -final class DomainEvents implements IteratorAggregate -{ - /** - * @param array $domainEvents - */ - private function __construct(private readonly array $domainEvents) - { - } - - public static function create(DomainEvent ...$domainEvents): self - { - return new self($domainEvents); - } - - public function getIterator(): Traversable - { - return new ArrayIterator($this->domainEvents); - } - - /** - * @template S - * @param Closure(DomainEvent): S $callback - * @return array - */ - public function map(Closure $callback): array - { - return array_map($callback, $this->domainEvents); - } -} diff --git a/src/Event/StudentEvent.php b/src/Event/StudentEvent.php deleted file mode 100644 index bca3fa0..0000000 --- a/src/Event/StudentEvent.php +++ /dev/null @@ -1,17 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'studentId'); - Assert::string($data['studentId']); - return new self( - StudentId::fromString($data['studentId']), - ); - } -} diff --git a/src/Event/StudentSubscribedToCourse.php b/src/Event/StudentSubscribedToCourse.php deleted file mode 100644 index 702abd2..0000000 --- a/src/Event/StudentSubscribedToCourse.php +++ /dev/null @@ -1,38 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'studentId'); - Assert::string($data['studentId']); - return new self( - CourseId::fromString($data['courseId']), - StudentId::fromString($data['studentId']), - ); - } -} diff --git a/src/Event/StudentUnsubscribedFromCourse.php b/src/Event/StudentUnsubscribedFromCourse.php deleted file mode 100644 index 87ae101..0000000 --- a/src/Event/StudentUnsubscribedFromCourse.php +++ /dev/null @@ -1,35 +0,0 @@ - $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'courseId'); - Assert::string($data['courseId']); - Assert::keyExists($data, 'studentId'); - Assert::string($data['studentId']); - return new self(StudentId::fromString($data['studentId']), CourseId::fromString($data['courseId']),); - } -} diff --git a/src/EventSerializer.php b/src/EventSerializer.php deleted file mode 100644 index d14c9b1..0000000 --- a/src/EventSerializer.php +++ /dev/null @@ -1,68 +0,0 @@ -event; - } - try { - $payload = json_decode($event->data->value, true, 512, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new RuntimeException(sprintf('Failed to decode JSON: %s', $e->getMessage()), 1684510536, $e); - } - Assert::isArray($payload); - /** @var class-string $eventClassName */ - $eventClassName = '\\Wwwision\\DCBExample\\Event\\' . $event->type->value; - $domainEvent = $eventClassName::fromArray($payload); - Assert::isInstanceOf($domainEvent, DomainEvent::class); - return $domainEvent; - } - - public function convertDomainEvent(DomainEvent $domainEvent): Event - { - try { - $eventData = json_encode($domainEvent, JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new RuntimeException(sprintf('Failed to JSON encode payload of domain event %s: %s', get_debug_type($domainEvent), $e->getMessage()), 1685965020, $e); - } - $tags = []; - if ($domainEvent instanceof CourseEvent) { - $tags[] = "course:{$domainEvent->courseId->value}"; - } - if ($domainEvent instanceof StudentEvent) { - $tags[] = "student:{$domainEvent->studentId->value}"; - } - return Event::create( - type: substr($domainEvent::class, strrpos($domainEvent::class, '\\') + 1), - data: $eventData, - tags: $tags, - ); - } -} diff --git a/src/Exception/ConstraintException.php b/src/Exception/ConstraintException.php deleted file mode 100644 index 39204c6..0000000 --- a/src/Exception/ConstraintException.php +++ /dev/null @@ -1,14 +0,0 @@ -studentId = $studentId; + $this->courseId = $courseId; + } +} diff --git a/src/Features/CourseSubscription/Commands/UnsubscribeStudentFromCourse.php b/src/Features/CourseSubscription/Commands/UnsubscribeStudentFromCourse.php new file mode 100644 index 0000000..4f5d3ae --- /dev/null +++ b/src/Features/CourseSubscription/Commands/UnsubscribeStudentFromCourse.php @@ -0,0 +1,28 @@ +studentId = $studentId; + $this->courseId = $courseId; + } +} diff --git a/src/Features/CourseSubscription/Events/StudentSubscribedToCourse.php b/src/Features/CourseSubscription/Events/StudentSubscribedToCourse.php new file mode 100644 index 0000000..81f410b --- /dev/null +++ b/src/Features/CourseSubscription/Events/StudentSubscribedToCourse.php @@ -0,0 +1,34 @@ +studentId = $studentId; + $this->courseId = $courseId; + } +} diff --git a/src/Features/CourseSubscription/Events/StudentUnsubscribedFromCourse.php b/src/Features/CourseSubscription/Events/StudentUnsubscribedFromCourse.php new file mode 100644 index 0000000..b0233ea --- /dev/null +++ b/src/Features/CourseSubscription/Events/StudentUnsubscribedFromCourse.php @@ -0,0 +1,34 @@ +studentId = $studentId; + $this->courseId = $courseId; + } +} diff --git a/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php b/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php new file mode 100644 index 0000000..709a7d6 --- /dev/null +++ b/src/Features/CourseSubscription/SubscribeStudentToCourseCommandHandler.php @@ -0,0 +1,40 @@ +eventStore->appendWithState( + stateProjection: StudentProjections::subscriptions($command->studentId), + constraints: fn (CourseIds $studentSubscriptions) => [ + Course::exists($command->courseId), + Student::isRegistered($command->studentId), + Course::hasFreeSeats($command->courseId), + not(Student::isSubscribedToCourse($command->studentId, $command->courseId)), + Student::numberOfSubscriptionsIsBelowLimit($command->studentId), + Course::hasNoScheduleConflicts($command->courseId, $studentSubscriptions), + ], + onSuccess: fn () => new StudentSubscribedToCourse($command->studentId, $command->courseId), + ); + } +} diff --git a/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php b/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php new file mode 100644 index 0000000..ba5642b --- /dev/null +++ b/src/Features/CourseSubscription/UnsubscribeStudentFromCourseCommandHandler.php @@ -0,0 +1,31 @@ +eventStore->append( + constraints: [ + Course::exists($command->courseId), + Student::isRegistered($command->studentId), + Student::isSubscribedToCourse($command->studentId, $command->courseId), + ], + onSuccess: static fn () => new StudentUnsubscribedFromCourse($command->studentId, $command->courseId), + ); + } +} diff --git a/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php b/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php new file mode 100644 index 0000000..18940e4 --- /dev/null +++ b/src/Features/DefineCourse/ChangeCourseCapacityCommandHandler.php @@ -0,0 +1,32 @@ +eventStore->append( + constraints: [ + Course::exists($command->courseId), + not(Course::capacityEquals($command->courseId, $command->newCapacity)), + Course::numberOfSubscriptionsIsBelowCapacity($command->courseId, $command->newCapacity->value), + ], + onSuccess: static fn () => new CourseCapacityChanged($command->courseId, $command->newCapacity), + ); + } +} diff --git a/src/Features/DefineCourse/Commands/ChangeCourseCapacity.php b/src/Features/DefineCourse/Commands/ChangeCourseCapacity.php new file mode 100644 index 0000000..78ae7ba --- /dev/null +++ b/src/Features/DefineCourse/Commands/ChangeCourseCapacity.php @@ -0,0 +1,28 @@ +courseId = $courseId; + $this->newCapacity = $newCapacity; + } +} diff --git a/src/Features/DefineCourse/Commands/DefineCourse.php b/src/Features/DefineCourse/Commands/DefineCourse.php new file mode 100644 index 0000000..12e2a01 --- /dev/null +++ b/src/Features/DefineCourse/Commands/DefineCourse.php @@ -0,0 +1,45 @@ +courseId = $courseId; + $this->initialCapacity = $initialCapacity; + $this->courseTitle = $courseTitle; + $this->schedule = $schedule; + } +} diff --git a/src/Features/DefineCourse/Commands/RenameCourse.php b/src/Features/DefineCourse/Commands/RenameCourse.php new file mode 100644 index 0000000..ffd962d --- /dev/null +++ b/src/Features/DefineCourse/Commands/RenameCourse.php @@ -0,0 +1,28 @@ +courseId = $courseId; + $this->newTitle = $newTitle; + } +} diff --git a/src/Features/DefineCourse/Commands/RescheduleCourse.php b/src/Features/DefineCourse/Commands/RescheduleCourse.php new file mode 100644 index 0000000..a8eb85f --- /dev/null +++ b/src/Features/DefineCourse/Commands/RescheduleCourse.php @@ -0,0 +1,31 @@ +courseId = $courseId; + $this->newSchedule = $newSchedule; + } +} diff --git a/src/Features/DefineCourse/DefineCourseCommandHandler.php b/src/Features/DefineCourse/DefineCourseCommandHandler.php new file mode 100644 index 0000000..30eb4c2 --- /dev/null +++ b/src/Features/DefineCourse/DefineCourseCommandHandler.php @@ -0,0 +1,30 @@ +eventStore->append( + constraints: [ + not(Course::exists($command->courseId)) + ], + onSuccess: static fn () => new CourseDefined($command->courseId, $command->initialCapacity, $command->courseTitle, $command->schedule), + ); + } +} diff --git a/src/Features/DefineCourse/Events/CourseCapacityChanged.php b/src/Features/DefineCourse/Events/CourseCapacityChanged.php new file mode 100644 index 0000000..1d927c6 --- /dev/null +++ b/src/Features/DefineCourse/Events/CourseCapacityChanged.php @@ -0,0 +1,32 @@ +courseId = $courseId; + $this->newCapacity = $newCapacity; + } +} diff --git a/src/Features/DefineCourse/Events/CourseDefined.php b/src/Features/DefineCourse/Events/CourseDefined.php new file mode 100644 index 0000000..244ce33 --- /dev/null +++ b/src/Features/DefineCourse/Events/CourseDefined.php @@ -0,0 +1,49 @@ +courseId = $courseId; + $this->initialCapacity = $initialCapacity; + $this->courseTitle = $courseTitle; + $this->schedule = $schedule; + } +} diff --git a/src/Features/DefineCourse/Events/CourseRenamed.php b/src/Features/DefineCourse/Events/CourseRenamed.php new file mode 100644 index 0000000..bd88cf7 --- /dev/null +++ b/src/Features/DefineCourse/Events/CourseRenamed.php @@ -0,0 +1,32 @@ +courseId = $courseId; + $this->newTitle = $newTitle; + } +} diff --git a/src/Features/DefineCourse/Events/CourseRescheduled.php b/src/Features/DefineCourse/Events/CourseRescheduled.php new file mode 100644 index 0000000..54830b0 --- /dev/null +++ b/src/Features/DefineCourse/Events/CourseRescheduled.php @@ -0,0 +1,35 @@ +courseId = $courseId; + $this->newSchedule = $newSchedule; + } +} diff --git a/src/Features/DefineCourse/RenameCourseCommandHandler.php b/src/Features/DefineCourse/RenameCourseCommandHandler.php new file mode 100644 index 0000000..8632651 --- /dev/null +++ b/src/Features/DefineCourse/RenameCourseCommandHandler.php @@ -0,0 +1,31 @@ +eventStore->append( + constraints: [ + Course::exists($command->courseId), + not(Course::titleEquals($command->courseId, $command->newTitle)), + ], + onSuccess: static fn () => new CourseRenamed($command->courseId, $command->newTitle), + ); + } +} diff --git a/src/Features/DefineCourse/RescheduleCourseCommandHandler.php b/src/Features/DefineCourse/RescheduleCourseCommandHandler.php new file mode 100644 index 0000000..4b581cd --- /dev/null +++ b/src/Features/DefineCourse/RescheduleCourseCommandHandler.php @@ -0,0 +1,39 @@ +eventStore->appendWithState( + stateProjection: ProjectionChain::start( + CourseProjections::coursesWithConflictingSchedule($command->courseId, $command->newSchedule) + )->then( + CourseProjections::subscribedStudents(...) + ), + constraints: fn(StudentIds $affectedStudentIds) => [ + Course::exists($command->courseId), + not(Course::hasMatchingSubscriptions($command->courseId, $affectedStudentIds)), + ], + onSuccess: fn () => new CourseRescheduled($command->courseId, $command->newSchedule), + ); + } +} diff --git a/src/Features/RegisterStudent/Commands/RegisterStudent.php b/src/Features/RegisterStudent/Commands/RegisterStudent.php new file mode 100644 index 0000000..09bc760 --- /dev/null +++ b/src/Features/RegisterStudent/Commands/RegisterStudent.php @@ -0,0 +1,21 @@ +studentId = $studentId; + } +} diff --git a/src/Features/RegisterStudent/Events/StudentRegistered.php b/src/Features/RegisterStudent/Events/StudentRegistered.php new file mode 100644 index 0000000..46327c6 --- /dev/null +++ b/src/Features/RegisterStudent/Events/StudentRegistered.php @@ -0,0 +1,25 @@ +studentId = $studentId; + } +} diff --git a/src/Features/RegisterStudent/RegisterStudentCommandHandler.php b/src/Features/RegisterStudent/RegisterStudentCommandHandler.php new file mode 100644 index 0000000..c03469c --- /dev/null +++ b/src/Features/RegisterStudent/RegisterStudentCommandHandler.php @@ -0,0 +1,30 @@ +eventStore->append( + constraints: [ + not(Student::isRegistered($command->studentId)) + ], + onSuccess: fn () => new StudentRegistered($command->studentId), + ); + } +} diff --git a/src/Model/Course/CourseDecisionModels.php b/src/Model/Course/CourseDecisionModels.php new file mode 100644 index 0000000..e0e7840 --- /dev/null +++ b/src/Model/Course/CourseDecisionModels.php @@ -0,0 +1,105 @@ + $state + ); + } + + public static function hasNoScheduleConflicts(CourseId $referenceCourseId, CourseIds $courseIds): Constraint + { + $projections = [CourseProjections::schedule($referenceCourseId)]; + foreach ($courseIds as $courseId) { + $projections[] = CourseProjections::schedule($courseId); + } + // A plain (array) composite rather than hydrating CourseSchedules directly: schedule() yields null for a course + // that was never defined, and CourseSchedules' constructor does not accept null. Null schedules are skipped here. + $projection = CompositeProjection::create($projections); + return Constraint::create( + name: __FUNCTION__, + projection: $projection, + predicate: static function (array $schedules): bool { + $knownSchedules = array_values(array_filter($schedules, static fn ($schedule) => $schedule instanceof CourseSchedule)); + return !CourseSchedules::create(...$knownSchedules)->hasOverlaps(); + }, + ); + } + + public static function capacityEquals(CourseId $courseId, CourseCapacity $candidate): Constraint + { + return Constraint::create( + name: 'courseCapacityEquals', + projection: CourseProjections::capacity($courseId), + predicate: static fn (CourseCapacity $currentCapacity) => $currentCapacity->equals($candidate) + ); + } + + public static function hasFreeSeats(CourseId $courseId): Constraint + { + /** @var CompositeProjection $projection */ + $projection = CompositeProjection::create([ + 'courseCapacity' => CourseProjections::capacity($courseId), + 'numberOfCourseSubscriptions' => CourseProjections::numberOfSubscriptions($courseId), + ], stdClass::class); + return Constraint::create( + name: 'courseHasCapacity', + projection: $projection, + predicate: static fn (object $state) => $state->courseCapacity->value > $state->numberOfCourseSubscriptions + ); + } + + public static function numberOfSubscriptionsIsBelowCapacity(CourseId $courseId, int $capacity): Constraint + { + return Constraint::create( + name: 'numberOfCourseSubscriptionsIsBelowCapacity', + projection: CourseProjections::numberOfSubscriptions($courseId), + predicate: static fn (int $numberOfCourseSubscriptions) => $numberOfCourseSubscriptions <= $capacity + ); + } + + public static function hasMatchingSubscriptions(CourseId $courseId, StudentIds $studentIds): Constraint + { + return Constraint::create( + name: __FUNCTION__, + projection: CourseProjections::subscribedStudents($courseId), + predicate: static fn (StudentIds $subscribedStudentIds) => $studentIds->intersects($subscribedStudentIds), + ); + } + + public static function titleEquals(CourseId $courseId, CourseTitle $candidate): Constraint + { + return Constraint::create( + name: 'courseTitleEquals', + projection: CourseProjections::title($courseId), + predicate: static fn (CourseTitle $currentTitle) => $currentTitle->equals($candidate) + ); + } +} diff --git a/src/Model/Course/CourseProjections.php b/src/Model/Course/CourseProjections.php new file mode 100644 index 0000000..e97bb35 --- /dev/null +++ b/src/Model/Course/CourseProjections.php @@ -0,0 +1,108 @@ + + */ + public static function idIsUsed(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: false) + ->when(CourseDefined::class, true) + ; + } + + /** + * @return Projection + */ + public static function capacity(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: CourseCapacity::fromInteger(0)) + ->when(CourseDefined::class, fn($_, CourseDefined $event) => $event->initialCapacity) + ->when(CourseCapacityChanged::class, fn($_, CourseCapacityChanged $event) => $event->newCapacity) + ; + } + + /** + * @return Projection + */ + public static function numberOfSubscriptions(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: 0) + ->when(StudentSubscribedToCourse::class, fn(int $state) => $state + 1) + ->when(StudentUnsubscribedFromCourse::class, fn(int $state) => $state - 1) + ; + } + + /** + * @return Projection + */ + public static function title(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: CourseTitle::fromString('')) + ->when(CourseDefined::class, static fn ($_, CourseDefined $event) => $event->courseTitle) + ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newTitle) + ; + } + + /** + * @return Projection + */ + public static function schedule(CourseId $courseId): Projection + { + return AtomicProjection::create($courseId, initialState: null) + ->when(CourseDefined::class, static fn ($_, CourseDefined $event) => $event->schedule) + ->when(CourseRescheduled::class, static fn ($_, CourseRescheduled $event) => $event->newSchedule) + ; + } + + /** + * @return Projection + */ + public static function coursesWithConflictingSchedule(CourseId $courseIdToIgnore, CourseSchedule $schedule): Projection + { + // TODO introduce tags for time ranges to reduce the number of events to look up? + return AtomicProjection::create(Tags::create(), initialState: CourseIds::none()) + ->when(CourseDefined::class, static fn (CourseIds $state, CourseDefined $event) => !$event->courseId->equals($courseIdToIgnore) && $event->schedule->overlaps($schedule) ? $state->with($event->courseId) : $state) + ->when(CourseRescheduled::class, static fn (CourseIds $state, CourseRescheduled $event) => !$event->courseId->equals($courseIdToIgnore) && $event->newSchedule->overlaps($schedule) ? $state->with($event->courseId) : $state) + ; + } + + /** + * @return Projection + */ + public static function subscribedStudents(CourseId|CourseIds $courseIds): Projection + { + if ($courseIds instanceof CourseId) { + $courseIds = CourseIds::create($courseIds); + } + return new SubscribedStudentsProjection($courseIds); + } +} diff --git a/src/Types/CourseCapacity.php b/src/Model/Course/Dto/CourseCapacity.php similarity index 93% rename from src/Types/CourseCapacity.php rename to src/Model/Course/Dto/CourseCapacity.php index 816f460..045c6eb 100644 --- a/src/Types/CourseCapacity.php +++ b/src/Model/Course/Dto/CourseCapacity.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Model\Course\Dto; use JsonSerializable; use Webmozart\Assert\Assert; diff --git a/src/Types/CourseId.php b/src/Model/Course/Dto/CourseId.php similarity index 71% rename from src/Types/CourseId.php rename to src/Model/Course/Dto/CourseId.php index 3849174..947f86c 100644 --- a/src/Types/CourseId.php +++ b/src/Model/Course/Dto/CourseId.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Model\Course\Dto; use JsonSerializable; -use Wwwision\DCBEventStore\Types\Tag; +use Wwwision\DCBEventStore\Event\Tag; +use Wwwision\DCBTools\Event\ProvidesTags; /** * Globally unique identifier of a course (usually represented as a UUID v4) */ -final readonly class CourseId implements JsonSerializable +final readonly class CourseId implements ProvidesTags, JsonSerializable { private function __construct(public string $value) { @@ -31,7 +32,7 @@ public function equals(self $other): bool return $other->value === $this->value; } - public function toTag(): Tag + public function tags(): Tag { return Tag::fromString("course:$this->value"); } diff --git a/src/Types/CourseIds.php b/src/Model/Course/Dto/CourseIds.php similarity index 67% rename from src/Types/CourseIds.php rename to src/Model/Course/Dto/CourseIds.php index f0502ff..6f4903a 100644 --- a/src/Types/CourseIds.php +++ b/src/Model/Course/Dto/CourseIds.php @@ -2,12 +2,16 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Model\Course\Dto; use ArrayIterator; +use Closure; use Countable; use IteratorAggregate; use Traversable; +use Wwwision\DCBEventStore\Event\Tag; +use Wwwision\DCBEventStore\Event\Tags; +use Wwwision\DCBTools\Event\ProvidesTags; use function array_filter; use function array_map; @@ -17,13 +21,13 @@ * * @implements IteratorAggregate */ -final class CourseIds implements IteratorAggregate, Countable +final class CourseIds implements IteratorAggregate, Countable, ProvidesTags { /** * @param CourseId[] $ids */ private function __construct( - public readonly array $ids, + private readonly array $ids, ) { //Assert::notEmpty($this->ids, 'CourseIds must not be empty'); } @@ -78,4 +82,28 @@ public function count(): int { return count($this->ids); } + + public function isEmpty(): bool + { + return $this->ids === []; + } + + /** + * @template T + * @param Closure(CourseId): T $callback + * @return array + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->ids); + } + + public function tags(): Tags + { + $tags = Tags::create(); + foreach ($this->ids as $id) { + $tags = $tags->merge($id->tags()); + } + return $tags; + } } diff --git a/src/Model/Course/Dto/CourseSchedule.php b/src/Model/Course/Dto/CourseSchedule.php new file mode 100644 index 0000000..af6408d --- /dev/null +++ b/src/Model/Course/Dto/CourseSchedule.php @@ -0,0 +1,44 @@ + $schedule + */ + public static function fromArray(array $schedule): self + { + Assert::isMap($schedule); + Assert::keyExists($schedule, 'start'); + Assert::string($schedule['start']); + Assert::keyExists($schedule, 'end'); + Assert::string($schedule['end']); + return new self( + DateAndTime::fromString($schedule['start']), + DateAndTime::fromString($schedule['end']), + ); + } + + public function overlaps(self $other): bool + { + return $this->start < $other->end + && $other->start < $this->end; + } +} diff --git a/src/Model/Course/Dto/CourseSchedules.php b/src/Model/Course/Dto/CourseSchedules.php new file mode 100644 index 0000000..e6e4946 --- /dev/null +++ b/src/Model/Course/Dto/CourseSchedules.php @@ -0,0 +1,65 @@ + + */ +final class CourseSchedules implements IteratorAggregate +{ + /** + * @var array + */ + private array $schedules; + + public function __construct( + CourseSchedule ...$schedules, + ) { + $this->schedules = array_values($schedules); + } + + public static function create(CourseSchedule ...$schedules): self + { + return new self(...$schedules); + } + + public static function none(): self + { + return new self(...[]); + } + + /** + * @param array $schedules + */ + public static function fromArray(array $schedules): self + { + return self::create(...array_map(static fn (array|CourseSchedule $schedule) => is_array($schedule) ? CourseSchedule::fromArray($schedule) : $schedule, $schedules)); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->schedules); + } + + public function hasOverlaps(): bool + { + $sorted = $this->schedules; + usort($sorted, static fn (CourseSchedule $a, CourseSchedule $b) => $a->start->value <=> $b->start->value); + for ($i = 1, $count = count($sorted); $i < $count; $i++) { + if ($sorted[$i]->start->value < $sorted[$i - 1]->end->value) { + return true; + } + } + return false; + } +} diff --git a/src/Types/CourseTitle.php b/src/Model/Course/Dto/CourseTitle.php similarity index 91% rename from src/Types/CourseTitle.php rename to src/Model/Course/Dto/CourseTitle.php index 1d83332..5e8c2de 100644 --- a/src/Types/CourseTitle.php +++ b/src/Model/Course/Dto/CourseTitle.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Model\Course\Dto; use JsonSerializable; diff --git a/src/Model/Course/Dto/DateAndTime.php b/src/Model/Course/Dto/DateAndTime.php new file mode 100644 index 0000000..0af0c30 --- /dev/null +++ b/src/Model/Course/Dto/DateAndTime.php @@ -0,0 +1,40 @@ +setTimezone(new \DateTimeZone('UTC'))->format(self::FORMAT)); + } + + public function toPhpDateTime(): DateTimeImmutable + { + $result = DateTimeImmutable::createFromFormat(self::FORMAT, $this->value, new \DateTimeZone('UTC')); + Assert::isInstanceOf($result, DateTimeImmutable::class); + return $result; + } + + public function jsonSerialize(): string + { + return $this->value; + } +} diff --git a/src/Model/Course/SubscribedStudentsProjection.php b/src/Model/Course/SubscribedStudentsProjection.php new file mode 100644 index 0000000..1852341 --- /dev/null +++ b/src/Model/Course/SubscribedStudentsProjection.php @@ -0,0 +1,69 @@ + + */ +final class SubscribedStudentsProjection implements Projection +{ + private StudentIds $state; + + public function __construct( + private readonly CourseIds $courseIds, + ) { + $this->state = StudentIds::none(); + } + + public function apply(DomainEvent $event, SequencedEvent $envelope): void + { + if ($event instanceof StudentSubscribedToCourse && $this->courseIds->contains($event->courseId)) { + $this->state = $this->state->with($event->studentId); + } elseif ($event instanceof StudentUnsubscribedFromCourse && $this->courseIds->contains($event->courseId)) { + $this->state = $this->state->without($event->studentId); + } + } + + public function state(): StudentIds + { + return $this->state; + } + + public function query(EventSerializer $eventSerializer): Query + { + $eventTypes = [ + $eventSerializer->resolveEventType(StudentSubscribedToCourse::class), + $eventSerializer->resolveEventType(StudentUnsubscribedFromCourse::class), + ]; + $items = []; + foreach ($this->courseIds as $courseId) { + $items[] = QueryItem::create(eventTypes: $eventTypes, tags: [$courseId->tags()]); + } + if ($items === []) { + // No courses to consider: a bounded query that matches no event (rather than scanning all subscriptions). + $items[] = QueryItem::create(eventTypes: $eventTypes, tags: [Tag::fromString('none:none')]); + } + return Query::fromItems(...$items); + } +} diff --git a/src/Types/StudentId.php b/src/Model/Student/Dto/StudentId.php similarity index 58% rename from src/Types/StudentId.php rename to src/Model/Student/Dto/StudentId.php index 31f2e3c..0697eca 100644 --- a/src/Types/StudentId.php +++ b/src/Model/Student/Dto/StudentId.php @@ -2,15 +2,16 @@ declare(strict_types=1); -namespace Wwwision\DCBExample\Types; +namespace Wwwision\DCBExample\Model\Student\Dto; use JsonSerializable; -use Wwwision\DCBEventStore\Types\Tag; +use Wwwision\DCBEventStore\Event\Tag; +use Wwwision\DCBTools\Event\ProvidesTags; /** * Globally unique identifier of a student (usually represented as a UUID v4) */ -final readonly class StudentId implements JsonSerializable +final readonly class StudentId implements ProvidesTags, JsonSerializable { private function __construct(public string $value) { @@ -26,7 +27,12 @@ public function jsonSerialize(): string return $this->value; } - public function toTag(): Tag + public function equals(self $other): bool + { + return $other->value === $this->value; + } + + public function tags(): Tag { return Tag::fromString("student:$this->value"); } diff --git a/src/Model/Student/Dto/StudentIds.php b/src/Model/Student/Dto/StudentIds.php new file mode 100644 index 0000000..5845079 --- /dev/null +++ b/src/Model/Student/Dto/StudentIds.php @@ -0,0 +1,116 @@ + + */ +final class StudentIds implements IteratorAggregate, Countable, ProvidesTags +{ + /** + * @param StudentId[] $ids + */ + private function __construct( + private readonly array $ids, + ) { + //Assert::notEmpty($this->ids, 'StudentIds must not be empty'); + } + + public static function create(StudentId ...$ids): self + { + return new self($ids); + } + + public static function none(): self + { + return new self([]); + } + + public static function fromStrings(string ...$ids): self + { + return new self(array_map(static fn (string $type) => StudentId::fromString($type), $ids)); + } + + public function contains(StudentId $id): bool + { + foreach ($this->ids as $existingId) { + if ($existingId->equals($id)) { + return true; + } + } + return false; + } + + /** + * Returns true if this set intersects with the given set + */ + public function intersects(self $other): bool + { + foreach ($other as $id) { + if ($this->contains($id)) { + return true; + } + } + return false; + } + + public function with(StudentId $studentId): self + { + if ($this->contains($studentId)) { + return $this; + } + return new self([...$this->ids, $studentId]); + } + + public function without(StudentId $studentId): self + { + if (!$this->contains($studentId)) { + return $this; + } + return new self(array_filter($this->ids, static fn (StudentId $id) => !$id->equals($studentId))); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->ids); + } + + public function count(): int + { + return count($this->ids); + } + + /** + * @template T + * @param Closure(StudentId): T $callback + * @return array + */ + public function map(Closure $callback): array + { + return array_map($callback, $this->ids); + } + + public function tags(): Tags + { + $tags = Tags::create(); + foreach ($this->ids as $id) { + $tags = $tags->merge($id->tags()); + } + return $tags; + } +} diff --git a/src/Model/Student/Dto/StudentSubscriptions.php b/src/Model/Student/Dto/StudentSubscriptions.php new file mode 100644 index 0000000..b8e1045 --- /dev/null +++ b/src/Model/Student/Dto/StudentSubscriptions.php @@ -0,0 +1,18 @@ + $state + ); + } + + public static function isSubscribedToCourse(StudentId $studentId, CourseId $courseId): Constraint + { + return Constraint::create( + name: 'studentSubscribedToCourse', + projection: StudentProjections::subscriptions($studentId), + predicate: static fn (CourseIds $courseIds) => $courseIds->contains($courseId), + ); + } + + public static function numberOfSubscriptionsIsBelowLimit(StudentId $studentId): Constraint + { + return Constraint::create( + name: 'numberOfStudentSubscriptionsIsBelowLimit', + projection: StudentProjections::subscriptions($studentId), + predicate: static fn (CourseIds $courseIds) => $courseIds->count() < self::MAX_SUBSCRIPTIONS_PER_STUDENT, + ); + } +} diff --git a/src/Model/Student/StudentProjections.php b/src/Model/Student/StudentProjections.php new file mode 100644 index 0000000..57fd423 --- /dev/null +++ b/src/Model/Student/StudentProjections.php @@ -0,0 +1,44 @@ + + */ + public static function idIsUsed(StudentId $studentId): Projection + { + return AtomicProjection::create($studentId, initialState: false) + ->when(StudentRegistered::class, true) + ; + } + + /** + * @return Projection + */ + public static function subscriptions(StudentId $studentId): Projection + { + return AtomicProjection::create($studentId, initialState: 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)) + ; + } +} diff --git a/src/Projection/ClosureProjection.php b/src/Projection/ClosureProjection.php deleted file mode 100644 index dac8e44..0000000 --- a/src/Projection/ClosureProjection.php +++ /dev/null @@ -1,75 +0,0 @@ - - */ -final class ClosureProjection implements Projection, StreamCriteriaAware -{ - /** - * @param S $initialState - * @param array $handlers - */ - private function __construct( - private readonly mixed $initialState, - public readonly array $handlers, - public readonly bool $onlyLastEvent, - ) { - } - - /** - * @template PS - * @param PS $initialState - * @return self - */ - public static function create(mixed $initialState, bool $onlyLastEvent = false): self - { - return new self($initialState, [], $onlyLastEvent); - } - - /** - * @template E of DomainEvent - * @param class-string $class - * @param callable(S, E): S $cb - * @return self - */ - public function when(string $class, callable $cb): self - { - return new self($this->initialState, [...$this->handlers, $class => $cb], $this->onlyLastEvent); - } - - /** - * @return S - */ - public function initialState(): mixed - { - return $this->initialState; - } - - /** - * @param S $state - * @return S - */ - public function apply(mixed $state, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): mixed - { - if (!array_key_exists($domainEvent::class, $this->handlers)) { - return $state; - } - return $this->handlers[$domainEvent::class]($state, $domainEvent, $eventEnvelope); - } - - public function getCriteria(): Criteria - { - $eventTypes = EventTypes::fromStrings(...array_map(static fn($domainEventClassName) => substr($domainEventClassName, strrpos($domainEventClassName, '\\') + 1), array_keys($this->handlers))); - return Criteria::create(Criteria\EventTypesAndTagsCriterion::create(eventTypes: $eventTypes, onlyLastEvent: $this->onlyLastEvent)); - } -} diff --git a/src/Projection/CompositeProjection.php b/src/Projection/CompositeProjection.php deleted file mode 100644 index 7f8ec20..0000000 --- a/src/Projection/CompositeProjection.php +++ /dev/null @@ -1,72 +0,0 @@ - - */ -final class CompositeProjection implements Projection, StreamCriteriaAware -{ - /** - * @param array> $projections - */ - private function __construct( - private readonly array $projections, - ) { - } - - /** - * @param array $projections - */ - public static function create(array $projections): self // @phpstan-ignore-line TODO fix - { - return new self($projections); - } - - - /** - * @return S - */ - public function initialState(): object - { - $state = new stdClass(); - foreach ($this->projections as $projectionKey => $projection) { - $state->{$projectionKey} = $projection->initialState(); - } - return $state; // @phpstan-ignore-line TODO fix - } - - /** - * @param S $state - * @return S - */ - public function apply(mixed $state, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): object - { - foreach ($this->projections as $projectionKey => $projection) { - if ($projection instanceof StreamCriteriaAware && !$projection->getCriteria()->matchesEvent($eventEnvelope->event)) { - continue; - } - $state->{$projectionKey} = $projection->apply($state->{$projectionKey}, $domainEvent, $eventEnvelope); - } - return $state; - } - - public function getCriteria(): Criteria - { - $criteria = Criteria::create(); - foreach ($this->projections as $projection) { - if ($projection instanceof StreamCriteriaAware) { - $criteria = $criteria->merge($projection->getCriteria()); - } - } - return $criteria; - } -} diff --git a/src/Projection/NullProjection.php b/src/Projection/NullProjection.php deleted file mode 100644 index 700c0b1..0000000 --- a/src/Projection/NullProjection.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -final class NullProjection implements Projection -{ - public function initialState(): mixed - { - return null; - } - - public function apply(mixed $state, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): mixed - { - return null; - } -} diff --git a/src/Projection/Projection.php b/src/Projection/Projection.php deleted file mode 100644 index 57e9495..0000000 --- a/src/Projection/Projection.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -final class TaggedProjection implements Projection, StreamCriteriaAware -{ - /** - * @param Projection $wrapped - */ - private function __construct( - private readonly Tags $tags, - private readonly Projection $wrapped, - ) { - } - - /** - * @template PS - * @param Tags|Tag $tags - * @param Projection $handlers - * @return self - */ - public static function create(Tags|Tag $tags, Projection $handlers): self - { - return new self( - $tags instanceof Tag ? Tags::create($tags) : $tags, - $handlers, - ); - } - - /** - * @return S - */ - public function initialState(): mixed - { - return $this->wrapped->initialState(); - } - - /** - * @param S $state - * @return S - */ - public function apply(mixed $state, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): mixed - { - if (!$eventEnvelope->event->tags->containEvery($this->tags)) { - return $state; - } - return $this->wrapped->apply($state, $domainEvent, $eventEnvelope); - } - - public function getCriteria(): Criteria - { - $criteria = []; - if ($this->wrapped instanceof StreamCriteriaAware) { - foreach ($this->wrapped->getCriteria() as $criterion) { - $criteria[] = EventTypesAndTagsCriterion::create( - eventTypes: $criterion->eventTypes, - tags: $criterion->tags !== null ? $criterion->tags->merge($this->tags) : $this->tags, - ); - } - } else { - $criteria[] = EventTypesAndTagsCriterion::create( - tags: $this->tags, - ); - } - return Criteria::fromArray($criteria); - } -} diff --git a/tests/Behat/Bootstrap/FeatureContext.php b/tests/Behat/Bootstrap/FeatureContext.php deleted file mode 100644 index f25f4cb..0000000 --- a/tests/Behat/Bootstrap/FeatureContext.php +++ /dev/null @@ -1,378 +0,0 @@ -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; - public Events $readEvents; - - public function __construct(private EventStore $inner) { - $this->appendedEvents = Events::none(); - $this->readEvents = Events::none(); - } - - public function setup(): void - { - $this->inner->setup(); - } - - public function read(StreamQuery $query, ReadOptions|null $options = null): EventStream - { - $innerStream = $this->inner->read($query, $options); - $eventEnvelopes = []; - foreach ($innerStream as $eventEnvelope) { - $this->readEvents = $this->readEvents->append($eventEnvelope->event); - $eventEnvelopes[] = $eventEnvelope; - } - return InMemoryEventStream::create(...$eventEnvelopes); - } - - public function append(Events|Event $events, AppendCondition $condition): void - { - $this->inner->append($events, $condition); - if ($events instanceof Event) { - $events = Events::fromArray([$events]); - } - $this->appendedEvents = $events; - } - }; - $this->commandHandler = new CommandHandler($this->eventStore); - $this->eventSerializer = new EventSerializer(); - } - - /** - * @AfterScenario - */ - public function throwConstraintException(): void - { - if ($this->lastConstraintException !== null) { - throw $this->lastConstraintException; - } - } - - /** - * 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 ---------------------- - - /** - * @Given course :courseIds exists with the title :courseTitle and a capacity of :initialCapacity - * @Given course :courseIds exists with a capacity of :initialCapacity - * @Given course :courseIds exists with the title :courseTitle - * @Given course(s) :courseIds exist(s) - */ - public function courseExists(string $courseIds, string|null $courseTitle = null, int|null $initialCapacity = null): void - { - $domainEvents = []; - foreach (explode(',', $courseIds) as $courseId) { - $domainEvents[] = new CourseCreated( - CourseId::fromString($courseId), - CourseCapacity::fromInteger($initialCapacity ?? 10), - courseTitle::fromString($courseTitle ?? ('course ' . $courseId)), - ); - } - $this->appendEvents(...$domainEvents); - } - - /** - * @Given student :studentIds is registered - * @Given students :studentIds are registered - */ - public function studentIsRegistered(string $studentIds): void - { - $domainEvents = []; - foreach (explode(',', $studentIds) as $studentId) { - $domainEvents[] = new StudentRegistered( - StudentId::fromString($studentId), - ); - } - $this->appendEvents(...$domainEvents); - } - - /** - * @Given student :studentId is subscribed to course(s) :courseIds - */ - public function studentIsSubscribedToCourses(string $studentId, string $courseIds): void - { - $domainEvents = []; - foreach (explode(',', $courseIds) as $courseId) { - $domainEvents[] = new StudentSubscribedToCourse( - CourseId::fromString($courseId), - StudentId::fromString($studentId), - ); - } - $this->appendEvents(...$domainEvents); - } - - /** - * @Given student :studentId is unsubscribed from course(s) :courseIds - */ - public function studentIsUnsubscribedFromCourses(string $studentId, string $courseIds): void - { - $domainEvents = []; - foreach (explode(',', $courseIds) as $courseId) { - $domainEvents[] = new StudentUnsubscribedFromCourse(StudentId::fromString($studentId), CourseId::fromString($courseId), - ); - } - $this->appendEvents(...$domainEvents); - } - - // -------------- COMMANDS ---------------------- - - /** - * @When a new course is created with id :courseId, title :courseTitle and capacity of :initialCapacity - * @When a new course is created with id :courseId and capacity of :initialCapacity - * @When a new course is created with id :courseId and title :courseTitle - * @When a new course is created with id :courseId - */ - public function aNewCourseIsCreated(string $courseId, string|null $courseTitle = null, int|null $initialCapacity = null): void - { - $command = CreateCourse::create( - courseId: $courseId, - initialCapacity: $initialCapacity ?? 10, - courseTitle: $courseTitle ?? ('course ' . $courseId), - ); - $this->handleCommandAndCatchException($command); - } - - /** - * @When course :courseId is renamed to :newCourseTitle - */ - public function courseIsRenamed(string $courseId, string $newCourseTitle): void - { - $command = RenameCourse::create( - courseId: $courseId, - newCourseTitle: $newCourseTitle, - ); - $this->handleCommandAndCatchException($command); - } - - /** - * @When course :courseId capacity is changed to :newCapacity - */ - public function courseCapacityIsChanged(string $courseId, int $newCapacity): void - { - $command = UpdateCourseCapacity::create( - courseId: $courseId, - newCapacity: $newCapacity, - ); - $this->handleCommandAndCatchException($command); - } - - /** - * @When a new student is registered with id :studentId - */ - public function aNewCourseIsRegistered(string $studentId): void - { - $command = RegisterStudent::create( - StudentId::fromString($studentId), - ); - $this->handleCommandAndCatchException($command); - } - - /** - * @When student :studentId subscribes to course :courseId - */ - public function studentSubscribesToCourse(string $studentId, string $courseId): void - { - $command = SubscribeStudentToCourse::create( - CourseId::fromString($courseId), - StudentId::fromString($studentId), - ); - $this->handleCommandAndCatchException($command); - } - - /** - * @When student :studentId unsubscribes from course :courseId - */ - public function studentUnsubscribesFromCourse(string $studentId, string $courseId): void - { - $command = UnsubscribeStudentFromCourse::create( - CourseId::fromString($courseId), - StudentId::fromString($studentId), - ); - $this->handleCommandAndCatchException($command); - } - - /** - * @Then the command should be rejected with the following message: - */ - public function theCommandShouldBeRejectedWithTheFollowingMessage(PyStringNode $expectedErrorMessage): void - { - Assert::assertNotNull($this->lastConstraintException, 'Expected an error, but none was thrown'); - Assert::assertSame($expectedErrorMessage->getRaw(), $this->lastConstraintException->getMessage(), 'Error message did not match the expected'); - $this->lastConstraintException = null; - } - - /** - * @Then the command should pass without errors - */ - public function theCommandShouldPassWithoutErrors(): void - { - Assert::assertNull($this->lastConstraintException, 'Expected no error, but one was thrown'); - } - - /** - * @Then no events should be read - */ - public function noEventsShouldBeRead(): void - { - Assert::assertCount(0, $this->eventStore->readEvents); - } - - /** - * @Then the following event(s) should be read: - */ - public function theFollowingEventsShouldBeRead(TableNode $expectedEventsTable): void - { - self::asserEvents($expectedEventsTable, $this->eventStore->readEvents); - } - - - /** - * @Then no events should be appended - */ - public function noEventsShouldBeAppended(): void - { - Assert::assertSame(0, $this->eventStore->appendedEvents->count(), 'Expected no events to be appended'); - } - - /** - * @Then the following event(s) should be appended: - */ - public function theFollowingEventsShouldBeAppended(TableNode $expectedEventsTable): void - { - self::asserEvents($expectedEventsTable, $this->eventStore->appendedEvents); - } - - private static function asserEvents(TableNode $expectedEventsTable, Events $events): void - { - $expectedEvents = array_map(static fn (array $col) => array_map(static fn(string $val) => json_decode($val, true, 512, JSON_THROW_ON_ERROR), $col), $expectedEventsTable->getColumnsHash()); - $actualEvents = []; - $index = 0; - foreach ($events as $event) { - $actualEvents[] = self::eventToArray(isset($expectedEvents[$index]) ? array_keys($expectedEvents[$index]) : ['Id', 'Type', 'Data', 'Tags'], $event); - $index ++; - } - Assert::assertEquals($expectedEvents, $actualEvents); - } - - private static function eventToArray(array $keys, Event $event): array - { - $supportedKeys = ['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); - } - $actualAsArray = [ - 'Type' => $event->type->value, - 'Data' => json_decode($event->data->value, true, 512, JSON_THROW_ON_ERROR), - 'Tags' => $event->tags->toStrings(), - ]; - foreach (array_diff($supportedKeys, $keys) as $unusedKey) { - unset($actualAsArray[$unusedKey]); - } - return $actualAsArray; - } - - // ---------------------------- - - private function handleCommandAndCatchException(Command $command): void - { - $this->eventStore->appendedEvents = Events::none(); - $this->eventStore->readEvents = Events::none(); - try { - $this->commandHandler->handle($command); - } catch (ConstraintException $exception) { - $this->lastConstraintException = $exception; - } - } - - private function appendEvents(DomainEvent ...$domainEvents): void - { - $this->eventStore->append(Events::fromArray(array_map($this->eventSerializer->convertDomainEvent(...), $domainEvents)), AppendCondition::noConstraints()); - } - - -} diff --git a/tests/Behat/CreateCourse.feature b/tests/Behat/CreateCourse.feature deleted file mode 100644 index d5fe24f..0000000 --- a/tests/Behat/CreateCourse.feature +++ /dev/null @@ -1,19 +0,0 @@ -Feature: Creating courses - - Scenario: Creating a new course with an ID that already exists - Given course "c1" exists - When a new course is created with id "c1" - Then the command should be rejected with the following message: - """ - Failed to create course with id "c1" because a course with that id already exists - """ - And no events should be appended - - Scenario: Creating a new course - Given course "c1" exists - When a new course is created with id "c2", title "course 02" and capacity of 10 - Then no events should be read - And the command should pass without errors - And the following event should be appended: - | 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 deleted file mode 100644 index 12eeacc..0000000 --- a/tests/Behat/RegisterStudent.feature +++ /dev/null @@ -1,18 +0,0 @@ -Feature: Registering students - - Scenario: Registering a new student with an ID that already exists - Given student "s1" is registered - When a new student is registered with id "s1" - Then the command should be rejected with the following message: - """ - Failed to register student with id "s1" because a student with that id already exists - """ - - Scenario: Registering a new student - Given student "s1" is registered - When a new student is registered with id "s2" - Then no events should be read -# And the command should pass without errors -# And the following event should be appended: -# | 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 deleted file mode 100644 index 16501a3..0000000 --- a/tests/Behat/RenameCourse.feature +++ /dev/null @@ -1,45 +0,0 @@ -Feature: Renaming courses - - Scenario: Renaming non-existing course - Given course "c1" exists - When course "non-existing" is renamed to "New course Title" - Then the command should be rejected with the following message: - """ - Failed to rename course with id "non-existing" because a course with that id does not exist - """ - And no events should be appended - - Scenario: Renaming non-existing course without actually changing the name - Given course "c1" exists with the title "course 01" - When course "c1" is renamed to "course 01" - Then the command should be rejected with the following message: - """ - Failed to rename course with id "c1" to "course 01" because this is already the title of this course - """ - And no events should be appended - - Scenario: Renaming course - 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 | Tags | - | "CourseCreated" | ["course:c1"] | - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "CourseRenamed" | {"courseId": "c1", "newCourseTitle": "course 01 renamed"} | ["course:c1"] | - - Scenario: Renaming course - Given course "c1" exists with the title "course 01" - When course "c1" is renamed to "course 01 renamed 1" - When course "c1" is renamed to "course 01 renamed 2" - When course "c1" is renamed to "course 01 renamed 3" - Then the following events should be read: - | Type | Tags | - | "CourseCreated" | ["course:c1"] | - | "CourseRenamed" | ["course:c1"] | - | "CourseRenamed" | ["course:c1"] | - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "CourseRenamed" | {"courseId": "c1", "newCourseTitle": "course 01 renamed 3"} | ["course:c1"] | \ No newline at end of file diff --git a/tests/Behat/SubscribeStudentToCourse.feature b/tests/Behat/SubscribeStudentToCourse.feature deleted file mode 100644 index 4c2b737..0000000 --- a/tests/Behat/SubscribeStudentToCourse.feature +++ /dev/null @@ -1,78 +0,0 @@ -Feature: Subscribing students to courses - - Scenario: Subscribing non-existing student to course - Given course "c1" exists - When student "non-existing" subscribes to course "c1" - Then the command should be rejected with the following message: - """ - Failed to subscribe student with id "non-existing" to course with id "c1" because a student with that id does not exist - """ - And no events should be appended - - Scenario: Subscribing student to non-existing course - Given course "c1" exists - And student "s1" is registered - When student "s1" subscribes to course "non-existing" - Then the command should be rejected with the following message: - """ - Failed to subscribe student with id "s1" to course with id "non-existing" because a course with that id does not exist - """ - And no events should be appended - - Scenario: Subscribing student to course that the student is already subscribed to - Given course "c1" exists - And student "s1" is registered - And student "s1" is subscribed to course "c1" - When student "s1" subscribes to course "c1" - Then the command should be rejected with the following message: - """ - Failed to subscribe student with id "s1" to course with id "c1" because that student is already subscribed to this course - """ - And no events should be appended - - Scenario: Subscribing student to course that has already reached its capacity - Given course "c1" exists with a capacity of 3 - And students "s1,s2,s3,s4" are registered - And student "s1" is subscribed to course "c1" - And student "s2" is subscribed to course "c1" - And student "s3" is subscribed to course "c1" - And student "s4" subscribes to course "c1" - Then the command should be rejected with the following message: - """ - Failed to subscribe student with id "s4" to course with id "c1" because the course's capacity of 3 is reached - """ - And no events should be appended - - Scenario: Subscribing student to 11 courses - Given courses "c1,c2,c3,c4,c5,c6,c7,c8,c9,c10,c11" exist - And student "s1" is registered - And student "s1" is subscribed to courses "c1,c2,c3,c4,c5,c6,c7,c8,c9,c10" - When student "s1" subscribes to course "c11" - Then the command should be rejected with the following message: - """ - Failed to subscribe student with id "s1" to course with id "c11" because that student is already subscribed the maximum of 10 courses - """ - And no events should be appended - - Scenario: Subscribing student to 10 courses - Given courses "c1,c2,c3,c4,c5,c6,c7,c8,c9,c10" exist - And student "s1" is registered - 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 | 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 | 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 deleted file mode 100644 index c6f24b8..0000000 --- a/tests/Behat/UnsubscribeStudentFromCourse.feature +++ /dev/null @@ -1,47 +0,0 @@ -Feature: Unsubscribing students from courses - - Scenario: Unsubscribing non-existing student from course - Given course "c1" exists - When student "non-existing" unsubscribes from course "c1" - Then the command should be rejected with the following message: - """ - Failed to unsubscribe student with id "non-existing" from course with id "c1" because a student with that id does not exist - """ - And no events should be appended - - Scenario: Unsubscribing student from non-existing course - Given course "c1" exists - And student "s1" is registered - When student "s1" unsubscribes from course "non-existing" - Then the command should be rejected with the following message: - """ - Failed to unsubscribe student with id "s1" from course with id "non-existing" because a course with that id does not exist - """ - And no events should be appended - - Scenario: Unsubscribing student from course that the student is not subscribed to - Given courses "c1,c2" exists - And student "s1" is registered - And student "s1" is subscribed to course "c1" - When student "s1" unsubscribes from course "c2" - Then the command should be rejected with the following message: - """ - Failed to unsubscribe student with id "s1" from course with id "c2" because that student is not subscribed to this course - """ - And no events should be appended - - Scenario: Unsubscribing student from course - Given courses "c1,c2,c3,c4" exist - And students "s1,s2,s3,s4" are registered - And student "s1" is subscribed to course "c1" - And student "s2" is subscribed to course "c1" - And student "s2" unsubscribes from course "c1" - Then the following events should be read: - | 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 | Tags | - | "StudentUnsubscribedFromCourse" | {"courseId": "c1", "studentId": "s2"} | ["course:c1", "student:s2"] | diff --git a/tests/Behat/UpdateCourseCapacity.feature b/tests/Behat/UpdateCourseCapacity.feature deleted file mode 100644 index 271350e..0000000 --- a/tests/Behat/UpdateCourseCapacity.feature +++ /dev/null @@ -1,57 +0,0 @@ -Feature: Updating the capacity of a course - - Scenario: Changing capacity of a non-existing course - Given course "c1" exists - When course "c2" capacity is changed to 3 - Then the command should be rejected with the following message: - """ - Failed to change capacity of course with id "c2" to 3 because a course with that id does not exist - """ - And no events should be appended - - Scenario: Changing capacity of a course to a value that is not different - Given course "c1" exists with a capacity of 3 - When course "c1" capacity is changed to 3 - Then the command should be rejected with the following message: - """ - Failed to change capacity of course with id "c1" to 3 because that is already the courses capacity - """ - And no events should be appended - - # NOTE: The following behavior actually deviates from https://sara.event-thinking.io/2023/04/kill-aggregate-chapter-6-the-aggregate-could-cause-unecessary-complexity.html - # "the course Capacity, can change at any time to any positive integer different from the current one (even if the number of currently subscribed students is larger than the new value)" - Scenario: Changing capacity of a course to a value that is lower than the currently active subscriptions - Given course "c1" exists with a capacity of 4 - And students "s1,s2,s3,s4" are registered - And student "s1" is subscribed to course "c1" - And student "s2" is subscribed to course "c1" - And student "s3" is subscribed to course "c1" - And student "s4" subscribes to course "c1" - And course "c1" capacity is changed to 3 - Then the command should be rejected with the following message: - """ - Failed to change capacity of course with id "c1" to 3 because it already has 4 active subscriptions - """ - And no events should be appended - - Scenario: Changing capacity of a course to a higher value - 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 | Tags | - | "CourseCreated" | ["course:c1"] | - And the command should pass without errors - And the following event should be appended: - | 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 | Tags | - | "CourseCreated" | ["course:c1"] | - And the command should pass without errors - And the following event should be appended: - | Type | Data | Tags | - | "CourseCapacityChanged" | {"courseId": "c1", "newCapacity": 3} | ["course:c1"] | \ No newline at end of file diff --git a/tests/Consistency/ArchitectureTest.php b/tests/Consistency/ArchitectureTest.php new file mode 100644 index 0000000..59cafc1 --- /dev/null +++ b/tests/Consistency/ArchitectureTest.php @@ -0,0 +1,90 @@ +analyze( + self::HANDLERS, + scenarios: [ + RenameCourseCommandHandler::class => HandlerScenario::create(command: new RenameCourse('c1', 'Another Title')), + ChangeCourseCapacityCommandHandler::class => HandlerScenario::create(command: new ChangeCourseCapacity('c1', 5)), + ], + ); + } + + public function testBoundaryBreadthStaysWithinLimits(): void + { + // Current high-water mark: SubscribeStudentToCourse reads 6 event types across courses and students (via a + // 2-pass projection chain) – the most entangled decision of the model. Tighten if a refactoring shrinks it. + $this->analyze()->assertBoundaryBreadthAtMost(maxEventTypes: 6, maxEntityTypes: 2, maxReadPasses: 3); + } + + public function testAllFeaturesFormASingleBoundedContext(): void + { + // CourseSubscription reads DefineCourse's and RegisterStudent's events, so none of the three features could be + // split off without importing the others' events – the model is (deliberately) one bounded context. + $coupling = $this->analyze()->coupling(); + + self::assertSame([['CourseSubscription', 'DefineCourse', 'RegisterStudent']], $coupling->clusters); + + $seams = array_map(static fn($shared) => $shared->eventType, $coupling->sharedEventTypes); + self::assertContains('CourseDefined', $seams); + self::assertContains('StudentRegistered', $seams); + } + + public function testEveryHandlerConflictsWithAConcurrentDuplicateOfItself(): void + { + $report = $this->analyze(); + + self::assertSame([], $report->withSelfRaceStatus(SelfRaceStatus::Inconclusive)); + $report->assertNoneVulnerable(); + } + + public function testContentionFanInStaysWithinLimits(): void + { + // Current high-water mark: SubscribeStudentToCourse can be conflicted by 5 of the 6 other handlers – the + // contention hotspot of the model (its decision model touches courses, students and subscriptions). + $this->analyze()->contention()->assertFanInAtMost(5); + } +} diff --git a/tests/Consistency/ChangeCourseCapacityConsistencyTest.php b/tests/Consistency/ChangeCourseCapacityConsistencyTest.php new file mode 100644 index 0000000..41a45e7 --- /dev/null +++ b/tests/Consistency/ChangeCourseCapacityConsistencyTest.php @@ -0,0 +1,64 @@ +handler = new ChangeCourseCapacityCommandHandler($this->tester->appender()); + } + + public function testChangingCapacityOfAnExistingCourseIsAccepted(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) // default capacity 10 + ->when(fn() => ($this->handler)($this->tester->build(ChangeCourseCapacity::class, newCapacity: 5))) + ->then($this->tester->build(CourseCapacityChanged::class, newCapacity: 5)) + ->thenBoundaryIsBounded(); + } + + public function testChangingCapacityToTheCurrentValueIsRejected(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) // default capacity 10 + ->when(fn() => ($this->handler)($this->tester->build(ChangeCourseCapacity::class, newCapacity: 10))) + ->thenFails('notCourseCapacityEquals'); + } + + public function testShrinkingCapacityBelowTheNumberOfSubscriptionsIsRejected(): void + { + $this->tester->given( + $this->tester->build(CourseDefined::class), // default capacity 10 + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's1'), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's2'), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's3'), + ) + ->when(fn() => ($this->handler)($this->tester->build(ChangeCourseCapacity::class, newCapacity: 2))) + ->thenFails('numberOfCourseSubscriptionsIsBelowCapacity'); + } + + public function testConcurrentSubscriptionWhileShrinkingCapacityConflicts(): void + { + // Safety: shrinking capacity to exactly the current headcount must be rejected if a seat is concurrently taken. + $this->tester->given( + $this->tester->build(CourseDefined::class, initialCapacity: 3), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's1'), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's2'), + ) + ->race($this->tester->build(StudentSubscribedToCourse::class, studentId: 's3')) // a third seat taken between read and append + ->when(fn() => ($this->handler)($this->tester->build(ChangeCourseCapacity::class, newCapacity: 2))) + ->thenConflicts(); + } +} diff --git a/tests/Consistency/ConsistencyTestCase.php b/tests/Consistency/ConsistencyTestCase.php new file mode 100644 index 0000000..2184942 --- /dev/null +++ b/tests/Consistency/ConsistencyTestCase.php @@ -0,0 +1,23 @@ +tester->given(...)->when(...)->then(...)`; each concrete + * test wires its handler in `setUp()`. + */ +abstract class ConsistencyTestCase extends TestCase +{ + protected ConcurrencyTester $tester; + + protected function setUp(): void + { + $this->tester = ConcurrencyTester::inMemory(CourseTestSupport::serializer(), CourseTestSupport::defaults()); + } +} diff --git a/tests/Consistency/CourseTestSupport.php b/tests/Consistency/CourseTestSupport.php new file mode 100644 index 0000000..dcbabfd --- /dev/null +++ b/tests/Consistency/CourseTestSupport.php @@ -0,0 +1,50 @@ +with(CourseId::class, static fn() => CourseId::fromString('c1')) + ->with(StudentId::class, static fn() => StudentId::fromString('s1')) + ->with(CourseSchedule::class, static fn() => CourseSchedule::fromArray(['start' => '2026-01-01 10:00:00', 'end' => '2026-01-01 11:00:00'])) + ->with(CourseCapacity::class, static fn() => CourseCapacity::fromInteger(10)) + ->with(CourseTitle::class, static fn() => CourseTitle::fromString('Course Title')); + } +} diff --git a/tests/Consistency/DefineCourseConsistencyTest.php b/tests/Consistency/DefineCourseConsistencyTest.php new file mode 100644 index 0000000..0c2fb07 --- /dev/null +++ b/tests/Consistency/DefineCourseConsistencyTest.php @@ -0,0 +1,51 @@ +handler = new DefineCourseCommandHandler($this->tester->appender()); + } + + public function testDefiningANewCourseIsAccepted(): void + { + $this->tester->when(fn() => ($this->handler)($this->tester->build(DefineCourse::class))) + ->then($this->tester->build(CourseDefined::class)) + ->thenBoundaryIsBounded(); + } + + public function testDefiningACourseWithAnExistingIdIsRejected(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) + ->when(fn() => ($this->handler)($this->tester->build(DefineCourse::class))) + ->thenFails('notCourseExists'); + } + + public function testConcurrentDefinitionOfTheSameCourseConflicts(): void + { + $this->tester->race($this->tester->build(CourseDefined::class)) + ->when(fn() => ($this->handler)($this->tester->build(DefineCourse::class))) + ->thenConflicts(); + } + + public function testConcurrentDefinitionOfADifferentCourseIsAccepted(): void + { + // No false contention: the boundary is scoped to course:c1. + $this->tester->race($this->tester->build(CourseDefined::class, courseId: 'c2')) + ->when(fn() => ($this->handler)($this->tester->build(DefineCourse::class))) + ->then($this->tester->build(CourseDefined::class)); + } +} diff --git a/tests/Consistency/HandlerBoundariesTest.php b/tests/Consistency/HandlerBoundariesTest.php new file mode 100644 index 0000000..2e6bdd6 --- /dev/null +++ b/tests/Consistency/HandlerBoundariesTest.php @@ -0,0 +1,61 @@ +assertAllBounded();`. + */ + public function testRescheduleIsTheOnlyHandlerWithAnUnboundedBoundary(): void + { + $report = HandlerBoundaryScanner::create(CourseTestSupport::serializer(), CourseTestSupport::defaults()) + ->scan(self::HANDLERS); + + $unbounded = $report->withStatus(BoundaryStatus::Unbounded); + self::assertCount(1, $unbounded); + self::assertSame(RescheduleCourseCommandHandler::class, $unbounded[0]->handlerClass); + self::assertContains('CourseDefined', $unbounded[0]->unboundedEventTypes); + self::assertContains('CourseRescheduled', $unbounded[0]->unboundedEventTypes); + } + + public function testEveryOtherHandlerIsBounded(): void + { + $report = HandlerBoundaryScanner::create(CourseTestSupport::serializer(), CourseTestSupport::defaults()) + ->scan(self::HANDLERS); + + $bounded = array_map(static fn($finding) => $finding->handlerClass, $report->withStatus(BoundaryStatus::Bounded)); + self::assertCount(6, $bounded); + self::assertNotContains(RescheduleCourseCommandHandler::class, $bounded); + } +} diff --git a/tests/Consistency/RegisterStudentConsistencyTest.php b/tests/Consistency/RegisterStudentConsistencyTest.php new file mode 100644 index 0000000..fdb77ef --- /dev/null +++ b/tests/Consistency/RegisterStudentConsistencyTest.php @@ -0,0 +1,52 @@ +handler = new RegisterStudentCommandHandler($this->tester->appender()); + } + + public function testRegisteringNewStudentIsAccepted(): void + { + $this->tester->when(fn() => ($this->handler)($this->tester->build(RegisterStudent::class))) + ->then($this->tester->build(StudentRegistered::class)) + ->thenBoundaryIsBounded(); + } + + public function testRegisteringAnAlreadyRegisteredStudentIsRejected(): void + { + $this->tester->given($this->tester->build(StudentRegistered::class)) + ->when(fn() => ($this->handler)($this->tester->build(RegisterStudent::class))) + ->thenFails('notStudentIsRegistered'); + } + + public function testConcurrentRegistrationOfTheSameStudentConflicts(): void + { + // Safety: a competing registration of the same student must reject this one. + $this->tester->race($this->tester->build(StudentRegistered::class)) + ->when(fn() => ($this->handler)($this->tester->build(RegisterStudent::class))) + ->thenConflicts(); + } + + public function testConcurrentRegistrationOfADifferentStudentIsAccepted(): void + { + // No false contention: the boundary is scoped to student:s1, so registering s2 concurrently is irrelevant. + $this->tester->race($this->tester->build(StudentRegistered::class, studentId: 's2')) + ->when(fn() => ($this->handler)($this->tester->build(RegisterStudent::class))) + ->then($this->tester->build(StudentRegistered::class)); + } +} diff --git a/tests/Consistency/RenameCourseConsistencyTest.php b/tests/Consistency/RenameCourseConsistencyTest.php new file mode 100644 index 0000000..d5600f0 --- /dev/null +++ b/tests/Consistency/RenameCourseConsistencyTest.php @@ -0,0 +1,53 @@ +handler = new RenameCourseCommandHandler($this->tester->appender()); + } + + public function testRenamingAnExistingCourseIsAccepted(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) + ->when(fn() => ($this->handler)($this->tester->build(RenameCourse::class, newTitle: 'New title'))) + ->then($this->tester->build(CourseRenamed::class, newTitle: 'New title')) + ->thenBoundaryIsBounded(); + } + + public function testRenamingANonExistentCourseIsRejected(): void + { + $this->tester->when(fn() => ($this->handler)($this->tester->build(RenameCourse::class, newTitle: 'New title'))) + ->thenFails('courseExists'); + } + + public function testRenamingToTheCurrentTitleIsRejected(): void + { + $this->tester->given($this->tester->build(CourseDefined::class, courseTitle: 'Same title')) + ->when(fn() => ($this->handler)($this->tester->build(RenameCourse::class, newTitle: 'Same title'))) + ->thenFails('notCourseTitleEquals'); + } + + public function testConcurrentRenameOfTheSameCourseConflicts(): void + { + // Safety: two renames of the same course must not both succeed. + $this->tester->given($this->tester->build(CourseDefined::class)) + ->race($this->tester->build(CourseRenamed::class, newTitle: 'Concurrent title')) + ->when(fn() => ($this->handler)($this->tester->build(RenameCourse::class, newTitle: 'New title'))) + ->thenConflicts(); + } +} diff --git a/tests/Consistency/RescheduleCourseConsistencyTest.php b/tests/Consistency/RescheduleCourseConsistencyTest.php new file mode 100644 index 0000000..0d1c37f --- /dev/null +++ b/tests/Consistency/RescheduleCourseConsistencyTest.php @@ -0,0 +1,98 @@ + '2026-08-01 10:00:00', 'end' => '2026-08-01 12:00:00']; + private const array EARLY = ['start' => '2026-08-01 08:00:00', 'end' => '2026-08-01 09:00:00']; + private const array FAR = ['start' => '2026-08-01 20:00:00', 'end' => '2026-08-01 21:00:00']; + private const array AFTERNOON = ['start' => '2026-08-01 14:00:00', 'end' => '2026-08-01 16:00:00']; + private const array LATE_AFTERNOON = ['start' => '2026-08-01 14:30:00', 'end' => '2026-08-01 16:30:00']; + private const array OVERLAPS_AFTERNOON = ['start' => '2026-08-01 15:00:00', 'end' => '2026-08-01 17:00:00']; + + private RescheduleCourseCommandHandler $handler; + + protected function setUp(): void + { + parent::setUp(); + $this->handler = new RescheduleCourseCommandHandler($this->tester->appender()); + } + + public function testReschedulingACourseWithoutConflictsIsAccepted(): void + { + $this->tester->given($this->tester->build(CourseDefined::class, schedule: self::MORNING)) + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::EARLY))) + ->then($this->tester->build(CourseRescheduled::class, newSchedule: self::EARLY)); + } + + public function testReschedulingIntoASlotThatClashesForASharedStudentIsRejected(): void + { + // s1 attends c1 and c2; moving c1 onto c2's slot would clash for s1 → rejected (single conflicting course). + $this->tester->given( + $this->tester->build(CourseDefined::class, schedule: self::MORNING), + $this->tester->build(CourseDefined::class, courseId: 'c2', schedule: self::AFTERNOON), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), + $this->tester->build(StudentSubscribedToCourse::class, courseId: 'c2'), + ) + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::OVERLAPS_AFTERNOON))) + ->thenFails('notHasMatchingSubscriptions'); + } + + /** + * ⚠️ KNOWN BUG (detected by the harness): the reschedule consistency boundary is UNBOUNDED. + * + * `coursesWithConflictingSchedule` projects over empty tags, so its query — and therefore the AppendCondition — + * matches every `CourseDefined`/`CourseRescheduled` event of every course. A correct bounded fix needs a + * candidate-by-time-bucket-then-verify redesign (schedules are mutable, so naive slot tagging over-rejects). + */ + public function testRescheduleConsistencyBoundaryIsUnbounded(): void + { + $this->tester->given($this->tester->build(CourseDefined::class, schedule: self::MORNING)) + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::EARLY))) + ->then($this->tester->build(CourseRescheduled::class, newSchedule: self::EARLY)) + ->thenBoundaryIsUnbounded(); + } + + /** + * ⚠️ KNOWN BUG (characterization test): because the boundary is unbounded, defining a completely unrelated, + * non-overlapping course concurrently FALSELY conflicts with this reschedule. + * + * Correct behaviour would be `then($this->tester->build(CourseRescheduled::class, newSchedule: self::EARLY))`. + */ + public function testUnrelatedConcurrentCourseDefinitionFalselyConflicts(): void + { + $this->tester->given($this->tester->build(CourseDefined::class, schedule: self::MORNING)) + ->race($this->tester->build(CourseDefined::class, courseId: 'c2', schedule: self::FAR)) // unrelated, non-overlapping + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::EARLY))) + ->thenConflicts(); // BUG: should append CourseRescheduled; the global boundary turns an unrelated write into a conflict + } + + public function testClashWithMultipleConflictingCoursesIsDetected(): void + { + // The new slot overlaps both c2 and c3; s1 attends c1 and c2, so the reschedule must be rejected even though + // there is more than one conflicting course (regression test for the multi-course union of subscribed students). + $this->tester->given( + $this->tester->build(CourseDefined::class, schedule: self::MORNING), + $this->tester->build(CourseDefined::class, courseId: 'c2', schedule: self::AFTERNOON), + $this->tester->build(CourseDefined::class, courseId: 'c3', schedule: self::LATE_AFTERNOON), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), + $this->tester->build(StudentSubscribedToCourse::class, courseId: 'c2'), + ) + ->when(fn() => ($this->handler)($this->tester->build(RescheduleCourse::class, newSchedule: self::OVERLAPS_AFTERNOON))) + ->thenFails('notHasMatchingSubscriptions'); + } +} diff --git a/tests/Consistency/SubscribeStudentToCourseConsistencyTest.php b/tests/Consistency/SubscribeStudentToCourseConsistencyTest.php new file mode 100644 index 0000000..80a6557 --- /dev/null +++ b/tests/Consistency/SubscribeStudentToCourseConsistencyTest.php @@ -0,0 +1,99 @@ + '2026-08-01 10:00:00', 'end' => '2026-08-01 12:00:00']; // c1 + private const array AFTERNOON = ['start' => '2026-08-01 14:00:00', 'end' => '2026-08-01 16:00:00']; // c2 (no overlap with morning) + private const array MIDDAY = ['start' => '2026-08-01 11:00:00', 'end' => '2026-08-01 13:00:00']; // overlaps morning + + private SubscribeStudentToCourseCommandHandler $handler; + + protected function setUp(): void + { + parent::setUp(); + $this->handler = new SubscribeStudentToCourseCommandHandler($this->tester->appender()); + } + + public function testSubscribingARegisteredStudentToAnExistingCourseIsAccepted(): void + { + $this->tester->given($this->tester->build(CourseDefined::class), $this->tester->build(StudentRegistered::class)) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->then($this->tester->build(StudentSubscribedToCourse::class)) + ->thenBoundaryIsBounded(); + } + + public function testSubscribingToANonExistentCourseIsRejected(): void + { + $this->tester->given($this->tester->build(StudentRegistered::class)) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenFails('courseExists'); + } + + public function testSubscribingAnUnregisteredStudentIsRejected(): void + { + $this->tester->given($this->tester->build(CourseDefined::class)) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenFails('studentIsRegistered'); + } + + public function testSubscribingToAFullCourseIsRejected(): void + { + $this->tester->given( + $this->tester->build(CourseDefined::class, initialCapacity: 1), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentRegistered::class, studentId: 's2'), + $this->tester->build(StudentSubscribedToCourse::class, studentId: 's2'), // the only seat is taken + ) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenFails('courseHasCapacity'); + } + + public function testConcurrentSubscriptionFillingTheLastSeatConflicts(): void + { + // Safety: capacity must not be exceeded when the last seat is taken concurrently. + $this->tester->given( + $this->tester->build(CourseDefined::class, initialCapacity: 1), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentRegistered::class, studentId: 's2'), + ) + ->race($this->tester->build(StudentSubscribedToCourse::class, studentId: 's2')) // fills the only seat between read and append + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenConflicts(); + } + + public function testConcurrentDuplicateSubscriptionConflicts(): void + { + // Safety: the same student must not be subscribed to the same course twice concurrently. + $this->tester->given($this->tester->build(CourseDefined::class), $this->tester->build(StudentRegistered::class)) + ->race($this->tester->build(StudentSubscribedToCourse::class)) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class))) + ->thenConflicts(); + } + + public function testScheduleConflictIntroducedByARescheduleIsDetected(): void + { + // c2 is rescheduled to overlap c1 (which s1 attends), so subscribing s1 to c2 must be rejected. + $this->tester->given( + $this->tester->build(CourseDefined::class, schedule: self::MORNING), + $this->tester->build(CourseDefined::class, courseId: 'c2', schedule: self::AFTERNOON), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), // s1 -> c1 + $this->tester->build(CourseRescheduled::class, courseId: 'c2', newSchedule: self::MIDDAY), // c2 now overlaps c1 + ) + ->when(fn() => ($this->handler)($this->tester->build(SubscribeStudentToCourse::class, courseId: 'c2'))) + ->thenFails('hasNoScheduleConflicts'); + } +} diff --git a/tests/Consistency/UnsubscribeStudentFromCourseConsistencyTest.php b/tests/Consistency/UnsubscribeStudentFromCourseConsistencyTest.php new file mode 100644 index 0000000..449b1ee --- /dev/null +++ b/tests/Consistency/UnsubscribeStudentFromCourseConsistencyTest.php @@ -0,0 +1,60 @@ +handler = new UnsubscribeStudentFromCourseCommandHandler($this->tester->appender()); + } + + public function testUnsubscribingASubscribedStudentIsAccepted(): void + { + $this->tester->given( + $this->tester->build(CourseDefined::class), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), + ) + ->when(fn() => ($this->handler)($this->tester->build(UnsubscribeStudentFromCourse::class))) + ->then($this->tester->build(StudentUnsubscribedFromCourse::class)) + ->thenBoundaryIsBounded(); + } + + public function testUnsubscribingAStudentThatIsNotSubscribedIsRejected(): void + { + $this->tester->given( + $this->tester->build(CourseDefined::class), + $this->tester->build(StudentRegistered::class), + ) + ->when(fn() => ($this->handler)($this->tester->build(UnsubscribeStudentFromCourse::class))) + ->thenFails('studentSubscribedToCourse'); + } + + public function testConcurrentUnsubscribeOfTheSameSubscriptionConflicts(): void + { + // Safety: the same subscription must not be removed twice concurrently. + $this->tester->given( + $this->tester->build(CourseDefined::class), + $this->tester->build(StudentRegistered::class), + $this->tester->build(StudentSubscribedToCourse::class), + ) + ->race($this->tester->build(StudentUnsubscribedFromCourse::class)) + ->when(fn() => ($this->handler)($this->tester->build(UnsubscribeStudentFromCourse::class))) + ->thenConflicts(); + } +} diff --git a/tests/PHPStan/DecisionModelPhpStanExtension.php b/tests/PHPStan/DecisionModelPhpStanExtension.php deleted file mode 100644 index 3487e86..0000000 --- a/tests/PHPStan/DecisionModelPhpStanExtension.php +++ /dev/null @@ -1,43 +0,0 @@ -getName() === 'buildDecisionModel'; - } - - public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type - { - $args = $methodCall->getArgs(); - $properties = []; - foreach ($args as $argExpression) { - $nameOfParam = $argExpression->getAttributes()['originalArg']->name->name; - /** @var GenericObjectType $projectionObjectType */ - $projectionObjectType = $scope->getType($argExpression->value); - $properties[$nameOfParam] = $projectionObjectType->getTemplateType(Projection::class, 'S'); - } - return new GenericObjectType(DecisionModel::class, [new ObjectShapeType($properties, [])]); - } -} \ No newline at end of file