Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: FEATURE: Course read model example #6

Draft
wants to merge 1 commit into
base: feature/library-statemachine
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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:
9 changes: 5 additions & 4 deletions composer.json
Original file line number Diff line number Diff line change
@@ -33,17 +33,18 @@
"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",
"phpstan/phpstan": "^1.10",
"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": {
29 changes: 12 additions & 17 deletions index.php
Original file line number Diff line number Diff line change
@@ -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')));
123 changes: 123 additions & 0 deletions src/Adapters/DbalCourseProjectionAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

namespace Wwwision\DCBExample\Adapters;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Schema\Comparator;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Webmozart\Assert\Assert;
use Wwwision\DCBExample\ReadModel\Course\Course;
use Wwwision\DCBExample\ReadModel\Course\CourseProjectionAdapter;
use Wwwision\DCBExample\ReadModel\Course\Courses;
use Wwwision\DCBExample\Types\CourseId;
use Wwwision\DCBExample\Types\CourseTitle;
use Wwwision\Types\Parser;
use Wwwision\Types\Schema\StringSchema;
use function Wwwision\Types\instantiate;

final readonly class DbalCourseProjectionAdapter implements CourseProjectionAdapter
{

private const TABLE_NAME = 'dcv_example_courses_p_courses';

public function __construct(
private Connection $connection,
) {
}

public function saveCourse(Course $course): void
{
$data = self::courseToDatabaseRow($course);
$assignments = [];
$parameters = [];
foreach ($data as $columnName => $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<mixed> $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<mixed>
*/
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),
];
}
}
32 changes: 30 additions & 2 deletions src/App.php
Original file line number Diff line number Diff line change
@@ -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
14 changes: 7 additions & 7 deletions src/CourseStateProjection.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php /** @noinspection ALL */
<?php /** @noinspection PhpUnusedPrivateMethodInspection */

declare(strict_types=1);

@@ -52,17 +52,17 @@ public function apply($state, DomainEvent $domainEvent, EventEnvelope $eventEnve
return $state;
}

private function whenCourseCreated(CourseState $state, CourseCreated $event): CourseState
private function whenCourseCreated(CourseState $state, CourseCreated $domainEvent): CourseState
{
return $state->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) {
8 changes: 4 additions & 4 deletions src/EventSerializer.php
Original file line number Diff line number Diff line change
@@ -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<DomainEvent> $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);
17 changes: 1 addition & 16 deletions src/Events/CourseCapacityChanged.php
Original file line number Diff line number Diff line change
@@ -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<string, mixed> $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());
20 changes: 1 addition & 19 deletions src/Events/CourseCreated.php
Original file line number Diff line number Diff line change
@@ -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<string, mixed> $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());
17 changes: 1 addition & 16 deletions src/Events/CourseRenamed.php
Original file line number Diff line number Diff line change
@@ -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<string, mixed> $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());
18 changes: 0 additions & 18 deletions src/Events/DomainEvent.php

This file was deleted.

14 changes: 1 addition & 13 deletions src/Events/StudentRegistered.php
Original file line number Diff line number Diff line change
@@ -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<string, mixed> $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());
17 changes: 1 addition & 16 deletions src/Events/StudentSubscribedToCourse.php
Original file line number Diff line number Diff line change
@@ -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<string, mixed> $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());
14 changes: 1 addition & 13 deletions src/Events/StudentUnsubscribedFromCourse.php
Original file line number Diff line number Diff line change
@@ -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<string, mixed> $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());
29 changes: 29 additions & 0 deletions src/ReadModel/Course/Course.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Wwwision\DCBExample\ReadModel\Course;

use Wwwision\DCBExample\Types\CourseId;
use Wwwision\DCBExample\Types\CourseState;
use Wwwision\DCBExample\Types\CourseTitle;

final readonly class Course
{
public function __construct(
public CourseId $id,
public CourseTitle $title,
public CourseState $state,
) {
}

public function withTitle(CourseTitle $newTitle): self
{
return new self($this->id, $newTitle, $this->state);
}

public function withState(CourseState $newState): self
{
return new self($this->id, $this->title, $newState);
}
}
108 changes: 108 additions & 0 deletions src/ReadModel/Course/CourseProjection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php /** @noinspection PhpUnusedPrivateMethodInspection */

declare(strict_types=1);

namespace Wwwision\DCBExample\ReadModel\Course;

use RuntimeException;
use Webmozart\Assert\Assert;
use Wwwision\DCBEventStore\Types\EventEnvelope;
use Wwwision\DCBEventStore\Types\EventTypes;
use Wwwision\DCBEventStore\Types\Tags;
use Wwwision\DCBExample\CourseStateProjection;
use Wwwision\DCBExample\Events\CourseCapacityChanged;
use Wwwision\DCBExample\Events\CourseCreated;
use Wwwision\DCBExample\Events\CourseRenamed;
use Wwwision\DCBExample\Types\CourseId;
use Wwwision\DCBExample\Types\CourseState;
use Wwwision\DCBExample\Types\CourseStateValue;
use Wwwision\DCBLibrary\DomainEvent;
use Wwwision\DCBLibrary\EventTypesAware;
use Wwwision\DCBLibrary\Projection\PartitionedProjection;
use Wwwision\DCBLibrary\Projection\Projection;
use Wwwision\DCBLibrary\ProvidesReset;
use Wwwision\DCBLibrary\ProvidesSetup;
use Wwwision\DCBLibrary\TagsAware;

/**
* @implements Projection<?Course>
*/
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'))));
}
}
21 changes: 21 additions & 0 deletions src/ReadModel/Course/CourseProjectionAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Wwwision\DCBExample\ReadModel\Course;

use Wwwision\DCBExample\Types\CourseId;
use Wwwision\DCBLibrary\ProvidesReset;
use Wwwision\DCBLibrary\ProvidesSetup;

interface CourseProjectionAdapter extends ProvidesSetup, ProvidesReset
{

public function saveCourse(Course $course): void;

// -------- READ ----------

public function courses(): Courses;

public function courseById(CourseId $courseId): ?Course;
}
30 changes: 30 additions & 0 deletions src/ReadModel/Course/Courses.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Wwwision\DCBExample\ReadModel\Course;

use ArrayIterator;
use IteratorAggregate;
use Traversable;
use Wwwision\Types\Attributes\ListBased;

/**
* @implements IteratorAggregate<Course>
*/
#[ListBased(itemClassName: Course::class)]
final readonly class Courses implements IteratorAggregate
{
/**
* @param array<Course> $courses
*/
private function __construct(
private array $courses,
) {
}

public function getIterator(): Traversable
{
return new ArrayIterator($this->courses);
}
}
12 changes: 6 additions & 6 deletions src/Types/CourseCapacity.php
Original file line number Diff line number Diff line change
@@ -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
10 changes: 6 additions & 4 deletions src/Types/CourseId.php
Original file line number Diff line number Diff line change
@@ -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
18 changes: 10 additions & 8 deletions src/Types/CourseIds.php
Original file line number Diff line number Diff line change
@@ -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<CourseId>
*/
#[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,15 +60,15 @@ 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
{
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
2 changes: 2 additions & 0 deletions src/Types/CourseState.php
Original file line number Diff line number Diff line change
@@ -4,6 +4,8 @@

namespace Wwwision\DCBExample\Types;

use JsonSerializable;

final readonly class CourseState
{
private function __construct(
11 changes: 10 additions & 1 deletion src/Types/CourseStateValue.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
10 changes: 6 additions & 4 deletions src/Types/CourseTitle.php
Original file line number Diff line number Diff line change
@@ -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
10 changes: 6 additions & 4 deletions src/Types/StudentId.php
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion tests/Behat/Bootstrap/FeatureContext.php
Original file line number Diff line number Diff line change
@@ -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;