From 7da85a082c6f774180d80a00a101289ab8b2742d Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 20:03:56 +0200 Subject: [PATCH] Replace decision model builder with a Decision A Decision collects everything a command reads and appends the new events with a matching append condition, so handlers no longer pass the condition around. Reads can depend on earlier reads (chained reads), and an array of projections is still read with a single query. The append condition now uses the head of the store at the first read as `after` instead of the last matching event. With the last matching event, combining several reads either blocks every append or misses concurrent changes of the first read. Command handlers get a new Decision per dispatch as second argument when the command bus is configured with the DecisionParameterResolver, which the container does if dcb is enabled. --- docs/container.md | 8 +- docs/dynamic-consistency-boundary.md | 211 ++++--- .../Handler/ServiceNotResolvable.php | 5 + src/CommandBus/HandlerDescriptor.php | 4 +- src/CommandBus/ServiceHandlerProvider.php | 40 +- src/Container/Factory.php | 27 +- src/DecisionModel/Decision.php | 152 +++++ src/DecisionModel/DecisionAlreadyAppended.php | 16 + src/DecisionModel/DecisionFactory.php | 25 + src/DecisionModel/DecisionModel.php | 59 -- src/DecisionModel/DecisionModelBuilder.php | 16 - .../DecisionParameterResolver.php | 47 ++ .../StoreDecisionModelBuilder.php | 51 -- src/Projection/CheckpointPositions.php | 4 +- src/Projection/CompositeProjection.php | 10 +- src/Projection/ProjectionBuilder.php | 6 +- src/Projection/StoreProjectionBuilder.php | 11 +- src/Store/AppendStore.php | 7 + src/Store/InMemoryStore.php | 6 + src/Store/TaggableDoctrineDbalStore.php | 23 + .../DynamicConsistencyBoundaryBench.php | 57 +- .../Handler/ChangeCourseCapacityHandler.php | 26 +- .../Course/Handler/DefineCourseHandler.php | 23 +- .../SubscribeStudentToCourseHandler.php | 37 +- .../Projection/CourseCapacityProjection.php | 1 + .../Projection/CourseStudentsProjection.php | 43 ++ .../NumberOfCourseSubscriptionsProjection.php | 1 + ...NumberOfStudentSubscriptionsProjection.php | 1 + .../StudentAlreadySubscribedProjection.php | 1 + .../Projection/StudentCoursesProjection.php | 43 ++ .../DynamicConsistencyBoundaryTest.php | 169 ++++-- .../Invoice/Handler/CreateInvoiceHandler.php | 23 +- .../NextInvoiceNumberProjection.php | 1 + .../Store/TaggableDoctrineDbalStoreTest.php | 18 + .../Unit/CommandBus/HandlerDescriptorTest.php | 10 + .../CommandBus/ServiceHandlerProviderTest.php | 51 ++ tests/Unit/Container/FactoryTest.php | 11 +- .../DecisionModel/DecisionFactoryTest.php | 35 ++ .../Unit/DecisionModel/DecisionModelTest.php | 76 --- .../DecisionParameterResolverTest.php | 77 +++ tests/Unit/DecisionModel/DecisionTest.php | 525 ++++++++++++++++++ .../StoreDecisionModelBuilderTest.php | 279 ---------- tests/Unit/Store/InMemoryStoreTest.php | 18 + 43 files changed, 1507 insertions(+), 747 deletions(-) create mode 100644 src/DecisionModel/Decision.php create mode 100644 src/DecisionModel/DecisionAlreadyAppended.php create mode 100644 src/DecisionModel/DecisionFactory.php delete mode 100644 src/DecisionModel/DecisionModel.php delete mode 100644 src/DecisionModel/DecisionModelBuilder.php create mode 100644 src/DecisionModel/DecisionParameterResolver.php delete mode 100644 src/DecisionModel/StoreDecisionModelBuilder.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseStudentsProjection.php create mode 100644 tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentCoursesProjection.php create mode 100644 tests/Unit/DecisionModel/DecisionFactoryTest.php delete mode 100644 tests/Unit/DecisionModel/DecisionModelTest.php create mode 100644 tests/Unit/DecisionModel/DecisionParameterResolverTest.php create mode 100644 tests/Unit/DecisionModel/DecisionTest.php delete mode 100644 tests/Unit/DecisionModel/StoreDecisionModelBuilderTest.php diff --git a/docs/container.md b/docs/container.md index faf34fa3e..1e0b18143 100644 --- a/docs/container.md +++ b/docs/container.md @@ -193,8 +193,7 @@ so your service can use the event sourcing services. ```php use Patchlevel\EventSourcing\Container\Container; use Patchlevel\EventSourcing\Container\Factory; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\EventAppender; +use Psr\Clock\ClockInterface; $container = Factory::create([ 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app'], @@ -205,12 +204,13 @@ $container = Factory::create([ ], 'services' => [ CreateHotelHandler::class => static fn (Container $container): CreateHotelHandler => new CreateHotelHandler( - $container->get(DecisionModelBuilder::class), - $container->get(EventAppender::class), + $container->get(ClockInterface::class), ), ], ]); ``` +With `dcb` enabled, command handlers also get a new `Decision` as argument, +see [Dynamic Consistency Boundary](dynamic-consistency-boundary.md). Your services are registered last, so a service with the id of a built-in service replaces it. `parameters` are plain values, for example for the `#[Inject]` attribute of [aggregate handlers](command-bus.md). diff --git a/docs/dynamic-consistency-boundary.md b/docs/dynamic-consistency-boundary.md index 915228b04..76b8be1b5 100644 --- a/docs/dynamic-consistency-boundary.md +++ b/docs/dynamic-consistency-boundary.md @@ -187,6 +187,9 @@ Each projection: * Applies only the few events relevant to compute the decision value * Optionally filters events by tags +The generic type of `BasicProjection` is the type of the state. +Static analysis like PHPStan uses it to know what reading the projection returns. + The first projection answers only whether the hotel already exists. ```php @@ -194,6 +197,7 @@ use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Projection\BasicProjection; +/** @extends BasicProjection */ final class HotelExists extends BasicProjection { public function __construct( @@ -226,6 +230,7 @@ use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Projection\BasicProjection; +/** @extends BasicProjection */ final class NumberOfGuestsInHotel extends BasicProjection { public function __construct( @@ -264,6 +269,7 @@ use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Projection\BasicProjection; +/** @extends BasicProjection */ final class GuestAlreadyCheckedIn extends BasicProjection { public function __construct( @@ -312,6 +318,7 @@ use Patchlevel\EventSourcing\Attribute\Checkpoint; use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Projection\BasicProjection; +/** @extends BasicProjection */ final class NumberOfGuestsInHotel extends BasicProjection { public function __construct( @@ -363,90 +370,67 @@ If a projection only ever needs its last event, you can override `lastEventIsEno ## Define handlers We’ll implement three command handlers corresponding to our commands. +Each handler gets a `Decision` as second argument, a new one for every command. +It reads the projections it needs and appends the new events. First, we implement the handler for the `CreateHotel` command. ```php use Patchlevel\EventSourcing\Attribute\Handle; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\EventAppender; +use Patchlevel\EventSourcing\DecisionModel\Decision; final class CreateHotelHandler { - public function __construct( - private readonly DecisionModelBuilder $decisionModelBuilder, - private readonly EventAppender $eventAppender, - ) { - } - #[Handle] - public function __invoke(CreateHotel $command): void + public function __invoke(CreateHotel $command, Decision $decision): void { - $state = $this->decisionModelBuilder->build([ - 'hotelExists' => new HotelExists($command->hotelId), - ]); - - if ($state['hotelExists']) { + if ($decision->read(new HotelExists($command->hotelId))) { throw new RuntimeException('Hotel already exists'); } - $this->eventAppender->append([ + $decision->append([ new HotelCreated($command->hotelId, $command->hotelName), - ], $state->appendCondition); + ]); } } ``` :::note -Handlers build a Decision Model from the projections and then append events with an optimistic AppendCondition. -If any relevant event arrives between read and write, the append fails and you can retry. -The AppendCondition contains the query of the Decision Model and the index of the last event it has seen as `after`. -The append fails if an event matching the query exists with a higher index than `after`. +The decision remembers everything it has read. +The events are only appended if no event matching these reads was appended in the meantime. +Otherwise an `AppendConditionNotMet` exception is thrown and you can retry the command. ::: :::tip -To get type security, you can use our [phpstan extension](https://github.com/patchlevel/event-sourcing-phpstan-extension). +Use the [instant retry](command-bus.md) of the command bus to retry the command automatically. ::: The next handler implements the `CheckIn` command. ```php use Patchlevel\EventSourcing\Attribute\Handle; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\EventAppender; +use Patchlevel\EventSourcing\DecisionModel\Decision; final class CheckInHandler { - public function __construct( - private readonly DecisionModelBuilder $decisionModelBuilder, - private readonly EventAppender $eventAppender, - ) { - } - #[Handle] - public function __invoke(CheckIn $command): void + public function __invoke(CheckIn $command, Decision $decision): void { - $state = $this->decisionModelBuilder->build([ - 'hotelExists' => new HotelExists($command->hotelId), - 'guestCount' => new NumberOfGuestsInHotel($command->hotelId), - 'alreadyIn' => new GuestAlreadyCheckedIn($command->hotelId, $command->guestName), - ]); - - if (!$state['hotelExists']) { + if (!$decision->read(new HotelExists($command->hotelId))) { throw new RuntimeException('Hotel does not exist'); } - if ($state['alreadyIn']) { + if ($decision->read(new GuestAlreadyCheckedIn($command->hotelId, $command->guestName))) { throw new RuntimeException(sprintf('Guest "%s" already checked in', $command->guestName)); } // Optional policy example: max 5 guests - if ($state['guestCount'] >= 5) { + if ($decision->read(new NumberOfGuestsInHotel($command->hotelId)) >= 5) { throw new RuntimeException('Hotel is full'); } - $this->eventAppender->append([ + $decision->append([ new GuestIsCheckedIn($command->hotelId, $command->guestName), - ], $state->appendCondition); + ]); } } ``` @@ -454,36 +438,132 @@ And the last handler implements the `CheckOut` command. ```php use Patchlevel\EventSourcing\Attribute\Handle; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\EventAppender; +use Patchlevel\EventSourcing\DecisionModel\Decision; final class CheckOutHandler { - public function __construct( - private readonly DecisionModelBuilder $decisionModelBuilder, - private readonly EventAppender $eventAppender, - ) { + #[Handle] + public function __invoke(CheckOut $command, Decision $decision): void + { + if (!$decision->read(new HotelExists($command->hotelId))) { + throw new RuntimeException('Hotel does not exist'); + } + + if (!$decision->read(new GuestAlreadyCheckedIn($command->hotelId, $command->guestName))) { + throw new RuntimeException(sprintf('Guest "%s" is not checked in', $command->guestName)); + } + + $decision->append([ + new GuestIsCheckedOut($command->hotelId, $command->guestName), + ]); } +} +``` +### Reading several projections at once + +Every read is a query to the event store. +To save queries, pass an array of projections. +They are read with one query and the states are returned with the same keys. + +```php +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\DecisionModel\Decision; +final class CheckInHandler +{ #[Handle] - public function __invoke(CheckOut $command): void + public function __invoke(CheckIn $command, Decision $decision): void { - $state = $this->decisionModelBuilder->build([ + $state = $decision->read([ 'hotelExists' => new HotelExists($command->hotelId), 'alreadyIn' => new GuestAlreadyCheckedIn($command->hotelId, $command->guestName), + 'guestCount' => new NumberOfGuestsInHotel($command->hotelId), ]); if (!$state['hotelExists']) { throw new RuntimeException('Hotel does not exist'); } - if (!$state['alreadyIn']) { - throw new RuntimeException(sprintf('Guest "%s" is not checked in', $command->guestName)); + if ($state['alreadyIn']) { + throw new RuntimeException(sprintf('Guest "%s" already checked in', $command->guestName)); } - $this->eventAppender->append([ - new GuestIsCheckedOut($command->hotelId, $command->guestName), - ], $state->appendCondition); + if ($state['guestCount'] >= 5) { + throw new RuntimeException('Hotel is full'); + } + + $decision->append([ + new GuestIsCheckedIn($command->hotelId, $command->guestName), + ]); + } +} +``` +:::tip +With our [phpstan extension](https://github.com/patchlevel/event-sourcing-phpstan-extension), +PHPStan knows the type of each state in the array, here `array{hotelExists: bool, alreadyIn: bool, guestCount: int}`. +::: + +### Chained reads + +Sometimes you only know which events a decision needs after you have read others. +For example, moving all guests of one hotel to another is only allowed if none of them is already checked in there. +Which guests you have to check depends on the first read. + +Since the decision remembers all reads, you can simply read again based on what you have read before. + +```php +use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\DecisionModel\Decision; + +final class MoveGuestsHandler +{ + #[Handle] + public function __invoke(MoveGuests $command, Decision $decision): void + { + $events = []; + + foreach ($decision->read(new GuestsInHotel($command->fromHotelId)) as $guestName) { + if ($decision->read(new GuestAlreadyCheckedIn($command->toHotelId, $guestName))) { + throw new RuntimeException(sprintf('Guest "%s" is already checked in', $guestName)); + } + + $events[] = new GuestIsCheckedOut($command->fromHotelId, $guestName); + $events[] = new GuestIsCheckedIn($command->toHotelId, $guestName); + } + + $decision->append($events); + } +} +``` +If a guest checks into the first hotel in the meantime, the append fails, +even though the second read never looked at this guest. + +### Without the command bus + +If you don't use our command bus, for example with Symfony Messenger, +inject the `DecisionFactory` and start a new decision for each command. + +```php +use Patchlevel\EventSourcing\DecisionModel\DecisionFactory; + +final class CreateHotelHandler +{ + public function __construct( + private readonly DecisionFactory $decisionFactory, + ) { + } + + public function __invoke(CreateHotel $command): void + { + $decision = $this->decisionFactory->begin(); + + if ($decision->read(new HotelExists($command->hotelId))) { + throw new RuntimeException('Hotel already exists'); + } + + $decision->append([ + new HotelCreated($command->hotelId, $command->hotelName), + ]); } } ``` @@ -497,8 +577,8 @@ use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Tools\DsnParser; use Patchlevel\EventSourcing\CommandBus\ServiceHandlerProvider; use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; -use Patchlevel\EventSourcing\DecisionModel\StoreDecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\StoreEventAppender; +use Patchlevel\EventSourcing\DecisionModel\DecisionFactory; +use Patchlevel\EventSourcing\DecisionModel\DecisionParameterResolver; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\Dbal\PostgreSQLPlatformMiddleware; @@ -513,14 +593,16 @@ $serializer = new DefaultEventSerializer($eventRegistry); $eventStore = new TaggableDoctrineDbalStore($connection, $serializer, $eventRegistry); -$decisionModelBuilder = new StoreDecisionModelBuilder($eventStore); -$eventAppender = new StoreEventAppender($eventStore); +$decisionFactory = new DecisionFactory($eventStore); -$provider = new ServiceHandlerProvider([ - new CreateHotelHandler($decisionModelBuilder, $eventAppender), - new CheckInHandler($decisionModelBuilder, $eventAppender), - new CheckOutHandler($decisionModelBuilder, $eventAppender), -]); +$provider = new ServiceHandlerProvider( + [ + new CreateHotelHandler(), + new CheckInHandler(), + new CheckOutHandler(), + ], + new DecisionParameterResolver($decisionFactory), +); $commandBus = new SyncCommandBus($provider); ``` @@ -534,8 +616,7 @@ see [TaggableDoctrineDbalStore](store.md#taggabledoctrinedbalstore). :::tip For tests you can replace the `TaggableDoctrineDbalStore` with an `InMemoryStore`. It implements the -same tag query and conditional append, so the decision model and the append condition behave the same -way without a database. +same tag query and conditional append, so decisions behave the same way without a database. ::: :::note diff --git a/src/CommandBus/Handler/ServiceNotResolvable.php b/src/CommandBus/Handler/ServiceNotResolvable.php index 79913d386..f6632c041 100644 --- a/src/CommandBus/Handler/ServiceNotResolvable.php +++ b/src/CommandBus/Handler/ServiceNotResolvable.php @@ -28,6 +28,11 @@ public static function missingContainer(): self return new self('Container is not configured'); } + public static function unsupportedParameter(string $class, string $method, string $parameter): self + { + return new self(sprintf('Parameter "%s" in "%s::%s" can not be resolved', $parameter, $class, $method)); + } + public static function missingService(string $class, string $method, string $parameter, Throwable $exception): self { return new self( diff --git a/src/CommandBus/HandlerDescriptor.php b/src/CommandBus/HandlerDescriptor.php index 99a68a3e9..64bb301b9 100644 --- a/src/CommandBus/HandlerDescriptor.php +++ b/src/CommandBus/HandlerDescriptor.php @@ -12,10 +12,10 @@ final class HandlerDescriptor private readonly Closure $callable; private readonly string $name; - public function __construct(callable $callable) + public function __construct(callable $callable, string|null $name = null) { $this->callable = $callable(...); - $this->name = self::closureName($this->callable); + $this->name = $name ?? self::closureName($this->callable); } public function name(): string diff --git a/src/CommandBus/ServiceHandlerProvider.php b/src/CommandBus/ServiceHandlerProvider.php index 82beb319d..222cde692 100644 --- a/src/CommandBus/ServiceHandlerProvider.php +++ b/src/CommandBus/ServiceHandlerProvider.php @@ -4,6 +4,10 @@ namespace Patchlevel\EventSourcing\CommandBus; +use Closure; +use Patchlevel\EventSourcing\CommandBus\Handler\ParameterResolver; +use ReflectionMethod; + use function class_implements; use function class_parents; @@ -14,9 +18,14 @@ final class ServiceHandlerProvider implements HandlerProvider /** @var array> */ private array $handlers = []; - /** @param iterable $services */ + /** + * Without a parameter resolver, handlers are called with the command only. + * + * @param iterable $services + */ public function __construct( private readonly iterable $services, + private readonly ParameterResolver|null $parameterResolver = null, ) { } @@ -40,16 +49,13 @@ private function initialize(): void { foreach ($this->services as $service) { foreach (HandlerFinder::findInClass($service::class) as $handler) { - if ($handler->static) { - $this->handlers[$handler->commandClass][] = new HandlerDescriptor( - $service::{$handler->method}(...), - ); - - continue; - } + $callable = $handler->static + ? $service::{$handler->method}(...) + : $service->{$handler->method}(...); - $this->handlers[$handler->commandClass][] = new HandlerDescriptor( - $service->{$handler->method}(...), + $this->handlers[$handler->commandClass][] = $this->descriptor( + $callable, + new ReflectionMethod($service, $handler->method), ); } } @@ -57,6 +63,20 @@ private function initialize(): void $this->initialized = true; } + private function descriptor(Closure $callable, ReflectionMethod $method): HandlerDescriptor + { + $parameterResolver = $this->parameterResolver; + + if ($parameterResolver === null || $method->getNumberOfParameters() <= 1) { + return new HandlerDescriptor($callable); + } + + return new HandlerDescriptor( + static fn (object $command): mixed => $callable(...$parameterResolver->resolve($method, $command)), + $method->getDeclaringClass()->getName() . '::' . $method->getName(), + ); + } + /** * @param class-string $class * diff --git a/src/Container/Factory.php b/src/Container/Factory.php index 39bcd8443..edc232a5e 100644 --- a/src/Container/Factory.php +++ b/src/Container/Factory.php @@ -50,9 +50,9 @@ use Patchlevel\EventSourcing\Console\Command\WatchCommand; use Patchlevel\EventSourcing\Console\DoctrineHelper; use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; +use Patchlevel\EventSourcing\DecisionModel\DecisionFactory; +use Patchlevel\EventSourcing\DecisionModel\DecisionParameterResolver; use Patchlevel\EventSourcing\DecisionModel\EventAppender; -use Patchlevel\EventSourcing\DecisionModel\StoreDecisionModelBuilder; use Patchlevel\EventSourcing\DecisionModel\StoreEventAppender; use Patchlevel\EventSourcing\EventBus\AttributeListenerProvider; use Patchlevel\EventSourcing\EventBus\Consumer; @@ -205,7 +205,7 @@ final class Factory SnapshotStore::class, SubscriptionEngine::class, SubscriptionStore::class, - DecisionModelBuilder::class, + DecisionFactory::class, EventAppender::class, ProjectionBuilder::class, SchemaDirector::class, @@ -592,7 +592,10 @@ private static function configureCommandBus(array $config, Container $container) ), ] : [], - new CommandServiceHandlerProvider(self::resolveObjects($container, $commandBus['handlers'])), + new CommandServiceHandlerProvider( + self::resolveObjects($container, $commandBus['handlers']), + $config['dcb']['enabled'] ? new DecisionParameterResolver($container->get(DecisionFactory::class)) : null, + ), ...self::resolveServices($container, $commandBus['handler_providers'], HandlerProvider::class), ]), ); @@ -812,14 +815,6 @@ private static function configureDecisionModel(array $config, Container $contain return; } - $container->bind( - StoreDecisionModelBuilder::class, - static fn (Container $container): StoreDecisionModelBuilder => new StoreDecisionModelBuilder( - self::resolveService($container, Store::class, AppendStore::class), - ), - ); - $container->alias(DecisionModelBuilder::class, StoreDecisionModelBuilder::class); - /** @var string $defaultStreamName the store is responsible for its options */ $defaultStreamName = $config['store']['options']['default_stream_name'] ?? 'main'; @@ -833,6 +828,14 @@ private static function configureDecisionModel(array $config, Container $contain ); $container->alias(EventAppender::class, StoreEventAppender::class); + $container->bind( + DecisionFactory::class, + static fn (Container $container): DecisionFactory => new DecisionFactory( + self::resolveService($container, Store::class, AppendStore::class), + $container->get(EventAppender::class), + ), + ); + $container->bind( StoreProjectionBuilder::class, static fn (Container $container): StoreProjectionBuilder => new StoreProjectionBuilder( diff --git a/src/DecisionModel/Decision.php b/src/DecisionModel/Decision.php new file mode 100644 index 000000000..3a1634871 --- /dev/null +++ b/src/DecisionModel/Decision.php @@ -0,0 +1,152 @@ + */ + private array $subQueries = []; + + private bool $readsEverything = false; + + /** @var positive-int|0|null */ + private int|null $after = null; + + private bool $appended = false; + + public function __construct( + private readonly AppendStore $store, + EventAppender|null $eventAppender = null, + ) { + $this->eventAppender = $eventAppender ?? new StoreEventAppender($store); + } + + /** + * Reads the state of the projection and adds its query to the append condition. + * Pass an array of projections to read them with one query, the states are returned with the same keys. + * Later reads may depend on the state of earlier ones. + * + * @param Projection|array $projections + * + * @return ($projections is Projection ? S : array) + * + * @template S + * @template K of array-key + */ + public function read(Projection|array $projections): mixed + { + if (!$projections instanceof Projection) { + return $this->readAll($projections); + } + + /** @var S $state */ + $state = $this->readAll([$projections])[0]; + + return $state; + } + + /** + * Appends the events, but only if nothing the decision has read has changed in the meantime. + * Without any read, the events are appended without a condition. + * + * @param list $events + * + * @throws DecisionAlreadyAppended + */ + public function append(array $events, string|null $streamName = null): void + { + if ($this->appended) { + throw new DecisionAlreadyAppended(); + } + + $this->eventAppender->append($events, $this->appendCondition(), $streamName); + $this->appended = true; + } + + public function appendCondition(): AppendCondition|null + { + if ($this->after === null) { + return null; + } + + if ($this->readsEverything) { + return new AppendCondition(new Query(), $this->after); + } + + return new AppendCondition( + (new Query(...$this->subQueries))->optimize(), + $this->after, + ); + } + + /** + * @param array $projections + * + * @return array + * + * @template K of array-key + */ + private function readAll(array $projections): array + { + if ($this->appended) { + throw new DecisionAlreadyAppended(); + } + + if ($projections === []) { + return []; + } + + $from = CheckpointPositions::resolve($this->store, $projections); + $projection = new CompositeProjection($projections, $from); + $query = $projection->query(); + + // Everything up to the head of the first read is visible to this and every later read, + // so it is the `after` of the whole decision. + $after = $this->after ?? $this->store->head(); + + $stream = $this->store->query($query, $from === [] ? 0 : min($from)); + $state = $projection->initialState(); + + foreach ($stream as $message) { + if ($this->after === null) { + $after = max($after, $stream->index() ?? 0); + } + + $state = $projection->apply($state, $message); + } + + $this->after = $after; + + if ($query->subQueries === []) { + $this->readsEverything = true; + } else { + $this->subQueries = [...$this->subQueries, ...$query->subQueries]; + } + + /** @var array $result */ + $result = $state; + + return $result; + } +} diff --git a/src/DecisionModel/DecisionAlreadyAppended.php b/src/DecisionModel/DecisionAlreadyAppended.php new file mode 100644 index 000000000..04bbbbfc9 --- /dev/null +++ b/src/DecisionModel/DecisionAlreadyAppended.php @@ -0,0 +1,16 @@ +eventAppender = $eventAppender ?? new StoreEventAppender($store); + } + + public function begin(): Decision + { + return new Decision($this->store, $this->eventAppender); + } +} diff --git a/src/DecisionModel/DecisionModel.php b/src/DecisionModel/DecisionModel.php deleted file mode 100644 index 306d11627..000000000 --- a/src/DecisionModel/DecisionModel.php +++ /dev/null @@ -1,59 +0,0 @@ -, value-of> - * @template T as array - */ -final class DecisionModel implements ArrayAccess -{ - /** @param T $state */ - public function __construct( - public readonly array $state, - public readonly AppendCondition $appendCondition, - ) { - } - - public function offsetExists(mixed $offset): bool - { - return array_key_exists($offset, $this->state); - } - - /** - * @param TKey $offset - * - * @return T[TKey] - * - * @template TKey of key-of - */ - public function offsetGet(mixed $offset): mixed - { - if (!$this->offsetExists($offset)) { - throw new OutOfBoundsException("Offset '$offset' does not exist in the state."); - } - - return $this->state[$offset]; - } - - public function offsetSet(mixed $offset, mixed $value): void - { - throw new LogicException('State is immutable, cannot set value.'); - } - - public function offsetUnset(mixed $offset): void - { - throw new LogicException('State is immutable, cannot unset value.'); - } -} diff --git a/src/DecisionModel/DecisionModelBuilder.php b/src/DecisionModel/DecisionModelBuilder.php deleted file mode 100644 index 4db7c6a7e..000000000 --- a/src/DecisionModel/DecisionModelBuilder.php +++ /dev/null @@ -1,16 +0,0 @@ - $projections */ - public function build( - array $projections, - ): DecisionModel; -} diff --git a/src/DecisionModel/DecisionParameterResolver.php b/src/DecisionModel/DecisionParameterResolver.php new file mode 100644 index 000000000..4c0a54032 --- /dev/null +++ b/src/DecisionModel/DecisionParameterResolver.php @@ -0,0 +1,47 @@ + */ + public function resolve(ReflectionMethod $method, object $command): iterable + { + foreach ($method->getParameters() as $index => $parameter) { + if ($index === 0) { + yield $command; // first parameter is always the command + + continue; + } + + $type = $parameter->getType(); + + if (!$type instanceof ReflectionNamedType || $type->getName() !== Decision::class) { + throw ServiceNotResolvable::unsupportedParameter( + $method->getDeclaringClass()->getName(), + $method->getName(), + $parameter->getName(), + ); + } + + yield $this->decisionFactory->begin(); + } + } +} diff --git a/src/DecisionModel/StoreDecisionModelBuilder.php b/src/DecisionModel/StoreDecisionModelBuilder.php deleted file mode 100644 index 2f76e5548..000000000 --- a/src/DecisionModel/StoreDecisionModelBuilder.php +++ /dev/null @@ -1,51 +0,0 @@ - $projections */ - public function build( - array $projections, - ): DecisionModel { - $from = CheckpointPositions::resolve($this->store, $projections); - $projection = new CompositeProjection($projections, $from); - - $query = $projection->query(); - $stream = $this->store->query($query, $from === [] ? 0 : min($from)); - - $state = $projection->initialState(); - - $highestId = 0; - - foreach ($stream as $message) { - $highestId = max($highestId, $stream->index() ?? 0); - $state = $projection->apply($state, $message); - } - - return new DecisionModel( - $state, - new AppendCondition( - $query, - $highestId, - ), - ); - } -} diff --git a/src/Projection/CheckpointPositions.php b/src/Projection/CheckpointPositions.php index e358f8243..ff12e2b61 100644 --- a/src/Projection/CheckpointPositions.php +++ b/src/Projection/CheckpointPositions.php @@ -19,9 +19,9 @@ final class CheckpointPositions * Returns for each projection the index from which on it needs events. * That is the index of its last checkpoint event, or 0 if it has none. * - * @param array $projections + * @param array $projections * - * @return array + * @return array */ public static function resolve(AppendStore $store, array $projections): array { diff --git a/src/Projection/CompositeProjection.php b/src/Projection/CompositeProjection.php index 409880ceb..54b13c5d3 100644 --- a/src/Projection/CompositeProjection.php +++ b/src/Projection/CompositeProjection.php @@ -14,8 +14,8 @@ final class CompositeProjection { /** - * @param array $projections - * @param array $from messages before this index are not applied to the projection + * @param array $projections + * @param array $from messages before this index are not applied to the projection */ public function __construct( private readonly array $projections, @@ -40,7 +40,7 @@ public function query(): Query return $query->optimize(); } - /** @return array */ + /** @return array */ public function initialState(): array { return array_map(static function (Projection $projection) { @@ -49,9 +49,9 @@ public function initialState(): array } /** - * @param array $state + * @param array $state * - * @return array + * @return array */ public function apply(mixed $state, Message $message): mixed { diff --git a/src/Projection/ProjectionBuilder.php b/src/Projection/ProjectionBuilder.php index 39417a599..9edd50389 100644 --- a/src/Projection/ProjectionBuilder.php +++ b/src/Projection/ProjectionBuilder.php @@ -8,9 +8,11 @@ interface ProjectionBuilder { /** - * @param array $projections + * @param array $projections * - * @return array + * @return array + * + * @template K of array-key */ public function build( array $projections, diff --git a/src/Projection/StoreProjectionBuilder.php b/src/Projection/StoreProjectionBuilder.php index 3bc59480e..65f472cac 100644 --- a/src/Projection/StoreProjectionBuilder.php +++ b/src/Projection/StoreProjectionBuilder.php @@ -17,9 +17,11 @@ public function __construct( } /** - * @param array $projections + * @param array $projections * - * @return array + * @return array + * + * @template K of array-key */ public function build( array $projections, @@ -36,6 +38,9 @@ public function build( $state = $projection->apply($state, $message); } - return $state; + /** @var array $result */ + $result = $state; + + return $result; } } diff --git a/src/Store/AppendStore.php b/src/Store/AppendStore.php index 3bc987ed2..87b0ab693 100644 --- a/src/Store/AppendStore.php +++ b/src/Store/AppendStore.php @@ -22,4 +22,11 @@ public function append( * @param positive-int|0 $from */ public function query(Query $query, int $from = 0): Stream; + + /** + * Returns the highest index in the store, or 0 if the store is empty. + * + * @return positive-int|0 + */ + public function head(): int; } diff --git a/src/Store/InMemoryStore.php b/src/Store/InMemoryStore.php index ddad4022d..cc59588c5 100644 --- a/src/Store/InMemoryStore.php +++ b/src/Store/InMemoryStore.php @@ -122,6 +122,12 @@ public function query(Query $query, int $from = 0): Stream )); } + /** @return positive-int|0 */ + public function head(): int + { + return array_key_last($this->messages) ?? 0; + } + /** @param iterable $messages */ public function append(iterable $messages, AppendCondition|null $appendCondition = null): void { diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index 26a0a1434..6dafa0e47 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -552,6 +552,29 @@ public function query(Query $query, int $from = 0): Stream ); } + /** @return positive-int|0 */ + public function head(): int + { + $builder = $this->connection->createQueryBuilder() + ->select('MAX(id)') + ->from($this->config['table_name']); + + $result = $this->connection->fetchOne($builder->getSQL()); + + if ($result === null) { + return 0; + } + + if (!is_int($result) && !is_string($result)) { + throw new WrongQueryResult(); + } + + /** @var positive-int $head */ + $head = (int)$result; + + return $head; + } + /** * @param Closure():ClosureReturn $function * diff --git a/tests/Benchmark/DynamicConsistencyBoundaryBench.php b/tests/Benchmark/DynamicConsistencyBoundaryBench.php index b1f7bc2ff..1353b1ac1 100644 --- a/tests/Benchmark/DynamicConsistencyBoundaryBench.php +++ b/tests/Benchmark/DynamicConsistencyBoundaryBench.php @@ -4,9 +4,8 @@ namespace Patchlevel\EventSourcing\Tests\Benchmark; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; +use Patchlevel\EventSourcing\DecisionModel\DecisionFactory; use Patchlevel\EventSourcing\DecisionModel\EventAppender; -use Patchlevel\EventSourcing\DecisionModel\StoreDecisionModelBuilder; use Patchlevel\EventSourcing\DecisionModel\StoreEventAppender; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; @@ -51,7 +50,7 @@ final class DynamicConsistencyBoundaryBench private const CHECKPOINT_EVERY = 100; private TaggableDoctrineDbalStore $store; - private DecisionModelBuilder $decisionModelBuilder; + private DecisionFactory $decisionFactory; private EventAppender $eventAppender; private int $newStudent = 0; @@ -66,8 +65,8 @@ public function setUp(): void (new AttributeEventRegistryFactory())->create([__DIR__ . '/DynamicConsistencyBoundary/Events']), ); - $this->decisionModelBuilder = new StoreDecisionModelBuilder($this->store); $this->eventAppender = new StoreEventAppender($this->store); + $this->decisionFactory = new DecisionFactory($this->store, $this->eventAppender); $schemaDirector = new DoctrineSchemaDirector( $connection, @@ -123,15 +122,13 @@ public function setUp(): void #[Bench\Revs(10)] public function benchDecisionModelSmall(): void { - $this->decisionModelBuilder->build([ - 'courseExists' => new CourseExists('c500'), - ]); + $this->decisionFactory->begin()->read(new CourseExists('c500')); } #[Bench\Revs(10)] public function benchDecisionModelComposite(): void { - $this->decisionModelBuilder->build([ + $this->decisionFactory->begin()->read([ 'courseExists' => new CourseExists('c500'), 'courseCapacity' => new CourseCapacity('c500'), 'numberOfCourseSubscriptions' => new NumberOfCourseSubscriptions('c500'), @@ -140,28 +137,33 @@ public function benchDecisionModelComposite(): void ]); } + #[Bench\Revs(10)] + public function benchDecisionModelSingleReads(): void + { + $decision = $this->decisionFactory->begin(); + $decision->read(new CourseExists('c500')); + $decision->read(new CourseCapacity('c500')); + $decision->read(new NumberOfCourseSubscriptions('c500')); + $decision->read(new NumberOfStudentSubscriptions('s42')); + $decision->read(new StudentAlreadySubscribed('c500', 's42')); + } + #[Bench\Revs(10)] public function benchDecisionModel10000Events(): void { - $this->decisionModelBuilder->build([ - 'numberOfCourseSubscriptions' => new NumberOfCourseSubscriptions('long'), - ]); + $this->decisionFactory->begin()->read(new NumberOfCourseSubscriptions('long')); } #[Bench\Revs(10)] public function benchDecisionModel10000EventsWithCheckpoint(): void { - $this->decisionModelBuilder->build([ - 'numberOfCourseSubscriptions' => new NumberOfCourseSubscriptionsWithCheckpoint('checkpoint'), - ]); + $this->decisionFactory->begin()->read(new NumberOfCourseSubscriptionsWithCheckpoint('checkpoint')); } #[Bench\Revs(10)] public function benchDecisionModelOnlyLastEvent(): void { - $this->decisionModelBuilder->build([ - 'nextInvoiceNumber' => new NextInvoiceNumber(), - ]); + $this->decisionFactory->begin()->read(new NextInvoiceNumber()); } #[Bench\Revs(10)] @@ -169,18 +171,29 @@ public function benchDecideAndAppend(): void { $studentId = sprintf('new%d', ++$this->newStudent); - $decisionModel = $this->decisionModelBuilder->build([ + $decision = $this->decisionFactory->begin(); + $decision->read([ 'courseExists' => new CourseExists('c500'), 'courseCapacity' => new CourseCapacity('c500'), 'numberOfCourseSubscriptions' => new NumberOfCourseSubscriptions('c500'), 'numberOfStudentSubscriptions' => new NumberOfStudentSubscriptions($studentId), 'studentAlreadySubscribed' => new StudentAlreadySubscribed('c500', $studentId), ]); + $decision->append([new StudentSubscribedToCourse('c500', $studentId)]); + } - $this->eventAppender->append( - [new StudentSubscribedToCourse('c500', $studentId)], - $decisionModel->appendCondition, - ); + #[Bench\Revs(10)] + public function benchDecideAndAppendSingleReads(): void + { + $studentId = sprintf('new%d', ++$this->newStudent); + + $decision = $this->decisionFactory->begin(); + $decision->read(new CourseExists('c500')); + $decision->read(new CourseCapacity('c500')); + $decision->read(new NumberOfCourseSubscriptions('c500')); + $decision->read(new NumberOfStudentSubscriptions($studentId)); + $decision->read(new StudentAlreadySubscribed('c500', $studentId)); + $decision->append([new StudentSubscribedToCourse('c500', $studentId)]); } #[Bench\Revs(10)] diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Handler/ChangeCourseCapacityHandler.php b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/ChangeCourseCapacityHandler.php index f77288938..6ef2fb056 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Handler/ChangeCourseCapacityHandler.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/ChangeCourseCapacityHandler.php @@ -5,8 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Handler; use Patchlevel\EventSourcing\Attribute\Handle; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\EventAppender; +use Patchlevel\EventSourcing\DecisionModel\Decision; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Command\ChangeCourseCapacity; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\CourseCapacityChanged; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection\CourseCapacityProjection; @@ -15,35 +14,22 @@ final class ChangeCourseCapacityHandler { - public function __construct( - private readonly DecisionModelBuilder $decisionModelBuilder, - private readonly EventAppender $eventAppender, - ) { - } - #[Handle] - public function __invoke(ChangeCourseCapacity $command): void + public function __invoke(ChangeCourseCapacity $command, Decision $decision): void { - $state = $this->decisionModelBuilder->build( - [ - 'courseExists' => new CourseExists($command->courseId), - 'courseCapacity' => new CourseCapacityProjection($command->courseId), - ], - ); - - if (!$state['courseExists']) { + if (!$decision->read(new CourseExists($command->courseId))) { throw new RuntimeException('Course does not exist'); } - if ($state['courseCapacity'] === $command->capacity) { + if ($decision->read(new CourseCapacityProjection($command->courseId)) === $command->capacity) { return; } - $this->eventAppender->append([ + $decision->append([ new CourseCapacityChanged( $command->courseId, $command->capacity, ), - ], $state->appendCondition); + ]); } } diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Handler/DefineCourseHandler.php b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/DefineCourseHandler.php index bed13c144..383bead98 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Handler/DefineCourseHandler.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/DefineCourseHandler.php @@ -5,8 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Handler; use Patchlevel\EventSourcing\Attribute\Handle; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\EventAppender; +use Patchlevel\EventSourcing\DecisionModel\Decision; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Command\DefineCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\CourseDefined; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection\CourseExists; @@ -14,30 +13,18 @@ final class DefineCourseHandler { - public function __construct( - private readonly DecisionModelBuilder $decisionModelBuilder, - private readonly EventAppender $eventAppender, - ) { - } - #[Handle] - public function __invoke(DefineCourse $command): void + public function __invoke(DefineCourse $command, Decision $decision): void { - $state = $this->decisionModelBuilder->build( - [ - 'courseExists' => new CourseExists($command->courseId), - ], - ); - - if ($state['courseExists']) { + if ($decision->read(new CourseExists($command->courseId))) { throw new RuntimeException('Course already exists'); } - $this->eventAppender->append([ + $decision->append([ new CourseDefined( $command->courseId, $command->capacity, ), - ], $state->appendCondition); + ]); } } diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Handler/SubscribeStudentToCourseHandler.php b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/SubscribeStudentToCourseHandler.php index 840e84ca9..c47ef7564 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Handler/SubscribeStudentToCourseHandler.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Handler/SubscribeStudentToCourseHandler.php @@ -5,8 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Handler; use Patchlevel\EventSourcing\Attribute\Handle; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\EventAppender; +use Patchlevel\EventSourcing\DecisionModel\Decision; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Command\SubscribeStudentToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection\CourseCapacityProjection; @@ -18,27 +17,19 @@ final class SubscribeStudentToCourseHandler { - public function __construct( - private readonly DecisionModelBuilder $decisionModelBuilder, - private readonly EventAppender $eventAppender, - ) { - } - #[Handle] - public function __invoke(SubscribeStudentToCourse $command): void + public function __invoke(SubscribeStudentToCourse $command, Decision $decision): void { - $state = $this->decisionModelBuilder->build( - projections: [ - 'courseExists' => new CourseExists($command->courseId), - 'courseCapacity' => new CourseCapacityProjection($command->courseId), - 'numberOfCourseSubscriptions' => new NumberOfCourseSubscriptionsProjection($command->courseId), - 'numberOfStudentSubscriptions' => new NumberOfStudentSubscriptionsProjection($command->studentId), - 'studentAlreadySubscribed' => new StudentAlreadySubscribedProjection( - $command->studentId, - $command->courseId, - ), - ], - ); + $state = $decision->read([ + 'courseExists' => new CourseExists($command->courseId), + 'courseCapacity' => new CourseCapacityProjection($command->courseId), + 'numberOfCourseSubscriptions' => new NumberOfCourseSubscriptionsProjection($command->courseId), + 'numberOfStudentSubscriptions' => new NumberOfStudentSubscriptionsProjection($command->studentId), + 'studentAlreadySubscribed' => new StudentAlreadySubscribedProjection( + $command->studentId, + $command->courseId, + ), + ]); if (!$state['courseExists']) { throw new RuntimeException("Course {$command->courseId->toString()} does not exist"); @@ -56,11 +47,11 @@ public function __invoke(SubscribeStudentToCourse $command): void throw new RuntimeException("Student {$command->studentId->toString()} is already subscribed to 5 courses"); } - $this->eventAppender->append([ + $decision->append([ new StudentSubscribedToCourse( $command->studentId, $command->courseId, ), - ], $state->appendCondition); + ]); } } diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseCapacityProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseCapacityProjection.php index bb99f5938..1923f940d 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseCapacityProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseCapacityProjection.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\CourseCapacityChanged; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\CourseDefined; +/** @extends BasicProjection */ final class CourseCapacityProjection extends BasicProjection { public function __construct( diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseStudentsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseStudentsProjection.php new file mode 100644 index 000000000..651e4b677 --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/CourseStudentsProjection.php @@ -0,0 +1,43 @@ +> */ +final class CourseStudentsProjection extends BasicProjection +{ + public function __construct( + private readonly CourseId $courseId, + ) { + } + + /** + * @param list $state + * + * @return list + */ + #[Apply] + public function applyStudentSubscribedToCourse(array $state, StudentSubscribedToCourse $event): array + { + return [...$state, $event->studentId]; + } + + /** @return list */ + public function initialState(): array + { + return []; + } + + /** @return list */ + public function tagFilter(): array + { + return ["course:{$this->courseId->toString()}"]; + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php index e3a2d2d7d..039dc173c 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfCourseSubscriptionsProjection.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\CourseId; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; +/** @extends BasicProjection */ final class NumberOfCourseSubscriptionsProjection extends BasicProjection { public function __construct( diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php index dd22acba2..c2cf93203 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/NumberOfStudentSubscriptionsProjection.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\StudentId; +/** @extends BasicProjection */ final class NumberOfStudentSubscriptionsProjection extends BasicProjection { public function __construct( diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php index e6fc71749..33fe61ce3 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentAlreadySubscribedProjection.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Event\StudentSubscribedToCourse; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\StudentId; +/** @extends BasicProjection */ final class StudentAlreadySubscribedProjection extends BasicProjection { public function __construct( diff --git a/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentCoursesProjection.php b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentCoursesProjection.php new file mode 100644 index 000000000..9c60d688e --- /dev/null +++ b/tests/Integration/DynamicConsistencyBoundary/Course/Projection/StudentCoursesProjection.php @@ -0,0 +1,43 @@ +> */ +final class StudentCoursesProjection extends BasicProjection +{ + public function __construct( + private readonly StudentId $studentId, + ) { + } + + /** + * @param list $state + * + * @return list + */ + #[Apply] + public function applyStudentSubscribedToCourse(array $state, StudentSubscribedToCourse $event): array + { + return [...$state, $event->courseId]; + } + + /** @return list */ + public function initialState(): array + { + return []; + } + + /** @return list */ + public function tagFilter(): array + { + return ["student:{$this->studentId->toString()}"]; + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php index 7a1990094..10f43434a 100644 --- a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php +++ b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php @@ -8,17 +8,15 @@ use Patchlevel\EventSourcing\CommandBus\CommandBus; use Patchlevel\EventSourcing\CommandBus\ServiceHandlerProvider; use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; -use Patchlevel\EventSourcing\Container\Container; use Patchlevel\EventSourcing\Container\Factory; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\EventAppender; -use Patchlevel\EventSourcing\DecisionModel\StoreDecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\StoreEventAppender; +use Patchlevel\EventSourcing\DecisionModel\DecisionFactory; +use Patchlevel\EventSourcing\DecisionModel\DecisionParameterResolver; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Schema\SchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Store\AppendConditionNotMet; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; @@ -33,6 +31,8 @@ use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Handler\ChangeCourseCapacityHandler; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Handler\DefineCourseHandler; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Handler\SubscribeStudentToCourseHandler; +use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection\CourseStudentsProjection; +use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Projection\StudentCoursesProjection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\StudentId; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Command\CreateInvoice; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Event\InvoiceCreated; @@ -60,30 +60,19 @@ public function tearDown(): void public function testCourse(): void { - $store = new TaggableDoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Course/Event']), - (new AttributeEventRegistryFactory())->create([__DIR__ . '/Course/Event']), - ); - - $decisionModelBuilder = new StoreDecisionModelBuilder($store); - $eventAppender = new StoreEventAppender($store); + $store = $this->createStore(__DIR__ . '/Course/Event'); $commandBus = new SyncCommandBus( - new ServiceHandlerProvider([ - new DefineCourseHandler($decisionModelBuilder, $eventAppender), - new ChangeCourseCapacityHandler($decisionModelBuilder, $eventAppender), - new SubscribeStudentToCourseHandler($decisionModelBuilder, $eventAppender), - ]), - ); - - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, + new ServiceHandlerProvider( + [ + new DefineCourseHandler(), + new ChangeCourseCapacityHandler(), + new SubscribeStudentToCourseHandler(), + ], + new DecisionParameterResolver(new DecisionFactory($store)), + ), ); - $schemaDirector->create(); - $courseId = CourseId::generate(); $student1Id = StudentId::generate(); $student2Id = StudentId::generate(); @@ -105,29 +94,82 @@ public function testCourse(): void ], $store->load()); } - public function testInvoice(): void + public function testChainedReads(): void { - $store = new TaggableDoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Invoice/Event']), - (new AttributeEventRegistryFactory())->create([__DIR__ . '/Invoice/Event']), - ); + $store = $this->createStore(__DIR__ . '/Course/Event'); + $decisionFactory = new DecisionFactory($store); + $commandBus = $this->courseCommandBus($decisionFactory); - $decisionModelBuilder = new StoreDecisionModelBuilder($store); - $eventAppender = new StoreEventAppender($store); + $course1Id = CourseId::generate(); + $course2Id = CourseId::generate(); + $studentId = StudentId::generate(); - $commandBus = new SyncCommandBus( - new ServiceHandlerProvider([ - new CreateInvoiceHandler($decisionModelBuilder, $eventAppender), - ]), - ); + $commandBus->dispatch(new DefineCourse($course1Id, 10)); + $commandBus->dispatch(new DefineCourse($course2Id, 10)); + $commandBus->dispatch(new SubscribeStudentToCourse($studentId, $course1Id)); + $commandBus->dispatch(new SubscribeStudentToCourse($studentId, $course2Id)); - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, - ); + $decision = $decisionFactory->begin(); + + $studentIds = $decision->read(new CourseStudentsProjection($course1Id)); + self::assertEquals([$studentId], $studentIds); + + $courseIds = $decision->read(new StudentCoursesProjection($studentId)); + self::assertEquals([$course1Id, $course2Id], $courseIds); + + // the newest event only matches the second read, it must not block the append + $decision->append([new CourseCapacityChanged($course1Id, 5)]); + + self::assertSame(5, $store->head()); + } + + public function testChainedReadsDetectConcurrentChangeOfFirstRead(): void + { + $store = $this->createStore(__DIR__ . '/Course/Event'); + $decisionFactory = new DecisionFactory($store); + $commandBus = $this->courseCommandBus($decisionFactory); + + $course1Id = CourseId::generate(); + $course2Id = CourseId::generate(); + $course3Id = CourseId::generate(); + $student1Id = StudentId::generate(); + $student2Id = StudentId::generate(); - $schemaDirector->create(); + $commandBus->dispatch(new DefineCourse($course1Id, 10)); + $commandBus->dispatch(new DefineCourse($course2Id, 10)); + $commandBus->dispatch(new DefineCourse($course3Id, 10)); + $commandBus->dispatch(new SubscribeStudentToCourse($student1Id, $course1Id)); + $commandBus->dispatch(new SubscribeStudentToCourse($student1Id, $course2Id)); + + $decision = $decisionFactory->begin(); + + $studentIds = $decision->read(new CourseStudentsProjection($course1Id)); + self::assertEquals([$student1Id], $studentIds); + + // matches only the first read: student 2 is never looked at + $commandBus->dispatch(new SubscribeStudentToCourse($student2Id, $course1Id)); + // matches only the second read and moves the head past the event above + $commandBus->dispatch(new SubscribeStudentToCourse($student1Id, $course3Id)); + + foreach ($studentIds as $studentId) { + $decision->read(new StudentCoursesProjection($studentId)); + } + + $this->expectException(AppendConditionNotMet::class); + + $decision->append([new CourseCapacityChanged($course1Id, 5)]); + } + + public function testInvoice(): void + { + $store = $this->createStore(__DIR__ . '/Invoice/Event'); + + $commandBus = new SyncCommandBus( + new ServiceHandlerProvider( + [new CreateInvoiceHandler()], + new DecisionParameterResolver(new DecisionFactory($store)), + ), + ); $commandBus->dispatch(new CreateInvoice(10)); $commandBus->dispatch(new CreateInvoice(10)); @@ -165,18 +207,9 @@ public function testCourseWithContainer(): void ], 'services' => [ 'app.connection' => $this->connection, - DefineCourseHandler::class => static fn (Container $container): DefineCourseHandler => new DefineCourseHandler( - $container->get(DecisionModelBuilder::class), - $container->get(EventAppender::class), - ), - ChangeCourseCapacityHandler::class => static fn (Container $container): ChangeCourseCapacityHandler => new ChangeCourseCapacityHandler( - $container->get(DecisionModelBuilder::class), - $container->get(EventAppender::class), - ), - SubscribeStudentToCourseHandler::class => static fn (Container $container): SubscribeStudentToCourseHandler => new SubscribeStudentToCourseHandler( - $container->get(DecisionModelBuilder::class), - $container->get(EventAppender::class), - ), + DefineCourseHandler::class => static fn (): DefineCourseHandler => new DefineCourseHandler(), + ChangeCourseCapacityHandler::class => static fn (): ChangeCourseCapacityHandler => new ChangeCourseCapacityHandler(), + SubscribeStudentToCourseHandler::class => static fn (): SubscribeStudentToCourseHandler => new SubscribeStudentToCourseHandler(), ], ]; $container = Factory::create($configuration); @@ -205,4 +238,30 @@ public function testCourseWithContainer(): void ->withHeader(new StreamNameHeader('course')), ], $container->get(Store::class)->load()); } + + private function createStore(string $eventPath): TaggableDoctrineDbalStore + { + $store = new TaggableDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([$eventPath]), + (new AttributeEventRegistryFactory())->create([$eventPath]), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + return $store; + } + + private function courseCommandBus(DecisionFactory $decisionFactory): CommandBus + { + return new SyncCommandBus( + new ServiceHandlerProvider( + [ + new DefineCourseHandler(), + new SubscribeStudentToCourseHandler(), + ], + new DecisionParameterResolver($decisionFactory), + ), + ); + } } diff --git a/tests/Integration/DynamicConsistencyBoundary/Invoice/Handler/CreateInvoiceHandler.php b/tests/Integration/DynamicConsistencyBoundary/Invoice/Handler/CreateInvoiceHandler.php index 2d31aa42e..4ed06a5fa 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Invoice/Handler/CreateInvoiceHandler.php +++ b/tests/Integration/DynamicConsistencyBoundary/Invoice/Handler/CreateInvoiceHandler.php @@ -5,34 +5,21 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Handler; use Patchlevel\EventSourcing\Attribute\Handle; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; -use Patchlevel\EventSourcing\DecisionModel\EventAppender; +use Patchlevel\EventSourcing\DecisionModel\Decision; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Command\CreateInvoice; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Event\InvoiceCreated; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Projection\NextInvoiceNumberProjection; final class CreateInvoiceHandler { - public function __construct( - private readonly DecisionModelBuilder $decisionModelBuilder, - private readonly EventAppender $eventAppender, - ) { - } - #[Handle] - public function __invoke(CreateInvoice $command): void + public function __invoke(CreateInvoice $command, Decision $decision): void { - $state = $this->decisionModelBuilder->build( - [ - 'nextInvoiceNumber' => new NextInvoiceNumberProjection(), - ], - ); - - $this->eventAppender->append([ + $decision->append([ new InvoiceCreated( - $state['nextInvoiceNumber'], + $decision->read(new NextInvoiceNumberProjection()), $command->money, ), - ], $state->appendCondition); + ]); } } diff --git a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php index 2348b27df..949a628cc 100644 --- a/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php +++ b/tests/Integration/DynamicConsistencyBoundary/Invoice/Projection/NextInvoiceNumberProjection.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Projection\BasicProjection; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Invoice\Event\InvoiceCreated; +/** @extends BasicProjection */ final class NextInvoiceNumberProjection extends BasicProjection { /** @return list */ diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index a4b7cd8b1..60a6f2d4c 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -631,6 +631,24 @@ public function testAppendWithoutAppendCondition(): void self::assertStreamEquals($messages, $this->store->load()); } + public function testHeadEmpty(): void + { + self::assertSame(0, $this->store->head()); + } + + public function testHead(): void + { + $profileId = ProfileId::generate(); + + $this->store->append([ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new TagsHeader(['profile:' . $profileId->toString()])), + Message::create(new ExternEvent('test message')), + ]); + + self::assertSame(2, $this->store->head()); + } + public function testQueryTags(): void { $profileId1 = ProfileId::generate(); diff --git a/tests/Unit/CommandBus/HandlerDescriptorTest.php b/tests/Unit/CommandBus/HandlerDescriptorTest.php index 85ba3db27..c8064d097 100644 --- a/tests/Unit/CommandBus/HandlerDescriptorTest.php +++ b/tests/Unit/CommandBus/HandlerDescriptorTest.php @@ -43,6 +43,16 @@ public function testAnonymousFunction(): void self::assertEquals('Closure', $descriptor->name()); } + public function testExplicitName(): void + { + $handler = static function (): void { + }; + + $descriptor = new HandlerDescriptor($handler(...), 'Foo::bar'); + + self::assertEquals('Foo::bar', $descriptor->name()); + } + public function testAnonymousClass(): void { $handler = new class { diff --git a/tests/Unit/CommandBus/ServiceHandlerProviderTest.php b/tests/Unit/CommandBus/ServiceHandlerProviderTest.php index 06a06a780..a0ae1f9c4 100644 --- a/tests/Unit/CommandBus/ServiceHandlerProviderTest.php +++ b/tests/Unit/CommandBus/ServiceHandlerProviderTest.php @@ -5,9 +5,11 @@ namespace Patchlevel\EventSourcing\Tests\Unit\CommandBus; use Patchlevel\EventSourcing\Attribute\Handle; +use Patchlevel\EventSourcing\CommandBus\Handler\ParameterResolver; use Patchlevel\EventSourcing\CommandBus\ServiceHandlerProvider; use Patchlevel\EventSourcing\Tests\Unit\Fixture\BaseCommand; use Patchlevel\EventSourcing\Tests\Unit\Fixture\CreateProfile; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\SomeCommand; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -57,6 +59,55 @@ public static function handle(): void self::assertEquals($class::handle(...), $result[0]->callable()); } + public function testParameterResolver(): void + { + $class = new class () { + /** @var list */ + public array $arguments = []; + + #[Handle] + public function handle(CreateProfile $command, string $extra): void + { + $this->arguments = [$command, $extra]; + } + }; + + $command = new CreateProfile(ProfileId::fromString('1'), 'info'); + + $parameterResolver = $this->createMock(ParameterResolver::class); + $parameterResolver->method('resolve')->willReturn([$command, 'foo']); + + $provider = new ServiceHandlerProvider([$class], $parameterResolver); + + $result = [...$provider->handlerForCommand(CreateProfile::class)]; + + self::assertCount(1, $result); + self::assertSame($class::class . '::handle', $result[0]->name()); + + ($result[0]->callable())($command); + + self::assertSame([$command, 'foo'], $class->arguments); + } + + public function testParameterResolverIsSkippedForHandlersWithOnlyTheCommand(): void + { + $class = new class () { + #[Handle(CreateProfile::class)] + public function handle(): void + { + } + }; + + $parameterResolver = $this->createMock(ParameterResolver::class); + $parameterResolver->expects($this->never())->method('resolve'); + + $provider = new ServiceHandlerProvider([$class], $parameterResolver); + + $result = [...$provider->handlerForCommand(CreateProfile::class)]; + + self::assertEquals($class->handle(...), $result[0]->callable()); + } + public function testFindHandlerByInterface(): void { $command = new class () implements SomeCommand { diff --git a/tests/Unit/Container/FactoryTest.php b/tests/Unit/Container/FactoryTest.php index de193412a..da3d4c458 100644 --- a/tests/Unit/Container/FactoryTest.php +++ b/tests/Unit/Container/FactoryTest.php @@ -34,9 +34,8 @@ use Patchlevel\EventSourcing\Container\Factory; use Patchlevel\EventSourcing\Container\InvalidConfiguration; use Patchlevel\EventSourcing\Cryptography\DoctrineCipherKeyStore; -use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; +use Patchlevel\EventSourcing\DecisionModel\DecisionFactory; use Patchlevel\EventSourcing\DecisionModel\EventAppender; -use Patchlevel\EventSourcing\DecisionModel\StoreDecisionModelBuilder; use Patchlevel\EventSourcing\DecisionModel\StoreEventAppender; use Patchlevel\EventSourcing\EventBus\AttributeListenerProvider; use Patchlevel\EventSourcing\EventBus\Consumer; @@ -930,7 +929,8 @@ public function testCreateWithTaggableStore(): void $container = Factory::create($configuration); self::assertInstanceOf(TaggableDoctrineDbalStore::class, $container->get(Store::class)); - self::assertInstanceOf(StoreDecisionModelBuilder::class, $container->get(DecisionModelBuilder::class)); + self::assertTrue($container->has(DecisionFactory::class)); + $container->get(DecisionFactory::class); self::assertInstanceOf(StoreEventAppender::class, $container->get(EventAppender::class)); self::assertInstanceOf(AttributeEventTagExtractor::class, $container->get(EventTagExtractor::class)); } @@ -977,7 +977,7 @@ public function __construct( $message = $container->get(MessageDecorator::class)(Message::create($event)); self::assertFalse($message->hasHeader(TagsHeader::class)); - self::assertFalse($container->has(DecisionModelBuilder::class)); + self::assertFalse($container->has(DecisionFactory::class)); self::assertFalse($container->has(EventAppender::class)); } @@ -990,7 +990,8 @@ public function testInMemoryStoreProvidesDecisionModel(): void ]; $container = Factory::create($configuration); - self::assertInstanceOf(StoreDecisionModelBuilder::class, $container->get(DecisionModelBuilder::class)); + self::assertTrue($container->has(DecisionFactory::class)); + $container->get(DecisionFactory::class); self::assertInstanceOf(StoreEventAppender::class, $container->get(EventAppender::class)); } diff --git a/tests/Unit/DecisionModel/DecisionFactoryTest.php b/tests/Unit/DecisionModel/DecisionFactoryTest.php new file mode 100644 index 000000000..312be2130 --- /dev/null +++ b/tests/Unit/DecisionModel/DecisionFactoryTest.php @@ -0,0 +1,35 @@ +begin(), $factory->begin()); + } + + public function testUsesEventAppender(): void + { + $event = new ProfileVisited(ProfileId::fromString('1')); + + $eventAppender = $this->createMock(EventAppender::class); + $eventAppender->expects($this->once())->method('append')->with([$event], null, null); + + $factory = new DecisionFactory(new InMemoryStore(), $eventAppender); + $factory->begin()->append([$event]); + } +} diff --git a/tests/Unit/DecisionModel/DecisionModelTest.php b/tests/Unit/DecisionModel/DecisionModelTest.php deleted file mode 100644 index 0068844a4..000000000 --- a/tests/Unit/DecisionModel/DecisionModelTest.php +++ /dev/null @@ -1,76 +0,0 @@ -createAppendCondition(); - - $model = new DecisionModel([ - 'foo' => 123, - 'bar' => 'baz', - ], $appendCondition); - - self::assertSame(123, $model['foo']); - self::assertSame('baz', $model['bar']); - self::assertSame($appendCondition, $model->appendCondition); - } - - public function testOffsetExists(): void - { - $model = new DecisionModel(['a' => 1], $this->createAppendCondition()); - - self::assertTrue(isset($model['a'])); - /** @phpstan-ignore-next-line */ - self::assertFalse(isset($model['b'])); - } - - public function testUnknownKeyThrowsOutOfBounds(): void - { - $model = new DecisionModel(['a' => 1], $this->createAppendCondition()); - - $this->expectException(OutOfBoundsException::class); - // access non-existing key - /** @phpstan-ignore-next-line */ - $unused = $model['b']; - } - - public function testImmutableSetThrows(): void - { - $model = new DecisionModel(['a' => 1], $this->createAppendCondition()); - - $this->expectException(LogicException::class); - /** @phpstan-ignore-next-line */ - $model['a'] = 2; - } - - public function testImmutableUnsetThrows(): void - { - $model = new DecisionModel(['a' => 1], $this->createAppendCondition()); - - $this->expectException(LogicException::class); - /** @phpstan-ignore-next-line */ - unset($model['a']); - } -} diff --git a/tests/Unit/DecisionModel/DecisionParameterResolverTest.php b/tests/Unit/DecisionModel/DecisionParameterResolverTest.php new file mode 100644 index 000000000..f81f65bfe --- /dev/null +++ b/tests/Unit/DecisionModel/DecisionParameterResolverTest.php @@ -0,0 +1,77 @@ +resolve(new ReflectionMethod($handler, '__invoke'), $command)); + + self::assertCount(2, $arguments); + self::assertSame($command, $arguments[0]); + self::assertInstanceOf(Decision::class, $arguments[1]); + } + + public function testNewDecisionForEachCall(): void + { + $handler = new class () { + public function __invoke(CreateProfile $command, Decision $decision): void + { + } + }; + + $command = new CreateProfile(ProfileId::fromString('1'), 'info'); + $method = new ReflectionMethod($handler, '__invoke'); + + $resolver = new DecisionParameterResolver(new DecisionFactory(new InMemoryStore())); + + self::assertNotSame( + iterator_to_array($resolver->resolve($method, $command))[1], + iterator_to_array($resolver->resolve($method, $command))[1], + ); + } + + public function testUnsupportedParameter(): void + { + $handler = new class () { + public function __invoke(CreateProfile $command, string $foo): void + { + } + }; + + $resolver = new DecisionParameterResolver(new DecisionFactory(new InMemoryStore())); + + $this->expectException(ServiceNotResolvable::class); + + iterator_to_array($resolver->resolve( + new ReflectionMethod($handler, '__invoke'), + new CreateProfile(ProfileId::fromString('1'), 'info'), + )); + } +} diff --git a/tests/Unit/DecisionModel/DecisionTest.php b/tests/Unit/DecisionModel/DecisionTest.php new file mode 100644 index 000000000..250545d03 --- /dev/null +++ b/tests/Unit/DecisionModel/DecisionTest.php @@ -0,0 +1,525 @@ +appendCondition()); + } + + public function testReadEmptyStore(): void + { + $decision = new Decision(new InMemoryStore()); + + self::assertSame(0, $decision->read(new IncrementProjection(0, ['a']))); + self::assertEquals( + new AppendCondition(new Query(new SubQuery(['a'], [ProfileCreated::class])), 0), + $decision->appendCondition(), + ); + } + + public function testAfterIsHead(): void + { + $store = new InMemoryStore(); + $store->save( + $this->created('1', ['a']), + $this->created('2', ['b']), + ); + + $decision = new Decision($store); + + self::assertSame(1, $decision->read(new IncrementProjection(0, ['a']))); + self::assertSame(2, $decision->appendCondition()?->after); + } + + public function testAfterIncludesEventsAfterHead(): void + { + $message = $this->created('1', ['a']); + + $store = $this->createMock(AppendStore::class); + $store->method('head')->willReturn(0); + $store->method('query')->willReturn(new Stream([3 => $message])); + + $decision = new Decision($store); + + self::assertSame(1, $decision->read(new IncrementProjection(0, ['a']))); + self::assertSame(3, $decision->appendCondition()?->after); + } + + public function testChainedReads(): void + { + $store = new InMemoryStore(); + $store->save( + $this->created('1', ['a']), + $this->created('2', ['b']), + $this->created('3', ['b']), + ); + + $decision = new Decision($store); + + self::assertSame(1, $decision->read(new IncrementProjection(0, ['a']))); + self::assertSame(2, $decision->read(new IncrementProjection(0, ['b']))); + self::assertEquals( + new AppendCondition( + new Query( + new SubQuery(['a'], [ProfileCreated::class]), + new SubQuery(['b'], [ProfileCreated::class]), + ), + 3, + ), + $decision->appendCondition(), + ); + } + + public function testHeadIsOnlyLoadedForTheFirstRead(): void + { + $store = $this->createMock(AppendStore::class); + $store->expects($this->once())->method('head')->willReturn(5); + $store->expects($this->exactly(2))->method('query')->willReturn(new Stream()); + + $decision = new Decision($store); + $decision->read(new IncrementProjection(0, ['a'])); + $decision->read(new IncrementProjection(0, ['b'])); + + self::assertSame(5, $decision->appendCondition()?->after); + } + + public function testLaterReadsDoNotRaiseAfter(): void + { + $store = new InMemoryStore(); + $store->save( + $this->created('1', ['a']), + ); + + $decision = new Decision($store); + $decision->read(new IncrementProjection(0, ['a'])); + + $store->save( + $this->created('2', ['b']), + ); + + self::assertSame(1, $decision->read(new IncrementProjection(0, ['b']))); + self::assertSame(1, $decision->appendCondition()?->after); + } + + public function testReadWithoutSubQueryMatchesEverything(): void + { + $store = new InMemoryStore(); + $store->save( + $this->created('1', ['a']), + $this->visited('1', ['b']), + ); + + $decision = new Decision($store); + $decision->read(new IncrementProjection(0, ['a'])); + + $count = $decision->read(new /** @implements Projection */ class implements Projection { + public function initialState(): int + { + return 0; + } + + /** @param int $state */ + public function apply(mixed $state, Message $message): int + { + return $state + 1; + } + }); + + self::assertSame(2, $count); + self::assertEquals(new AppendCondition(new Query(), 2), $decision->appendCondition()); + } + + public function testReadMultipleProjectionsWithOneQuery(): void + { + $store = $this->createMock(AppendStore::class); + $store->expects($this->once())->method('head')->willReturn(0); + $store->expects($this->once())->method('query') + ->with(new Query( + new SubQuery(['a'], [ProfileCreated::class]), + new SubQuery(['b'], [ProfileCreated::class]), + )) + ->willReturn(new Stream([ + 1 => $this->created('1', ['a']), + 2 => $this->created('2', ['b']), + 3 => $this->created('3', ['b']), + ])); + + $decision = new Decision($store); + + self::assertSame( + ['a' => 1, 'b' => 2], + $decision->read([ + 'a' => new IncrementProjection(0, ['a']), + 'b' => new IncrementProjection(0, ['b']), + ]), + ); + self::assertSame(3, $decision->appendCondition()?->after); + } + + public function testReadListOfProjections(): void + { + $store = new InMemoryStore(); + $store->save( + $this->created('1', ['a']), + $this->created('2', ['b']), + ); + + $decision = new Decision($store); + + [$a, $b] = $decision->read([ + new IncrementProjection(0, ['a']), + new IncrementProjection(10, ['b']), + ]); + + self::assertSame(1, $a); + self::assertSame(11, $b); + } + + public function testReadEmptyArray(): void + { + $store = $this->createMock(AppendStore::class); + $store->expects($this->never())->method('head'); + $store->expects($this->never())->method('query'); + + $decision = new Decision($store); + + self::assertSame([], $decision->read([])); + self::assertNull($decision->appendCondition()); + } + + public function testReadMultipleAndSingleProjections(): void + { + $store = new InMemoryStore(); + $store->save( + $this->created('1', ['a']), + $this->created('2', ['b']), + $this->created('3', ['c']), + ); + + $decision = new Decision($store); + $decision->read([ + 'a' => new IncrementProjection(0, ['a']), + 'b' => new IncrementProjection(0, ['b']), + ]); + $decision->read(new IncrementProjection(0, ['c'])); + + self::assertEquals( + new AppendCondition( + new Query( + new SubQuery(['a'], [ProfileCreated::class]), + new SubQuery(['b'], [ProfileCreated::class]), + new SubQuery(['c'], [ProfileCreated::class]), + ), + 3, + ), + $decision->appendCondition(), + ); + } + + public function testReadSingleProjectionFollowedByMultiple(): void + { + $store = new InMemoryStore(); + $store->save( + $this->created('1', ['a']), + $this->created('2', ['b']), + $this->created('3', ['c']), + ); + + $decision = new Decision($store); + + self::assertSame(1, $decision->read(new IncrementProjection(0, ['a']))); + + $store->save($this->created('4', ['b'])); + + self::assertSame( + ['b' => 2, 'c' => 1], + $decision->read([ + 'b' => new IncrementProjection(0, ['b']), + 'c' => new IncrementProjection(0, ['c']), + ]), + ); + self::assertEquals( + new AppendCondition( + new Query( + new SubQuery(['a'], [ProfileCreated::class]), + new SubQuery(['b'], [ProfileCreated::class]), + new SubQuery(['c'], [ProfileCreated::class]), + ), + 3, + ), + $decision->appendCondition(), + ); + } + + public function testReadMultipleWithCheckpointOnlyForOneProjection(): void + { + $store = new InMemoryStore(); + $store->save( + Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de'))) + ->withHeader(new TagsHeader(['profile-1'])), + $this->visited('1', ['profile-1']), + $this->split(10, ['profile-1']), + $this->visited('1', ['profile-1']), + ); + + $decision = new Decision($store); + + self::assertSame( + ['created' => 1, 'visits' => 11], + $decision->read([ + 'created' => new IncrementProjection(0, ['profile-1']), + 'visits' => new VisitsProjection(['profile-1']), + ]), + ); + } + + public function testAppend(): void + { + $store = new InMemoryStore(); + + $decision = new Decision($store); + $decision->read(new IncrementProjection(0, ['profile:1'])); + $decision->append([new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de'))]); + + $messages = iterator_to_array($store->load()); + + self::assertCount(1, $messages); + self::assertSame('main', $messages[1]->header(StreamNameHeader::class)->streamName); + } + + public function testAppendWithStreamName(): void + { + $store = new InMemoryStore(); + + $decision = new Decision($store); + $decision->append([new ProfileVisited(ProfileId::fromString('1'))], 'profile'); + + $messages = iterator_to_array($store->load()); + + self::assertSame('profile', $messages[1]->header(StreamNameHeader::class)->streamName); + } + + public function testAppendFailsIfReadEventsChanged(): void + { + $store = new InMemoryStore(); + + $decision = new Decision($store); + $decision->read(new IncrementProjection(0, ['a'])); + + $store->save($this->created('1', ['a'])); + + $this->expectException(AppendConditionNotMet::class); + + $decision->append([new ProfileVisited(ProfileId::fromString('1'))]); + } + + public function testAppendTwice(): void + { + $decision = new Decision(new InMemoryStore()); + $decision->append([new ProfileVisited(ProfileId::fromString('1'))]); + + $this->expectException(DecisionAlreadyAppended::class); + + $decision->append([new ProfileVisited(ProfileId::fromString('1'))]); + } + + public function testReadAfterAppend(): void + { + $decision = new Decision(new InMemoryStore()); + $decision->append([new ProfileVisited(ProfileId::fromString('1'))]); + + $this->expectException(DecisionAlreadyAppended::class); + + $decision->read(new IncrementProjection(0, ['a'])); + } + + public function testOnlyLastEventProjectionsWithOverlappingTags(): void + { + $store = new InMemoryStore(); + $store->save( + Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('first@patchlevel.de'))) + ->withHeader(new TagsHeader(['a', 'b'])), + Message::create(new ProfileCreated(ProfileId::fromString('2'), Email::fromString('second@patchlevel.de'))) + ->withHeader(new TagsHeader(['a'])), + ); + + $decision = new Decision($store); + + self::assertSame( + ['a' => 'second@patchlevel.de', 'ab' => 'first@patchlevel.de'], + $decision->read([ + 'a' => new LastEmailProjection(['a']), + 'ab' => new LastEmailProjection(['a', 'b']), + ]), + ); + } + + public function testCheckpoint(): void + { + $store = new InMemoryStore(); + $store->save( + $this->visited('1', ['profile-1']), + $this->visited('1', ['profile-1']), + $this->split(10, ['profile-1']), + $this->visited('1', ['profile-1']), + $this->split(99, ['profile-2']), + $this->visited('1', ['profile-1']), + ); + + $decision = new Decision($store); + + self::assertSame(12, $decision->read(new VisitsProjection(['profile-1']))); + self::assertSame(6, $decision->appendCondition()?->after); + } + + public function testCheckpointReadsFromLastCheckpointEvent(): void + { + $splitMessage = $this->split(10, ['profile-1'])->withHeader(new IndexHeader(3)); + + $expectedQuery = new Query( + new SubQuery(['profile-1'], [ProfileVisited::class, SplittingEvent::class]), + ); + + $store = new class ($splitMessage) implements AppendStore { + /** @var list */ + public array $calls = []; + + public function __construct( + private readonly Message $splitMessage, + ) { + } + + /** @param iterable $messages */ + public function append(iterable $messages, AppendCondition|null $appendCondition = null): void + { + } + + public function query(Query $query, int $from = 0): Stream + { + $this->calls[] = [$query, $from]; + + return new Stream([3 => $this->splitMessage]); + } + + public function head(): int + { + return 3; + } + }; + + $decision = new Decision($store); + + self::assertSame(10, $decision->read(new VisitsProjection(['profile-1']))); + self::assertEquals(new AppendCondition($expectedQuery, 3), $decision->appendCondition()); + self::assertEquals( + [ + [new Query(new SubQuery(['profile-1'], [SplittingEvent::class], null, true)), 0], + [$expectedQuery, 3], + ], + $store->calls, + ); + } + + public function testCheckpointWithoutCheckpointEvent(): void + { + $store = new InMemoryStore(); + $store->save( + $this->visited('1', ['profile-1']), + $this->visited('1', ['profile-1']), + ); + + $decision = new Decision($store); + + self::assertSame(2, $decision->read(new VisitsProjection(['profile-1']))); + } + + public function testSplitStreamAloneIsNoCheckpoint(): void + { + $store = new InMemoryStore(); + $store->save( + $this->split(10, ['profile-1']), + $this->split(20, ['profile-1']), + ); + + $decision = new Decision($store); + + $splits = $decision->read(new class extends BasicProjection { + public function initialState(): int + { + return 0; + } + + #[Apply] + public function applySplittingEvent(int $state, SplittingEvent $event): int + { + return $state + 1; + } + + /** @return list */ + protected function tagFilter(): array + { + return ['profile-1']; + } + }); + + self::assertSame(2, $splits); + } + + /** @param list $tags */ + private function created(string $id, array $tags): Message + { + return Message::create(new ProfileCreated(ProfileId::fromString($id), Email::fromString('info@patchlevel.de'))) + ->withHeader(new TagsHeader($tags)); + } + + /** @param list $tags */ + private function visited(string $id, array $tags): Message + { + return Message::create(new ProfileVisited(ProfileId::fromString($id))) + ->withHeader(new TagsHeader($tags)); + } + + /** @param list $tags */ + private function split(int $visits, array $tags): Message + { + return Message::create(new SplittingEvent(Email::fromString('info@patchlevel.de'), $visits)) + ->withHeader(new TagsHeader($tags)); + } +} diff --git a/tests/Unit/DecisionModel/StoreDecisionModelBuilderTest.php b/tests/Unit/DecisionModel/StoreDecisionModelBuilderTest.php deleted file mode 100644 index 75d3f6429..000000000 --- a/tests/Unit/DecisionModel/StoreDecisionModelBuilderTest.php +++ /dev/null @@ -1,279 +0,0 @@ -createMock(AppendStore::class); - $store->expects($this->once())->method('query')->with(new Query())->willReturn(new Stream()); - - $builder = new StoreDecisionModelBuilder($store); - - $state = $builder->build([]); - - self::assertEquals([], $state->state); - self::assertEquals(new AppendCondition(new Query()), $state->appendCondition); - } - - public function testWithProjections(): void - { - $store = $this->createMock(AppendStore::class); - - $message = new Message( - new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('info@patchlevel.de'), - ), - ); - $message = $message->withHeader(new TagsHeader(['foo'])); - - $expectedQuery = new Query( - new SubQuery( - ['foo'], - [ProfileCreated::class], - ), - ); - - $store->expects($this->once())->method('query')->with($expectedQuery)->willReturn(new Stream([1 => $message])); - - $builder = new StoreDecisionModelBuilder($store); - - $state = $builder->build([ - 'counter' => new IncrementProjection(0, ['foo']), - ]); - - self::assertEquals(['counter' => 1], $state->state); - - self::assertEquals(new AppendCondition($expectedQuery, 1), $state->appendCondition); - } - - public function testOnlyLastEventProjectionsWithOverlappingTags(): void - { - $store = new InMemoryStore(); - $store->save( - Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('first@patchlevel.de'))) - ->withHeader(new TagsHeader(['a', 'b'])), - Message::create(new ProfileCreated(ProfileId::fromString('2'), Email::fromString('second@patchlevel.de'))) - ->withHeader(new TagsHeader(['a'])), - ); - - $builder = new StoreDecisionModelBuilder($store); - - $state = $builder->build([ - 'a' => new LastEmailProjection(['a']), - 'ab' => new LastEmailProjection(['a', 'b']), - ]); - - self::assertEquals( - ['a' => 'second@patchlevel.de', 'ab' => 'first@patchlevel.de'], - $state->state, - ); - } - - public function testEmptyStream(): void - { - $store = $this->createMock(AppendStore::class); - - $expectedQuery = new Query( - new SubQuery( - ['foo'], - [ProfileCreated::class], - ), - ); - - $store->expects($this->once())->method('query')->with($expectedQuery)->willReturn(new Stream()); - - $builder = new StoreDecisionModelBuilder($store); - - $state = $builder->build([ - 'counter' => new IncrementProjection(0, ['foo']), - ]); - - self::assertEquals(['counter' => 0], $state->state); - - self::assertEquals(new AppendCondition($expectedQuery, 0), $state->appendCondition); - } - - public function testCheckpoint(): void - { - $store = new InMemoryStore(); - $store->save( - $this->visited('1', ['profile-1']), - $this->visited('1', ['profile-1']), - $this->split(10, ['profile-1']), - $this->visited('1', ['profile-1']), - $this->split(99, ['profile-2']), - $this->visited('1', ['profile-1']), - ); - - $builder = new StoreDecisionModelBuilder($store); - - $state = $builder->build([ - 'visits' => new VisitsProjection(['profile-1']), - ]); - - self::assertSame(['visits' => 12], $state->state); - self::assertSame(6, $state->appendCondition->after); - } - - public function testCheckpointReadsFromLastCheckpointEvent(): void - { - $splitMessage = $this->split(10, ['profile-1'])->withHeader(new IndexHeader(3)); - - $expectedQuery = new Query( - new SubQuery(['profile-1'], [ProfileVisited::class, SplittingEvent::class]), - ); - - $store = new class ($splitMessage) implements AppendStore { - /** @var list */ - public array $calls = []; - - public function __construct( - private readonly Message $splitMessage, - ) { - } - - /** @param iterable $messages */ - public function append(iterable $messages, AppendCondition|null $appendCondition = null): void - { - } - - public function query(Query $query, int $from = 0): Stream - { - $this->calls[] = [$query, $from]; - - return new Stream([3 => $this->splitMessage]); - } - }; - - $builder = new StoreDecisionModelBuilder($store); - - $state = $builder->build([ - 'visits' => new VisitsProjection(['profile-1']), - ]); - - self::assertSame(['visits' => 10], $state->state); - self::assertEquals(new AppendCondition($expectedQuery, 3), $state->appendCondition); - self::assertEquals( - [ - [new Query(new SubQuery(['profile-1'], [SplittingEvent::class], null, true)), 0], - [$expectedQuery, 3], - ], - $store->calls, - ); - } - - public function testCheckpointWithoutCheckpointEvent(): void - { - $store = new InMemoryStore(); - $store->save( - $this->visited('1', ['profile-1']), - $this->visited('1', ['profile-1']), - ); - - $builder = new StoreDecisionModelBuilder($store); - - $state = $builder->build([ - 'visits' => new VisitsProjection(['profile-1']), - ]); - - self::assertSame(['visits' => 2], $state->state); - } - - public function testCheckpointWithProjectionWithoutCheckpoint(): void - { - $store = new InMemoryStore(); - $store->save( - Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('info@patchlevel.de'))) - ->withHeader(new TagsHeader(['profile-1'])), - $this->visited('1', ['profile-1']), - $this->split(10, ['profile-1']), - $this->visited('1', ['profile-1']), - ); - - $builder = new StoreDecisionModelBuilder($store); - - $state = $builder->build([ - 'created' => new IncrementProjection(0, ['profile-1']), - 'visits' => new VisitsProjection(['profile-1']), - ]); - - self::assertSame(['created' => 1, 'visits' => 11], $state->state); - } - - public function testSplitStreamAloneIsNoCheckpoint(): void - { - $store = new InMemoryStore(); - $store->save( - $this->split(10, ['profile-1']), - $this->split(20, ['profile-1']), - ); - - $builder = new StoreDecisionModelBuilder($store); - - $state = $builder->build([ - 'splits' => new class extends BasicProjection { - public function initialState(): int - { - return 0; - } - - #[Apply] - public function applySplittingEvent(int $state, SplittingEvent $event): int - { - return $state + 1; - } - - /** @return list */ - protected function tagFilter(): array - { - return ['profile-1']; - } - }, - ]); - - self::assertSame(['splits' => 2], $state->state); - } - - /** @param list $tags */ - private function visited(string $id, array $tags): Message - { - return Message::create(new ProfileVisited(ProfileId::fromString($id))) - ->withHeader(new TagsHeader($tags)); - } - - /** @param list $tags */ - private function split(int $visits, array $tags): Message - { - return Message::create(new SplittingEvent(Email::fromString('info@patchlevel.de'), $visits)) - ->withHeader(new TagsHeader($tags)); - } -} diff --git a/tests/Unit/Store/InMemoryStoreTest.php b/tests/Unit/Store/InMemoryStoreTest.php index a6ffd17c8..751c53c93 100644 --- a/tests/Unit/Store/InMemoryStoreTest.php +++ b/tests/Unit/Store/InMemoryStoreTest.php @@ -807,6 +807,24 @@ public function testQueryFromWithOnlyLastEventBeforeFrom(): void self::assertSame([], $stream->toList()); } + public function testHeadEmpty(): void + { + $store = new InMemoryStore(); + + self::assertSame(0, $store->head()); + } + + public function testHead(): void + { + $store = new InMemoryStore(); + $store->save( + new Message(new ProfileVisited(ProfileId::fromString('1'))), + new Message(new ProfileVisited(ProfileId::fromString('2'))), + ); + + self::assertSame(2, $store->head()); + } + public function testAppendAssignsIndex(): void { $store = new InMemoryStore();