diff --git a/.github/workflows/backward-compatibility-check.yml b/.github/workflows/backward-compatibility-check.yml index 17c0260fe..0a98d7837 100644 --- a/.github/workflows/backward-compatibility-check.yml +++ b/.github/workflows/backward-compatibility-check.yml @@ -22,7 +22,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index df70f2255..23f877cf5 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -46,7 +46,7 @@ jobs: extensions: pdo_sqlite - name: "Checkout base" - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.base_ref }} @@ -58,7 +58,7 @@ jobs: run: "vendor/bin/phpbench run tests/Benchmark --progress=none --report=default --tag=base" - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: clean: false diff --git a/.github/workflows/coding-standard.yml b/.github/workflows/coding-standard.yml index 9a465313c..c1f30b499 100644 --- a/.github/workflows/coding-standard.yml +++ b/.github/workflows/coding-standard.yml @@ -26,7 +26,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 000000000..2e3df1c3a --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,48 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Check Docs" + +on: + pull_request: + push: + branches: + - "[0-9]+.[0-9]+.x" + +jobs: + checkdocs: + name: "Check Docs" + + runs-on: ${{ matrix.operating-system }} + + strategy: + matrix: + dependencies: + - "locked" + php-version: + - "8.4" + operating-system: + - "ubuntu-latest" + + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Install PHP" + uses: "shivammathur/setup-php@2.37.2" + with: + coverage: none + php-version: "${{ matrix.php-version }}" + ini-values: memory_limit=-1, opcache.enable_cli=1 + + - uses: ramsey/composer-install@4.0.0 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: "extract php code" + run: "bin/docs-extract-php-code" + + - name: "lint php" + run: "php -l docs_php/*.php" + + - name: "docs code style" + run: "vendor/bin/phpcbf docs_php --exclude=SlevomatCodingStandard.TypeHints.DeclareStrictTypes" \ No newline at end of file diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index ddf6a9fe7..eb84cdcae 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -48,7 +48,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" @@ -103,7 +103,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" @@ -157,7 +157,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" @@ -191,7 +191,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" diff --git a/.github/workflows/mutation-tests-diff.yml b/.github/workflows/mutation-tests-diff.yml index aa5ed0abe..c38555ac7 100644 --- a/.github/workflows/mutation-tests-diff.yml +++ b/.github/workflows/mutation-tests-diff.yml @@ -22,7 +22,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/mutation-tests.yml b/.github/workflows/mutation-tests.yml index c4938a1c8..e4ee6a896 100644 --- a/.github/workflows/mutation-tests.yml +++ b/.github/workflows/mutation-tests.yml @@ -26,7 +26,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index ce467e19a..dfa0ae1a3 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -26,7 +26,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" diff --git a/.github/workflows/release-on-milestone-closed-triggering-release-event.yml b/.github/workflows/release-on-milestone-closed-triggering-release-event.yml index 8af28e8f5..923ca3fe1 100644 --- a/.github/workflows/release-on-milestone-closed-triggering-release-event.yml +++ b/.github/workflows/release-on-milestone-closed-triggering-release-event.yml @@ -18,7 +18,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Release" uses: "laminas/automatic-releases@v1" diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index d3f51fe16..749c003b7 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -38,7 +38,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: "Install PHP" uses: "shivammathur/setup-php@2.37.2" diff --git a/bin/docs-extract-php-code b/bin/docs-extract-php-code index d50b642df..a27094df7 100755 --- a/bin/docs-extract-php-code +++ b/bin/docs-extract-php-code @@ -3,6 +3,8 @@ use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; +use League\CommonMark\Extension\FrontMatter\Data\FrontMatterDataParserInterface; +use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\Node\Query; use League\CommonMark\Parser\MarkdownParser; use Wnx\CommonmarkMarkdownRenderer\MarkdownRendererExtension; @@ -11,6 +13,15 @@ require __DIR__ . '/../vendor/autoload.php'; $environment = new Environment([]); $environment->addExtension(new MarkdownRendererExtension()); +$environment->addExtension(new FrontMatterExtension( + // keep the raw YAML string so it round-trips byte-identically + new class implements FrontMatterDataParserInterface { + public function parse(string $frontMatter): string + { + return $frontMatter; + } + }, +)); $parser = new MarkdownParser($environment); diff --git a/bin/docs-inject-php-code b/bin/docs-inject-php-code index e4e81ead1..13694a10b 100755 --- a/bin/docs-inject-php-code +++ b/bin/docs-inject-php-code @@ -3,6 +3,9 @@ use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; +use League\CommonMark\Extension\FrontMatter\Data\FrontMatterDataParserInterface; +use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; +use League\CommonMark\Extension\FrontMatter\Output\RenderedContentWithFrontMatter; use League\CommonMark\Node\Query; use League\CommonMark\Parser\MarkdownParser; use Wnx\CommonmarkMarkdownRenderer\MarkdownRendererExtension; @@ -14,6 +17,15 @@ require __DIR__ . '/../vendor/autoload.php'; $environment = new Environment([]); $environment->addExtension(new MarkdownRendererExtension()); +$environment->addExtension(new FrontMatterExtension( + // keep the raw YAML string so it round-trips byte-identically + new class implements FrontMatterDataParserInterface { + public function parse(string $frontMatter): string + { + return $frontMatter; + } + }, +)); $parser = new MarkdownParser($environment); $markdownRenderer = new MarkdownRenderer($environment); @@ -62,7 +74,14 @@ foreach ($finder as $file) { $node->setLiteral(trim($code)); } - file_put_contents($file->getPathname(), $markdownRenderer->renderDocument($document)); + $rendered = $markdownRenderer->renderDocument($document); + $output = $rendered->getContent(); + + if ($rendered instanceof RenderedContentWithFrontMatter) { + $output = $rendered->getFrontMatter() . "---\n" . $output; + } + + file_put_contents($file->getPathname(), $output); } if (file_exists($targetDir)) { diff --git a/docker-compose.yaml b/docker-compose.yaml index 2174d030d..4677fcec0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -25,3 +25,11 @@ services: - MYSQL_DATABASE=eventstore ports: - 3306:3306 + + mariadb: + image: mariadb:12 + environment: + - MYSQL_ALLOW_EMPTY_PASSWORD="yes" + - MYSQL_DATABASE=eventstore + ports: + - 3307:3306 diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 04580ed50..8992147a0 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -172,7 +172,6 @@ final class HotelProjector // ... } ``` - If you still need the subscriber id elsewhere, read it from the metadata instead: ```php @@ -181,7 +180,6 @@ use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFact $metadata = (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class); $subscriberId = $metadata->id; ``` - ### SubscriberAccessor and RealSubscriberAccessor The `Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessor` and @@ -228,7 +226,6 @@ final class CustomResolver implements ArgumentResolver // ... support() } ``` - After: ```php @@ -242,7 +239,6 @@ final class CustomResolver implements ArgumentResolver // ... support() } ``` - ### Custom ArgumentResolver registration Custom argument resolvers are no longer passed to the `MetadataSubscriberAccessorRepository`. @@ -262,7 +258,6 @@ $engine = new DefaultSubscriptionEngine( $subscriberRepository, ); ``` - After: ```php @@ -277,7 +272,6 @@ $engine = new DefaultSubscriptionEngine( argumentResolvers: [new MyResolver()], ); ``` - ### Batchable Subscriber The `Patchlevel\EventSourcing\Subscription\Subscriber\BatchableSubscriber` interface has been removed. @@ -329,15 +323,13 @@ final class MigrationSubscriber implements BatchableSubscriber } } ``` - After: ```php -use Patchlevel\EventSourcing\Attribute\BatchState; use Patchlevel\EventSourcing\Attribute\BatchBegin; use Patchlevel\EventSourcing\Attribute\BatchFlush; use Patchlevel\EventSourcing\Attribute\BatchRollback; -use Patchlevel\EventSourcing\Attribute\BatchShouldFlush; +use Patchlevel\EventSourcing\Attribute\BatchState; final class MigrationBatch { @@ -355,8 +347,11 @@ final class MigrationSubscriber } #[Subscribe(NameChanged::class)] - public function handleNameChanged(NameChanged $event, #[BatchState] MigrationBatch $batch): void - { + public function handleNameChanged( + NameChanged $event, + #[BatchState] + MigrationBatch $batch, + ): void { $batch->nameChanged[$event->userId] = $event->name; } @@ -372,7 +367,6 @@ final class MigrationSubscriber } } ``` - ### Parallel subscription processing The subscription engine now processes one subscription at a time instead of driving a single shared @@ -391,7 +385,6 @@ separate `LockableSubscriptionStore` interface has been removed. Every custom st both: ```php -use Closure; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -415,7 +408,6 @@ interface SubscriptionStore public function inLock(Closure $closure): mixed; } ``` - `find()` no longer locks the matched rows: it is now a plain, unlocked snapshot read. The locking happens per subscription inside `claim()`. @@ -496,7 +488,6 @@ use Patchlevel\EventSourcing\Message\Pipe; $messages = (new Pipe($messages, $translator))->toArray(); ``` - after: ```php @@ -504,7 +495,6 @@ use Patchlevel\EventSourcing\Message\Stream; $messages = (new Stream($messages))->transform($translator)->toList(); ``` - ## Message ### AggregateHeader diff --git a/docs/aggregate.md b/docs/aggregate.md index 11c2babb1..83e2e501d 100644 --- a/docs/aggregate.md +++ b/docs/aggregate.md @@ -107,7 +107,6 @@ final class ProfileRegistered } } ``` - :::note You can find out more about [events](events.md). ::: @@ -182,7 +181,6 @@ final class NameChanged } } ``` - :::note Events should best be written in the past, as they describe a state that has happened. ::: @@ -259,7 +257,6 @@ final class ChangeNameHandler } } ``` - :::success Our aggregate can now be changed and saved. ::: @@ -306,13 +303,11 @@ final class Profile extends BasicAggregateRoot } } ``` - :::tip You don't necessarily need to define multiple `Apply` attributes with the event class if you define the event types in the method using a union type. ::: - ## Suppress missing apply methods Sometimes you have events that do not change the state of the aggregate itself, @@ -400,7 +395,6 @@ final class PersonalInformation extends BasicAggregateRoot You need to define the `SharedApplyContext` attribute on all aggregates that share the apply context. ::: - ## Stream Name The stream name is the name of the stream in the event store. @@ -436,7 +430,6 @@ final class GuestList extends BasicAggregateRoot // ... } ``` - :::tip You can find more about [splitting aggregates](aggregate.md#splitting-aggregates). ::: @@ -479,7 +472,6 @@ final class Profile extends BasicAggregateRoot } } ``` - :::danger Validations during "apply" should not happen, they will break the rebuilding of the aggregate! Instead validate the data *before* the event will be recorded. @@ -566,7 +558,6 @@ final class NameChanged } } ``` - :::warning You need to create a normalizer for the `Name` value object. So the payload must be serializable and unserializable as json. @@ -782,13 +773,11 @@ final class Shipping extends BasicAggregateRoot } } ``` - :::tip With the [SharedApplyContext](./aggregate.md#shared-apply-context) attribute, you can suppress missing applies for events that are handled by other aggregates ::: - ## Auto Initialize :::experimental @@ -803,11 +792,11 @@ The method must be static, receives the aggregate ID as an argument and must ret ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; -use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\AutoInitialize; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Identifier\Uuid; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot @@ -831,12 +820,10 @@ final class Profile extends BasicAggregateRoot } } ``` - :::note Recording events in the `initialize` method is optional but recommended. ::: - ## Aggregate Root Registry The library needs to know about all aggregates so that the correct aggregate class is used to load from the database. diff --git a/docs/command-bus.md b/docs/command-bus.md index 61a0cd91d..f566d4efa 100644 --- a/docs/command-bus.md +++ b/docs/command-bus.md @@ -42,7 +42,6 @@ final class CreateProfileHandler To use Service Handler you need to register the handler in the `ServiceHandlerProvider`. ::: - ### Multiple Handle Attributes A method can also have multiple `#[Handle]` attributes. @@ -61,7 +60,6 @@ final class CreateProfileHandler } } ``` - ### Union Types You can also use union types to handle multiple commands and the library will automatically detect the commands. @@ -78,7 +76,6 @@ final class CreateProfileHandler } } ``` - ### Inheritance The handler will also be invoked if the command implements an interface or extends a class that the handler expects. @@ -95,7 +92,6 @@ final class CreateProfileHandler } } ``` - ### Aggregate Handler Another way to handle commands is to use the aggregates themselves. @@ -136,7 +132,6 @@ final class Profile extends BasicAggregateRoot // ... apply methods } ``` - :::tip You can find more information about [aggregates](aggregate.md). ::: @@ -189,13 +184,11 @@ final class Profile extends BasicAggregateRoot // ... apply methods } ``` - :::tip If you want to automatically initialize an aggregate if it cannot be found in the store, you can use the [Auto Initialize](aggregate.md#auto-initialize) feature. ::: - #### Inject Service You can inject services into aggregate handler methods. @@ -230,7 +223,6 @@ final class Profile extends BasicAggregateRoot // ... apply methods } ``` - :::note The service must be registered in the service locator. ::: @@ -271,7 +263,6 @@ final class Profile extends BasicAggregateRoot // ... apply methods } ``` - :::note Injection in handler methods is only possible with the `AggregateHandlerProvider`. ::: @@ -324,7 +315,6 @@ final class CreateProfile } } ``` - :::tip You can override the default values for the maximum number of retries and the conditions by passing them to the `InstantRetry` attribute. diff --git a/docs/dynamic-consistency-boundary.md b/docs/dynamic-consistency-boundary.md index 220acf11e..49df73a4b 100644 --- a/docs/dynamic-consistency-boundary.md +++ b/docs/dynamic-consistency-boundary.md @@ -4,7 +4,7 @@ This feature is still experimental and may change in the future. Use it with caution. ::: - + Dynamic Consistency Boundary (DCB) is an event‑sourcing approach for making consistent, cross‑stream decisions without loading full aggregates. For each decision, it builds a minimal, purpose‑built state from a targeted subset of events selected via tags. @@ -17,7 +17,7 @@ DCB is a great fit when business rules span multiple streams. :::note You can read more about Dynamic Consistency Boundary on page [dcb.events](https://dcb.events/). ::: - + Since this approach differs slightly from the standard "aggregate" event sourcing principle, we will use the [Getting Started](./getting-started.md) example and build it as a DCB variant. @@ -85,10 +85,9 @@ final class GuestIsCheckedOut } } ``` - :::note You can find out more about [events](events.md). -::: +::: ## Define Commands @@ -298,12 +297,11 @@ final class CreateHotelHandler } } ``` - :::note -Handlers build a Decision Model from the projections and then append events with an optimistic AppendCondition. +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. ::: - + :::tip To get type security, you can use our [phpstan extension](https://github.com/patchlevel/event-sourcing-phpstan-extension). ::: diff --git a/docs/events.md b/docs/events.md index 3de402ee3..dd3d50bef 100644 --- a/docs/events.md +++ b/docs/events.md @@ -40,7 +40,8 @@ Here are some examples: * `profile.created` * `profile.name_changed` * `hotel.guest_checked_out` - ::: + +::: ## Alias diff --git a/docs/getting-started.md b/docs/getting-started.md index 9ef230c43..dcf86d1ef 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -26,8 +26,8 @@ final class HotelCreated A guest can check in by `name`: ```php -use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Identifier\Uuid; #[Event('hotel.guest_checked_in')] final class GuestIsCheckedIn @@ -42,8 +42,8 @@ final class GuestIsCheckedIn And also check out again: ```php -use Patchlevel\EventSourcing\Identifier\Uuid; use Patchlevel\EventSourcing\Attribute\Event; +use Patchlevel\EventSourcing\Identifier\Uuid; #[Event('hotel.guest_checked_out')] final class GuestIsCheckedOut @@ -324,7 +324,6 @@ $repositoryManager = new RunSubscriptionEngineRepositoryManager( $hotelRepository = $repositoryManager->get(Hotel::class); ``` - :::note You can find out more about the [store](store.md). ::: @@ -361,7 +360,6 @@ $schemaDirector->create(); /** @var SubscriptionEngine $engine */ $engine->execute(new Setup(skipBooting: true)); ``` - :::note You can use the predefined [cli commands](cli.md) for this. ::: @@ -388,7 +386,6 @@ $hotelRepository->save($hotel2); $hotels = $hotelProjector->getHotels(); ``` - :::note You can also use other forms of IDs such as uuid version 6 or a custom format. You can find more about [identifiers](identifier.md). diff --git a/docs/identifier.md b/docs/identifier.md index 7043da70d..b37d3c18d 100644 --- a/docs/identifier.md +++ b/docs/identifier.md @@ -30,11 +30,10 @@ use Patchlevel\EventSourcing\Identifier\Uuid; $uuid = Uuid::generate(); $uuid = Uuid::fromString('d6e8d7a0-4b0b-4e6a-8a9a-3a0b2d9d0e4e'); ``` - :::note UUID v7 provides k‑sortable identifiers that work well with append‑only streams and database indexes. See the ramsey docs for details. ::: - + ### `CustomId` `CustomId` is a minimal string‑backed identifier. Use it if you want full control over the string format or if your IDs are provided by an external system. diff --git a/docs/message.md b/docs/message.md index 9bc6c6920..efe05643e 100644 --- a/docs/message.md +++ b/docs/message.md @@ -101,6 +101,49 @@ use Patchlevel\EventSourcing\Message\Message; /** @var Message $message */ $message->header(ApplicationHeader::class); ``` +## Missing headers + +When a message is deserialized, every header name is resolved to its registered header class. +If a header name cannot be resolved, for example because the header class was removed or renamed, +the `DefaultHeadersSerializer` throws a `HeaderNameNotRegistered` exception by default. + +In some cases you want to keep reading old messages that still contain such headers without losing +their data. For this you can configure which header names should be handled gracefully. Those headers +are collected into a single `MissingHeaders` object instead of crashing. The raw names and payloads are +preserved, so you could still access them. + +```php +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; + +$serializer = DefaultHeadersSerializer::createFromPaths( + ['src/Header'], + ['legacyApplication', 'legacyTenant'], +); +``` +You can access the collected headers via the `MissingHeaders` object: + +```php +use Patchlevel\EventSourcing\Message\MissingHeaders; + +/** @var Message $message */ +$missingHeaders = $message->header(MissingHeaders::class); +$missingHeaders->headers; // ['legacyApplication' => [...], 'legacyTenant' => [...]] +``` +:::warning +Only the header names you list are handled gracefully. If a message contains an unregistered header +whose name is **not** in the list, deserialization still throws `HeaderNameNotRegistered`. +::: + +If you want to handle every unregistered header gracefully, you can use the `*` wildcard: + +```php +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; + +$serializer = DefaultHeadersSerializer::createFromPaths( + ['src/Header'], + ['*'], +); +``` ## Stream A `Stream` wraps an iterable of messages and allows you to chain multiple translators with `transform`. @@ -121,7 +164,6 @@ foreach ($stream as $message) { // do something with the message } ``` - :::tip A `Stream` is also what every store returns from its `load` method, so you can apply the same transformations to the messages you read from the store. Besides iterating, a `Stream` offers diff --git a/docs/personal-data.md b/docs/personal-data.md index dd5878f70..1d0420d71 100644 --- a/docs/personal-data.md +++ b/docs/personal-data.md @@ -39,7 +39,6 @@ final class EmailChanged } } ``` - :::tip You can use the `DataSubjectId` in aggregates for snapshots too. ::: @@ -64,7 +63,6 @@ final class EmailChanged } } ``` - :::tip You can use the `PersonalData` in aggregates for snapshots too. ::: @@ -94,7 +92,6 @@ final class ProfileChanged } } ``` - :::danger You have to deal with this case in your business logic such as aggregates and subscriptions. ::: @@ -146,13 +143,12 @@ $schemaDirector = new DoctrineSchemaDirector( Now we have to put the whole thing together in a Personal Data Payload Cryptographer. ```php -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; +use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; /** @var CipherKeyStore $cipherKeyStore */ $cryptographer = PersonalDataPayloadCryptographer::createWithDefaultSettings($cipherKeyStore); ``` - :::tip You can specify the cipher method with the second parameter. ::: @@ -171,7 +167,6 @@ DefaultEventSerializer::createFromPaths( cryptographer: $cryptographer, ); ``` - :::note More information can be found in the [events](events.md) documentation. ::: @@ -192,7 +187,6 @@ $snapshotStore = DefaultSnapshotStore::createDefault( $cryptographer, ); ``` - :::note More information can be found in the [snapshots](snapshots.md) documentation. ::: diff --git a/docs/split-stream.md b/docs/split-stream.md index 02cb0de00..ad10e6122 100644 --- a/docs/split-stream.md +++ b/docs/split-stream.md @@ -40,15 +40,14 @@ $repositoryManager = new DefaultRepositoryManager( new SplitStreamDecorator($eventMetadataFactory), ); ``` - :::note You can find out more about the [message decorator](message-decorator.md). ::: - + :::tip You can use multiple decorators with the `ChainMessageDecorator`. ::: - + ## Usage To use this feature you need to mark the event which should split the stream. @@ -70,20 +69,19 @@ final class BalanceReported } } ``` - :::warning -The event needs all data which is relevant the aggregate to be used since all past event will not be loaded! +The event needs all data which is relevant the aggregate to be used since all past event will not be loaded! Keep this in mind if you want to use this feature. ::: - + :::note This impacts only the aggregate loaded by the repository. Subscriptions will still receive all events. ::: - + :::tip You can combine this feature with the snapshot feature to increase the performance even more. ::: - + ## Learn more * [How to use message decorator](message-decorator.md) diff --git a/docs/store.md b/docs/store.md index a4545f247..0d5ecac61 100644 --- a/docs/store.md +++ b/docs/store.md @@ -33,7 +33,6 @@ $store = new StreamDoctrineDbalStore( DefaultEventSerializer::createFromPaths(['src/Event']), ); ``` - :::note You can find out more about [how to create a connection](https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html) in the doctrine dbal documentation. @@ -73,7 +72,6 @@ use Patchlevel\EventSourcing\Store\InMemoryStore; $store = new InMemoryStore(); ``` - :::tip You can pass messages to the constructor to initialize the store with some events. ::: @@ -119,7 +117,6 @@ $schemaDirector = new DoctrineSchemaDirector( $store, ); ``` - :::note How to setup [cli commands](cli.md) for the schema director is described in the CLI documentation. ::: @@ -224,7 +221,6 @@ $dependencyFactory->setService( $schemaProvider, ); ``` - :::note Here you can find more information on how to [configure doctrine migration](https://www.doctrine-project.org/projects/doctrine-migrations/en/3.3/reference/custom-configuration.html). @@ -300,6 +296,7 @@ $criteria = (new CriteriaBuilder()) A stream name has the format `[aggregateName]-[aggregateId]`. To match every stream of an aggregate, use a wildcard with `StreamCriterion::startWith('profile-')`. ::: + #### Stream The load method returns a `Stream` object and is a generator. @@ -319,7 +316,6 @@ foreach ($stream as $message) { $message->event(); // get the event } ``` - :::note You can find more information about the [`Message` object](message.md). ::: @@ -370,7 +366,6 @@ $store->save($message); $store->save($message1, $message2, $message3); $store->save(...$messages); ``` - :::note The saving happens in a transaction, so all messages are saved or none. The store locks the table for writing during each save by default. @@ -427,7 +422,6 @@ $store->transactional(static function () use ($command, $bankAccountRepository): $bankAccountRepository->save($accountTo); }); ``` - :::note The store locks the table for writing during the transaction by default. ::: diff --git a/docs/subscription.md b/docs/subscription.md index c68f9aa81..725e0960b 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -26,7 +26,6 @@ final class DoStuffSubscriber { } ``` - :::note For each subscriber ID, the engine will create a subscription. If the subscriber ID changes, a new subscription will be created. @@ -74,7 +73,6 @@ final class ProfileProjector } } ``` - :::warning MySQL and MariaDB don't support transactions for DDL statements. So you must use a different database connection for your subscriptions. @@ -149,7 +147,6 @@ final class DoStuffSubscriber } } ``` - :::tip If you are using psalm then you can install the event sourcing [plugin](https://github.com/patchlevel/event-sourcing-psalm-plugin) to make the event method return the correct type. @@ -219,7 +216,6 @@ final class DoStuffSubscriber } } ``` - :::tip You can also subscribe to multiple events and specify your argument using union type. ::: @@ -277,7 +273,6 @@ final class PublicProfileProjection // ... setup, teardown, ... } ``` - :::note More information can be found in the [reducer](message.md#reducer) documentation. ::: @@ -298,7 +293,6 @@ $engine = new DefaultSubscriptionEngine( ], ); ``` - ##### Recorded On Resolver The recorded on resolver resolves the recorded on date. @@ -346,14 +340,12 @@ final class OrderProjection } } ``` - The `emit` method writes the events into the subscriber's own `subscription_` stream. If you want to target a different stream, use `linkTo`: ```php $eventEmitter->linkTo('notifications', [new NotificationRequired($event->orderId)]); ``` - :::info The emitted events must be registered like any other event so the store can (de)serialize them. ::: @@ -386,7 +378,6 @@ $engine = new DefaultSubscriptionEngine( ], ); ``` - ###### When are events emitted Emitting events while a subscription is *booting* would create duplicates on every replay, so by @@ -405,7 +396,6 @@ final class OrderProjection // ... } ``` - * `EnableEventEmittingDuringBoot` — events are also emitted during `boot`. * `DisableEventEmitting` — events are never emitted, not even during `run`. @@ -439,7 +429,6 @@ final class CustomResolver implements ArgumentResolver } } ``` - ### Setup Subscribers can have one `setup` method that is executed when the subscription is created. @@ -467,7 +456,6 @@ final class ProfileProjector } } ``` - :::danger MySQL and MariaDB don't support transactions for DDL statements. So you must use a different database connection in your projectors, @@ -565,7 +553,6 @@ By default, we provide the following cleanup tasks for `doctrine/dbal`: | `DropIndexTask` | Drops an index from a table. | | `DropTableTask` | Drops a table. | - :::note If you are passing connection registry, you can use the connection name as parameter. The `connectionName` parameter is optional and defaults to the default connection. @@ -576,7 +563,6 @@ You can create your own cleanup tasks and handler. For more information, see [Cleanup Handler](#cleanup-handler). ::: - ### On Failed The subscription engine has a [retry strategy](#retry-strategy) to retry subscriptions that have an error. @@ -608,7 +594,6 @@ final class InvoiceProcessor } } ``` - :::warning Currently, the `OnFailed` method is only available for non-batchable subscribers. ::: @@ -632,7 +617,6 @@ final class ProfileSubscriber // ... } ``` - :::warning If you change the `subscriberID`, you must also change the table/collection name. Otherwise the table/collection will conflict with the old subscription. @@ -659,13 +643,13 @@ final class ProfileSubscriber // ... } ``` - :::note The different attributes have different default groups. * `Subscriber` - `default` * `Projector` - `projector` * `Processor` - `processor` + ::: ### Run Mode @@ -688,7 +672,6 @@ final class WelcomeEmailSubscriber // ... } ``` - :::tip If you want to create projections and run from the beginning, you can use the `Projector` attribute. ::: @@ -709,7 +692,6 @@ final class WelcomeEmailSubscriber // ... } ``` - :::tip If you want to process events from now, you can use the `Processor` attribute. ::: @@ -767,12 +749,12 @@ flush and rollback methods. ```php use Doctrine\DBAL\Connection; -use Patchlevel\EventSourcing\Attribute\BatchState; use Patchlevel\EventSourcing\Attribute\BatchBegin; use Patchlevel\EventSourcing\Attribute\BatchFlush; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\BatchRollback; use Patchlevel\EventSourcing\Attribute\BatchShouldFlush; +use Patchlevel\EventSourcing\Attribute\BatchState; +use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; final class MigrationBatch @@ -798,8 +780,11 @@ final class MigrationSubscriber } #[Subscribe(NameChanged::class)] - public function handleNameChanged(NameChanged $event, #[BatchState] MigrationBatch $batch): void - { + public function handleNameChanged( + NameChanged $event, + #[BatchState] + MigrationBatch $batch, + ): void { $batch->nameChanged[$event->userId] = $event->name; } @@ -829,7 +814,6 @@ final class MigrationSubscriber } } ``` - The `#[BatchBegin]` method is optional and called as soon as a subscriber wants to process an event. If no suitable event is found in the stream, batching will not start, and this method will not be called. Here, you can make all necessary preparations, such as opening a transaction, and optionally return the @@ -886,6 +870,7 @@ The Subscription Engine was inspired by the following two blog posts: * [Projection Building Blocks: What you'll need to build projections](https://barryosull.com/blog/projection-building-blocks-what-you-ll-need-to-build-projections/) * [Managing projectors is harder than you think](https://barryosull.com/blog/managing-projectors-is-harder-than-you-think/) + ::: ## Subscription ID @@ -1116,7 +1101,6 @@ $schemaDirector = new DoctrineSchemaDirector( ]), ); ``` - :::note You can find more about the schema configurator in the [store](store.md) documentation. ::: @@ -1175,7 +1159,6 @@ $retryStrategyRepository = new RetryStrategyRepository([ 'no_retry' => new NoRetryStrategy(), ]); ``` - :::note This is what our default configuration looks like if you do not define the retry strategy. ::: @@ -1199,7 +1182,6 @@ final class DropCollection } } ``` - :::warning The task class must be serializable. It will be stored in the subscription store. ::: @@ -1374,7 +1356,6 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; /** @var SubscriptionEngine $subscriptionEngine */ $catchupSubscriptionEngine = new CatchUpSubscriptionEngine($subscriptionEngine); ``` - :::tip You can use the `CatchUpSubscriptionEngine` in your tests to process the events immediately. ::: @@ -1391,7 +1372,6 @@ use Patchlevel\EventSourcing\Subscription\Engine\ThrowOnErrorSubscriptionEngine; /** @var SubscriptionEngine $subscriptionEngine */ $throwOnErrorSubscriptionEngine = new ThrowOnErrorSubscriptionEngine($subscriptionEngine); ``` - :::warning This is only for testing or development. Don't use it in production. The subscription engine has a built-in retry strategy to retry subscriptions that have failed. @@ -1419,7 +1399,6 @@ $eventBus = new RunSubscriptionEngineRepositoryManager( 100, // limit the number of messages ); ``` - :::danger By using this, you can't wrap the repository in a transaction. A rollback is not supported and can break the subscription engine. @@ -1453,7 +1432,6 @@ $subscriptionEngine->execute( ), ); ``` - :::note An `OR` check is made for the respective criteria and all criteria are checked with an `AND`. ::: @@ -1471,7 +1449,6 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->execute(new Setup()); ``` - :::tip You can skip the booting step with the `skipBooting` parameter: `new Setup(skipBooting: true)`. ::: @@ -1489,7 +1466,6 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->execute(new Boot()); ``` - :::tip You can limit the number of processed messages with the `limit` parameter: `new Boot(limit: 100)`. The limit applies **per subscription**, so one call processes at most `limit` messages for each @@ -1507,7 +1483,6 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->execute(new Run()); ``` - :::tip You can limit the number of processed messages with the `limit` parameter: `new Run(limit: 100)`. The limit applies **per subscription**: one `Run` call processes at most `limit` messages for each @@ -1518,9 +1493,9 @@ the lock is released afterwards. ### Parallel processing -Because every subscription is claimed independently by a worker, -you can start the same command in several worker processes at once. -Each worker takes the next available subscription while avoiding subscriptions that are already being processed, +Because every subscription is claimed independently by a worker, +you can start the same command in several worker processes at once. +Each worker takes the next available subscription while avoiding subscriptions that are already being processed, so the workload is distributed across workers without any static configuration. ```php @@ -1531,7 +1506,6 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; /** @var SubscriptionEngine $subscriptionEngine */ $subscriptionEngine->execute(new Run(limit: 100)); ``` - The per-subscription order is always preserved. There is no global ordering across different subscriptions, but since subscriptions are independent of each other this does not matter. diff --git a/docs/testing.md b/docs/testing.md index 07e8a28ed..f0cb26adc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -11,7 +11,6 @@ The helpers in this section live in a separate package that you install as a dev ```bash composer require --dev patchlevel/event-sourcing-phpunit ``` - ### Aggregate Unit Tests There is a special `TestCase` for aggregate tests that you can extend. By extending `AggregateRootTestCase`, you can use @@ -215,7 +214,6 @@ final class ProfileTest extends AggregateRootTestCase } } ``` - :::note You can find out more about the [clock](clock.md). ::: @@ -248,7 +246,6 @@ final class ProfileTest extends TestCase } } ``` - :::warning The `FakeRamseyUuidFactory` is only for testing purposes and supports only the version 7 which is used by the library. diff --git a/src/Message/MissingHeaders.php b/src/Message/MissingHeaders.php new file mode 100644 index 000000000..eee073b58 --- /dev/null +++ b/src/Message/MissingHeaders.php @@ -0,0 +1,19 @@ +> $headers */ + public function __construct( + public readonly array $headers, + ) { + } +} diff --git a/src/Message/Serializer/DefaultHeadersSerializer.php b/src/Message/Serializer/DefaultHeadersSerializer.php index 02addd9d5..46eebe40e 100644 --- a/src/Message/Serializer/DefaultHeadersSerializer.php +++ b/src/Message/Serializer/DefaultHeadersSerializer.php @@ -4,22 +4,30 @@ namespace Patchlevel\EventSourcing\Message\Serializer; +use Patchlevel\EventSourcing\Message\MissingHeaders; use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; +use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered; use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry; use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\MetadataHydrator; +use function in_array; use function is_array; final class DefaultHeadersSerializer implements HeadersSerializer { + private bool $handleAllHeadersGraceful; + + /** @param list $gracefulMissingHeaders */ public function __construct( private readonly MessageHeaderRegistry $messageHeaderRegistry, private readonly Hydrator $hydrator, private readonly Encoder $encoder, + private readonly array $gracefulMissingHeaders = [], ) { + $this->handleAllHeadersGraceful = in_array('*', $this->gracefulMissingHeaders, true); } /** @@ -30,6 +38,14 @@ public function serialize(array $headers, array $options = []): string { $serializedHeaders = []; foreach ($headers as $header) { + if ($header instanceof MissingHeaders) { + foreach ($header->headers as $name => $payload) { + $serializedHeaders[$name] = $payload; + } + + continue; + } + $serializedHeaders[$this->messageHeaderRegistry->headerName($header::class)] = $this->hydrator->extract($header); } @@ -46,27 +62,45 @@ public function deserialize(string $string, array $options = []): array $serializedHeaders = $this->encoder->decode($string, $options); $headers = []; + $missingHeaders = []; + foreach ($serializedHeaders as $headerName => $headerPayload) { if (!is_array($headerPayload)) { throw new InvalidArgument('header payload must be an array'); } - $headers[] = $this->hydrator->hydrate( - $this->messageHeaderRegistry->headerClass($headerName), - $headerPayload, - ); + try { + $headers[] = $this->hydrator->hydrate( + $this->messageHeaderRegistry->headerClass($headerName), + $headerPayload, + ); + } catch (HeaderNameNotRegistered $exception) { + if (!$this->handleAllHeadersGraceful && !in_array($headerName, $this->gracefulMissingHeaders, true)) { + throw $exception; + } + + $missingHeaders[$headerName] = $headerPayload; + } + } + + if ($missingHeaders !== []) { + $headers[] = new MissingHeaders($missingHeaders); } return $headers; } - /** @param list $paths */ - public static function createFromPaths(array $paths): static + /** + * @param list $paths + * @param list $gracefulMissingHeaders + */ + public static function createFromPaths(array $paths, array $gracefulMissingHeaders = []): static { return new self( (new AttributeMessageHeaderRegistryFactory())->create($paths), new MetadataHydrator(), new JsonEncoder(), + $gracefulMissingHeaders, ); } @@ -76,6 +110,7 @@ public static function createDefault(): static MessageHeaderRegistry::createWithInternalHeaders(), new MetadataHydrator(), new JsonEncoder(), + [], ); } } diff --git a/src/Store/LockCouldNotBeAcquired.php b/src/Store/LockCouldNotBeAcquired.php new file mode 100644 index 000000000..ce1e2b265 --- /dev/null +++ b/src/Store/LockCouldNotBeAcquired.php @@ -0,0 +1,24 @@ +connection->fetchAllAssociative( + $lockTimeout = $this->config['lock_timeout']; + + if ($platform instanceof MariaDBPlatform && $lockTimeout < 0) { + $lockTimeout = self::INFINITE_MARIADB_LOCK_TIMEOUT; + } + + $result = $this->connection->fetchOne( sprintf( 'SELECT GET_LOCK("%s", %d)', $this->config['lock_id'], - $this->config['lock_timeout'], + $lockTimeout, ), ); + if ($result === 0) { + throw LockCouldNotBeAcquired::byTimeout($this->config['lock_id'], $this->config['lock_timeout']); + } + + if ($result !== 1) { + throw LockCouldNotBeAcquired::byError($this->config['lock_id']); + } + return; } @@ -605,13 +627,21 @@ private function unlock(): void } if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { - $this->connection->fetchAllAssociative( + $result = $this->connection->fetchOne( sprintf( 'SELECT RELEASE_LOCK("%s")', $this->config['lock_id'], ), ); + if ($result === 0) { + throw LockCouldNotBeFreed::notOurs($this->config['lock_id']); + } + + if ($result !== 1) { + throw LockCouldNotBeFreed::notExist($this->config['lock_id']); + } + return; } diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index 0db57009e..9a22040ba 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -48,6 +48,7 @@ use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; use PDO; +use Pdo\Pgsql; use Psr\Clock\ClockInterface; use Ramsey\Uuid\Uuid; use RuntimeException; @@ -72,6 +73,7 @@ use function str_replace; use const JSON_THROW_ON_ERROR; +use const PHP_VERSION_ID; /** @experimental */ final class TaggableDoctrineDbalStore implements Store, AppendStore, SubscriptionStore, DoctrineSchemaConfigurator @@ -86,6 +88,14 @@ final class TaggableDoctrineDbalStore implements Store, AppendStore, Subscriptio */ private const DEFAULT_LOCK_ID = 133742; + /** + * MariaDB does not support an infinite (negative) lock timeout. Very large values such as + * PHP_INT_MAX overflow its internal timeout arithmetic and make GET_LOCK return NULL. We + * therefore use a large but safe value (INT32_MAX minus a small buffer) as "effectively + * infinite" wait. + */ + private const INFINITE_MARIADB_LOCK_TIMEOUT = 2_147_482_647; + private readonly HeadersSerializer $headersSerializer; private readonly ClockInterface $clock; @@ -660,10 +670,15 @@ public function wait(int $timeoutMilliseconds): void $this->connection->executeStatement(sprintf('LISTEN "%s"', $this->config['table_name'])); - /** @var PDO $nativeConnection */ - $nativeConnection = $this->connection->getNativeConnection(); - - $nativeConnection->pgsqlGetNotify(PDO::FETCH_ASSOC, $timeoutMilliseconds); + if (PHP_VERSION_ID >= 80400) { + /** @var Pgsql $nativeConnection */ + $nativeConnection = $this->connection->getNativeConnection(); + $nativeConnection->getNotify(PDO::FETCH_ASSOC, $timeoutMilliseconds); + } else { + /** @var PDO $nativeConnection */ + $nativeConnection = $this->connection->getNativeConnection(); + $nativeConnection->pgsqlGetNotify(PDO::FETCH_ASSOC, $timeoutMilliseconds); + } } public function setupSubscription(): void @@ -757,14 +772,28 @@ private function lock(): void } if ($this->isMariaDb || $this->isMysql) { - $this->connection->fetchAllAssociative( + $lockTimeout = $this->config['lock_timeout']; + + if ($this->isMariaDb && $lockTimeout < 0) { + $lockTimeout = self::INFINITE_MARIADB_LOCK_TIMEOUT; + } + + $result = $this->connection->fetchOne( sprintf( 'SELECT GET_LOCK("%s", %d)', $this->config['lock_id'], - $this->config['lock_timeout'], + $lockTimeout, ), ); + if ($result === 0) { + throw LockCouldNotBeAcquired::byTimeout($this->config['lock_id'], $this->config['lock_timeout']); + } + + if ($result !== 1) { + throw LockCouldNotBeAcquired::byError($this->config['lock_id']); + } + return; } @@ -772,9 +801,7 @@ private function lock(): void return; // sql locking is not needed because of file locking } - throw new LockingNotImplemented( - $this->connection->getDatabasePlatform()::class, - ); + throw new LockingNotImplemented($this->connection->getDatabasePlatform()::class); } private function unlock(): void @@ -786,13 +813,21 @@ private function unlock(): void } if ($this->isMariaDb || $this->isMysql) { - $this->connection->fetchAllAssociative( + $result = $this->connection->fetchOne( sprintf( 'SELECT RELEASE_LOCK("%s")', $this->config['lock_id'], ), ); + if ($result === 0) { + throw LockCouldNotBeFreed::notOurs($this->config['lock_id']); + } + + if ($result !== 1) { + throw LockCouldNotBeFreed::notExist($this->config['lock_id']); + } + return; } @@ -800,9 +835,7 @@ private function unlock(): void return; // sql locking is not needed because of file locking } - throw new LockingNotImplemented( - $this->connection->getDatabasePlatform()::class, - ); + throw new LockingNotImplemented($this->connection->getDatabasePlatform()::class); } /** @return Closure(): string */ diff --git a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php index d9798e8b5..fcaf5114e 100644 --- a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php @@ -6,6 +6,9 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Schema\Schema; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\Message\Message; @@ -19,6 +22,7 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\LockCouldNotBeAcquired; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; @@ -51,6 +55,7 @@ public function setUp(): void $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), clock: $this->clock, + config: ['lock_timeout' => 1], ); $schemaDirector = new DoctrineSchemaDirector( @@ -396,6 +401,45 @@ public function testSave10000Messages(): void self::assertEquals(10000, $result); } + public function testSaveLockTimeout(): void + { + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $this->markTestSkipped('SQLite does not support locks'); + } + + if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->markTestSkipped('PostgreSQL does lock indefinitely'); + } + + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ]; + + $connection = DriverManager::getConnection($this->connection->getParams()); + + $lock = $connection->fetchOne( + sprintf( + 'SELECT GET_LOCK("%s", %d)', + 133742, + 1, + ), + ); + self::assertSame(1, $lock); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('The lock with id [133742] could not be acquired with a timeout of 1'); + try { + $this->store->save(...$messages); + } finally { + $connection->close(); + } + } + public function testLoad(): void { $profileId = ProfileId::generate(); diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index ad9d732a2..8cb9e0460 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -6,6 +6,9 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; @@ -23,6 +26,7 @@ use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\Header\TagsHeader; +use Patchlevel\EventSourcing\Store\LockCouldNotBeAcquired; use Patchlevel\EventSourcing\Store\Query; use Patchlevel\EventSourcing\Store\SubQuery; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; @@ -407,6 +411,45 @@ public function testSave10000Messages(): void self::assertEquals(10000, $result); } + public function testSaveLockTimeout(): void + { + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $this->markTestSkipped('SQLite does not support locks'); + } + + if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->markTestSkipped('PostgreSQL does lock indefinitely'); + } + + $profileId = ProfileId::generate(); + + $messages = [ + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ]; + + $connection = DriverManager::getConnection($this->connection->getParams()); + + $lock = $connection->fetchOne( + sprintf( + 'SELECT GET_LOCK("%s", %d)', + 133742, + 1, + ), + ); + self::assertSame(1, $lock); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('The lock with id [133742] could not be acquired with a timeout of 1'); + try { + $this->store->save(...$messages); + } finally { + $connection->close(); + } + } + public function testLoad(): void { $profileId = ProfileId::generate(); diff --git a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php index 9e52c025c..5ed313e60 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -5,8 +5,10 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Message\Serializer; use DateTimeImmutable; +use Patchlevel\EventSourcing\Message\MissingHeaders; use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; +use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered; use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; use Patchlevel\EventSourcing\Store\ArchivedHeader; use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; @@ -60,4 +62,94 @@ public function testDeserialize(): void $deserializedMessage, ); } + + public function testDeserializeUnknownHeadersAsMissingHeaders(): void + { + $serializer = new DefaultHeadersSerializer( + (new AttributeMessageHeaderRegistryFactory())->create([ + __DIR__ . '/../../Fixture', + ]), + new MetadataHydrator(), + new JsonEncoder(), + ['removed', 'alsoRemoved'], + ); + + $deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}'); + + self::assertEquals( + [ + new StreamNameHeader('profile-1'), + new MissingHeaders([ + 'removed' => ['foo' => 'bar'], + 'alsoRemoved' => ['baz' => 1], + ]), + ], + $deserializedMessage, + ); + } + + public function testDeserializeUnknownHeaderNotConfiguredCrashes(): void + { + $serializer = new DefaultHeadersSerializer( + (new AttributeMessageHeaderRegistryFactory())->create([ + __DIR__ . '/../../Fixture', + ]), + new MetadataHydrator(), + new JsonEncoder(), + ['removed'], + ); + + $this->expectException(HeaderNameNotRegistered::class); + + $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"notListed":{"baz":1}}'); + } + + public function testDeserializeWildcardHandlesAllUnknownHeaders(): void + { + $serializer = new DefaultHeadersSerializer( + (new AttributeMessageHeaderRegistryFactory())->create([ + __DIR__ . '/../../Fixture', + ]), + new MetadataHydrator(), + new JsonEncoder(), + ['*'], + ); + + $deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}'); + + self::assertEquals( + [ + new StreamNameHeader('profile-1'), + new MissingHeaders([ + 'removed' => ['foo' => 'bar'], + 'alsoRemoved' => ['baz' => 1], + ]), + ], + $deserializedMessage, + ); + } + + public function testSerializeMissingHeadersRoundTrip(): void + { + $serializer = new DefaultHeadersSerializer( + (new AttributeMessageHeaderRegistryFactory())->create([ + __DIR__ . '/../../Fixture', + ]), + new MetadataHydrator(), + new JsonEncoder(), + ); + + $content = $serializer->serialize([ + new StreamNameHeader('profile-1'), + new MissingHeaders([ + 'removed' => ['foo' => 'bar'], + 'alsoRemoved' => ['baz' => 1], + ]), + ]); + + self::assertEquals( + '{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}', + $content, + ); + } } diff --git a/tests/Unit/Store/LockCouldNotBeAcquiredTest.php b/tests/Unit/Store/LockCouldNotBeAcquiredTest.php new file mode 100644 index 000000000..53de8ad05 --- /dev/null +++ b/tests/Unit/Store/LockCouldNotBeAcquiredTest.php @@ -0,0 +1,35 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testByError(): void + { + $exception = LockCouldNotBeAcquired::byError(133742); + + self::assertSame( + 'There was an error when tried to get the lock with id [133742]', + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/LockCouldNotBeFreedTest.php b/tests/Unit/Store/LockCouldNotBeFreedTest.php new file mode 100644 index 000000000..c2f90fdb0 --- /dev/null +++ b/tests/Unit/Store/LockCouldNotBeFreedTest.php @@ -0,0 +1,35 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } + + public function testNotOurs(): void + { + $exception = LockCouldNotBeFreed::notOurs(133742); + + self::assertSame( + 'The lock with id [133742] could not be freed as it is not ours', + $exception->getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php index 0c0501e46..254d00058 100644 --- a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php @@ -32,6 +32,8 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\LockCouldNotBeAcquired; +use Patchlevel\EventSourcing\Store\LockCouldNotBeFreed; use Patchlevel\EventSourcing\Store\MissingDataForStorage; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; @@ -660,10 +662,10 @@ public function __invoke(): void $connection ->expects($this->exactly(2)) - ->method('fetchAllAssociative') + ->method('fetchOne') ->willReturnMap([ - ['SELECT GET_LOCK("133742", -1)', []], - ['SELECT RELEASE_LOCK("133742")', []], + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], ]); $connection @@ -704,10 +706,10 @@ public function __invoke(): void $connection ->expects($this->exactly(2)) - ->method('fetchAllAssociative') + ->method('fetchOne') ->willReturnMap([ - ['SELECT GET_LOCK("133742", -1)', []], - ['SELECT RELEASE_LOCK("133742")', []], + ['SELECT GET_LOCK("133742", 2147482647)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], ]); $connection @@ -871,10 +873,10 @@ public function __invoke(): void $connection ->expects($this->exactly(2)) - ->method('fetchAllAssociative') + ->method('fetchOne') ->willReturnMap([ - ['SELECT GET_LOCK("133742", -1)', []], - ['SELECT RELEASE_LOCK("133742")', []], + ['SELECT GET_LOCK("133742", 2147482647)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], ]); $connection @@ -896,6 +898,273 @@ public function __invoke(): void $store->transactional($callback(...)); } + public function testTransactionalWithMariaDBCustomLockTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 5)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_timeout' => 5], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithMariaDBZeroLockTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 0)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_timeout' => 0], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalLockCouldNotBeAcquiredByTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT GET_LOCK("133742", 5)') + ->willReturn(0); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + config: ['lock_timeout' => 5], + ); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('The lock with id [133742] could not be acquired with a timeout of 5'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeAcquiredByError(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT GET_LOCK("133742", -1)') + ->willReturn(null); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('There was an error when tried to get the lock with id [133742]'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeFreedNotOurs(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', 0], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeFreed::class); + $this->expectExceptionMessage('The lock with id [133742] could not be freed as it is not ours'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeFreedNotExist(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', null], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new StreamDoctrineDbalStore( + $connection, + $eventSerializer, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeFreed::class); + $this->expectExceptionMessage('The lock with id [133742] could not be freed as it does not exist'); + + $store->transactional($callback(...)); + } + public function testSaveWithOneEvent(): void { $recordedOn = new DateTimeImmutable(); diff --git a/tests/Unit/Store/TaggableDoctrineDbalStoreTest.php b/tests/Unit/Store/TaggableDoctrineDbalStoreTest.php new file mode 100644 index 000000000..f65225ce4 --- /dev/null +++ b/tests/Unit/Store/TaggableDoctrineDbalStoreTest.php @@ -0,0 +1,4782 @@ +createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE (stream = :stream_0) AND (playhead > :from_playhead) AND (archived = :archived) ORDER BY id ASC', [ + 'stream_0' => 'profile-1', + 'from_playhead' => 0, + 'archived' => false, + ], $this->isArray()) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithLimit(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE (stream = :stream_0) AND (playhead > :from_playhead) AND (archived = :archived) ORDER BY id ASC LIMIT 10', [ + 'stream_0' => 'profile-1', + 'from_playhead' => 0, + 'archived' => false, + ], $this->isArray()) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + 10, + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithOffset(): void + { + if (method_exists(AbstractPlatform::class, 'supportsLimitOffset')) { + $this->markTestSkipped('In older DBAL versions platforms did not need to support this'); + } + + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE (stream = :stream_0) AND (playhead > :from_playhead) AND (archived = :archived) ORDER BY id ASC OFFSET 5', [ + 'stream_0' => 'profile-1', + 'from_playhead' => 0, + 'archived' => false, + ], $this->isArray()) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + offset: 5, + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithIndex(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE (stream = :stream_0) AND (playhead > :from_playhead) AND (id > :from_index) AND (archived = :archived) ORDER BY id ASC', [ + 'stream_0' => 'profile-1', + 'from_playhead' => 0, + 'archived' => false, + 'from_index' => 1, + ], $this->isArray()) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->fromIndex(1) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithLike(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE (stream LIKE :stream_0) AND (playhead > :from_playhead) AND (archived = :archived) ORDER BY id ASC', [ + 'stream_0' => 'profile-%', + 'from_playhead' => 0, + 'archived' => false, + ], $this->isArray()) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-*') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithLikeAll(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE (playhead > :from_playhead) AND (archived = :archived) ORDER BY id ASC', [ + 'from_playhead' => 0, + 'archived' => false, + ], $this->isArray()) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('*') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadMultipleStream(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE ((stream LIKE :stream_0) OR (stream = :stream_1)) AND (playhead > :from_playhead) AND (archived = :archived) ORDER BY id ASC', [ + 'stream_0' => 'profile-%', + 'stream_1' => 'foo', + 'from_playhead' => 0, + 'archived' => false, + ], $this->isArray()) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName(['profile-*', 'foo']) + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithOneEvent(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new ArrayIterator( + [ + [ + 'id' => 1, + 'stream' => 'profile-1', + 'playhead' => '1', + 'event_id' => '1', + 'event_name' => 'profile.created', + 'event_payload' => '{"profileId": "1", "email": "s"}', + 'tags' => '[]', + 'recorded_on' => '2021-02-17 10:00:00', + 'archived' => '0', + 'custom_headers' => '[]', + ], + ], + )); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE (stream = :stream_0) AND (playhead > :from_playhead) AND (archived = :archived) ORDER BY id ASC', [ + 'stream_0' => 'profile-1', + 'from_playhead' => 0, + 'archived' => false, + ], $this->isArray()) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + $abstractPlatform + ->expects($this->once()) + ->method('getDateTimeTzFormatString') + ->willReturn('Y-m-d H:i:s'); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('deserialize') + ->with(new SerializedEvent('profile.created', '{"profileId": "1", "email": "s"}')) + ->willReturn(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('deserialize') + ->with('[]') + ->willReturn([]); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + + $message = $stream->current(); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + + self::assertInstanceOf(Message::class, $message); + self::assertInstanceOf(ProfileCreated::class, $message->event()); + self::assertSame('profile-1', $message->header(StreamNameHeader::class)->streamName); + self::assertSame(1, $message->header(PlayheadHeader::class)->playhead); + self::assertEquals( + new DateTimeImmutable('2021-02-17 10:00:00'), + $message->header(RecordedOnHeader::class)->recordedOn, + ); + + iterator_to_array($stream); + + self::assertSame(null, $stream->index()); + self::assertSame(0, $stream->position()); + } + + public function testLoadWithTwoEvents(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new ArrayIterator( + [ + [ + 'id' => 1, + 'stream' => 'profile-1', + 'playhead' => '1', + 'event_id' => '1', + 'event_name' => 'profile.created', + 'event_payload' => '{"profileId": "1", "email": "s"}', + 'tags' => '[]', + 'recorded_on' => '2021-02-17 10:00:00', + 'archived' => '0', + 'custom_headers' => '[]', + ], + [ + 'id' => 2, + 'stream' => 'profile-1', + 'playhead' => '2', + 'event_id' => '2', + 'event_name' => 'profile.email_changed', + 'event_payload' => '{"profileId": "1", "email": "d"}', + 'tags' => '[]', + 'recorded_on' => '2021-02-17 11:00:00', + 'archived' => '0', + 'custom_headers' => '[]', + ], + ], + )); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store WHERE (stream = :stream_0) AND (playhead > :from_playhead) AND (archived = :archived) ORDER BY id ASC', + [ + 'stream_0' => 'profile-1', + 'from_playhead' => 0, + 'archived' => false, + ], + $this->isArray(), + ) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + $abstractPlatform + ->expects($this->exactly(2)) + ->method('getDateTimeTzFormatString') + ->willReturn('Y-m-d H:i:s'); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->exactly(2)) + ->method('deserialize') + ->willReturnCallback(new ReturnCallback([ + [ + [new SerializedEvent('profile.created', '{"profileId": "1", "email": "s"}'), []], + new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s')), + ], + [ + [new SerializedEvent('profile.email_changed', '{"profileId": "1", "email": "d"}'), []], + new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('d')), + ], + ])); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->exactly(2)) + ->method('deserialize') + ->with('[]') + ->willReturn([]); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + + $message = $stream->current(); + + self::assertSame(1, $stream->index()); + self::assertSame(0, $stream->position()); + + self::assertInstanceOf(Message::class, $message); + self::assertInstanceOf(ProfileCreated::class, $message->event()); + self::assertSame('profile-1', $message->header(StreamNameHeader::class)->streamName); + self::assertSame(1, $message->header(PlayheadHeader::class)->playhead); + self::assertEquals( + new DateTimeImmutable('2021-02-17 10:00:00'), + $message->header(RecordedOnHeader::class)->recordedOn, + ); + + $stream->next(); + $message = $stream->current(); + + self::assertSame(2, $stream->index()); + self::assertSame(1, $stream->position()); + + self::assertInstanceOf(Message::class, $message); + self::assertInstanceOf(ProfileEmailChanged::class, $message->event()); + self::assertSame('profile-1', $message->header(StreamNameHeader::class)->streamName); + self::assertSame(2, $message->header(PlayheadHeader::class)->playhead); + self::assertEquals( + new DateTimeImmutable('2021-02-17 11:00:00'), + $message->header(RecordedOnHeader::class)->recordedOn, + ); + } + + public function testTransactional(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithMySQL(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithMariaDB(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 2147482647)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithPostgreSQL(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('SELECT pg_advisory_xact_lock(133742)'); + + $connection + ->expects($this->atLeastOnce()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalNested(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('SELECT pg_advisory_xact_lock(133742)'); + + $connection + ->expects($this->exactly(2)) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $store->transactional(static function () use ($store, $callback): void { + $store->transactional($callback(...)); + }); + + self::assertTrue($callback->called); + } + + public function testTransactionalTwice(): void + { + $callback = new class () { + public int $called = 0; + + public function __invoke(): void + { + $this->called++; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + $connection + ->expects($this->exactly(2)) + ->method('executeStatement') + ->with('SELECT pg_advisory_xact_lock(133742)'); + + $connection + ->expects($this->exactly(2)) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $store->transactional($callback(...)); + $store->transactional($callback(...)); + + self::assertEquals(2, $callback->called); + } + + public function testTransactionalUnlockByException(): void + { + $callback = new class () { + public function __invoke(): void + { + throw new RuntimeException(); + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 2147482647)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(RuntimeException::class); + + $store->transactional($callback(...)); + } + + public function testTransactionalWithMariaDBCustomLockTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 5)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['lock_timeout' => 5], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithMariaDBZeroLockTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MariaDBPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", 0)', 1], + ['SELECT RELEASE_LOCK("133742")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['lock_timeout' => 0], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalLockCouldNotBeAcquiredByTimeout(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT GET_LOCK("133742", 5)') + ->willReturn(0); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['lock_timeout' => 5], + ); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('The lock with id [133742] could not be acquired with a timeout of 5'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeAcquiredByError(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT GET_LOCK("133742", -1)') + ->willReturn(null); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeAcquired::class); + $this->expectExceptionMessage('There was an error when tried to get the lock with id [133742]'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeFreedNotOurs(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', 0], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeFreed::class); + $this->expectExceptionMessage('The lock with id [133742] could not be freed as it is not ours'); + + $store->transactional($callback(...)); + } + + public function testTransactionalLockCouldNotBeFreedNotExist(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("133742", -1)', 1], + ['SELECT RELEASE_LOCK("133742")', null], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(LockCouldNotBeFreed::class); + $this->expectExceptionMessage('The lock with id [133742] could not be freed as it does not exist'); + + $store->transactional($callback(...)); + } + + public function testSaveWithOneEvent(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with("INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", ['profile-1', 1, '1', 'profile_created', '', [], $recordedOn, false, '[]'], [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + ]); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->save($message); + } + + public function testSaveWithTwoEvents(): void + { + $recordedOn = new DateTimeImmutable(); + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new EventIdHeader('1')); + $message2 = Message::create(new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('d'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new EventIdHeader('2')); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->willReturnMap([ + [$message1->event(), new SerializedEvent('profile_created', '')], + [$message2->event(), new SerializedEvent('profile_email_changed', '')], + ]); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + "INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?),\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + 'profile-1', + 1, + '1', + 'profile_created', + '', + [], + $recordedOn, + false, + '[]', + 'profile-1', + 2, + '2', + 'profile_email_changed', + '', + [], + $recordedOn, + false, + '[]', + ], + [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + 14 => Type::getType(Types::JSON), + 15 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 16 => Type::getType(Types::BOOLEAN), + ], + ); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->save($message1, $message2); + } + + public function testSaveWithUniqueConstraintViolation(): void + { + $recordedOn = new DateTimeImmutable(); + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new RecordedOnHeader($recordedOn)); + $message2 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new EventIdHeader('2')) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->with($message1->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + "INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?),\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", + [ + 'profile-1', + 1, + '1', + 'profile_created', + '', + [], + $recordedOn, + false, + '[]', + 'profile-1', + 1, + '2', + 'profile_created', + '', + [], + $recordedOn, + false, + '[]', + ], + [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + 14 => Type::getType(Types::JSON), + 15 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 16 => Type::getType(Types::BOOLEAN), + ], + ) + ->willThrowException(new UniqueConstraintViolationException(new Exception('foo'), null)); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(UniqueConstraintViolation::class); + $store->save($message1, $message2); + } + + public function testSaveWithThousandEvents(): void + { + $recordedOn = new DateTimeImmutable(); + + $messages = []; + for ($i = 1; $i <= 10000; $i++) { + $messages[] = Message::create(new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader($i)) + ->withHeader(new RecordedOnHeader($recordedOn)); + } + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->exactly(10000)) + ->method('serialize') + ->with($messages[0]->event()) + ->willReturn(new SerializedEvent( + 'profile_email_changed', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->exactly(10000)) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->exactly(2)) + ->method('executeStatement'); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->save(...$messages); + } + + public function testSaveWithCustomHeaders(): void + { + $customHeaders = [ + new FooHeader('foo'), + new BazHeader('baz'), + ]; + + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new EventIdHeader('1')) + ->withHeaders($customHeaders); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with($customHeaders) + ->willReturn('{foo: "foo", baz: "baz"}'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with("INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", ['profile-1', 1, '1', 'profile_created', '', [], $recordedOn, false, '{foo: "foo", baz: "baz"}'], [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + ]); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->save($message); + } + + public function testAppendWithOneMessage(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + 'INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) SELECT :stream0, :playhead0, :event_id0, :event_name0, :event_payload0, :tags0, :recorded_on0, :archived0, :custom_headers0', + [ + 'stream0' => 'profile-1', + 'playhead0' => 1, + 'event_id0' => '1', + 'event_name0' => 'profile_created', + 'event_payload0' => '', + 'tags0' => [], + 'recorded_on0' => $recordedOn, + 'archived0' => false, + 'custom_headers0' => '[]', + ], + [ + 'tags0' => Type::getType(Types::JSON), + 'recorded_on0' => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 'archived0' => Type::getType(Types::BOOLEAN), + ], + ) + ->willReturn(1); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->append([$message]); + } + + public function testAppendWithTwoMessages(): void + { + $recordedOn = new DateTimeImmutable(); + $message1 = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new EventIdHeader('1')); + $message2 = Message::create(new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('d'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new EventIdHeader('2')) + ->withHeader(new TagsHeader(['foo'])); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->willReturnMap([ + [$message1->event(), new SerializedEvent('profile_created', '')], + [$message2->event(), new SerializedEvent('profile_email_changed', '')], + ]); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->exactly(2)) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + 'INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) SELECT :stream0, :playhead0, :event_id0, :event_name0, :event_payload0, :tags0, :recorded_on0, :archived0, :custom_headers0 UNION ALL SELECT :stream1, :playhead1, :event_id1, :event_name1, :event_payload1, :tags1, :recorded_on1, :archived1, :custom_headers1', + [ + 'stream0' => 'profile-1', + 'playhead0' => 1, + 'event_id0' => '1', + 'event_name0' => 'profile_created', + 'event_payload0' => '', + 'tags0' => [], + 'recorded_on0' => $recordedOn, + 'archived0' => false, + 'custom_headers0' => '[]', + 'stream1' => 'profile-1', + 'playhead1' => 2, + 'event_id1' => '2', + 'event_name1' => 'profile_email_changed', + 'event_payload1' => '', + 'tags1' => ['foo'], + 'recorded_on1' => $recordedOn, + 'archived1' => false, + 'custom_headers1' => '[]', + ], + [ + 'tags0' => Type::getType(Types::JSON), + 'recorded_on0' => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 'archived0' => Type::getType(Types::BOOLEAN), + 'tags1' => Type::getType(Types::JSON), + 'recorded_on1' => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 'archived1' => Type::getType(Types::BOOLEAN), + ], + ) + ->willReturn(2); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->append([$message1, $message2]); + } + + public function testAppendWithPostgresCasts(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + 'INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) SELECT :stream0, :playhead0::int, :event_id0, :event_name0, :event_payload0::jsonb, :tags0::jsonb, :recorded_on0::timestamptz, :archived0::boolean, :custom_headers0::jsonb', + [ + 'stream0' => 'profile-1', + 'playhead0' => 1, + 'event_id0' => '1', + 'event_name0' => 'profile_created', + 'event_payload0' => '', + 'tags0' => [], + 'recorded_on0' => $recordedOn, + 'archived0' => false, + 'custom_headers0' => '[]', + ], + [ + 'tags0' => Type::getType(Types::JSON), + 'recorded_on0' => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 'archived0' => Type::getType(Types::BOOLEAN), + ], + ) + ->willReturn(1); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['locking' => false], + ); + $store->append([$message]); + } + + public function testAppendWithHeaderFallbacks(): void + { + $now = new DateTimeImmutable('2025-01-01 10:00:00'); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $clock = $this->createMock(ClockInterface::class); + $clock + ->expects($this->once()) + ->method('now') + ->willReturn($now); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + 'INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) SELECT :stream0, :playhead0, :event_id0, :event_name0, :event_payload0, :tags0, :recorded_on0, :archived0, :custom_headers0', + $this->callback(static function (array $parameters) use ($now): bool { + return $parameters['stream0'] === 'main' + && $parameters['playhead0'] === null + && is_string($parameters['event_id0']) + && Uuid::isValid($parameters['event_id0']) + && $parameters['event_name0'] === 'profile_created' + && $parameters['event_payload0'] === '' + && $parameters['tags0'] === [] + && $parameters['recorded_on0'] === $now + && $parameters['archived0'] === false + && $parameters['custom_headers0'] === '[]'; + }), + [ + 'tags0' => Type::getType(Types::JSON), + 'recorded_on0' => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 'archived0' => Type::getType(Types::BOOLEAN), + ], + ) + ->willReturn(1); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + $clock, + ); + $store->append([$message]); + } + + public function testAppendWithAppendCondition(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->exactly(4)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->exactly(3)) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($mockedConnection), + ); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + 'INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) SELECT :stream0, :playhead0, :event_id0, :event_name0, :event_payload0, :tags0, :recorded_on0, :archived0, :custom_headers0 WHERE (SELECT events.id FROM event_store events INNER JOIN (SELECT id FROM (SELECT id FROM event_store WHERE stream = :param1) j GROUP BY j.id) ej ON ej.id = events.id ORDER BY events.id DESC LIMIT 1) = :highestId', + [ + 'stream0' => 'profile-1', + 'playhead0' => 1, + 'event_id0' => '1', + 'event_name0' => 'profile_created', + 'event_payload0' => '', + 'tags0' => [], + 'recorded_on0' => $recordedOn, + 'archived0' => false, + 'custom_headers0' => '[]', + 'highestId' => 5, + 'param1' => 'profile-1', + ], + [ + 'tags0' => Type::getType(Types::JSON), + 'recorded_on0' => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 'archived0' => Type::getType(Types::BOOLEAN), + 'param1' => ParameterType::STRING, + ], + ) + ->willReturn(1); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->append( + [$message], + new AppendCondition(new Query(new SubQuery(streamName: 'profile-1')), 5), + ); + } + + public function testAppendWithAppendConditionZeroSequence(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($mockedConnection), + ); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + 'INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) SELECT :stream0, :playhead0, :event_id0, :event_name0, :event_payload0, :tags0, :recorded_on0, :archived0, :custom_headers0 WHERE NOT EXISTS (SELECT events.id FROM event_store events ORDER BY events.id DESC LIMIT 1)', + [ + 'stream0' => 'profile-1', + 'playhead0' => 1, + 'event_id0' => '1', + 'event_name0' => 'profile_created', + 'event_payload0' => '', + 'tags0' => [], + 'recorded_on0' => $recordedOn, + 'archived0' => false, + 'custom_headers0' => '[]', + ], + [ + 'tags0' => Type::getType(Types::JSON), + 'recorded_on0' => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 'archived0' => Type::getType(Types::BOOLEAN), + ], + ) + ->willReturn(1); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->append( + [$message], + new AppendCondition(new Query(), 0), + ); + } + + public function testAppendConditionNotMet(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($mockedConnection), + ); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + 'INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) SELECT :stream0, :playhead0, :event_id0, :event_name0, :event_payload0, :tags0, :recorded_on0, :archived0, :custom_headers0 WHERE (SELECT events.id FROM event_store events ORDER BY events.id DESC LIMIT 1) = :highestId', + [ + 'stream0' => 'profile-1', + 'playhead0' => 1, + 'event_id0' => '1', + 'event_name0' => 'profile_created', + 'event_payload0' => '', + 'tags0' => [], + 'recorded_on0' => $recordedOn, + 'archived0' => false, + 'custom_headers0' => '[]', + 'highestId' => 5, + ], + [ + 'tags0' => Type::getType(Types::JSON), + 'recorded_on0' => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 'archived0' => Type::getType(Types::BOOLEAN), + ], + ) + ->willReturn(0); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(AppendConditionNotMet::class); + $store->append( + [$message], + new AppendCondition(new Query(), 5), + ); + } + + public function testAppendWithUniqueConstraintViolation(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + 'INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) SELECT :stream0, :playhead0, :event_id0, :event_name0, :event_payload0, :tags0, :recorded_on0, :archived0, :custom_headers0', + [ + 'stream0' => 'profile-1', + 'playhead0' => 1, + 'event_id0' => '1', + 'event_name0' => 'profile_created', + 'event_payload0' => '', + 'tags0' => [], + 'recorded_on0' => $recordedOn, + 'archived0' => false, + 'custom_headers0' => '[]', + ], + [ + 'tags0' => Type::getType(Types::JSON), + 'recorded_on0' => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 'archived0' => Type::getType(Types::BOOLEAN), + ], + ) + ->willThrowException(new UniqueConstraintViolationException(new Exception('foo'), null)); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(UniqueConstraintViolation::class); + $store->append([$message]); + } + + public function testQueryWithEmptyQuery(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store events ORDER BY events.id ASC', + [], + $this->isArray(), + ) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->query(new Query()); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testQueryWithOnlyEmptySubQueries(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store events ORDER BY events.id ASC', + [], + $this->isArray(), + ) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->query(new Query(new SubQuery())); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testQueryWithStreamName(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store events INNER JOIN (SELECT id FROM (SELECT id FROM event_store WHERE stream = :param1) j GROUP BY j.id) ej ON ej.id = events.id ORDER BY events.id ASC', + ['param1' => 'profile-1'], + $this->isArray(), + ) + ->willReturn($result); + + $connection + ->expects($this->exactly(5)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->exactly(3)) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->query(new Query(new SubQuery(streamName: 'profile-1'))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testQueryWithTags(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store events INNER JOIN (SELECT id FROM (SELECT id FROM event_store WHERE NOT EXISTS(SELECT value FROM JSON_EACH(:param1) WHERE value NOT IN (SELECT value FROM JSON_EACH(tags)))) j GROUP BY j.id) ej ON ej.id = events.id ORDER BY events.id ASC', + ['param1' => '["foo","bar"]'], + $this->isArray(), + ) + ->willReturn($result); + + $connection + ->expects($this->exactly(5)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->exactly(3)) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->query(new Query(new SubQuery(tags: ['foo', 'bar']))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testQueryWithTagsOnPostgreSQL(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store events INNER JOIN (SELECT id FROM (SELECT id FROM event_store WHERE tags @> :param1::jsonb) j GROUP BY j.id) ej ON ej.id = events.id ORDER BY events.id ASC', + ['param1' => '["foo"]'], + $this->isArray(), + ) + ->willReturn($result); + + $connection + ->expects($this->exactly(5)) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + $connection + ->expects($this->exactly(3)) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->query(new Query(new SubQuery(tags: ['foo']))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testQueryWithTagsOnMySQL(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store events INNER JOIN (SELECT id FROM (SELECT id FROM event_store WHERE JSON_CONTAINS(tags, :param1)) j GROUP BY j.id) ej ON ej.id = events.id ORDER BY events.id ASC', + ['param1' => '["foo"]'], + $this->isArray(), + ) + ->willReturn($result); + + $connection + ->expects($this->exactly(5)) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + $connection + ->expects($this->exactly(3)) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->query(new Query(new SubQuery(tags: ['foo']))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testQueryWithTagsOnNotSupportedPlatform(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->never()) + ->method('executeQuery'); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $connection + ->expects($this->exactly(2)) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(RuntimeException::class); + $doctrineDbalStore->query(new Query(new SubQuery(tags: ['foo']))); + } + + public function testQueryWithEvents(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store events INNER JOIN (SELECT id FROM (SELECT id FROM event_store WHERE event_name IN (:param1)) j GROUP BY j.id) ej ON ej.id = events.id ORDER BY events.id ASC', + ['param1' => ['profile.created']], + $this->isArray(), + ) + ->willReturn($result); + + $connection + ->expects($this->exactly(5)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->exactly(3)) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry(['profile.created' => ProfileCreated::class]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->query(new Query(new SubQuery(events: [ProfileCreated::class]))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testQueryWithOnlyLastEvent(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store events INNER JOIN (SELECT id FROM (SELECT MAX(id) AS id FROM event_store WHERE stream = :param1) j GROUP BY j.id) ej ON ej.id = events.id ORDER BY events.id ASC', + ['param1' => 'profile-1'], + $this->isArray(), + ) + ->willReturn($result); + + $connection + ->expects($this->exactly(5)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->exactly(3)) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->query(new Query(new SubQuery(streamName: 'profile-1', onlyLastEvent: true))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testQueryWithMultipleSubQueries(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM event_store events INNER JOIN (SELECT id FROM (SELECT id FROM event_store WHERE stream = :param1 UNION ALL SELECT id FROM event_store WHERE stream = :param2) j GROUP BY j.id) ej ON ej.id = events.id ORDER BY events.id ASC', + [ + 'param1' => 'profile-1', + 'param2' => 'profile-2', + ], + $this->isArray(), + ) + ->willReturn($result); + + $connection + ->expects($this->exactly(6)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->exactly(4)) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->query(new Query( + new SubQuery(streamName: 'profile-1'), + new SubQuery(streamName: 'profile-2'), + )); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithEmptyStreamCriterion(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store ORDER BY id ASC', [], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(new Criteria(new StreamCriterion())); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithToPlayhead(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE playhead < :to_playhead ORDER BY id ASC', ['to_playhead' => 10], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->toPlayhead(10) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithToIndex(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE id < :to_index ORDER BY id ASC', ['to_index' => 100], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(new Criteria(new ToIndexCriterion(100))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithEvents(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE event_name IN (:events) ORDER BY id ASC', [ + 'events' => ['profile.created'], + ], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load( + (new CriteriaBuilder()) + ->events(['profile.created']) + ->build(), + ); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithEventId(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE event_id = :event_id ORDER BY id ASC', ['event_id' => '1'], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(new Criteria(new EventIdCriterion('1'))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithTag(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE NOT EXISTS(SELECT value FROM JSON_EACH(:tags) WHERE value NOT IN (SELECT value FROM JSON_EACH(tags))) ORDER BY id ASC', ['tags' => '["foo"]'], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(new Criteria(new TagCriterion(['foo']))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithTagOnPostgreSQL(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE tags @> :tags::jsonb ORDER BY id ASC', ['tags' => '["foo"]'], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(new Criteria(new TagCriterion(['foo']))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithTagOnMySQL(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new EmptyIterator()); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store WHERE JSON_CONTAINS(tags, :tags) ORDER BY id ASC', ['tags' => '["foo"]'], $this->isArray()) + ->willReturn($result); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(new Criteria(new TagCriterion(['foo']))); + + self::assertSame(null, $stream->index()); + self::assertSame(null, $stream->position()); + } + + public function testLoadWithTagOnNotSupportedPlatform(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->never()) + ->method('executeQuery'); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(RuntimeException::class); + $doctrineDbalStore->load(new Criteria(new TagCriterion(['foo']))); + } + + public function testLoadWithUnsupportedCriterion(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->never()) + ->method('executeQuery'); + + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(UnsupportedCriterion::class); + $doctrineDbalStore->load(new Criteria(new stdClass())); + } + + public function testStreams(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('fetchFirstColumn') + ->willReturn(['foo', 'profile-1']); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT DISTINCT stream FROM event_store ORDER BY stream', [], []) + ->willReturn($result); + + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + self::assertSame(['foo', 'profile-1'], $doctrineDbalStore->streams()); + } + + public function testRemove(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('DELETE FROM event_store', [], []) + ->willReturn(1); + + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $doctrineDbalStore->remove(); + } + + public function testRemoveWithCriteria(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('DELETE FROM event_store WHERE stream = :stream_0', ['stream_0' => 'profile-1'], $this->isArray()) + ->willReturn(1); + + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $doctrineDbalStore->remove( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->build(), + ); + } + + public function testArchive(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('UPDATE event_store SET archived = :value', ['value' => true], $this->isArray()) + ->willReturn(1); + + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $doctrineDbalStore->archive(); + } + + public function testArchiveWithCriteria(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('UPDATE event_store SET archived = :value WHERE stream = :stream_0', [ + 'stream_0' => 'profile-1', + 'value' => true, + ], $this->isArray()) + ->willReturn(1); + + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $doctrineDbalStore->archive( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->build(), + ); + } + + public function testSaveWithoutStreamNameHeader(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with("INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", ['main', 1, '1', 'profile_created', '', [], $recordedOn, false, '[]'], [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + ]); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->save($message); + } + + public function testSaveWithCustomDefaultStreamName(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with("INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", ['custom', 1, '1', 'profile_created', '', [], $recordedOn, false, '[]'], [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + ]); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['default_stream_name' => 'custom'], + ); + $store->save($message); + } + + public function testSaveWithTags(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new TagsHeader(['foo', 'bar'])); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with("INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", ['profile-1', 1, '1', 'profile_created', '', ['foo', 'bar'], $recordedOn, false, '[]'], [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + ]); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->save($message); + } + + public function testSaveWithKeepIndex(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(42)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with("INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers, id) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ['profile-1', 1, '1', 'profile_created', '', [], $recordedOn, false, '[]', 42], [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + ]); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['keep_index' => true], + ); + $store->save($message); + } + + public function testSaveWithKeepIndexMissingHeader(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->never()) + ->method('executeStatement'); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['keep_index' => true], + ); + + $this->expectException(MissingDataForStorage::class); + $store->save($message); + } + + public function testSaveWithKeepIndexOnPostgres(): void + { + $recordedOn = new DateTimeImmutable(); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new EventIdHeader('1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(42)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->exactly(2)) + ->method('executeStatement') + ->willReturnCallback(new ReturnCallback([ + [ + [ + "INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers, id) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ['profile-1', 1, '1', 'profile_created', '', [], $recordedOn, false, '[]', 42], + [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + ], + ], + 1, + ], + [ + ["SELECT setval('event_store_id_seq', (SELECT MAX(id) FROM event_store));", [], []], + 1, + ], + ])); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['keep_index' => true, 'locking' => false], + ); + $store->save($message); + } + + public function testLoadWithTagsAndArchivedHeader(): void + { + $connection = $this->createMock(Connection::class); + $result = $this->createMock(Result::class); + $result + ->expects($this->once()) + ->method('iterateAssociative') + ->willReturn(new ArrayIterator( + [ + [ + 'id' => 1, + 'stream' => 'profile-1', + 'playhead' => '1', + 'event_id' => '1', + 'event_name' => 'profile.created', + 'event_payload' => '{"profileId": "1", "email": "s"}', + 'tags' => '["foo"]', + 'recorded_on' => '2021-02-17 10:00:00', + 'archived' => '1', + 'custom_headers' => '[]', + ], + ], + )); + + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with('SELECT * FROM event_store ORDER BY id ASC', [], $this->isArray()) + ->willReturn($result); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + $abstractPlatform + ->expects($this->once()) + ->method('getDateTimeTzFormatString') + ->willReturn('Y-m-d H:i:s'); + + $connection + ->expects($this->exactly(3)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('deserialize') + ->with(new SerializedEvent('profile.created', '{"profileId": "1", "email": "s"}')) + ->willReturn(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('deserialize') + ->with('[]') + ->willReturn([]); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $stream = $doctrineDbalStore->load(); + $message = $stream->current(); + + self::assertInstanceOf(Message::class, $message); + self::assertSame(['foo'], $message->header(TagsHeader::class)->tags); + self::assertTrue($message->hasHeader(ArchivedHeader::class)); + } + + public function testSupportSubscription(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + self::assertTrue($doctrineDbalStore->supportSubscription()); + } + + public function testSupportSubscriptionNotPostgres(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + self::assertFalse($doctrineDbalStore->supportSubscription()); + } + + public function testTransactionalWithoutLocking(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->never()) + ->method('fetchOne'); + $connection + ->expects($this->never()) + ->method('executeStatement'); + + $connection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['locking' => false], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithCustomLockId(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new MySQLPlatform()); + + $connection + ->expects($this->exactly(2)) + ->method('fetchOne') + ->willReturnMap([ + ['SELECT GET_LOCK("42", -1)', 1], + ['SELECT RELEASE_LOCK("42")', 1], + ]); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['lock_id' => 42], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testTransactionalWithPostgreSQLCustomLockId(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new PostgreSQLPlatform()); + + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('SELECT pg_advisory_xact_lock(42)'); + + $connection + ->expects($this->once()) + ->method('transactional') + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + config: ['lock_id' => 42], + ); + + $store->transactional($callback(...)); + + self::assertTrue($callback->called); + } + + public function testSaveWithNoMessages(): void + { + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->never()) + ->method('transactional'); + $mockedConnection + ->expects($this->never()) + ->method('executeStatement'); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->save(); + } + + public function testSaveWithHeaderFallbacks(): void + { + $now = new DateTimeImmutable('2025-01-01 10:00:00'); + $message = Message::create(new ProfileCreated(ProfileId::fromString('1'), Email::fromString('s'))); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->once()) + ->method('serialize') + ->with($message->event()) + ->willReturn(new SerializedEvent( + 'profile_created', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->once()) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $clock = $this->createMock(ClockInterface::class); + $clock + ->expects($this->once()) + ->method('now') + ->willReturn($now); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement') + ->with( + "INSERT INTO event_store (stream, playhead, event_id, event_name, event_payload, tags, recorded_on, archived, custom_headers) VALUES\n(?, ?, ?, ?, ?, ?, ?, ?, ?)", + $this->callback(static function (array $parameters) use ($now): bool { + return $parameters[0] === 'main' + && $parameters[1] === null + && is_string($parameters[2]) + && Uuid::isValid($parameters[2]) + && $parameters[3] === 'profile_created' + && $parameters[4] === '' + && $parameters[5] === [] + && $parameters[6] === $now + && $parameters[7] === false + && $parameters[8] === '[]'; + }), + [ + 5 => Type::getType(Types::JSON), + 6 => Type::getType(Types::DATETIMETZ_IMMUTABLE), + 7 => Type::getType(Types::BOOLEAN), + ], + ); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + $clock, + ); + $store->save($message); + } + + public function testSaveWithExactBatchSize(): void + { + $recordedOn = new DateTimeImmutable(); + + $messages = []; + for ($i = 1; $i <= 7281; $i++) { + $messages[] = Message::create(new ProfileEmailChanged(ProfileId::fromString('1'), Email::fromString('s'))) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader($i)) + ->withHeader(new RecordedOnHeader($recordedOn)); + } + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventSerializer + ->expects($this->exactly(7281)) + ->method('serialize') + ->with($messages[0]->event()) + ->willReturn(new SerializedEvent( + 'profile_email_changed', + '', + )); + + $eventRegistry = new EventRegistry([]); + + $headersSerializer = $this->createMock(HeadersSerializer::class); + $headersSerializer + ->expects($this->exactly(7281)) + ->method('serialize') + ->with([]) + ->willReturn('[]'); + + $mockedConnection = $this->createMock(Connection::class); + $mockedConnection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $mockedConnection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback( + static fn (Closure $closure): mixed => $closure(), + ); + + $mockedConnection + ->expects($this->once()) + ->method('executeStatement'); + + $store = new TaggableDoctrineDbalStore( + $mockedConnection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $store->save(...$messages); + } + + public function testWaitNotPostgres(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->never()) + ->method('executeStatement'); + $connection + ->expects($this->never()) + ->method('getNativeConnection'); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $doctrineDbalStore->wait(100); + } + + public function testConnection(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + self::assertSame($connection, $doctrineDbalStore->connection()); + } + + public function testTransactionalLockingNotImplemented(): void + { + $callback = new class () { + public bool $called = false; + + public function __invoke(): void + { + $this->called = true; + } + }; + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + $connection + ->expects($this->never()) + ->method('fetchOne'); + $connection + ->expects($this->never()) + ->method('executeStatement'); + $connection + ->expects($this->once()) + ->method('transactional') + ->with($this->isInstanceOf(Closure::class)) + ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $store = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(LockingNotImplemented::class); + $store->transactional($callback(...)); + } + + public function testCount(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT COUNT(*) FROM event_store WHERE (stream = :stream_0) AND (playhead > :from_playhead) AND (archived = :archived)', [ + 'stream_0' => 'profile-1', + 'from_playhead' => 0, + 'archived' => false, + ], $this->isArray()) + ->willReturn('1'); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $count = $doctrineDbalStore->count( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + + self::assertSame(1, $count); + } + + public function testCountWrongResult(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT COUNT(*) FROM event_store WHERE (stream = :stream_0) AND (playhead > :from_playhead) AND (archived = :archived)', [ + 'stream_0' => 'profile-1', + 'from_playhead' => 0, + 'archived' => false, + ], $this->isArray()) + ->willReturn([]); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $abstractPlatform + ->expects($this->once()) + ->method('createSelectSQLBuilder') + ->willReturn(new DefaultSelectSQLBuilder( + $abstractPlatform, + 'FOR UPDATE', + 'SKIP LOCKED', + )); + $connection + ->expects($this->exactly(2)) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + + $queryBuilder = new QueryBuilder($connection); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturn($queryBuilder); + $connection + ->expects($this->once()) + ->method('createExpressionBuilder') + ->willReturn(new ExpressionBuilder($connection)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $this->expectException(WrongQueryResult::class); + $doctrineDbalStore->count( + (new CriteriaBuilder()) + ->streamName('profile-1') + ->fromPlayhead(0) + ->archived(false) + ->build(), + ); + } + + public function testSetupSubscription(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(3)) + ->method('executeStatement') + ->willReturnMap([ + [ + <<<'SQL' + CREATE OR REPLACE FUNCTION notify_event_store() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify('event_store', NEW.stream::text); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + SQL, + 1, + ], + ['DROP TRIGGER IF EXISTS notify_trigger ON event_store;', 1], + ['CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON event_store FOR EACH ROW EXECUTE PROCEDURE notify_event_store();', 1], + ]); + + $abstractPlatform = $this->createMock(PostgreSQLPlatform::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $doctrineDbalStore->setupSubscription(); + } + + public function testSetupSubscriptionWithOtherStoreTableName(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->exactly(3)) + ->method('executeStatement') + ->willReturnMap([ + [ + <<<'SQL' + CREATE OR REPLACE FUNCTION new.notify_event_store() RETURNS TRIGGER AS $$ + BEGIN + PERFORM pg_notify('new.event_store', NEW.stream::text); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + SQL, + 1, + ], + ['DROP TRIGGER IF EXISTS notify_trigger ON new.event_store;', 1], + ['CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON new.event_store FOR EACH ROW EXECUTE PROCEDURE new.notify_event_store();', 1], + ]); + + $abstractPlatform = $this->createMock(PostgreSQLPlatform::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + $clock = $this->createMock(ClockInterface::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + $clock, + ['table_name' => 'new.event_store'], + ); + $doctrineDbalStore->setupSubscription(); + } + + public function testSetupSubscriptionNotPostgres(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->never()) + ->method('executeStatement'); + + $abstractPlatform = $this->createMock(AbstractPlatform::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $doctrineDbalStore->setupSubscription(); + } + + #[RequiresPhp('>= 8.4')] + public function testWait(): void + { + $nativeConnection = $this->createMock(Pgsql::class); + $nativeConnection + ->expects($this->once()) + ->method('getNotify') + ->with(PDO::FETCH_ASSOC, 100) + ->willReturn([]); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('LISTEN "event_store"') + ->willReturn(1); + $connection + ->expects($this->once()) + ->method('getNativeConnection') + ->willReturn($nativeConnection); + + $abstractPlatform = $this->createMock(PostgreSQLPlatform::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $doctrineDbalStore->wait(100); + } + + #[RequiresPhp('< 8.4')] + public function testWaitDeprecatedFunction(): void + { + $nativeConnection = $this->getMockBuilder(PDO::class) + ->disableOriginalConstructor() + ->addMethods(['pgsqlGetNotify']) + ->getMock(); + $nativeConnection + ->expects($this->once()) + ->method('pgsqlGetNotify') + ->with(PDO::FETCH_ASSOC, 100) + ->willReturn([]); + + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('executeStatement') + ->with('LISTEN "event_store"') + ->willReturn(1); + $connection + ->expects($this->once()) + ->method('getNativeConnection') + ->willReturn($nativeConnection); + + $abstractPlatform = $this->createMock(PostgreSQLPlatform::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($abstractPlatform); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + $doctrineDbalStore->wait(100); + } + + public function testConfigureSchemaWithDifferentConnection(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($this->createMock(AbstractPlatform::class)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $differentConnection = $this->createMock(Connection::class); + + $schema = new Schema(); + $doctrineDbalStore->configureSchema($schema, $differentConnection); + + self::assertEquals(new Schema(), $schema); + } + + public function testConfigureSchema(): void + { + $connection = $this->createMock(Connection::class); + $connection + ->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($this->createMock(AbstractPlatform::class)); + + $eventSerializer = $this->createMock(EventSerializer::class); + $eventRegistry = new EventRegistry([]); + $headersSerializer = $this->createMock(HeadersSerializer::class); + + $doctrineDbalStore = new TaggableDoctrineDbalStore( + $connection, + $eventSerializer, + $eventRegistry, + $headersSerializer, + ); + + $expectedSchema = new Schema(); + $table = $expectedSchema->createTable('event_store'); + $table->addColumn('id', Types::BIGINT) + ->setAutoincrement(true) + ->setNotnull(true); + $table->addColumn('stream', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('playhead', Types::INTEGER) + ->setNotnull(false); + $table->addColumn('event_id', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('event_name', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('event_payload', Types::JSON) + ->setNotnull(true); + $table->addColumn('recorded_on', Types::DATETIMETZ_IMMUTABLE) + ->setNotnull(true); + $table->addColumn('archived', Types::BOOLEAN) + ->setNotnull(true) + ->setDefault(false); + $table->addColumn('tags', Types::JSON) + ->setLength(255) + ->setNotnull(false); + $table->addColumn('custom_headers', Types::JSON) + ->setNotnull(true); + + $table->addPrimaryKeyConstraint( + PrimaryKeyConstraint::editor()->setColumnNames( + UnqualifiedName::unquoted('id'), + )->create(), + ); + $table->addUniqueIndex(['event_id']); + $table->addUniqueIndex(['stream', 'playhead']); + $table->addIndex(['stream', 'playhead', 'archived']); + + $schema = new Schema(); + $doctrineDbalStore->configureSchema($schema, $connection); + + self::assertEquals($expectedSchema, $schema); + } +}