Skip to content
Draft
Show file tree
Hide file tree
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
83 changes: 17 additions & 66 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
use Wwwision\DCBExample\Events\StudentRegistered;
use Wwwision\DCBExample\Events\StudentSubscribedToCourse;
use Wwwision\DCBExample\Events\StudentUnsubscribedFromCourse;
use Wwwision\DCBExample\Types\CourseCapacity;
use Wwwision\DCBExample\Types\CourseId;
use Wwwision\DCBExample\Types\CourseIds;
use Wwwision\DCBExample\Types\CourseState;
use Wwwision\DCBExample\Types\CourseStateValue;
use Wwwision\DCBExample\Types\CourseTitle;
use Wwwision\DCBExample\Types\StudentId;
use Wwwision\DCBLibrary\Adapters\SynchronousCatchUpQueue;
Expand Down Expand Up @@ -61,8 +62,8 @@ public function handle(Command $command): void

private function handleCreateCourse(CreateCourse $command): void
{
$this->eventPublisher->conditionalAppend(self::courseExists($command->courseId), function (bool $courseExists) use ($command) {
if ($courseExists) {
$this->eventPublisher->conditionalAppend(new CourseStateProjection($command->courseId), function (CourseState $state) use ($command) {
if ($state->value !== CourseStateValue::NON_EXISTING) {
throw new ConstraintException(sprintf('Failed to create course with id "%s" because a course with that id already exists', $command->courseId->value), 1684593925);
}
return new CourseCreated($command->courseId, $command->initialCapacity, $command->courseTitle);
Expand All @@ -72,10 +73,10 @@ private function handleCreateCourse(CreateCourse $command): void
private function handleRenameCourse(RenameCourse $command): void
{
$this->eventPublisher->conditionalAppend(CompositeProjection::create([
'courseExists' => self::courseExists($command->courseId),
'courseState' => new CourseStateProjection($command->courseId),
'courseTitle' => self::courseTitle($command->courseId),
]), function ($state) use ($command) {
if (!$state->courseExists) {
if ($state->courseState->value === CourseStateValue::NON_EXISTING) {
throw new ConstraintException(sprintf('Failed to rename course with id "%s" because a course with that id does not exist', $command->courseId->value), 1684509782);
}
if ($state->courseTitle !== null && $state->courseTitle->equals($command->newCourseTitle)) {
Expand All @@ -99,19 +100,17 @@ private function handleSubscribeStudentToCourse(SubscribeStudentToCourse $comman
{
$this->eventPublisher->conditionalAppend(CompositeProjection::create([
'studentRegistered' => self::studentRegistered($command->studentId),
'courseExists' => self::courseExists($command->courseId),
'courseCapacity' => self::courseCapacity($command->courseId),
'numberOfCourseSubscriptions' => self::numberOfCourseSubscriptions($command->courseId),
'courseState' => new CourseStateProjection($command->courseId),
'studentSubscriptions' => self::studentSubscriptions($command->studentId),
]), function ($state) use ($command) {
if (!$state->studentRegistered) {
throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a student with that id does not exist', $command->studentId->value, $command->courseId->value), 1686914105);
}
if (!$state->courseExists) {
if ($state->courseState->value === CourseStateValue::NON_EXISTING) {
throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1685266122);
}
if ($state->courseCapacity->value === $state->numberOfCourseSubscriptions) {
throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because the course\'s capacity of %d is reached', $command->studentId->value, $command->courseId->value, $state->courseCapacity->value), 1684603201);
if ($state->courseState->value === CourseStateValue::FULLY_BOOKED) {
throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because the course\'s capacity of %d is reached', $command->studentId->value, $command->courseId->value, $state->courseState->capacity->value), 1684603201);
}
if ($state->studentSubscriptions->contains($command->courseId)) {
throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because that student is already subscribed to this course', $command->studentId->value, $command->courseId->value), 1684510963);
Expand All @@ -127,11 +126,11 @@ private function handleSubscribeStudentToCourse(SubscribeStudentToCourse $comman
private function handleUnsubscribeStudentFromCourse(UnsubscribeStudentFromCourse $command): void
{
$this->eventPublisher->conditionalAppend(CompositeProjection::create([
'courseExists' => self::courseExists($command->courseId),
'courseState' => new CourseStateProjection($command->courseId),
'studentRegistered' => self::studentRegistered($command->studentId),
'studentSubscriptions' => self::studentSubscriptions($command->studentId),
]), function ($state) use ($command) {
if (!$state->courseExists) {
if ($state->courseState->value === CourseStateValue::NON_EXISTING) {
throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1684579448);
}
if (!$state->studentRegistered) {
Expand All @@ -146,70 +145,22 @@ private function handleUnsubscribeStudentFromCourse(UnsubscribeStudentFromCourse

private function handleUpdateCourseCapacity(UpdateCourseCapacity $command): void
{
$this->eventPublisher->conditionalAppend(CompositeProjection::create([
'courseExists' => self::courseExists($command->courseId),
'courseCapacity' => self::courseCapacity($command->courseId),
'numberOfCourseSubscriptions' => self::numberOfCourseSubscriptions($command->courseId),
]), function ($state) use ($command) {
if (!$state->courseExists) {
$this->eventPublisher->conditionalAppend(new CourseStateProjection($command->courseId), function (CourseState $state) use ($command) {
if ($state->value === CourseStateValue::NON_EXISTING) {
throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because a course with that id does not exist', $command->courseId->value, $command->newCapacity->value), 1684604283);
}
if ($state->courseCapacity->equals($command->newCapacity)) {
if ($command->newCapacity->equals($state->capacity)) {
throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because that is already the courses capacity', $command->courseId->value, $command->newCapacity->value), 1686819073);
}
if ($state->numberOfCourseSubscriptions > $command->newCapacity->value) {
throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because it already has %d active subscriptions', $command->courseId->value, $command->newCapacity->value, $state->numberOfCourseSubscriptions), 1684604361);
if ($state->numberOfSubscriptions > $command->newCapacity->value) {
throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because it already has %d active subscriptions', $command->courseId->value, $command->newCapacity->value, $state->numberOfSubscriptions), 1684604361);
}
return new CourseCapacityChanged($command->courseId, $command->newCapacity);
});
}

// -----------------------------

/**
* @return Projection<bool>
*/
private static function courseExists(CourseId $courseId): Projection
{
return InMemoryProjection::create(
Tags::create($courseId->toTag()),
[
CourseCreated::class => static fn () => true,
],
false
);
}

/**
* @return Projection<CourseCapacity>
*/
private static function courseCapacity(CourseId $courseId): Projection
{
return InMemoryProjection::create(
Tags::create($courseId->toTag()),
[
CourseCreated::class => static fn($_, CourseCreated $event) => $event->initialCapacity,
CourseCapacityChanged::class => static fn($_, CourseCapacityChanged $event) => $event->newCapacity,
],
CourseCapacity::fromInteger(0)
);
}

/**
* @return Projection<int>
*/
private static function numberOfCourseSubscriptions(CourseId $courseId): Projection
{
return InMemoryProjection::create(
Tags::create($courseId->toTag()),
[
StudentSubscribedToCourse::class => static fn(int $state) => $state + 1,
StudentUnsubscribedFromCourse::class => static fn(int $state) => $state - 1,
],
0
);
}

/**
* @return Projection<CourseTitle>
*/
Expand Down
90 changes: 90 additions & 0 deletions src/CourseStateProjection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php /** @noinspection ALL */

declare(strict_types=1);

namespace Wwwision\DCBExample;

use Wwwision\DCBEventStore\Types\EventEnvelope;
use Wwwision\DCBEventStore\Types\EventTypes;
use Wwwision\DCBEventStore\Types\StreamQuery\Criteria\EventTypesAndTagsCriterion;
use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery;
use Wwwision\DCBEventStore\Types\Tags;
use Wwwision\DCBExample\Events\CourseCapacityChanged;
use Wwwision\DCBExample\Events\CourseCreated;
use Wwwision\DCBExample\Events\StudentSubscribedToCourse;
use Wwwision\DCBExample\Events\StudentUnsubscribedFromCourse;
use Wwwision\DCBExample\Types\CourseId;
use Wwwision\DCBExample\Types\CourseStateValue;
use Wwwision\DCBLibrary\DomainEvent;
use Wwwision\DCBLibrary\Projection\Projection;
use Wwwision\DCBExample\Types\CourseState;
use Wwwision\DCBLibrary\StreamQueryAware;

/**
* @implements Projection<CourseState>
*/
final class CourseStateProjection implements Projection, StreamQueryAware
{

public function __construct(
private readonly CourseId $courseId,
) {
}

public function initialState(): CourseState
{
return CourseState::initial();
}

/**
* @param CourseState $state
* @return CourseState
*/
public function apply($state, DomainEvent $domainEvent, EventEnvelope $eventEnvelope): CourseState
{
if (!$domainEvent->tags()->contain($this->courseId->toTag())) {
return $state;
}
$handlerMethodName = 'when' . substr($domainEvent::class, strrpos($domainEvent::class, '\\') + 1);
if (method_exists($this, $handlerMethodName)) {
$state = $this->{$handlerMethodName}($state, $domainEvent);
}
return $state;
}

private function whenCourseCreated(CourseState $state, CourseCreated $event): CourseState
{
return $state->withValue(CourseStateValue::CREATED)->withCapacity($event->initialCapacity);
}

private function whenCourseCapacityChanged(CourseState $state, CourseCapacityChanged $event): CourseState
{
return $state->withCapacity($event->newCapacity);
}

private function whenStudentSubscribedToCourse(CourseState $state, StudentSubscribedToCourse $event): CourseState
{
$state = $state->withNumberOfSubscriptions($state->numberOfSubscriptions + 1);
if ($state->numberOfSubscriptions === $state->capacity->value) {
$state = $state->withValue(CourseStateValue::FULLY_BOOKED);
}
return $state;
}

private function whenStudentUnsubscribedFromCourse(CourseState $state, StudentUnsubscribedFromCourse $event): CourseState
{
$state = $state->withNumberOfSubscriptions($state->numberOfSubscriptions + 1);
if ($state->numberOfSubscriptions < $state->capacity->value && $state->value === CourseStateValue::FULLY_BOOKED) {
$state = $state->withValue(CourseStateValue::CREATED);
}
return $state;
}

public function adjustStreamQuery(StreamQuery $query): StreamQuery
{
return $query->withCriterion(new EventTypesAndTagsCriterion(
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')))),
Tags::create($this->courseId->toTag()),
));
}
}
60 changes: 60 additions & 0 deletions src/Types/CourseState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Wwwision\DCBExample\Types;

final readonly class CourseState
{
private function __construct(
public CourseStateValue $value,
public CourseCapacity $capacity,
public int $numberOfSubscriptions,
) {
}

public static function initial(): self
{
return new self(
CourseStateValue::NON_EXISTING,
CourseCapacity::fromInteger(0),
0
);
}

public function withValue(CourseStateValue $value): self
{
if ($value === $this->value) {
return $this;
}
return new self(
$value,
$this->capacity,
$this->numberOfSubscriptions,
);
}

public function withCapacity(CourseCapacity $newCapacity): self
{
if ($newCapacity->equals($this->capacity)) {
return $this;
}
return new self(
$this->value,
$newCapacity,
$this->numberOfSubscriptions,
);
}

public function withNumberOfSubscriptions(int $newNumberOfSubscriptions): self
{
if ($newNumberOfSubscriptions === $this->numberOfSubscriptions) {
return $this;
}
return new self(
$this->value,
$this->capacity,
$newNumberOfSubscriptions,
);
}
}
12 changes: 12 additions & 0 deletions src/Types/CourseStateValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Wwwision\DCBExample\Types;

enum CourseStateValue
{
case NON_EXISTING;
case CREATED;
case FULLY_BOOKED;
}
1 change: 1 addition & 0 deletions tests/Behat/UnsubscribeStudentFromCourse.feature
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Feature: Unsubscribing students from courses
| Type | Tags |
| "CourseCreated" | ["course:c1"] |
| "StudentRegistered" | ["student:s2"] |
| "StudentSubscribedToCourse" | ["course:c1", "student:s1"] |
| "StudentSubscribedToCourse" | ["course:c1", "student:s2"] |
And the command should pass without errors
And the following event should be appended:
Expand Down