diff --git a/README.md b/README.md index 4fb0e2b..5c80089 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,23 @@ This package models the example of this presentation (with a few deviations) usi ### Important Classes / Concepts -* [Command](src%2FCommand) are just a concept of this example package. They implement the [Command Marker Interface](src%2FCommand%2FCommand.php) +* [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 -* The [Projections](src%2FProjection%2FProjection.php) are surprisingly small because they focus on a single responsibility +* 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 -* This package contains no Read Model (i.e. classic projections) yet +* *Note:* This package contains no Read Model (i.e. classic projections) yet ### Considerations / Findings I always had the feeling, that the focus on Event Streams is a distraction to Domain-driven design. So I was very happy to come across this concept. -So far I didn't have the chance to test it in a real world scenario, but it makes a lot of sense to me and IMO this example shows, that the approach -really works out in practice (in spite of some minor caveats in the current implementation). +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): + +* 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 ## Usage diff --git a/composer.json b/composer.json index 434f692..9d30f1e 100644 --- a/composer.json +++ b/composer.json @@ -21,10 +21,9 @@ ], "require": { "php": ">=8.4", - "ramsey/uuid": "^4.7", "webmozart/assert": "^1.11", - "wwwision/dcb-eventstore": "@dev", - "wwwision/dcb-eventstore-doctrine": "@dev" + "wwwision/dcb-eventstore": "^4", + "wwwision/dcb-eventstore-doctrine": "^4" }, "require-dev": { "roave/security-advisories": "dev-latest", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 484be13..81d4af7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,4 +1,9 @@ parameters: level: 9 paths: - - src \ No newline at end of file + - src +services: + - + class: Wwwision\DCBExample\Tests\PHPStan\DecisionModelPhpStanExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension \ No newline at end of file diff --git a/src/CommandHandler.php b/src/CommandHandler.php index 7dd4354..27fcefa 100644 --- a/src/CommandHandler.php +++ b/src/CommandHandler.php @@ -4,11 +4,10 @@ namespace Wwwision\DCBExample; -use Closure; use RuntimeException; +use Webmozart\Assert\Assert; use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStore\Types\AppendCondition; -use Wwwision\DCBEventStore\Types\Events; use Wwwision\DCBEventStore\Types\ExpectedHighestSequenceNumber; use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; use Wwwision\DCBExample\Command\Command; @@ -18,11 +17,10 @@ use Wwwision\DCBExample\Command\SubscribeStudentToCourse; use Wwwision\DCBExample\Command\UnsubscribeStudentFromCourse; use Wwwision\DCBExample\Command\UpdateCourseCapacity; +use Wwwision\DCBExample\DecisionModel\DecisionModel; use Wwwision\DCBExample\Event\CourseCapacityChanged; use Wwwision\DCBExample\Event\CourseCreated; use Wwwision\DCBExample\Event\CourseRenamed; -use Wwwision\DCBExample\Event\DomainEvent; -use Wwwision\DCBExample\Event\DomainEvents; use Wwwision\DCBExample\Event\StudentRegistered; use Wwwision\DCBExample\Event\StudentSubscribedToCourse; use Wwwision\DCBExample\Event\StudentUnsubscribedFromCourse; @@ -30,7 +28,6 @@ use Wwwision\DCBExample\Projection\ClosureProjection; use Wwwision\DCBExample\Projection\CompositeProjection; use Wwwision\DCBExample\Projection\Projection; -use Wwwision\DCBExample\Projection\StreamCriteriaAware; use Wwwision\DCBExample\Projection\TaggedProjection; use Wwwision\DCBExample\Types\CourseCapacity; use Wwwision\DCBExample\Types\CourseId; @@ -38,6 +35,7 @@ use Wwwision\DCBExample\Types\CourseTitle; use Wwwision\DCBExample\Types\StudentId; +use function PHPStan\dumpType; use function sprintf; /** @@ -68,128 +66,111 @@ public function handle(Command $command): void private function handleCreateCourse(CreateCourse $command): void { - $this->conditionalAppend( - self::courseExists($command->courseId), - function (bool $courseExists) use ($command) { - if ($courseExists) { - throw new ConstraintException(sprintf('Failed to create course with id "%s" because a course with that id already exists', $command->courseId->value), 1684593925); - } - return new CourseCreated($command->courseId, $command->initialCapacity, $command->courseTitle); - } + $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 { - $this->conditionalAppend( - CompositeProjection::create([ - 'courseExists' => self::courseExists($command->courseId), - 'courseTitle' => self::courseTitle($command->courseId), - ]), - /** @param object{courseExists: bool, courseTitle: CourseTitle} $state */ - function (object $state) use ($command) { - if (!$state->courseExists) { - throw new ConstraintException(sprintf('Failed to rename course with id "%s" because a course with that id does not exist', $command->courseId->value), 1684509782); - } - if ($state->courseTitle->equals($command->newCourseTitle)) { - throw new ConstraintException(sprintf('Failed to rename course with id "%s" to "%s" because this is already the title of this course', $command->courseId->value, $command->newCourseTitle->value), 1684509837); - } - return new CourseRenamed($command->courseId, $command->newCourseTitle); - } + $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 { - $this->conditionalAppend( - CompositeProjection::create([ - 'studentRegistered' => self::studentRegistered($command->studentId), - ]), - /** @param object{studentRegistered: bool} $state */ - function (object $state) use ($command) { - if ($state->studentRegistered) { - throw new ConstraintException(sprintf('Failed to register student with id "%s" because a student with that id already exists', $command->studentId->value), 1684579300); - } - return new StudentRegistered($command->studentId); - } + $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 { - $this->conditionalAppend( - CompositeProjection::create([ - 'studentRegistered' => self::studentRegistered($command->studentId), - 'courseExists' => self::courseExists($command->courseId), - 'courseCapacity' => self::courseCapacity($command->courseId), - 'numberOfCourseSubscriptions' => self::numberOfCourseSubscriptions($command->courseId), - 'studentSubscriptions' => self::studentSubscriptions($command->studentId), - ]), - /** @param object{studentRegistered: bool, courseExists: bool, courseCapacity: CourseCapacity, numberOfCourseSubscriptions: int, studentSubscriptions: CourseIds} $state */ - function (object $state) use ($command) { - if (!$state->studentRegistered) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a student with that id does not exist', $command->studentId->value, $command->courseId->value), 1686914105); - } - if (!$state->courseExists) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1685266122); - } - if ($state->courseCapacity->value === $state->numberOfCourseSubscriptions) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because the course\'s capacity of %d is reached', $command->studentId->value, $command->courseId->value, $state->courseCapacity->value), 1684603201); - } - if ($state->studentSubscriptions->contains($command->courseId)) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because that student is already subscribed to this course', $command->studentId->value, $command->courseId->value), 1684510963); - } - $maximumSubscriptionsPerStudent = 10; - if ($state->studentSubscriptions->count() === $maximumSubscriptionsPerStudent) { - throw new ConstraintException(sprintf('Failed to subscribe student with id "%s" to course with id "%s" because that student is already subscribed the maximum of %d courses', $command->studentId->value, $command->courseId->value, $maximumSubscriptionsPerStudent), 1684605232); - } - return new StudentSubscribedToCourse($command->courseId, $command->studentId); - } + $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 { - $this->conditionalAppend( - CompositeProjection::create([ - 'courseExists' => self::courseExists($command->courseId), - 'studentRegistered' => self::studentRegistered($command->studentId), - 'studentSubscriptions' => self::studentSubscriptions($command->studentId), - ]), - /** @param object{courseExists: bool, studentRegistered: bool, studentSubscriptions: CourseIds} $state */ - function (object $state) use ($command) { - if (!$state->courseExists) { - throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because a course with that id does not exist', $command->studentId->value, $command->courseId->value), 1684579448); - } - if (!$state->studentRegistered) { - throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because a student with that id does not exist', $command->studentId->value, $command->courseId->value), 1684579463); - } - if (!$state->studentSubscriptions->contains($command->courseId)) { - throw new ConstraintException(sprintf('Failed to unsubscribe student with id "%s" from course with id "%s" because that student is not subscribed to this course', $command->studentId->value, $command->courseId->value), 1684579464); - } - return new StudentUnsubscribedFromCourse($command->studentId, $command->courseId); - } + $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 { - $this->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) { - throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because a course with that id does not exist', $command->courseId->value, $command->newCapacity->value), 1684604283); - } - if ($state->courseCapacity->equals($command->newCapacity)) { - throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because that is already the courses capacity', $command->courseId->value, $command->newCapacity->value), 1686819073); - } - if ($state->numberOfCourseSubscriptions > $command->newCapacity->value) { - throw new ConstraintException(sprintf('Failed to change capacity of course with id "%s" to %d because it already has %d active subscriptions', $command->courseId->value, $command->newCapacity->value, $state->numberOfCourseSubscriptions), 1684604361); - } - return new CourseCapacityChanged($command->courseId, $command->newCapacity); - }); + $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); } // ----------------------------- @@ -205,12 +186,11 @@ private static function courseExists(CourseId $courseId): Projection initialState: false, onlyLastEvent: true, ) - ->when(CourseCreated::class, fn() => true) + ->when(CourseCreated::class, fn() => true) ); } /** - * @param CourseId $courseId * @return Projection */ private static function courseCapacity(CourseId $courseId): Projection @@ -226,8 +206,7 @@ private static function courseCapacity(CourseId $courseId): Projection } /** - * @param CourseId $courseId - * @return Projection + * @return Projection */ private static function numberOfCourseSubscriptions(CourseId $courseId): Projection { @@ -242,7 +221,6 @@ private static function numberOfCourseSubscriptions(CourseId $courseId): Project } /** - * @param CourseId $courseId * @return Projection */ private static function courseTitle(CourseId $courseId): Projection @@ -252,11 +230,14 @@ private static function courseTitle(CourseId $courseId): Projection ClosureProjection::create( initialState: CourseTitle::fromString(''), ) - ->when(CourseCreated::class, static fn ($_, CourseCreated $event) => $event->courseTitle) - ->when(CourseRenamed::class, static fn ($_, CourseRenamed $event) => $event->newCourseTitle) + ->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( @@ -270,7 +251,6 @@ private static function studentRegistered(StudentId $studentId): Projection } /** - * @param StudentId $studentId * @return Projection */ private static function studentSubscriptions(StudentId $studentId): Projection @@ -285,30 +265,25 @@ private static function studentSubscriptions(StudentId $studentId): Projection ); } + + // ------------------------------------ + /** - * @template S - * @param Projection $projection - * @param Closure(S): (DomainEvent|DomainEvents) $eventProducer - * @return void + * @phpstan-ignore-next-line */ - public function conditionalAppend(Projection $projection, Closure $eventProducer): void + private function buildDecisionModel(Projection ...$projections): DecisionModel { $query = StreamQuery::wildcard(); - if ($projection instanceof StreamCriteriaAware) { - $query = $query->withCriteria($projection->getCriteria()); - } + Assert::isMap($projections); + $compositeProjection = CompositeProjection::create($projections); + $query = $query->withCriteria($compositeProjection->getCriteria()); $expectedHighestSequenceNumber = ExpectedHighestSequenceNumber::none(); - $state = $projection->initialState(); + $state = $compositeProjection->initialState(); foreach ($this->eventStore->read($query) as $eventEnvelope) { $domainEvent = $this->eventSerializer->convertEvent($eventEnvelope->event); - $state = $projection->apply($state, $domainEvent, $eventEnvelope); + $state = $compositeProjection->apply($state, $domainEvent, $eventEnvelope); $expectedHighestSequenceNumber = ExpectedHighestSequenceNumber::fromSequenceNumber($eventEnvelope->sequenceNumber); } - $domainEvents = $eventProducer($state); - if ($domainEvents instanceof DomainEvent) { - $domainEvents = DomainEvents::create($domainEvents); - } - $events = Events::fromArray($domainEvents->map($this->eventSerializer->convertDomainEvent(...))); - $this->eventStore->append($events, new AppendCondition($query, $expectedHighestSequenceNumber)); + return new DecisionModel($state, new AppendCondition($query, $expectedHighestSequenceNumber)); } } diff --git a/src/DecisionModel/DecisionModel.php b/src/DecisionModel/DecisionModel.php new file mode 100644 index 0000000..264ebe8 --- /dev/null +++ b/src/DecisionModel/DecisionModel.php @@ -0,0 +1,22 @@ +projections as $projectionKey => $projection) { - if ($projection instanceof StreamCriteriaAware && !$projection->getCriteria()->hashes()->intersect($eventEnvelope->criterionHashes)) { + if ($projection instanceof StreamCriteriaAware && !$projection->getCriteria()->matchesEvent($eventEnvelope->event)) { continue; } $state->{$projectionKey} = $projection->apply($state->{$projectionKey}, $domainEvent, $eventEnvelope); diff --git a/src/Projection/TaggedProjection.php b/src/Projection/TaggedProjection.php index 25267fb..4d91b07 100644 --- a/src/Projection/TaggedProjection.php +++ b/src/Projection/TaggedProjection.php @@ -66,9 +66,6 @@ public function getCriteria(): Criteria $criteria = []; if ($this->wrapped instanceof StreamCriteriaAware) { foreach ($this->wrapped->getCriteria() as $criterion) { - if (!$criterion instanceof EventTypesAndTagsCriterion) { - throw new RuntimeException(sprintf('%s only supports criteria of type %s, given: %s', self::class, EventTypesAndTagsCriterion::class, get_debug_type($criterion))); - } $criteria[] = EventTypesAndTagsCriterion::create( eventTypes: $criterion->eventTypes, tags: $criterion->tags !== null ? $criterion->tags->merge($this->tags) : $this->tags, diff --git a/tests/Behat/Bootstrap/FeatureContext.php b/tests/Behat/Bootstrap/FeatureContext.php index 6a16aba..f25f4cb 100644 --- a/tests/Behat/Bootstrap/FeatureContext.php +++ b/tests/Behat/Bootstrap/FeatureContext.php @@ -12,20 +12,15 @@ use Doctrine\DBAL\Platforms\SqlitePlatform; use InvalidArgumentException; use PHPUnit\Framework\Assert; -use RuntimeException; -use Throwable; use Wwwision\DCBEventStore\EventStore; use Wwwision\DCBEventStore\EventStream; -use Wwwision\DCBEventStore\Helpers\InMemoryEventStore; use Wwwision\DCBEventStore\Helpers\InMemoryEventStream; use Wwwision\DCBEventStore\Types\AppendCondition; use Wwwision\DCBEventStore\Types\Event; use Wwwision\DCBEventStore\Types\Events; use Wwwision\DCBEventStore\Types\ReadOptions; -use Wwwision\DCBEventStore\Types\SequenceNumber; use Wwwision\DCBEventStore\Types\StreamQuery\StreamQuery; use Wwwision\DCBEventStoreDoctrine\DoctrineEventStore; -use Wwwision\DCBExample\CommandHandler; use Wwwision\DCBExample\Command\Command; use Wwwision\DCBExample\Command\CreateCourse; use Wwwision\DCBExample\Command\RegisterStudent; @@ -33,27 +28,27 @@ use Wwwision\DCBExample\Command\SubscribeStudentToCourse; use Wwwision\DCBExample\Command\UnsubscribeStudentFromCourse; use Wwwision\DCBExample\Command\UpdateCourseCapacity; +use Wwwision\DCBExample\CommandHandler; use Wwwision\DCBExample\Event\CourseCreated; use Wwwision\DCBExample\Event\DomainEvent; -use Wwwision\DCBExample\EventSerializer; use Wwwision\DCBExample\Event\StudentRegistered; use Wwwision\DCBExample\Event\StudentSubscribedToCourse; use Wwwision\DCBExample\Event\StudentUnsubscribedFromCourse; +use Wwwision\DCBExample\EventSerializer; use Wwwision\DCBExample\Exception\ConstraintException; use Wwwision\DCBExample\Types\CourseCapacity; use Wwwision\DCBExample\Types\CourseId; use Wwwision\DCBExample\Types\CourseTitle; use Wwwision\DCBExample\Types\StudentId; + use function array_diff; use function array_keys; use function array_map; use function explode; -use function func_get_args; -use function get_debug_type; use function implode; use function json_decode; -use function reset; use function sprintf; + use const JSON_THROW_ON_ERROR; final class FeatureContext implements Context diff --git a/tests/PHPStan/DecisionModelPhpStanExtension.php b/tests/PHPStan/DecisionModelPhpStanExtension.php new file mode 100644 index 0000000..3487e86 --- /dev/null +++ b/tests/PHPStan/DecisionModelPhpStanExtension.php @@ -0,0 +1,43 @@ +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