From b671bbb04dddbdabd5928d4084c11d6d91f20be5 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Fri, 12 Dec 2025 14:22:29 +0100 Subject: [PATCH] Add QueryBus integration test which was missing, also add "full cycle" bench which dispatches a command and then query the name. Fix a deprecation notice regarding `SplObjectStorage` --- phpstan-baseline.neon | 36 ++++- .../Engine/SubscriptionManager.php | 14 +- .../Command/ChangeProfileName.php | 18 +++ .../Command/CreateProfile.php | 16 +++ .../Events/NameChanged.php | 4 +- .../Benchmark/BasicImplementation/Profile.php | 2 +- .../ProfileWithCommands.php | 68 ++++++++++ .../Projection/BatchProfileProjector.php | 5 +- .../Projection/ProfileProjector.php | 16 ++- .../Query/QueryProfileName.php | 14 ++ tests/Benchmark/CommandToQueryBench.php | 128 ++++++++++++++++++ .../BasicIntegrationTest.php | 68 ++++++++++ .../Projection/ProfileProjector.php | 11 ++ .../Query/QueryProfileName.php | 14 ++ 14 files changed, 393 insertions(+), 21 deletions(-) create mode 100644 tests/Benchmark/BasicImplementation/Command/ChangeProfileName.php create mode 100644 tests/Benchmark/BasicImplementation/Command/CreateProfile.php create mode 100644 tests/Benchmark/BasicImplementation/ProfileWithCommands.php create mode 100644 tests/Benchmark/BasicImplementation/Query/QueryProfileName.php create mode 100644 tests/Benchmark/CommandToQueryBench.php create mode 100644 tests/Integration/BasicImplementation/Query/QueryProfileName.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 514dfbe7a..2ee97190e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -42,12 +42,6 @@ parameters: count: 1 path: src/Message/Serializer/DefaultHeadersSerializer.php - - - message: '#^Call to an undefined method Patchlevel\\EventSourcing\\Store\\Store\:\:archive\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Repository/DefaultRepository.php - - message: '#^Parameter \#1 \.\.\.\$streamName of class Patchlevel\\EventSourcing\\Store\\Criteria\\StreamCriterion constructor expects string, string\|null given\.$#' identifier: argument.type @@ -168,6 +162,24 @@ parameters: count: 3 path: src/Subscription/ThrowableToErrorContextTransformer.php + - + message: '#^Property Patchlevel\\EventSourcing\\Tests\\Benchmark\\BasicImplementation\\ProfileWithCommands\:\:\$id is never read, only written\.$#' + identifier: property.onlyWritten + count: 1 + path: tests/Benchmark/BasicImplementation/ProfileWithCommands.php + + - + message: '#^Cannot access offset ''name'' on array\\|false\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php + + - + message: '#^Method Patchlevel\\EventSourcing\\Tests\\Benchmark\\BasicImplementation\\Projection\\ProfileProjector\:\:getProfileName\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php + - message: '#^Parameter \#1 \$id of static method Patchlevel\\EventSourcing\\Tests\\Benchmark\\BasicImplementation\\Profile\:\:create\(\) expects Patchlevel\\EventSourcing\\Tests\\Benchmark\\BasicImplementation\\ProfileId, Patchlevel\\EventSourcing\\Aggregate\\AggregateRootId given\.$#' identifier: argument.type @@ -234,6 +246,18 @@ parameters: count: 1 path: tests/Integration/BasicImplementation/ProfileWithCommands.php + - + message: '#^Cannot access offset ''name'' on array\\|false\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: tests/Integration/BasicImplementation/Projection/ProfileProjector.php + + - + message: '#^Method Patchlevel\\EventSourcing\\Tests\\Integration\\BasicImplementation\\Projection\\ProfileProjector\:\:getProfileName\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 1 + path: tests/Integration/BasicImplementation/Projection/ProfileProjector.php + - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Tests\\\\Integration\\\\ChildAggregate\\\\Profile'' and Patchlevel\\EventSourcing\\Tests\\Integration\\ChildAggregate\\Profile will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index 96a36f9b9..1350a13da 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -77,28 +77,28 @@ public function find(SubscriptionCriteria $criteria): array public function add(Subscription ...$subscriptions): void { foreach ($subscriptions as $sub) { - $this->forAdd->attach($sub); + $this->forAdd->offsetSet($sub); } } public function update(Subscription ...$subscriptions): void { foreach ($subscriptions as $sub) { - $this->forUpdate->attach($sub); + $this->forUpdate->offsetSet($sub); } } public function remove(Subscription ...$subscriptions): void { foreach ($subscriptions as $sub) { - $this->forRemove->attach($sub); + $this->forRemove->offsetSet($sub); } } public function flush(): void { foreach ($this->forAdd as $subscription) { - if ($this->forRemove->contains($subscription)) { + if ($this->forRemove->offsetExists($subscription)) { continue; } @@ -106,11 +106,11 @@ public function flush(): void } foreach ($this->forUpdate as $subscription) { - if ($this->forAdd->contains($subscription)) { + if ($this->forAdd->offsetExists($subscription)) { continue; } - if ($this->forRemove->contains($subscription)) { + if ($this->forRemove->offsetExists($subscription)) { continue; } @@ -118,7 +118,7 @@ public function flush(): void } foreach ($this->forRemove as $subscription) { - if ($this->forAdd->contains($subscription)) { + if ($this->forAdd->offsetExists($subscription)) { continue; } diff --git a/tests/Benchmark/BasicImplementation/Command/ChangeProfileName.php b/tests/Benchmark/BasicImplementation/Command/ChangeProfileName.php new file mode 100644 index 000000000..f8f9521a5 --- /dev/null +++ b/tests/Benchmark/BasicImplementation/Command/ChangeProfileName.php @@ -0,0 +1,18 @@ +recordThat(new NameChanged($name)); + $this->recordThat(new NameChanged($this->id, $name)); } public function changeEmail(string $email): void diff --git a/tests/Benchmark/BasicImplementation/ProfileWithCommands.php b/tests/Benchmark/BasicImplementation/ProfileWithCommands.php new file mode 100644 index 000000000..a448f05a8 --- /dev/null +++ b/tests/Benchmark/BasicImplementation/ProfileWithCommands.php @@ -0,0 +1,68 @@ +recordThat(new ProfileCreated($command->id, $command->name, null)); + + return $self; + } + + #[Handle] + public function changeName( + ChangeProfileName $command, + ClockInterface $clock, + #[Inject('env')] + string $env, + ): void { + $this->recordThat(new NameChanged($this->id, $command->name)); + } + + #[Apply] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->profileId; + $this->name = $event->name; + } + + #[Apply] + protected function applyNameChanged(NameChanged $event): void + { + $this->name = $event->name; + } + + public function name(): string + { + return $this->name; + } +} diff --git a/tests/Benchmark/BasicImplementation/Projection/BatchProfileProjector.php b/tests/Benchmark/BasicImplementation/Projection/BatchProfileProjector.php index 1669225c1..8896906f2 100644 --- a/tests/Benchmark/BasicImplementation/Projection/BatchProfileProjector.php +++ b/tests/Benchmark/BasicImplementation/Projection/BatchProfileProjector.php @@ -13,7 +13,6 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Events\ProfileCreated; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; #[Projector('profile')] final class BatchProfileProjector implements BatchableSubscriber @@ -53,9 +52,9 @@ public function onProfileCreated(ProfileCreated $profileCreated): void } #[Subscribe(NameChanged::class)] - public function onNameChanged(NameChanged $nameChanged, ProfileId $profileId): void + public function onNameChanged(NameChanged $nameChanged): void { - $this->nameChanged[$profileId->toString()] = $nameChanged->name; + $this->nameChanged[$nameChanged->profileId->toString()] = $nameChanged->name; } public function table(): string diff --git a/tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php b/tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php index ef100be3a..98f16b3c2 100644 --- a/tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php +++ b/tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Projection; use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\Attribute\Answer; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; @@ -12,7 +13,7 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Events\ProfileCreated; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Query\QueryProfileName; #[Projector('profile')] final class ProfileProjector @@ -49,15 +50,24 @@ public function onProfileCreated(ProfileCreated $profileCreated): void } #[Subscribe(NameChanged::class)] - public function onNameChanged(NameChanged $nameChanged, ProfileId $profileId): void + public function onNameChanged(NameChanged $nameChanged): void { $this->connection->update( $this->table(), ['name' => $nameChanged->name], - ['id' => $profileId->toString()], + ['id' => $nameChanged->profileId->toString()], ); } + #[Answer] + public function getProfileName(QueryProfileName $queryProfileName): string + { + return $this->connection->fetchAssociative( + "SELECT name FROM {$this->table()} WHERE id = :id;", + ['id' => $queryProfileName->id->toString()], + )['name']; + } + public function table(): string { return 'projection_' . $this->subscriberId(); diff --git a/tests/Benchmark/BasicImplementation/Query/QueryProfileName.php b/tests/Benchmark/BasicImplementation/Query/QueryProfileName.php new file mode 100644 index 000000000..a26ecad93 --- /dev/null +++ b/tests/Benchmark/BasicImplementation/Query/QueryProfileName.php @@ -0,0 +1,14 @@ + ProfileWithCommands::class]); + + $manager = new DefaultRepositoryManager( + $aggregateRootRegistry, + $store, + null, + new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + ); + + $projectionConnection = DbalManager::createConnection(); + $profileProjection = new ProfileProjector($projectionConnection); + + $engine = new DefaultSubscriptionEngine( + $store, + new InMemorySubscriptionStore(), + new MetadataSubscriberAccessorRepository([ + $profileProjection, + new SendEmailProcessor(), + ]), + ); + + $manager = new RunSubscriptionEngineRepositoryManager($manager, $engine); + + $this->commandBus = SyncCommandBus::createForAggregateHandlers( + $aggregateRootRegistry, + $manager, + new ServiceLocator([ + ClockInterface::class => new SystemClock(), + 'env' => 'test', + ]), + ); + + $this->queryBus = new SyncQueryBus(new ServiceHandlerProvider([$profileProjection])); + + $schemaDirector = new DoctrineSchemaDirector($connection, $store); + + $schemaDirector->create(); + $engine->setup(skipBooting: true); + + $this->updateId = ProfileId::generate(); + $this->commandBus->dispatch(new CreateProfile($this->updateId, 'Peter')); + } + + #[Bench\Revs(10)] + public function benchCreate(): void + { + $id = ProfileId::generate(); + $this->commandBus->dispatch(new CreateProfile($id, 'James')); + $result = $this->queryBus->dispatch(new QueryProfileName($id)); + + assert($result === 'James'); + } + + #[Bench\Revs(10)] + public function benchUpdate(): void + { + $this->commandBus->dispatch(new ChangeProfileName($this->updateId, 'James Doe')); + $result = $this->queryBus->dispatch(new QueryProfileName($this->updateId)); + + assert($result === 'James Doe'); + } + + #[Bench\Revs(10)] + public function benchBoth(): void + { + $id = ProfileId::generate(); + $this->commandBus->dispatch(new CreateProfile($id, 'James')); + $result = $this->queryBus->dispatch(new QueryProfileName($id)); + assert($result === 'James'); + + $this->commandBus->dispatch(new ChangeProfileName($id, 'James Doe')); + $result = $this->queryBus->dispatch(new QueryProfileName($id)); + assert($result === 'James Doe'); + } +} diff --git a/tests/Integration/BasicImplementation/BasicIntegrationTest.php b/tests/Integration/BasicImplementation/BasicIntegrationTest.php index 6b0a05add..19c11d0d3 100644 --- a/tests/Integration/BasicImplementation/BasicIntegrationTest.php +++ b/tests/Integration/BasicImplementation/BasicIntegrationTest.php @@ -15,6 +15,8 @@ use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Message\Translator\UntilEventTranslator; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; +use Patchlevel\EventSourcing\QueryBus\ServiceHandlerProvider; +use Patchlevel\EventSourcing\QueryBus\SyncQueryBus; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; @@ -36,6 +38,7 @@ use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\MessageDecorator\FooMessageDecorator; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Processor\SendEmailProcessor; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Projection\ProfileProjector; +use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Query\QueryProfileName; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; @@ -324,4 +327,69 @@ public function testCommandBus(): void self::assertSame('John Doe', $profile->name()); self::assertSame(1, SendEmailMock::count()); } + + public function testQueryBus(): void + { + $store = new DoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + DefaultHeadersSerializer::createFromPaths([ + __DIR__ . '/Header', + ]), + ); + + $aggregateRootRegistry = new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]); + + $manager = new DefaultRepositoryManager( + new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]), + $store, + null, + new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), + new FooMessageDecorator(), + ); + + $profileProjection = new ProfileProjector($this->connection); + + $engine = new DefaultSubscriptionEngine( + $store, + new InMemorySubscriptionStore(), + new MetadataSubscriberAccessorRepository([ + $profileProjection, + new SendEmailProcessor(), + ]), + ); + + $manager = new RunSubscriptionEngineRepositoryManager( + $manager, + $engine, + ); + + $commandBus = SyncCommandBus::createForAggregateHandlers( + $aggregateRootRegistry, + $manager, + new ServiceLocator([ + ClockInterface::class => new SystemClock(), + 'env' => 'test', + ]), + ); + + $queryBus = new SyncQueryBus(new ServiceHandlerProvider([$profileProjection])); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + $engine->setup(skipBooting: true); + + $profileId = ProfileId::generate(); + + $commandBus->dispatch(new CreateProfile($profileId, 'John')); + $commandBus->dispatch(new ChangeProfileName($profileId, 'John Doe')); + + $result = $queryBus->dispatch(new QueryProfileName($profileId)); + + self::assertSame('John Doe', $result); + } } diff --git a/tests/Integration/BasicImplementation/Projection/ProfileProjector.php b/tests/Integration/BasicImplementation/Projection/ProfileProjector.php index 1e33973fc..f83ca17f3 100644 --- a/tests/Integration/BasicImplementation/Projection/ProfileProjector.php +++ b/tests/Integration/BasicImplementation/Projection/ProfileProjector.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Schema\Table; +use Patchlevel\EventSourcing\Attribute\Answer; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; @@ -13,6 +14,7 @@ use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\ProfileCreated; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\ProfileId; +use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Query\QueryProfileName; #[Projector('profile-1')] final class ProfileProjector @@ -62,4 +64,13 @@ public function handleNameChanged(NameChanged $nameChanged, ProfileId $profileId ], ); } + + #[Answer] + public function getProfileName(QueryProfileName $queryProfileName): string + { + return $this->connection->fetchAssociative( + 'SELECT name FROM projection_profile WHERE id = :id', + ['id' => $queryProfileName->id->toString()], + )['name']; + } } diff --git a/tests/Integration/BasicImplementation/Query/QueryProfileName.php b/tests/Integration/BasicImplementation/Query/QueryProfileName.php new file mode 100644 index 000000000..607db5012 --- /dev/null +++ b/tests/Integration/BasicImplementation/Query/QueryProfileName.php @@ -0,0 +1,14 @@ +