diff --git a/README.md b/README.md index 557460e..3cee2d2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ 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(new SubscribeStudentToCourse(CourseId::fromString('c1'), StudentId::fromString('s2'))); +$app->handle(new SubscribeStudentToCourse(CourseId::fromString('c1'), StudentId::fromString('s2'))); ``` to the end of the file, which should lead to the following exception: diff --git a/composer.json b/composer.json index 9f010ed..a855a9a 100644 --- a/composer.json +++ b/composer.json @@ -33,9 +33,11 @@ "ext-ctype": "*", "ramsey/uuid": "^4.7", "webmozart/assert": "^1.11", - "wwwision/dcb-eventstore": "@dev", - "wwwision/dcb-eventstore-doctrine": "@dev", - "wwwision/dcb-library": "@dev" + "wwwision/dcb-eventstore": "dev-main as 3.0.0", + "wwwision/types": "^1.1", + "wwwision/dcb-eventstore-doctrine": "dev-main as 3.0.0", + "wwwision/dcb-library": "@dev", + "wwwision/dcb-library-doctrine": "@dev" }, "require-dev": { "roave/security-advisories": "dev-latest", @@ -43,7 +45,6 @@ "squizlabs/php_codesniffer": "^4.0.x-dev", "phpunit/phpunit": "^10.2", "behat/behat": "^3.13", - "wwwision/dcb-library-phpstanextension": "@dev", "phpstan/extension-installer": "^1.3" }, "replace": { diff --git a/index.php b/index.php index 451c0d1..a3f218a 100644 --- a/index.php +++ b/index.php @@ -2,8 +2,6 @@ declare(strict_types=1); use Doctrine\DBAL\DriverManager; -use Wwwision\DCBEventStore\EventStore; -use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; use Wwwision\DCBExample\App; use Wwwision\DCBExample\Commands\Command; use Wwwision\DCBExample\Commands\CreateCourse; @@ -16,36 +14,33 @@ use Wwwision\DCBExample\Types\CourseId; use Wwwision\DCBExample\Types\CourseTitle; use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBLibraryDoctrine\DbalCheckpointStorage; require __DIR__ . '/vendor/autoload.php'; -/** We use an in-memory SQLite database for the events (@see https://www.doctrine-project.org/projects/doctrine-dbal/en/2.4/reference/configuration.html for how to configure other database backends) **/ -$connection = DriverManager::getConnection(['url' => 'sqlite:///:memory:']); - -/** The second parameter is the table name to store the events in **/ -$eventStore = DoctrineEventStore::create($connection, 'dcb_events'); - -/** The {@see EventStore::setup()} method is used to make sure that the Events Store backend is set up (i.e. required tables are created and their schema up-to-date) **/ -$eventStore->setup(); +$dsn = $argv[1] ?? 'sqlite:///:memory:'; +$connection = DriverManager::getConnection(['url' => $dsn]); /** @var {@see App} is the central authority to handle {@see Command}s */ -$commandHandler = new App($eventStore); +$app = new App($connection); // Example: // 1. Create a course (c1) -$commandHandler->handle(new CreateCourse(CourseId::fromString('c1'), CourseCapacity::fromInteger(10), CourseTitle::fromString('Course 02'))); +$app->handle(new CreateCourse(CourseId::fromString('c1'), CourseCapacity::fromInteger(10), CourseTitle::fromString('Course 01'))); +$app->handle(new CreateCourse(CourseId::fromString('c2'), CourseCapacity::fromInteger(15), CourseTitle::fromString('Course 02'))); // 2. rename it, register a student (s1) and subscribe it to the course, change the course capacity, unregister the student -$commandHandler->handle(new RenameCourse(CourseId::fromString('c1'), CourseTitle::fromString('Course 01 renamed again'))); +$app->handle(new RenameCourse(CourseId::fromString('c1'), CourseTitle::fromString('Course 01 renamed again'))); // 3. register a student (s1) in the system -$commandHandler->handle(new RegisterStudent(StudentId::fromString('s1'))); +$app->handle(new RegisterStudent(StudentId::fromString('s1'))); // 4. subscribe student (s1) to course (s1) -$commandHandler->handle(new SubscribeStudentToCourse(CourseId::fromString('c1'), StudentId::fromString('s1'))); +$app->handle(new SubscribeStudentToCourse(CourseId::fromString('c1'), StudentId::fromString('s1'))); +$app->handle(new SubscribeStudentToCourse(CourseId::fromString('c2'), StudentId::fromString('s1'))); // 5. change capacity of course (c1) to 5 -$commandHandler->handle(new UpdateCourseCapacity(CourseId::fromString('c1'), CourseCapacity::fromInteger(5))); +$app->handle(new UpdateCourseCapacity(CourseId::fromString('c1'), CourseCapacity::fromInteger(5))); // 6. unsubscribe student (s1) from course (c1) -$commandHandler->handle(new UnsubscribeStudentFromCourse(CourseId::fromString('c1'), StudentId::fromString('s1'))); +$app->handle(new UnsubscribeStudentFromCourse(CourseId::fromString('c1'), StudentId::fromString('s1'))); diff --git a/src/Adapters/DbalCourseProjectionAdapter.php b/src/Adapters/DbalCourseProjectionAdapter.php new file mode 100644 index 0000000..ce06432 --- /dev/null +++ b/src/Adapters/DbalCourseProjectionAdapter.php @@ -0,0 +1,123 @@ + $value) { + $assignments[$columnName] = $this->connection->quoteIdentifier($columnName) . ' = :' . $columnName; + $parameters[$columnName] = $value; + } + $sql = 'INSERT INTO ' . self::TABLE_NAME . ' SET ' . (implode(', ', $assignments)) . ' ON DUPLICATE KEY UPDATE ' . (implode(', ', $assignments)); + $this->connection->executeStatement($sql, $parameters); + } + + public function courses(): Courses + { + $rows = $this->connection->fetchAllAssociative('SELECT * FROM ' . self::TABLE_NAME); + $instances = array_map(self::courseFromDatabaseRow(...), $rows); + return instantiate(Courses::class, $instances); + } + + public function courseById(CourseId $courseId): ?Course + { + $row = $this->connection->fetchAssociative('SELECT * FROM ' . self::TABLE_NAME . ' WHERE id = :courseId', ['courseId' => $courseId->value]); + if ($row === false) { + return null; + } + return self::courseFromDatabaseRow($row); + } + + // -------- HELPERS, INFRASTRUCTURE ---------- + + public function setup(): void + { + $schemaManager = $this->connection->createSchemaManager(); + $schemaDiff = (new Comparator())->compareSchemas($schemaManager->introspectSchema(), self::databaseSchema()); + foreach ($schemaDiff->toSaveSql($this->connection->getDatabasePlatform()) as $statement) { + $this->connection->executeStatement($statement); + } + } + + public function reset(): void + { + $this->connection->executeStatement('TRUNCATE TABLE ' . self::TABLE_NAME); + } + + private static function databaseSchema(): Schema + { + $schema = new Schema(); + $table = $schema->createTable(self::TABLE_NAME); + + $table->addColumn('id', Types::STRING, ['length' => self::maxLength(CourseId::class)]); + $table->addColumn('title', Types::STRING, ['length' => self::maxLength(CourseTitle::class), 'notnull' => false]); + $table->addColumn('state', Types::JSON); + $table->setPrimaryKey(['id']); + + return $schema; + } + + /** + * @param class-string $className + */ + private static function maxLength(string $className): int + { + $schema = Parser::getSchema($className); + Assert::isInstanceOf($schema, StringSchema::class, sprintf('Failed to determine max length for class %s: Expected an instance of %%2$s. Got: %%s', $className)); + Assert::notNull($schema->maxLength, sprintf('Failed to determine max length for class %s: No maxLength constraint defined', $className)); + return $schema->maxLength; + } + + /** + * @param array $row + */ + private static function courseFromDatabaseRow(array $row): Course + { + return instantiate(Course::class, [ + 'id' => $row['id'], + 'title' => $row['title'], + 'state' => json_decode($row['state'], true, 512, JSON_THROW_ON_ERROR), + ]); + } + + /** + * @return array + */ + private static function courseToDatabaseRow(Course $course): array + { + return [ + 'id' => $course->id->value, + 'title' => $course->title->value, + 'state' => json_encode($course->state, JSON_THROW_ON_ERROR), + ]; + } +} diff --git a/src/App.php b/src/App.php index fec69bf..ccff7d5 100644 --- a/src/App.php +++ b/src/App.php @@ -4,9 +4,12 @@ namespace Wwwision\DCBExample; +use Doctrine\DBAL\Connection; use RuntimeException; use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStore\Types\Tags; +use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; +use Wwwision\DCBExample\Adapters\DbalCourseProjectionAdapter; use Wwwision\DCBExample\Commands\Command; use Wwwision\DCBExample\Commands\CreateCourse; use Wwwision\DCBExample\Commands\RegisterStudent; @@ -20,6 +23,7 @@ use Wwwision\DCBExample\Events\StudentRegistered; use Wwwision\DCBExample\Events\StudentSubscribedToCourse; use Wwwision\DCBExample\Events\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\ReadModel\Course\CourseProjection; use Wwwision\DCBExample\Types\CourseId; use Wwwision\DCBExample\Types\CourseIds; use Wwwision\DCBExample\Types\CourseState; @@ -28,11 +32,13 @@ use Wwwision\DCBExample\Types\StudentId; use Wwwision\DCBLibrary\Adapters\SynchronousCatchUpQueue; use Wwwision\DCBLibrary\EventHandling\EventHandlers; +use Wwwision\DCBLibrary\EventHandling\ProjectionEventHandler; use Wwwision\DCBLibrary\EventPublisher; use Wwwision\DCBLibrary\Exceptions\ConstraintException; use Wwwision\DCBLibrary\Projection\CompositeProjection; use Wwwision\DCBLibrary\Projection\InMemoryProjection; use Wwwision\DCBLibrary\Projection\Projection; +use Wwwision\DCBLibraryDoctrine\DbalCheckpointStorage; use function sprintf; /** @@ -42,9 +48,31 @@ { private EventPublisher $eventPublisher; - public function __construct(EventStore $eventStore) + public function __construct(Connection $connection) { - $this->eventPublisher = new EventPublisher($eventStore, new EventSerializer(), new SynchronousCatchUpQueue($eventStore, EventHandlers::create())); + + /** The second parameter is the table name to store the events in **/ + $eventStore = DoctrineEventStore::create($connection, 'dcb_events'); + + /** The {@see EventStore::setup()} method is used to make sure that the Events Store backend is set up (i.e. required tables are created and their schema up-to-date) **/ + $eventStore->setup(); + $connection->executeStatement('TRUNCATE TABLE dcb_events'); + + $eventSerializer = new EventSerializer(); + + $courseProjection = new CourseProjection(new DbalCourseProjectionAdapter($connection)); + $courseProjection->setup(); + $courseProjection->reset(); + + $courseProjectionEventHandler = new ProjectionEventHandler($courseProjection, new DbalCheckpointStorage($connection, 'dcb_checkpoints', $courseProjection::class), $eventSerializer); + $courseProjectionEventHandler->setup(); + $courseProjectionEventHandler->reset(); + + $eventHandlers = EventHandlers::create() + ->with('CourseProjection', $courseProjectionEventHandler); + + + $this->eventPublisher = new EventPublisher($eventStore, $eventSerializer, new SynchronousCatchUpQueue($eventStore, $eventHandlers)); } public function handle(Command $command): void diff --git a/src/CourseStateProjection.php b/src/CourseStateProjection.php index 15d3099..fd6f230 100644 --- a/src/CourseStateProjection.php +++ b/src/CourseStateProjection.php @@ -1,4 +1,4 @@ -withValue(CourseStateValue::CREATED)->withCapacity($event->initialCapacity); + return $state->withValue(CourseStateValue::CREATED)->withCapacity($domainEvent->initialCapacity); } - private function whenCourseCapacityChanged(CourseState $state, CourseCapacityChanged $event): CourseState + private function whenCourseCapacityChanged(CourseState $state, CourseCapacityChanged $domainEvent): CourseState { - return $state->withCapacity($event->newCapacity); + return $state->withCapacity($domainEvent->newCapacity); } - private function whenStudentSubscribedToCourse(CourseState $state, StudentSubscribedToCourse $event): CourseState + private function whenStudentSubscribedToCourse(CourseState $state, StudentSubscribedToCourse $domainEvent): CourseState { $state = $state->withNumberOfSubscriptions($state->numberOfSubscriptions + 1); if ($state->numberOfSubscriptions === $state->capacity->value) { @@ -71,7 +71,7 @@ private function whenStudentSubscribedToCourse(CourseState $state, StudentSubscr return $state; } - private function whenStudentUnsubscribedFromCourse(CourseState $state, StudentUnsubscribedFromCourse $event): CourseState + private function whenStudentUnsubscribedFromCourse(CourseState $state, StudentUnsubscribedFromCourse $domainEvent): CourseState { $state = $state->withNumberOfSubscriptions($state->numberOfSubscriptions + 1); if ($state->numberOfSubscriptions < $state->capacity->value && $state->value === CourseStateValue::FULLY_BOOKED) { diff --git a/src/EventSerializer.php b/src/EventSerializer.php index 9222f6b..6980834 100644 --- a/src/EventSerializer.php +++ b/src/EventSerializer.php @@ -9,8 +9,7 @@ use Webmozart\Assert\Assert; use Wwwision\DCBEventStore\Types\Event; use Wwwision\DCBEventStore\Types\EventData; -use Wwwision\DCBExample\Events\DomainEvent; -use Wwwision\DCBLibrary\DomainEvent as BaseDomainEvent; +use Wwwision\DCBLibrary\DomainEvent; use Wwwision\DCBEventStore\Types\EventId; use Wwwision\DCBEventStore\Types\EventMetadata; use Wwwision\DCBEventStore\Types\EventType; @@ -22,6 +21,7 @@ use function strrpos; use function substr; +use function Wwwision\Types\instantiate; use const JSON_THROW_ON_ERROR; /** @@ -39,12 +39,12 @@ public function convertEvent(Event $event): DomainEvent Assert::isArray($payload); /** @var class-string $eventClassName */ $eventClassName = '\\Wwwision\\DCBExample\\Events\\' . $event->type->value; - $domainEvent = $eventClassName::fromArray($payload); + $domainEvent = instantiate($eventClassName, $payload); Assert::isInstanceOf($domainEvent, DomainEvent::class); return $domainEvent; } - public function convertDomainEvent(BaseDomainEvent $domainEvent): Event + public function convertDomainEvent(DomainEvent $domainEvent): Event { try { $eventData = json_encode($domainEvent, JSON_THROW_ON_ERROR); diff --git a/src/Events/CourseCapacityChanged.php b/src/Events/CourseCapacityChanged.php index 312c363..46f697a 100644 --- a/src/Events/CourseCapacityChanged.php +++ b/src/Events/CourseCapacityChanged.php @@ -4,10 +4,10 @@ namespace Wwwision\DCBExample\Events; -use Webmozart\Assert\Assert; use Wwwision\DCBEventStore\Types\Tags; use Wwwision\DCBExample\Types\CourseCapacity; use Wwwision\DCBExample\Types\CourseId; +use Wwwision\DCBLibrary\DomainEvent; /** * Domain Events that occurs when the total capacity of a course has changed @@ -20,21 +20,6 @@ public function __construct( ) { } - /** - * @param array $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']), - ); - } - public function tags(): Tags { return Tags::create($this->courseId->toTag()); diff --git a/src/Events/CourseCreated.php b/src/Events/CourseCreated.php index 6a6219c..1f36bed 100644 --- a/src/Events/CourseCreated.php +++ b/src/Events/CourseCreated.php @@ -4,11 +4,11 @@ namespace Wwwision\DCBExample\Events; -use Webmozart\Assert\Assert; use Wwwision\DCBEventStore\Types\Tags; use Wwwision\DCBExample\Types\CourseCapacity; use Wwwision\DCBExample\Types\CourseId; use Wwwision\DCBExample\Types\CourseTitle; +use Wwwision\DCBLibrary\DomainEvent; /** * Domain Events that occurs when a new course was created @@ -22,24 +22,6 @@ public function __construct( ) { } - /** - * @param array $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']), - ); - } - public function tags(): Tags { return Tags::create($this->courseId->toTag()); diff --git a/src/Events/CourseRenamed.php b/src/Events/CourseRenamed.php index e81196e..bed7077 100644 --- a/src/Events/CourseRenamed.php +++ b/src/Events/CourseRenamed.php @@ -4,10 +4,10 @@ namespace Wwwision\DCBExample\Events; -use Webmozart\Assert\Assert; use Wwwision\DCBEventStore\Types\Tags; use Wwwision\DCBExample\Types\CourseId; use Wwwision\DCBExample\Types\CourseTitle; +use Wwwision\DCBLibrary\DomainEvent; /** * Domain Events that occurs when the title of a course has changed @@ -20,21 +20,6 @@ public function __construct( ) { } - /** - * @param array $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']), - ); - } - public function tags(): Tags { return Tags::create($this->courseId->toTag()); diff --git a/src/Events/DomainEvent.php b/src/Events/DomainEvent.php deleted file mode 100644 index 54c9b2c..0000000 --- a/src/Events/DomainEvent.php +++ /dev/null @@ -1,18 +0,0 @@ - $data - */ - public static function fromArray(array $data): self; -} diff --git a/src/Events/StudentRegistered.php b/src/Events/StudentRegistered.php index f121682..81ec332 100644 --- a/src/Events/StudentRegistered.php +++ b/src/Events/StudentRegistered.php @@ -4,9 +4,9 @@ namespace Wwwision\DCBExample\Events; -use Webmozart\Assert\Assert; use Wwwision\DCBEventStore\Types\Tags; use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBLibrary\DomainEvent; /** * Domain Events that occurs when a new student was registered in the system @@ -18,18 +18,6 @@ public function __construct( ) { } - /** - * @param array $data - */ - public static function fromArray(array $data): self - { - Assert::keyExists($data, 'studentId'); - Assert::string($data['studentId']); - return new self( - StudentId::fromString($data['studentId']), - ); - } - public function tags(): Tags { return Tags::create($this->studentId->toTag()); diff --git a/src/Events/StudentSubscribedToCourse.php b/src/Events/StudentSubscribedToCourse.php index 79f46f8..db9fc76 100644 --- a/src/Events/StudentSubscribedToCourse.php +++ b/src/Events/StudentSubscribedToCourse.php @@ -4,10 +4,10 @@ namespace Wwwision\DCBExample\Events; -use Webmozart\Assert\Assert; use Wwwision\DCBEventStore\Types\Tags; use Wwwision\DCBExample\Types\CourseId; use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBLibrary\DomainEvent; /** * Domain Events that occurs when a student was subscribed to a course @@ -22,21 +22,6 @@ public function __construct( ) { } - /** - * @param array $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']), - ); - } - public function tags(): Tags { return Tags::create($this->courseId->toTag(), $this->studentId->toTag()); diff --git a/src/Events/StudentUnsubscribedFromCourse.php b/src/Events/StudentUnsubscribedFromCourse.php index bc5eabc..cb77583 100644 --- a/src/Events/StudentUnsubscribedFromCourse.php +++ b/src/Events/StudentUnsubscribedFromCourse.php @@ -4,10 +4,10 @@ namespace Wwwision\DCBExample\Events; -use Webmozart\Assert\Assert; use Wwwision\DCBEventStore\Types\Tags; use Wwwision\DCBExample\Types\CourseId; use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBLibrary\DomainEvent; /** * Domain Events that occurs when a student was unsubscribed from a course @@ -22,18 +22,6 @@ public function __construct( ) { } - /** - * @param array $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']),); - } - public function tags(): Tags { return Tags::create($this->courseId->toTag(), $this->studentId->toTag()); diff --git a/src/ReadModel/Course/Course.php b/src/ReadModel/Course/Course.php new file mode 100644 index 0000000..4bd1234 --- /dev/null +++ b/src/ReadModel/Course/Course.php @@ -0,0 +1,29 @@ +id, $newTitle, $this->state); + } + + public function withState(CourseState $newState): self + { + return new self($this->id, $this->title, $newState); + } +} \ No newline at end of file diff --git a/src/ReadModel/Course/CourseProjection.php b/src/ReadModel/Course/CourseProjection.php new file mode 100644 index 0000000..702c17c --- /dev/null +++ b/src/ReadModel/Course/CourseProjection.php @@ -0,0 +1,108 @@ + + */ +final readonly class CourseProjection implements PartitionedProjection, ProvidesReset, ProvidesSetup, EventTypesAware +{ + + public function __construct( + private CourseProjectionAdapter $adapter, + ) { + } + + public function partitionKey(DomainEvent $domainEvent): string + { + foreach ($domainEvent->tags() as $tag) { + if ($tag->key === 'course') { + return $tag->value; + } + } + throw new RuntimeException(sprintf('Failed to partition projection %s for domain event of type %s', self::class, $domainEvent::class), 1693302957); + } + + public function loadState(string $partitionKey): ?Course + { + return $this->adapter->courseById(CourseId::fromString($partitionKey)); + } + + public function saveState($state): void + { + Assert::isInstanceOf($state, Course::class); + $this->adapter->saveCourse($state); + } + + public function initialState(): null + { + return null; + } + + /** + * @param Course|null $course + * @param DomainEvent $domainEvent + * @param EventEnvelope $eventEnvelope + * @return ?Course + */ + public function apply($course, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): ?Course + { + if ($course instanceof Course) { + $courseStateProjection = new CourseStateProjection($course->id); + $course = $course->withState($courseStateProjection->apply($course->state, $domainEvent, $eventEnvelope)); + } + $handlerMethodName = 'when' . substr($domainEvent::class, strrpos($domainEvent::class, '\\') + 1); + if (method_exists($this, $handlerMethodName)) { + $course = $this->{$handlerMethodName}($course, $domainEvent); + } + return $course; + } + + private function whenCourseCreated($_, CourseCreated $domainEvent): Course + { + return new Course($domainEvent->courseId, $domainEvent->courseTitle, CourseState::initial()); + } + + private function whenCourseCapacityChanged(Course $course, CourseCapacityChanged $domainEvent): Course + { + return $course->withState($course->state->withCapacity($domainEvent->newCapacity)); + } + + + public function reset(): void + { + $this->adapter->reset(); + } + + public function setup(): void + { + $this->adapter->setup(); + } + + public function eventTypes(): EventTypes + { + return EventTypes::fromArray(array_map(static fn (string $handlerMethodName) => substr($handlerMethodName, 4), array_filter(get_class_methods($this), static fn (string $methodName) => str_starts_with($methodName, 'when')))); + } +} \ No newline at end of file diff --git a/src/ReadModel/Course/CourseProjectionAdapter.php b/src/ReadModel/Course/CourseProjectionAdapter.php new file mode 100644 index 0000000..c4a88fa --- /dev/null +++ b/src/ReadModel/Course/CourseProjectionAdapter.php @@ -0,0 +1,21 @@ + + */ +#[ListBased(itemClassName: Course::class)] +final readonly class Courses implements IteratorAggregate +{ + /** + * @param array $courses + */ + private function __construct( + private array $courses, + ) { + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->courses); + } +} \ No newline at end of file diff --git a/src/Types/CourseCapacity.php b/src/Types/CourseCapacity.php index 816f460..211bab0 100644 --- a/src/Types/CourseCapacity.php +++ b/src/Types/CourseCapacity.php @@ -5,21 +5,21 @@ namespace Wwwision\DCBExample\Types; use JsonSerializable; -use Webmozart\Assert\Assert; +use Wwwision\Types\Attributes\Description; +use Wwwision\Types\Attributes\IntegerBased; +use function Wwwision\Types\instantiate; -/** - * Total capacity (available seats) of a course - */ +#[Description('Total capacity (available seats) of a course')] +#[IntegerBased(minimum: 0)] final readonly class CourseCapacity implements JsonSerializable { private function __construct(public int $value) { - Assert::natural($this->value, 'Capacity must not be a negative value, given: %d'); } public static function fromInteger(int $value): self { - return new self($value); + return instantiate(self::class, $value); } public function equals(self $other): bool diff --git a/src/Types/CourseId.php b/src/Types/CourseId.php index 2f0d124..5de1e30 100644 --- a/src/Types/CourseId.php +++ b/src/Types/CourseId.php @@ -6,10 +6,12 @@ use JsonSerializable; use Wwwision\DCBEventStore\Types\Tag; +use Wwwision\Types\Attributes\Description; +use Wwwision\Types\Attributes\StringBased; +use function Wwwision\Types\instantiate; -/** - * Globally unique identifier of a course (usually represented as a UUID v4) - */ +#[Description('Globally unique identifier of a course (usually represented as a UUID v4)')] +#[StringBased(maxLength: 100)] final readonly class CourseId implements JsonSerializable { private function __construct(public string $value) @@ -18,7 +20,7 @@ private function __construct(public string $value) public static function fromString(string $value): self { - return new self($value); + return instantiate(self::class, $value); } public function jsonSerialize(): string diff --git a/src/Types/CourseIds.php b/src/Types/CourseIds.php index f0502ff..aaf3ff2 100644 --- a/src/Types/CourseIds.php +++ b/src/Types/CourseIds.php @@ -9,14 +9,17 @@ use IteratorAggregate; use Traversable; +use Wwwision\Types\Attributes\Description; +use Wwwision\Types\Attributes\ListBased; use function array_filter; use function array_map; +use function Wwwision\Types\instantiate; /** - * A type-safe set of {@see CourseId} instances - * * @implements IteratorAggregate */ +#[Description('A type-safe set of {@see CourseId} instances')] +#[ListBased(itemClassName: CourseId::class)] final class CourseIds implements IteratorAggregate, Countable { /** @@ -25,22 +28,21 @@ final class CourseIds implements IteratorAggregate, Countable private function __construct( public readonly array $ids, ) { - //Assert::notEmpty($this->ids, 'CourseIds must not be empty'); } public static function create(CourseId ...$ids): self { - return new self($ids); + return instantiate(self::class, $ids); } public static function none(): self { - return new self([]); + return instantiate(self::class, []); } public static function fromStrings(string ...$ids): self { - return new self(array_map(static fn (string $type) => CourseId::fromString($type), $ids)); + return self::create(...array_map(static fn (string $type) => CourseId::fromString($type), $ids)); } public function contains(CourseId $id): bool @@ -58,7 +60,7 @@ public function with(CourseId $courseId): self if ($this->contains($courseId)) { return $this; } - return new self([...$this->ids, $courseId]); + return self::create(...[...$this->ids, $courseId]); } public function without(CourseId $courseId): self @@ -66,7 +68,7 @@ public function without(CourseId $courseId): self if (!$this->contains($courseId)) { return $this; } - return new self(array_filter($this->ids, static fn (CourseId $id) => !$id->equals($courseId))); + return self::create(...array_filter($this->ids, static fn (CourseId $id) => !$id->equals($courseId))); } public function getIterator(): Traversable diff --git a/src/Types/CourseState.php b/src/Types/CourseState.php index a452c82..81d70bd 100644 --- a/src/Types/CourseState.php +++ b/src/Types/CourseState.php @@ -4,6 +4,8 @@ namespace Wwwision\DCBExample\Types; +use JsonSerializable; + final readonly class CourseState { private function __construct( diff --git a/src/Types/CourseStateValue.php b/src/Types/CourseStateValue.php index b4ab4d5..e6e0600 100644 --- a/src/Types/CourseStateValue.php +++ b/src/Types/CourseStateValue.php @@ -4,9 +4,18 @@ namespace Wwwision\DCBExample\Types; -enum CourseStateValue +use JsonSerializable; +use Wwwision\Types\Attributes\Description; + +#[Description('Finite state a course can be in')] +enum CourseStateValue implements JsonSerializable { case NON_EXISTING; case CREATED; case FULLY_BOOKED; + + public function jsonSerialize(): string + { + return $this->name; + } } diff --git a/src/Types/CourseTitle.php b/src/Types/CourseTitle.php index 1d83332..fdbf58b 100644 --- a/src/Types/CourseTitle.php +++ b/src/Types/CourseTitle.php @@ -5,10 +5,12 @@ namespace Wwwision\DCBExample\Types; use JsonSerializable; +use Wwwision\Types\Attributes\Description; +use Wwwision\Types\Attributes\StringBased; +use function Wwwision\Types\instantiate; -/** - * The title of a course - */ +#[Description('The title of a course')] +#[StringBased(maxLength: 100)] final readonly class CourseTitle implements JsonSerializable { private function __construct(public string $value) @@ -17,7 +19,7 @@ private function __construct(public string $value) public static function fromString(string $value): self { - return new self($value); + return instantiate(self::class, $value); } public function jsonSerialize(): string diff --git a/src/Types/StudentId.php b/src/Types/StudentId.php index b71fb23..78d27e2 100644 --- a/src/Types/StudentId.php +++ b/src/Types/StudentId.php @@ -6,10 +6,12 @@ use JsonSerializable; use Wwwision\DCBEventStore\Types\Tag; +use Wwwision\Types\Attributes\Description; +use Wwwision\Types\Attributes\StringBased; +use function Wwwision\Types\instantiate; -/** - * Globally unique identifier of a student (usually represented as a UUID v4) - */ +#[Description('Globally unique identifier of a student (usually represented as a UUID v4)')] +#[StringBased] final readonly class StudentId implements JsonSerializable { private function __construct(public string $value) @@ -18,7 +20,7 @@ private function __construct(public string $value) public static function fromString(string $value): self { - return new self($value); + return instantiate(self::class, $value); } public function jsonSerialize(): string diff --git a/tests/Behat/Bootstrap/FeatureContext.php b/tests/Behat/Bootstrap/FeatureContext.php index 4746a2e..b38a724 100644 --- a/tests/Behat/Bootstrap/FeatureContext.php +++ b/tests/Behat/Bootstrap/FeatureContext.php @@ -30,7 +30,6 @@ use Wwwision\DCBExample\Commands\UnsubscribeStudentFromCourse; use Wwwision\DCBExample\Commands\UpdateCourseCapacity; use Wwwision\DCBExample\Events\CourseCreated; -use Wwwision\DCBExample\Events\DomainEvent; use Wwwision\DCBExample\Events\StudentRegistered; use Wwwision\DCBExample\Events\StudentSubscribedToCourse; use Wwwision\DCBExample\Events\StudentUnsubscribedFromCourse; @@ -39,6 +38,7 @@ use Wwwision\DCBExample\Types\CourseId; use Wwwision\DCBExample\Types\CourseTitle; use Wwwision\DCBExample\Types\StudentId; +use Wwwision\DCBLibrary\DomainEvent; use Wwwision\DCBLibrary\Exceptions\ConstraintException; use function array_diff; use function array_keys;