From 9d92e60de7c14fe43fae9ce30a676aae9846b664 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Mon, 14 Sep 2026 19:00:27 +0200 Subject: [PATCH 01/18] Improve docs deployement to minimise CI times. * on versioned branches only trigger deploy if a changed landed in the docs * on release always trigger deploy --- .github/workflows/docs-deploy.yml | 51 ++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 7cb425e02..03b44ed3e 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -4,19 +4,68 @@ on: push: branches: - "[0-9]+.[0-9]+.x" + paths: + - "docs/**" release: types: - published +permissions: + contents: read + jobs: trigger: runs-on: ubuntu-latest steps: + - name: Check whether this ref belongs to the latest release of its major version + id: check + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + if [ "$EVENT_NAME" = "release" ]; then + current="$RELEASE_TAG" + else + current="$REF_NAME" + fi + + major="${current%%.*}" + + latest_tag=$(gh release list \ + --repo "$GITHUB_REPOSITORY" \ + --exclude-drafts \ + --exclude-pre-releases \ + --limit 1000 \ + --json tagName \ + --jq '.[].tagName' \ + | grep -E "^${major}\.[0-9]+\.[0-9]+$" \ + | sort -V \ + | tail -n1) + latest_branch="${latest_tag%.*}.x" + + if [ "$EVENT_NAME" = "release" ]; then + expected="$latest_tag" + else + expected="$latest_branch" + fi + + echo "latest release of ${major}.x: ${latest_tag:-none} (branch ${latest_branch}), current: ${current}" + + if [ -n "$latest_tag" ] && [ "$current" = "$expected" ]; then + echo "deploy=true" >> "$GITHUB_OUTPUT" + else + echo "deploy=false" >> "$GITHUB_OUTPUT" + echo "Skipping docs deployment: ${current} is not the latest release of ${major}.x." + fi + - name: Trigger workflow in other repo + if: steps.check.outputs.deploy == 'true' run: | curl -L -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${{ secrets.ORGANIZATION_ADMIN_TOKEN }}" \ -H "X-GitHub-Api-Version: 2026-03-10" \ https://api.github.com/repos/patchlevel/patchlevel.dev/actions/workflows/prod-deployment.yaml/dispatches \ - -d '{"ref":"main"}' \ No newline at end of file + -d '{"ref":"main"}' From 6558e90f2ee29f0341560f43288a7ffdffe9bf7d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:25:46 +0000 Subject: [PATCH 02/18] Update php Docker tag to v8.5 | datasource | package | from | to | | ---------- | ------- | ---- | --- | | docker | php | 8.3 | 8.5 | Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1b956b370..7e26a5a71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.3 +FROM php:8.5 ARG EXTENSIONS="pcntl zip intl bcmath" From cfb4ec32d3bd14750ba1a248460565ff03ea42e4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:25:50 +0000 Subject: [PATCH 03/18] Update mariadb Docker tag to v13 | datasource | package | from | to | | ---------- | ------- | ---- | -- | | docker | mariadb | 12 | 13 | Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index f1386d16c..d205b56ef 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,7 +16,7 @@ services: - 3306:3306 mariadb: - image: mariadb:12 + image: mariadb:13 environment: - MYSQL_ALLOW_EMPTY_PASSWORD="yes" - MYSQL_DATABASE=eventstore From fabb4ad450d41b940917864bd8f2e1a7a249240d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:57:16 +0000 Subject: [PATCH 04/18] Update mysql Docker tag to v26 | datasource | package | from | to | | ---------- | ------- | ---- | -- | | docker | mysql | 8 | 26 | Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index d205b56ef..2fe653dd5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: - 5432:5432 mysql: - image: mysql:8 + image: mysql:26 environment: - MYSQL_ALLOW_EMPTY_PASSWORD="yes" - MYSQL_DATABASE=eventstore From 9734bd923d4c100b738ebc550b230c5ae3bd2aac Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Mon, 28 Sep 2026 17:43:51 +0200 Subject: [PATCH 05/18] Fix update bug for mariadb/mysql due to other counting --- .../Store/DoctrineSubscriptionStore.php | 17 +++- .../DoctrineSubscriptionStoreTest.php | 80 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 34e86ae09..d8feaa185 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -178,7 +178,22 @@ public function update(Subscription $subscription): void ], ); - if ($effectedRows === 0) { + if ($effectedRows !== 0) { + return; + } + + // mysql and mariadb count the changed rows, not the matched ones. + // so an update without any change also affects no rows, even if the subscription exists. + $exists = $this->connection->fetchOne( + $this->connection->createQueryBuilder() + ->select('1') + ->from($this->tableName) + ->where('id = :id') + ->getSQL(), + ['id' => $subscription->id()], + ); + + if ($exists === false) { throw new SubscriptionNotFound($subscription->id()); } } diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php new file mode 100644 index 000000000..e223332b4 --- /dev/null +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -0,0 +1,80 @@ +connection = DbalManager::createConnection(); + + $this->store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $this->store, + ); + + $schemaDirector->create(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testUpdate(): void + { + $this->store->add(new Subscription('foo')); + + $subscription = $this->store->get('foo'); + $subscription->active(); + $subscription->changePosition(42); + + $this->store->update($subscription); + + $loaded = $this->store->get('foo'); + + self::assertSame(Status::Active, $loaded->status()); + self::assertSame(42, $loaded->position()); + } + + public function testUpdateWithoutChanges(): void + { + $this->store->add(new Subscription('foo')); + + // the clock is frozen, so all values stay the same and mysql reports no affected rows + $this->store->update($this->store->get('foo')); + $this->store->update($this->store->get('foo')); + + self::assertSame('foo', $this->store->get('foo')->id()); + } + + public function testUpdateUnknownSubscription(): void + { + $this->expectException(SubscriptionNotFound::class); + + $this->store->update(new Subscription('foo')); + } +} From 8307c76c5bdea5420b37d4c05804b4e28477eec7 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Mon, 28 Sep 2026 19:58:59 +0200 Subject: [PATCH 06/18] Fix a bug, when using mysql/mariadb and catching up subscription it may happen that an already handled subscription appear unhandled for the engine due how mysql/mariadb work regarding affected rows. Also fix an api gurantee issue: catch unqiue constraint exception from doctrine and throw a subscription already exists exception --- .../Store/DoctrineSubscriptionStore.php | 43 +- .../Store/SubscriptionAlreadyExists.php | 5 +- .../DoctrineSubscriptionStoreTest.php | 1053 ++++++++++++++++- .../Store/SubscriptionAlreadyExistsTest.php | 14 + 4 files changed, 1074 insertions(+), 41 deletions(-) diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index d8feaa185..baa928b7e 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -9,6 +9,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\DriverException; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Schema\Schema; @@ -129,25 +130,29 @@ public function add(Subscription $subscription): void $subscription->updateLastSavedAt($this->clock->now()); - $this->connection->insert( - $this->tableName, - [ - 'id' => $subscription->id(), - 'group_name' => $subscription->group(), - 'run_mode' => $subscription->runMode()->value, - 'status' => $subscription->status()->value, - 'position' => $subscription->position(), - 'error_message' => $subscriptionError?->errorMessage, - 'error_previous_status' => $subscriptionError?->previousStatus?->value, - 'error_context' => $subscriptionError?->errorContext !== null ? json_encode($subscriptionError->errorContext, JSON_THROW_ON_ERROR) : null, - 'retry_attempt' => $subscription->retryAttempt(), - 'last_saved_at' => $subscription->lastSavedAt(), - 'cleanup_tasks' => $subscription->cleanupTasks() !== null ? serialize($subscription->cleanupTasks()) : null, - ], - [ - 'last_saved_at' => Types::DATETIME_IMMUTABLE, - ], - ); + try { + $this->connection->insert( + $this->tableName, + [ + 'id' => $subscription->id(), + 'group_name' => $subscription->group(), + 'run_mode' => $subscription->runMode()->value, + 'status' => $subscription->status()->value, + 'position' => $subscription->position(), + 'error_message' => $subscriptionError?->errorMessage, + 'error_previous_status' => $subscriptionError?->previousStatus?->value, + 'error_context' => $subscriptionError?->errorContext !== null ? json_encode($subscriptionError->errorContext, JSON_THROW_ON_ERROR) : null, + 'retry_attempt' => $subscription->retryAttempt(), + 'last_saved_at' => $subscription->lastSavedAt(), + 'cleanup_tasks' => $subscription->cleanupTasks() !== null ? serialize($subscription->cleanupTasks()) : null, + ], + [ + 'last_saved_at' => Types::DATETIME_IMMUTABLE, + ], + ); + } catch (UniqueConstraintViolationException $e) { + throw new SubscriptionAlreadyExists($subscription->id(), $e); + } } public function update(Subscription $subscription): void diff --git a/src/Subscription/Store/SubscriptionAlreadyExists.php b/src/Subscription/Store/SubscriptionAlreadyExists.php index 42a749c24..cdf9f0689 100644 --- a/src/Subscription/Store/SubscriptionAlreadyExists.php +++ b/src/Subscription/Store/SubscriptionAlreadyExists.php @@ -5,13 +5,14 @@ namespace Patchlevel\EventSourcing\Subscription\Store; use RuntimeException; +use Throwable; use function sprintf; final class SubscriptionAlreadyExists extends RuntimeException { - public function __construct(string $id) + public function __construct(string $id, Throwable|null $previous = null) { - parent::__construct(sprintf('Subscription "%s" already exists.', $id)); + parent::__construct(sprintf('Subscription "%s" already exists.', $id), 0, $previous); } } diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index e223332b4..c2e35f3dc 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -6,55 +6,68 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use Doctrine\DBAL\Schema\Schema; +use Generator; +use LogicException; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; +use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropIndexTask; +use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropTableTask; +use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionAlreadyExists; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Tests\DbalManager; use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use RuntimeException; + +use function array_map; +use function str_repeat; #[CoversNothing] final class DoctrineSubscriptionStoreTest extends TestCase { private Connection $connection; - private DoctrineSubscriptionStore $store; public function setUp(): void { $this->connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } - $this->store = new DoctrineSubscriptionStore( + public function testUpdate(): void + { + $store = new DoctrineSubscriptionStore( $this->connection, new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), ); $schemaDirector = new DoctrineSchemaDirector( $this->connection, - $this->store, + $store, ); $schemaDirector->create(); - } - - public function tearDown(): void - { - $this->connection->close(); - } - public function testUpdate(): void - { - $this->store->add(new Subscription('foo')); + $store->add(new Subscription('foo')); - $subscription = $this->store->get('foo'); + $subscription = $store->get('foo'); $subscription->active(); $subscription->changePosition(42); - $this->store->update($subscription); + $store->update($subscription); - $loaded = $this->store->get('foo'); + $loaded = $store->get('foo'); self::assertSame(Status::Active, $loaded->status()); self::assertSame(42, $loaded->position()); @@ -62,19 +75,1019 @@ public function testUpdate(): void public function testUpdateWithoutChanges(): void { - $this->store->add(new Subscription('foo')); + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); // the clock is frozen, so all values stay the same and mysql reports no affected rows - $this->store->update($this->store->get('foo')); - $this->store->update($this->store->get('foo')); + $store->update($store->get('foo')); + $store->update($store->get('foo')); - self::assertSame('foo', $this->store->get('foo')->id()); + self::assertSame('foo', $store->get('foo')->id()); } public function testUpdateUnknownSubscription(): void { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $this->expectException(SubscriptionNotFound::class); + + $store->update(new Subscription('foo')); + } + + public function testAddAndGet(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription( + 'foo', + 'bar', + RunMode::FromNow, + Status::Active, + 10, + null, + 2, + null, + [new DropTableTask('baz')], + )); + + $loaded = $store->get('foo'); + + self::assertSame('foo', $loaded->id()); + self::assertSame('bar', $loaded->group()); + self::assertSame(RunMode::FromNow, $loaded->runMode()); + self::assertSame(Status::Active, $loaded->status()); + self::assertSame(10, $loaded->position()); + self::assertNull($loaded->subscriptionError()); + self::assertSame(2, $loaded->retryAttempt()); + self::assertEquals(new DateTimeImmutable('2021-01-01T00:00:00'), $loaded->lastSavedAt()); + self::assertEquals([new DropTableTask('baz')], $loaded->cleanupTasks()); + } + + public function testAddDuplicateSubscription(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo', position: 42)); + + $exception = null; + + try { + $store->add(new Subscription('foo')); + } catch (SubscriptionAlreadyExists $e) { + $exception = $e; + } + + self::assertInstanceOf(SubscriptionAlreadyExists::class, $exception); + self::assertInstanceOf(UniqueConstraintViolationException::class, $exception->getPrevious()); + self::assertSame(42, $store->get('foo')->position()); + } + + public function testGetUnknownSubscription(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $this->expectException(SubscriptionNotFound::class); + + $store->get('foo'); + } + + public function testUpdateAllFields(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + + $subscription = $store->get('foo'); + $subscription->changeGroup('bar'); + $subscription->changeRunMode(RunMode::Once); + $subscription->changePosition(42); + $subscription->replaceCleanupTasks([new DropTableTask('baz')]); + + $store->update($subscription); + + $loaded = $store->get('foo'); + + self::assertSame('bar', $loaded->group()); + self::assertSame(RunMode::Once, $loaded->runMode()); + self::assertSame(42, $loaded->position()); + self::assertEquals([new DropTableTask('baz')], $loaded->cleanupTasks()); + } + + public function testUpdateWithError(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo', status: Status::Active)); + + $subscription = $store->get('foo'); + $subscription->error(new RuntimeException('error message')); + + $store->update($subscription); + + $loaded = $store->get('foo'); + + self::assertSame(Status::Error, $loaded->status()); + + $error = $loaded->subscriptionError(); + + self::assertNotNull($error); + self::assertSame('error message', $error->errorMessage); + self::assertSame(Status::Active, $error->previousStatus); + self::assertNotNull($error->errorContext); + self::assertSame(RuntimeException::class, $error->errorContext[0]['class']); + self::assertSame('error message', $error->errorContext[0]['message']); + } + + public function testUpdateWithFailedFromString(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo', status: Status::Active)); + + $subscription = $store->get('foo'); + $subscription->failed('error message'); + + $store->update($subscription); + + $loaded = $store->get('foo'); + + self::assertSame(Status::Failed, $loaded->status()); + + $error = $loaded->subscriptionError(); + + self::assertNotNull($error); + self::assertSame('error message', $error->errorMessage); + self::assertSame(Status::Active, $error->previousStatus); + self::assertNull($error->errorContext); + } + + public function testUpdateRefreshesLastSavedAt(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')); + + $store = new DoctrineSubscriptionStore( + $this->connection, + $clock, + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + + $clock->update(new DateTimeImmutable('2021-01-02T12:30:00')); + + $subscription = $store->get('foo'); + $store->update($subscription); + + self::assertEquals(new DateTimeImmutable('2021-01-02T12:30:00'), $subscription->lastSavedAt()); + self::assertEquals(new DateTimeImmutable('2021-01-02T12:30:00'), $store->get('foo')->lastSavedAt()); + } + + public function testUpdateAfterRetry(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo', status: Status::Active)); + + $subscription = $store->get('foo'); + $subscription->error('error message'); + $store->update($subscription); + + $subscription = $store->get('foo'); + $subscription->doRetry(); + $store->update($subscription); + + $loaded = $store->get('foo'); + + self::assertSame(Status::Active, $loaded->status()); + self::assertNull($loaded->subscriptionError()); + self::assertSame(1, $loaded->retryAttempt()); + } + + public function testUpdateResetCleanupTasks(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo', cleanupTasks: [new DropTableTask('baz')])); + + $subscription = $store->get('foo'); + $subscription->replaceCleanupTasks(null); + + $store->update($subscription); + + self::assertNull($store->get('foo')->cleanupTasks()); + } + + public function testUpdateWithChangesAfterUpdateWithoutChanges(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + + $store->update($store->get('foo')); + + $subscription = $store->get('foo'); + $subscription->changePosition(42); + $store->update($subscription); + + self::assertSame(42, $store->get('foo')->position()); + } + + public function testUpdateDoesNotAffectOtherSubscriptions(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + $store->add(new Subscription('bar')); + + $subscription = $store->get('foo'); + $subscription->changePosition(42); + $store->update($subscription); + + self::assertSame(42, $store->get('foo')->position()); + self::assertSame(0, $store->get('bar')->position()); + } + + public function testUpdateRemovedSubscription(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + + $subscription = $store->get('foo'); + $store->remove($subscription); + $this->expectException(SubscriptionNotFound::class); - $this->store->update(new Subscription('foo')); + $store->update($subscription); + } + + public function testRemove(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + $store->add(new Subscription('bar')); + + $store->remove($store->get('foo')); + + self::assertSame(['bar'], $this->ids($store->find())); + } + + public function testRemoveUnknownSubscription(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + + $store->remove(new Subscription('bar')); + + self::assertSame(['foo'], $this->ids($store->find())); + } + + public function testFindWithoutCriteria(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('c')); + $store->add(new Subscription('a')); + $store->add(new Subscription('b')); + + self::assertSame(['a', 'b', 'c'], $this->ids($store->find())); + } + + public function testFindEmpty(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + self::assertSame([], $store->find()); + } + + public function testFindByIds(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('a')); + $store->add(new Subscription('b')); + $store->add(new Subscription('c')); + + self::assertSame( + ['a', 'c'], + $this->ids($store->find(new SubscriptionCriteria(ids: ['a', 'c', 'unknown']))), + ); + } + + public function testFindByGroups(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('a', 'foo')); + $store->add(new Subscription('b', 'bar')); + $store->add(new Subscription('c', 'baz')); + + self::assertSame( + ['a', 'b'], + $this->ids($store->find(new SubscriptionCriteria(groups: ['foo', 'bar']))), + ); + } + + public function testFindByStatus(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('a', status: Status::Active)); + $store->add(new Subscription('b', status: Status::Error)); + $store->add(new Subscription('c', status: Status::New)); + + self::assertSame( + ['a', 'b'], + $this->ids($store->find(new SubscriptionCriteria(status: [Status::Active, Status::Error]))), + ); + } + + public function testFindByCombinedCriteria(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('a', 'foo', status: Status::Active)); + $store->add(new Subscription('b', 'foo', status: Status::New)); + $store->add(new Subscription('c', 'bar', status: Status::Active)); + $store->add(new Subscription('d', 'foo', status: Status::Active)); + + self::assertSame( + ['a'], + $this->ids($store->find(new SubscriptionCriteria( + ids: ['a', 'b', 'c'], + groups: ['foo'], + status: [Status::Active], + ))), + ); + } + + public function testFindWithEmptyCriteriaLists(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('a')); + + self::assertSame([], $store->find(new SubscriptionCriteria(ids: []))); + self::assertSame([], $store->find(new SubscriptionCriteria(groups: []))); + self::assertSame([], $store->find(new SubscriptionCriteria(status: []))); + } + + public function testInLock(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + + $result = $store->inLock(static function () use ($store): string { + $subscription = $store->find(new SubscriptionCriteria(ids: ['foo']))[0]; + $subscription->changePosition(42); + $store->update($subscription); + + return $subscription->id(); + }); + + self::assertSame('foo', $result); + self::assertSame(42, $store->get('foo')->position()); + self::assertFalse($this->connection->isTransactionActive()); + } + + public function testInLockWithUpdateWithoutChanges(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + + $store->inLock(static function () use ($store): void { + foreach ($store->find() as $subscription) { + $store->update($subscription); + } + }); + + self::assertFalse($this->connection->isTransactionActive()); + self::assertSame('foo', $store->get('foo')->id()); + } + + public function testInLockCommitsWhenClosureThrows(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + + $exception = null; + + try { + $store->inLock(static function () use ($store): void { + $subscription = $store->get('foo'); + $subscription->changePosition(42); + $store->update($subscription); + + throw new RuntimeException('error'); + }); + } catch (RuntimeException $e) { + $exception = $e; + } + + self::assertSame('error', $exception->getMessage()); + self::assertFalse($this->connection->isTransactionActive()); + self::assertSame(42, $store->get('foo')->position()); + } + + public function testCustomTableName(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + 'custom_subscriptions', + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $schemaManager = $this->connection->createSchemaManager(); + + self::assertTrue($schemaManager->tablesExist(['custom_subscriptions'])); + self::assertFalse($schemaManager->tablesExist(['subscriptions'])); + + $store->add(new Subscription('foo')); + + $subscription = $store->get('foo'); + $subscription->changePosition(42); + $store->update($subscription); + + self::assertSame(42, $store->get('foo')->position()); + } + + public function testAddSetsLastSavedAt(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $subscription = new Subscription('foo'); + + $store->add($subscription); + + self::assertEquals(new DateTimeImmutable('2021-01-01T00:00:00'), $subscription->lastSavedAt()); + } + + #[DataProvider('statusProvider')] + public function testStatusRoundTrip(Status $status): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo', status: $status)); + + self::assertSame($status, $store->get('foo')->status()); + self::assertSame(['foo'], $this->ids($store->find(new SubscriptionCriteria(status: [$status])))); + } + + public static function statusProvider(): Generator + { + foreach (Status::cases() as $status) { + yield $status->value => [$status]; + } + } + + #[DataProvider('runModeProvider')] + public function testRunModeRoundTrip(RunMode $runMode): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo', runMode: $runMode)); + + self::assertSame($runMode, $store->get('foo')->runMode()); + } + + public static function runModeProvider(): Generator + { + foreach (RunMode::cases() as $runMode) { + yield $runMode->value => [$runMode]; + } + } + + public function testMaxValues(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $id = str_repeat('a', 255); + $group = str_repeat('b', 32); + + $store->add(new Subscription($id, $group, position: 2147483647, retryAttempt: 2147483647)); + + $loaded = $store->get($id); + + self::assertSame($id, $loaded->id()); + self::assertSame($group, $loaded->group()); + self::assertSame(2147483647, $loaded->position()); + self::assertSame(2147483647, $loaded->retryAttempt()); + } + + public function testUpdateWithNestedErrors(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo', status: Status::Booting)); + + $subscription = $store->get('foo'); + $subscription->error(new RuntimeException('outer', 0, new LogicException('inner'))); + + $store->update($subscription); + + $error = $store->get('foo')->subscriptionError(); + + self::assertNotNull($error); + self::assertSame('outer', $error->errorMessage); + self::assertSame(Status::Booting, $error->previousStatus); + self::assertNotNull($error->errorContext); + self::assertCount(2, $error->errorContext); + self::assertSame(RuntimeException::class, $error->errorContext[0]['class']); + self::assertSame('outer', $error->errorContext[0]['message']); + self::assertSame(LogicException::class, $error->errorContext[1]['class']); + self::assertSame('inner', $error->errorContext[1]['message']); + } + + public function testUpdateWithLongUnicodeErrorMessage(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + // no 4-byte characters like emojis, the ci uses charset=utf8 (utf8mb3) for mysql and mariadb + $message = str_repeat('Fehler: äöü ß € ', 1000); + + $store->add(new Subscription('foo')); + + $subscription = $store->get('foo'); + $subscription->error(new RuntimeException($message)); + + $store->update($subscription); + + $error = $store->get('foo')->subscriptionError(); + + self::assertNotNull($error); + self::assertSame($message, $error->errorMessage); + self::assertNotNull($error->errorContext); + self::assertSame($message, $error->errorContext[0]['message']); + } + + public function testMultipleCleanupTasks(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $tasks = [ + new DropTableTask('foo', 'connection'), + new DropIndexTask('bar_idx', 'bar'), + ]; + + $store->add(new Subscription('foo', cleanupTasks: $tasks)); + + self::assertEquals($tasks, $store->get('foo')->cleanupTasks()); + } + + public function testRemoveAndAddAgain(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo', position: 42)); + $store->remove($store->get('foo')); + + $store->add(new Subscription('foo')); + + self::assertSame(0, $store->get('foo')->position()); + } + + public function testFindHydratesAllFields(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $subscription = new Subscription( + 'foo', + 'bar', + RunMode::Once, + Status::Active, + 10, + null, + 3, + null, + [new DropTableTask('baz')], + ); + $subscription->error('error message'); + + $store->add($subscription); + + $found = $store->find(new SubscriptionCriteria(ids: ['foo'])); + + self::assertCount(1, $found); + self::assertEquals($subscription, $found[0]); + } + + public function testFindByUnknownGroup(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->add(new Subscription('foo')); + + self::assertSame([], $store->find(new SubscriptionCriteria(groups: ['unknown']))); + } + + public function testConfigureSchemaForOtherDatabase(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $otherConnection = DbalManager::createConnection('other'); + + try { + $schema = new Schema(); + $store->configureSchema($schema, $otherConnection); + + self::assertFalse($schema->hasTable('subscriptions')); + } finally { + $otherConnection->close(); + } + } + + public function testConfigureSchemaForSameDatabase(): void + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + $schema = new Schema(); + $store->configureSchema($schema, $this->connection); + + self::assertTrue($schema->hasTable('subscriptions')); + } + + /** + * @param list $subscriptions + * + * @return list + */ + private function ids(array $subscriptions): array + { + return array_map(static fn (Subscription $subscription) => $subscription->id(), $subscriptions); } } diff --git a/tests/Unit/Subscription/Store/SubscriptionAlreadyExistsTest.php b/tests/Unit/Subscription/Store/SubscriptionAlreadyExistsTest.php index bd60c528b..647bfbcd1 100644 --- a/tests/Unit/Subscription/Store/SubscriptionAlreadyExistsTest.php +++ b/tests/Unit/Subscription/Store/SubscriptionAlreadyExistsTest.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Subscription\Store\SubscriptionAlreadyExists; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; #[CoversClass(SubscriptionAlreadyExists::class)] final class SubscriptionAlreadyExistsTest extends TestCase @@ -22,4 +23,17 @@ public function testCreate(): void self::assertSame(0, $exception->getCode()); } + + public function testCreateWithPrevious(): void + { + $previous = new RuntimeException('previous'); + $exception = new SubscriptionAlreadyExists('foo-1', $previous); + + self::assertSame( + 'Subscription "foo-1" already exists.', + $exception->getMessage(), + ); + + self::assertSame($previous, $exception->getPrevious()); + } } From 0c7912edc4eacd182e689b632cc0f230bf63c344 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Mon, 28 Sep 2026 20:06:38 +0200 Subject: [PATCH 07/18] Fix CI column related issue --- phpunit.xml.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9f953bade..3bc60af1a 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -27,7 +27,7 @@ - + From 02fca2f4bcdaeadf9df8dc9652b3040a8a29c346 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Mon, 28 Sep 2026 23:44:34 +0200 Subject: [PATCH 08/18] Add more store integration tests. These revealed some bugs: * drop index via task could have not worked due to casing issues * dont claim lock idefinelty with mysql/mariadb if lock could not aquire * event criterion passed wrong param type to dbal * cipher key store could return random key for one subject if mutliple existed --- phpstan-baseline.neon | 6 - .../ExtensionDoctrineCipherKeyStore.php | 13 +- src/Store/DoctrineDbalStore.php | 8 +- src/Store/StreamDoctrineDbalStore.php | 10 +- .../Cleanup/Dbal/DbalCleanupTaskHandler.php | 6 +- .../DoctrineCipherKeyStoreTest.php | 337 +++++++++++ .../ExtensionDoctrineCipherKeyStoreTest.php | 365 +++++++++++ .../Store/DoctrineDbalStoreTest.php | 45 ++ .../Integration/Store/Header/TraceHeader.php | 16 + .../Store/StreamDoctrineDbalStoreTest.php | 569 +++++++++++++++++- .../Cleanup/DbalCleanupTaskHandlerTest.php | 176 ++++++ 11 files changed, 1525 insertions(+), 26 deletions(-) create mode 100644 tests/Integration/Cryptography/DoctrineCipherKeyStoreTest.php create mode 100644 tests/Integration/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php create mode 100644 tests/Integration/Store/Header/TraceHeader.php create mode 100644 tests/Integration/Subscription/Cleanup/DbalCleanupTaskHandlerTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 81ec8d23c..affc4c4e6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -378,12 +378,6 @@ parameters: count: 1 path: tests/Integration/Store/Profile.php - - - message: '#^Using nullsafe method call on non\-nullable type Patchlevel\\EventSourcing\\Store\\Stream\. Use \-\> instead\.$#' - identifier: nullsafe.neverNull - count: 1 - path: tests/Integration/Store/StreamDoctrineDbalStoreTest.php - - message: '#^Parameter \#1 \$table of class Patchlevel\\EventSourcing\\Subscription\\Cleanup\\Dbal\\DropTableTask constructor expects non\-empty\-string, string given\.$#' identifier: argument.type diff --git a/src/Cryptography/ExtensionDoctrineCipherKeyStore.php b/src/Cryptography/ExtensionDoctrineCipherKeyStore.php index 2a8226fb8..9b5c82ff2 100644 --- a/src/Cryptography/ExtensionDoctrineCipherKeyStore.php +++ b/src/Cryptography/ExtensionDoctrineCipherKeyStore.php @@ -61,10 +61,15 @@ public function get(string $id): CipherKey public function currentKeyFor(string $subjectId): CipherKey { /** @var Row|false $result */ - $result = $this->connection->fetchAssociative( - "SELECT * FROM {$this->tableName} WHERE subject_id = :subject_id", - ['subject_id' => $subjectId], - ); + $result = $this->connection->createQueryBuilder() + ->select('*') + ->from($this->tableName) + ->where('subject_id = :subject_id') + ->orderBy('created_at', 'DESC') + ->addOrderBy('id', 'DESC') + ->setMaxResults(1) + ->setParameter('subject_id', $subjectId) + ->fetchAssociative(); if ($result === false) { throw CipherKeyNotExists::forSubjectId($subjectId); diff --git a/src/Store/DoctrineDbalStore.php b/src/Store/DoctrineDbalStore.php index f00ea5dee..927058e2b 100644 --- a/src/Store/DoctrineDbalStore.php +++ b/src/Store/DoctrineDbalStore.php @@ -485,8 +485,6 @@ private function executeSave( private function lock(): void { - $this->hasLock = true; - $platform = $this->connection->getDatabasePlatform(); if ($platform instanceof PostgreSQLPlatform) { @@ -497,6 +495,8 @@ private function lock(): void ), ); + $this->hasLock = true; + return; } @@ -523,10 +523,14 @@ private function lock(): void throw LockCouldNotBeAcquired::byError($this->config['lock_id']); } + $this->hasLock = true; + return; } if ($platform instanceof SQLitePlatform) { + $this->hasLock = true; + return; // sql locking is not needed because of file locking } diff --git a/src/Store/StreamDoctrineDbalStore.php b/src/Store/StreamDoctrineDbalStore.php index 6e0b18f10..b1f2c2855 100644 --- a/src/Store/StreamDoctrineDbalStore.php +++ b/src/Store/StreamDoctrineDbalStore.php @@ -215,7 +215,7 @@ private function applyCriteria(QueryBuilder $builder, Criteria $criteria): void break; case EventIdCriterion::class: $builder->andWhere('event_id = :event_id'); - $builder->setParameter('event_id', $criterion->eventId, ArrayParameterType::STRING); + $builder->setParameter('event_id', $criterion->eventId); break; default: throw new UnsupportedCriterion($criterion::class); @@ -564,8 +564,6 @@ private function executeSave( private function lock(): void { - $this->hasLock = true; - $platform = $this->connection->getDatabasePlatform(); if ($platform instanceof PostgreSQLPlatform) { @@ -576,6 +574,8 @@ private function lock(): void ), ); + $this->hasLock = true; + return; } @@ -602,10 +602,14 @@ private function lock(): void throw LockCouldNotBeAcquired::byError($this->config['lock_id']); } + $this->hasLock = true; + return; } if ($platform instanceof SQLitePlatform) { + $this->hasLock = true; + return; // sql locking is not needed because of file locking } diff --git a/src/Subscription/Cleanup/Dbal/DbalCleanupTaskHandler.php b/src/Subscription/Cleanup/Dbal/DbalCleanupTaskHandler.php index 6fe084a9a..a3639a2b5 100644 --- a/src/Subscription/Cleanup/Dbal/DbalCleanupTaskHandler.php +++ b/src/Subscription/Cleanup/Dbal/DbalCleanupTaskHandler.php @@ -37,8 +37,10 @@ public function __invoke(object $task): void } foreach ($schemaManager->introspectTableIndexesByUnquotedName($task->table) as $index) { - if (strtolower($index->getObjectName()->toString()) === strtolower($task->index)) { - $schemaManager->dropIndex($task->index, $task->table); + $indexName = $index->getObjectName()->getIdentifier()->getValue(); + + if (strtolower($indexName) === strtolower($task->index)) { + $schemaManager->dropIndex($indexName, $task->table); break; } } diff --git a/tests/Integration/Cryptography/DoctrineCipherKeyStoreTest.php b/tests/Integration/Cryptography/DoctrineCipherKeyStoreTest.php new file mode 100644 index 000000000..464e5d441 --- /dev/null +++ b/tests/Integration/Cryptography/DoctrineCipherKeyStoreTest.php @@ -0,0 +1,337 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testStoreAndGet(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $key = new CipherKey('the-key', 'aes256', 'the-iv'); + + $store->store('foo', $key); + $store->clear(); + + $loaded = $store->get('foo'); + + self::assertSame($key->key, $loaded->key); + self::assertSame($key->method, $loaded->method); + self::assertSame($key->iv, $loaded->iv); + } + + public function testGetFromAnotherInstance(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $key = new CipherKey('the-key', 'aes256', 'the-iv'); + + $store->store('foo', $key); + + $loaded = (new DoctrineCipherKeyStore($this->connection))->get('foo'); + + self::assertEquals($key, $loaded); + } + + public function testGetUnknownKey(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('foo'); + } + + public function testGetIsCached(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store('foo', new CipherKey('the-key', 'aes256', 'the-iv')); + + $this->connection->executeStatement('DELETE FROM crypto_keys'); + + self::assertSame('the-key', $store->get('foo')->key); + + $store->clear(); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('foo'); + } + + public function testBinaryKeyRoundTrip(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $key = (new OpensslCipherKeyFactory())(); + + $store->store('foo', $key); + $store->clear(); + + $loaded = $store->get('foo'); + + self::assertSame($key->key, $loaded->key); + self::assertSame($key->method, $loaded->method); + self::assertSame($key->iv, $loaded->iv); + } + + public function testStoredKeyDecryptsData(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $cipher = new OpensslCipher(); + $key = (new OpensslCipherKeyFactory())(); + + $encrypted = $cipher->encrypt($key, 'john@example.com'); + + $store->store('foo', $key); + $store->clear(); + + self::assertSame('john@example.com', $cipher->decrypt($store->get('foo'), $encrypted)); + } + + public function testStoreDuplicateSubject(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store('foo', new CipherKey('first-key', 'aes256', 'first-iv')); + + $exception = null; + + try { + $store->store('foo', new CipherKey('second-key', 'aes256', 'second-iv')); + } catch (UniqueConstraintViolationException $e) { + $exception = $e; + } + + self::assertNotNull($exception); + + $store->clear(); + + self::assertSame('first-key', $store->get('foo')->key); + } + + public function testRemove(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store('foo', new CipherKey('the-key', 'aes256', 'the-iv')); + $store->remove('foo'); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('foo'); + } + + public function testRemoveUnknownKey(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->remove('foo'); + + self::assertEquals(0, $this->connection->fetchOne('SELECT COUNT(*) FROM crypto_keys')); + } + + public function testRemoveOnlyAffectsTheSubject(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store('foo', new CipherKey('foo-key', 'aes256', 'foo-iv')); + $store->store('bar', new CipherKey('bar-key', 'aes256', 'bar-iv')); + + $store->remove('foo'); + $store->clear(); + + self::assertSame('bar-key', $store->get('bar')->key); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('foo'); + } + + public function testRemoveAndStoreAgain(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store('foo', new CipherKey('first-key', 'aes256', 'first-iv')); + $store->remove('foo'); + $store->store('foo', new CipherKey('second-key', 'aes256', 'second-iv')); + $store->clear(); + + self::assertSame('second-key', $store->get('foo')->key); + } + + public function testMaxSubjectIdLength(): void + { + $store = new DoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $id = str_repeat('a', 255); + + $store->store($id, new CipherKey('the-key', 'aes256', 'the-iv')); + $store->clear(); + + self::assertSame('the-key', $store->get($id)->key); + } + + public function testCustomTableName(): void + { + $store = new DoctrineCipherKeyStore($this->connection, 'custom_crypto_keys'); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store('foo', new CipherKey('the-key', 'aes256', 'the-iv')); + $store->clear(); + + self::assertSame('the-key', $store->get('foo')->key); + self::assertEquals(1, $this->connection->fetchOne('SELECT COUNT(*) FROM custom_crypto_keys')); + } + + public function testConfigureSchemaSameDatabase(): void + { + $otherConnection = DbalManager::createConnection(); + + $store = new DoctrineCipherKeyStore($this->connection); + + $schema = new Schema(); + + $store->configureSchema($schema, $otherConnection); + + self::assertTrue($schema->hasTable('crypto_keys')); + + $otherConnection->close(); + } + + public function testConfigureSchemaNotSameDatabase(): void + { + $otherConnection = DbalManager::createConnection('other'); + + $store = new DoctrineCipherKeyStore($this->connection); + + $schema = new Schema(); + + $store->configureSchema($schema, $otherConnection); + + self::assertFalse($schema->hasTable('crypto_keys')); + + $otherConnection->close(); + } +} diff --git a/tests/Integration/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php b/tests/Integration/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php new file mode 100644 index 000000000..d1028e27c --- /dev/null +++ b/tests/Integration/Cryptography/ExtensionDoctrineCipherKeyStoreTest.php @@ -0,0 +1,365 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testStoreAndGet(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $key = new CipherKey('key-1', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00')); + + $store->store($key); + + $loaded = $store->get('key-1'); + + self::assertSame('key-1', $loaded->id); + self::assertSame('foo', $loaded->subjectId); + self::assertSame($key->key, $loaded->key); + self::assertSame($key->method, $loaded->method); + self::assertSame('2020-01-01 00:00:00', $loaded->createdAt->format('Y-m-d H:i:s')); + } + + public function testGetUnknownKey(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $this->expectException(CipherKeyNotExists::class); + $this->expectExceptionMessage('Cipher key with id "key-1" does not exist.'); + + $store->get('key-1'); + } + + public function testCurrentKeyFor(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-1', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + + self::assertSame('key-1', $store->currentKeyFor('foo')->id); + } + + public function testCurrentKeyForUnknownSubject(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-1', 'bar', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + + $this->expectException(CipherKeyNotExists::class); + $this->expectExceptionMessage('Cipher key for subject id "foo" does not exist.'); + + $store->currentKeyFor('foo'); + } + + public function testCurrentKeyForReturnsTheNewestKey(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-b', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-02 00:00:00'))); + $store->store(new CipherKey('key-c', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-03 00:00:00'))); + $store->store(new CipherKey('key-a', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + + self::assertSame('key-c', $store->currentKeyFor('foo')->id); + } + + public function testCurrentKeyForWithSameCreatedAtIsDeterministic(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-b', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + $store->store(new CipherKey('key-c', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + $store->store(new CipherKey('key-a', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + + // the created_at has only second precision, so the id decides between keys of the same second + self::assertSame('key-c', $store->currentKeyFor('foo')->id); + } + + public function testOldKeysStayLoadable(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-1', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + $store->store(new CipherKey('key-2', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-02 00:00:00'))); + + self::assertSame('foo', $store->get('key-1')->subjectId); + self::assertSame('foo', $store->get('key-2')->subjectId); + } + + public function testGetFromAnotherInstance(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $key = new CipherKey('key-1', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00')); + + $store->store($key); + + $loaded = (new ExtensionDoctrineCipherKeyStore($this->connection))->get('key-1'); + + self::assertSame($key->key, $loaded->key); + } + + public function testStoredKeyDecryptsData(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $cipher = new OpensslCipher(); + $key = (new OpensslCipherKeyFactory())('foo'); + + $encrypted = $cipher->encrypt($key, 'john@example.com'); + + $store->store($key); + + self::assertSame('john@example.com', $cipher->decrypt($store->currentKeyFor('foo'), $encrypted)); + } + + public function testStoreDuplicateId(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-1', 'foo', 'first-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + + $exception = null; + + try { + $store->store(new CipherKey('key-1', 'bar', 'second-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-02 00:00:00'))); + } catch (UniqueConstraintViolationException $e) { + $exception = $e; + } + + self::assertNotNull($exception); + self::assertSame('first-key', $store->get('key-1')->key); + } + + public function testRemove(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-1', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + $store->store(new CipherKey('key-2', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-02 00:00:00'))); + + $store->remove('key-2'); + + self::assertSame('key-1', $store->currentKeyFor('foo')->id); + + $this->expectException(CipherKeyNotExists::class); + + $store->get('key-2'); + } + + public function testRemoveUnknownKey(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-1', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + + $store->remove('key-2'); + + self::assertSame('key-1', $store->get('key-1')->id); + } + + public function testRemoveWithSubjectId(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-1', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + $store->store(new CipherKey('key-2', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-02 00:00:00'))); + $store->store(new CipherKey('key-3', 'bar', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + + $store->removeWithSubjectId('foo'); + + self::assertSame('key-3', $store->currentKeyFor('bar')->id); + + $this->expectException(CipherKeyNotExists::class); + + $store->currentKeyFor('foo'); + } + + public function testMaxIdLength(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $id = str_repeat('a', 255); + $subjectId = str_repeat('b', 255); + + $store->store(new CipherKey($id, $subjectId, 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + + self::assertSame($id, $store->currentKeyFor($subjectId)->id); + } + + public function testCustomTableName(): void + { + $store = new ExtensionDoctrineCipherKeyStore($this->connection, 'custom_cryptography_keys'); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + $store, + ); + + $schemaDirector->create(); + + $store->store(new CipherKey('key-1', 'foo', 'the-key', 'aes-256-gcm', new DateTimeImmutable('2020-01-01 00:00:00'))); + + self::assertSame('key-1', $store->currentKeyFor('foo')->id); + self::assertEquals(1, $this->connection->fetchOne('SELECT COUNT(*) FROM custom_cryptography_keys')); + } + + public function testConfigureSchemaSameDatabase(): void + { + $otherConnection = DbalManager::createConnection(); + + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schema = new Schema(); + + $store->configureSchema($schema, $otherConnection); + + self::assertTrue($schema->hasTable('cryptography_keys')); + + $otherConnection->close(); + } + + public function testConfigureSchemaNotSameDatabase(): void + { + $otherConnection = DbalManager::createConnection('other'); + + $store = new ExtensionDoctrineCipherKeyStore($this->connection); + + $schema = new Schema(); + + $store->configureSchema($schema, $otherConnection); + + self::assertFalse($schema->hasTable('cryptography_keys')); + + $otherConnection->close(); + } +} diff --git a/tests/Integration/Store/DoctrineDbalStoreTest.php b/tests/Integration/Store/DoctrineDbalStoreTest.php index 51008b9b7..69451ced6 100644 --- a/tests/Integration/Store/DoctrineDbalStoreTest.php +++ b/tests/Integration/Store/DoctrineDbalStoreTest.php @@ -25,6 +25,7 @@ use Patchlevel\EventSourcing\Tests\Integration\Store\Events\ProfileCreated; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; +use Throwable; use function json_decode; use function sprintf; @@ -238,6 +239,50 @@ public function testSaveLockTimeout(): void } } + public function testLockIsAcquiredAgainAfterTimeout(): 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(); + + $message = Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new AggregateHeader( + 'profile', + $profileId->toString(), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )); + + $connection = DriverManager::getConnection($this->connection->getParams()); + + try { + self::assertSame(1, $connection->fetchOne('SELECT GET_LOCK("133742", 1)')); + + $exception = null; + + try { + $this->store->save($message); + } catch (Throwable $e) { + $exception = $e; + } + + self::assertInstanceOf(LockCouldNotBeAcquired::class, $exception); + + // the lock is still held by the other connection, so the second save must also wait for it + $this->expectException(LockCouldNotBeAcquired::class); + + $this->store->save($message); + } finally { + $connection->close(); + } + } + public function testLoad(): void { $profileId = ProfileId::generate(); diff --git a/tests/Integration/Store/Header/TraceHeader.php b/tests/Integration/Store/Header/TraceHeader.php new file mode 100644 index 000000000..8360ff8cd --- /dev/null +++ b/tests/Integration/Store/Header/TraceHeader.php @@ -0,0 +1,16 @@ +connection->getDatabasePlatform() instanceof SQLitePlatform) { - $this->markTestSkipped('SQLite does not support locks'); - } - - if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { - $this->markTestSkipped('PostgreSQL does lock indefinitely'); - } + $this->skipIfNoLockTimeout(); $profileId = ProfileId::generate(); @@ -513,6 +520,8 @@ public function testLoadWithWildcard(): void $stream?->close(); } + $stream = null; + try { $stream = $this->store->load(new Criteria(new StreamCriterion('*-*'))); @@ -584,6 +593,535 @@ public function testRemove(): void self::assertEquals(['foo'], $streams); } + public function testCount(): void + { + $profileId = ProfileId::generate(); + + $this->store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(2)), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')), + ); + + self::assertSame(3, $this->store->count()); + self::assertSame(2, $this->store->count(new Criteria(new StreamCriterion('profile-*')))); + self::assertSame(1, $this->store->count(new Criteria(new StreamCriterion('foo')))); + } + + public function testLoadWithLimitOffsetAndBackwards(): void + { + $profileId = ProfileId::generate(); + $streamName = sprintf('profile-%s', $profileId->toString()); + + $messages = []; + + for ($playhead = 1; $playhead <= 5; $playhead++) { + $messages[] = Message::create(new ProfileCreated($profileId, 'name-' . $playhead)) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader($playhead)); + } + + $this->store->save(...$messages); + + $stream = null; + + try { + $stream = $this->store->load(limit: 2); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(limit: 2, offset: 2); + + self::assertSame([3, 4], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(limit: 3, offset: 0); + + self::assertSame([1, 2, 3], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(backwards: true); + + self::assertSame([5, 4, 3, 2, 1], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testLoadWithIndexCriteria(): void + { + $profileId = ProfileId::generate(); + $streamName = sprintf('profile-%s', $profileId->toString()); + + $messages = []; + + for ($playhead = 1; $playhead <= 5; $playhead++) { + $messages[] = Message::create(new ProfileCreated($profileId, 'name-' . $playhead)) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader($playhead)); + } + + $this->store->save(...$messages); + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new FromIndexCriterion(3))); + + self::assertSame([4, 5], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new ToIndexCriterion(3))); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new FromPlayheadCriterion(2))); + + self::assertSame([3, 4, 5], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new FromPlayheadCriterion(2), new ToPlayheadCriterion(5))); + + self::assertSame([3, 4], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testLoadWithEventCriteria(): void + { + $profileId = ProfileId::generate(); + $eventId = '0190e47e-77e9-7b90-bf62-08bbf0ab9b4b'; + + $this->store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new EventIdHeader($eventId)), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')), + ); + + $stream = null; + + try { + $stream = $this->store->load(new Criteria(new EventsCriterion(['profile.created']))); + + self::assertCount(1, iterator_to_array($stream)); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(new Criteria(new EventIdCriterion($eventId))); + $messages = iterator_to_array($stream); + + self::assertCount(1, $messages); + self::assertSame($eventId, $messages[0]->header(EventIdHeader::class)->eventId); + } finally { + $stream?->close(); + } + } + + public function testLoadWithArchivedCriterion(): void + { + $profileId = ProfileId::generate(); + $streamName = sprintf('profile-%s', $profileId->toString()); + + $this->store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader(1)), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader(2)), + ); + + $this->store->archive(new Criteria(new StreamCriterion($streamName), new ToPlayheadCriterion(2))); + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new ArchivedCriterion(false))); + + self::assertSame([2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new ArchivedCriterion(true))); + + self::assertSame([1], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testUnsupportedCriterion(): void + { + $this->expectException(UnsupportedCriterion::class); + + $this->store->count(new Criteria(new AggregateIdCriterion('1'))); + } + + public function testLoadEmptyStore(): void + { + $stream = null; + + try { + $stream = $this->store->load(); + + self::assertSame([], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + self::assertSame(0, $this->store->count()); + } + + public function testLoadMultipleStreams(): void + { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-b')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-c')) + ->withHeader(new PlayheadHeader(3)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + + $stream = null; + + try { + $stream = $this->store->load(new Criteria(new StreamCriterion('profile-a', 'profile-b'))); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testSaveWithoutStreamName(): void + { + $message = Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))); + + $this->expectException(MissingDataForStorage::class); + + $this->store->save($message); + } + + public function testSaveWithCustomHeaders(): void + { + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + DefaultHeadersSerializer::createFromPaths([__DIR__ . '/Header']), + clock: $this->clock, + ); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00')))->withHeader(new TraceHeader('trace-1')), + ); + + $stream = null; + + try { + $stream = $store->load(); + $loaded = $stream->current(); + + self::assertInstanceOf(Message::class, $loaded); + self::assertEquals(new TraceHeader('trace-1'), $loaded->header(TraceHeader::class)); + } finally { + $stream?->close(); + } + } + + public function testTransactionalRollsBackOnException(): void + { + $exception = null; + + try { + $this->store->transactional(function (): void { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + + throw new RuntimeException('error'); + }); + } catch (RuntimeException $e) { + $exception = $e; + } + + self::assertNotNull($exception); + self::assertSame(0, $this->store->count()); + self::assertSame(0, $this->connection->getTransactionNestingLevel()); + } + + public function testTransactionalNested(): void + { + $this->store->transactional(function (): void { + $this->store->transactional(function (): void { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + }); + + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + }); + + $stream = null; + + try { + $stream = $this->store->load(); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + self::assertSame(0, $this->connection->getTransactionNestingLevel()); + } + + public function testTransactionalTwice(): void + { + $this->store->transactional(function (): void { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + }); + + $this->store->transactional(function (): void { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + }); + + $stream = null; + + try { + $stream = $this->store->load(); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testLockIsReleasedAfterException(): void + { + $this->skipIfNoLockTimeout(); + + try { + $this->store->transactional(static function (): void { + throw new RuntimeException('error'); + }); + } catch (RuntimeException) { + // expected + } + + $otherConnection = DriverManager::getConnection($this->connection->getParams()); + + try { + $otherStore = new StreamDoctrineDbalStore( + $otherConnection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + clock: $this->clock, + config: ['lock_timeout' => 1], + ); + + $otherStore->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + + self::assertSame(1, $this->store->count()); + } finally { + $otherConnection->close(); + } + } + + public function testLockIsAcquiredAgainAfterTimeout(): void + { + $this->skipIfNoLockTimeout(); + + $otherConnection = DriverManager::getConnection($this->connection->getParams()); + + try { + self::assertSame(1, $otherConnection->fetchOne('SELECT GET_LOCK("133742", 1)')); + + $exception = null; + + try { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + } catch (Throwable $e) { + $exception = $e; + } + + self::assertInstanceOf(LockCouldNotBeAcquired::class, $exception); + + // the lock is still held by the other connection, so the second save must also wait for it + $this->expectException(LockCouldNotBeAcquired::class); + + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + } finally { + $otherConnection->close(); + } + } + + public function testSubscriptionSupport(): void + { + self::assertSame( + $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform, + $this->store->supportSubscription(), + ); + + // without support both are a no-op, with support no notification arrives within the timeout + $this->store->setupSubscription(); + $this->store->wait(10); + + self::assertSame(0, $this->store->count()); + } + + public function testSetupSubscriptionCreatesTheNotifyTrigger(): void + { + if (!$this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { + $this->markTestSkipped('only postgres supports the subscription notifications'); + } + + $this->store->setupSubscription(); + $this->store->setupSubscription(); + + $trigger = $this->connection->fetchOne( + "SELECT tgname FROM pg_trigger WHERE tgname = 'notify_trigger' AND tgrelid = 'event_store'::regclass", + ); + + self::assertSame('notify_trigger', $trigger); + } + public function testConfigureSchemaSameDatabase(): void { $connection = DbalManager::createConnection(); @@ -619,4 +1157,17 @@ public function testConfigureSchemaNotSameDatabase(): void self::assertFalse($schema->hasTable('event_store')); } + + private function skipIfNoLockTimeout(): void + { + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $this->markTestSkipped('SQLite does not support locks'); + } + + if (!($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform)) { + return; + } + + $this->markTestSkipped('PostgreSQL does lock indefinitely'); + } } diff --git a/tests/Integration/Subscription/Cleanup/DbalCleanupTaskHandlerTest.php b/tests/Integration/Subscription/Cleanup/DbalCleanupTaskHandlerTest.php new file mode 100644 index 000000000..ef33bf206 --- /dev/null +++ b/tests/Integration/Subscription/Cleanup/DbalCleanupTaskHandlerTest.php @@ -0,0 +1,176 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testDropTable(): void + { + $this->connection->executeStatement( + 'CREATE TABLE projection (id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL)', + ); + $this->connection->executeStatement('CREATE INDEX projection_name_idx ON projection (name)'); + + (new DbalCleanupTaskHandler($this->connection))(new DropTableTask('projection')); + + self::assertFalse($this->connection->createSchemaManager()->tablesExist(['projection'])); + } + + public function testDropUnknownTable(): void + { + $this->connection->executeStatement( + 'CREATE TABLE projection (id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL)', + ); + $this->connection->executeStatement('CREATE INDEX projection_name_idx ON projection (name)'); + + (new DbalCleanupTaskHandler($this->connection))(new DropTableTask('unknown')); + + self::assertTrue($this->connection->createSchemaManager()->tablesExist(['projection'])); + } + + public function testDropIndex(): void + { + $this->connection->executeStatement( + 'CREATE TABLE projection (id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL)', + ); + $this->connection->executeStatement('CREATE INDEX projection_name_idx ON projection (name)'); + + (new DbalCleanupTaskHandler($this->connection))(new DropIndexTask('projection_name_idx', 'projection')); + + $indexes = array_map( + static fn (Index $index) => strtolower($index->getObjectName()->getIdentifier()->getValue()), + $this->connection->createSchemaManager()->introspectTableIndexesByUnquotedName('projection'), + ); + + self::assertNotContains('projection_name_idx', $indexes); + self::assertTrue($this->connection->createSchemaManager()->tablesExist(['projection'])); + } + + public function testDropIndexIgnoresTheCase(): void + { + $this->connection->executeStatement( + 'CREATE TABLE projection (id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL)', + ); + $this->connection->executeStatement('CREATE INDEX projection_name_idx ON projection (name)'); + + (new DbalCleanupTaskHandler($this->connection))(new DropIndexTask('PROJECTION_NAME_IDX', 'projection')); + + $indexes = array_map( + static fn (Index $index) => strtolower($index->getObjectName()->getIdentifier()->getValue()), + $this->connection->createSchemaManager()->introspectTableIndexesByUnquotedName('projection'), + ); + + self::assertNotContains('projection_name_idx', $indexes); + } + + public function testDropUnknownIndex(): void + { + $this->connection->executeStatement( + 'CREATE TABLE projection (id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL)', + ); + $this->connection->executeStatement('CREATE INDEX projection_name_idx ON projection (name)'); + + (new DbalCleanupTaskHandler($this->connection))(new DropIndexTask('unknown_idx', 'projection')); + + $indexes = array_map( + static fn (Index $index) => strtolower($index->getObjectName()->getIdentifier()->getValue()), + $this->connection->createSchemaManager()->introspectTableIndexesByUnquotedName('projection'), + ); + + self::assertContains('projection_name_idx', $indexes); + } + + public function testDropIndexOfUnknownTable(): void + { + $this->connection->executeStatement( + 'CREATE TABLE projection (id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL)', + ); + $this->connection->executeStatement('CREATE INDEX projection_name_idx ON projection (name)'); + + (new DbalCleanupTaskHandler($this->connection))(new DropIndexTask('projection_name_idx', 'unknown')); + + $indexes = array_map( + static fn (Index $index) => strtolower($index->getObjectName()->getIdentifier()->getValue()), + $this->connection->createSchemaManager()->introspectTableIndexesByUnquotedName('projection'), + ); + + self::assertContains('projection_name_idx', $indexes); + } + + public function testConnectionNameWithRegistry(): void + { + $this->connection->executeStatement( + 'CREATE TABLE projection (id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL)', + ); + $this->connection->executeStatement('CREATE INDEX projection_name_idx ON projection (name)'); + + $registry = $this->createMock(ConnectionRegistry::class); + $registry->expects($this->once()) + ->method('getConnection') + ->with('projection_connection') + ->willReturn($this->connection); + + (new DbalCleanupTaskHandler($registry))(new DropTableTask('projection', 'projection_connection')); + + self::assertFalse($this->connection->createSchemaManager()->tablesExist(['projection'])); + } + + public function testConnectionNameWithoutRegistry(): void + { + $this->connection->executeStatement( + 'CREATE TABLE projection (id VARCHAR(255) NOT NULL PRIMARY KEY, name VARCHAR(255) NOT NULL)', + ); + $this->connection->executeStatement('CREATE INDEX projection_name_idx ON projection (name)'); + + $this->expectException(ConnectionNameNotSupported::class); + + (new DbalCleanupTaskHandler($this->connection))(new DropTableTask('projection', 'other')); + } + + public function testSupports(): void + { + $handler = new DbalCleanupTaskHandler($this->connection); + + self::assertTrue($handler->supports(new DropTableTask('projection'))); + self::assertTrue($handler->supports(new DropIndexTask('projection_name_idx', 'projection'))); + self::assertFalse($handler->supports(new stdClass())); + } + + public function testUnsupportedTask(): void + { + $this->expectException(CleanupTaskNotSupported::class); + + (new DbalCleanupTaskHandler($this->connection))(new stdClass()); + } +} From 5167b79e4cb5a1fd9a5d187c66ca8fc2008e47fa Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Tue, 29 Sep 2026 11:04:44 +0200 Subject: [PATCH 09/18] Fix save of multiple messages in a rare case, when keep_index option is active and exactly 7281 messages were saved that the setval was not called --- src/Store/StreamDoctrineDbalStore.php | 6 +-- .../Store/StreamDoctrineDbalStoreTest.php | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/Store/StreamDoctrineDbalStore.php b/src/Store/StreamDoctrineDbalStore.php index b1f2c2855..123ce4b00 100644 --- a/src/Store/StreamDoctrineDbalStore.php +++ b/src/Store/StreamDoctrineDbalStore.php @@ -324,12 +324,10 @@ function () use ($messages): void { $position = 0; } - if ($position === 0) { - return; + if ($position !== 0) { + $this->executeSave($columns, $placeholders, $parameters, $types, $this->connection); } - $this->executeSave($columns, $placeholders, $parameters, $types, $this->connection); - if (!$this->config['keep_index'] || !($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform)) { return; } diff --git a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php index 5a83739be..7ddd13ba0 100644 --- a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php @@ -210,6 +210,50 @@ public function testSaveWithIndex(): void ); } + public function testSaveWithIndexExactBatchSize(): void + { + $profileId = ProfileId::generate(); + + $messages = []; + + // 65535 max parameters / 9 columns = 7281 messages per batch + for ($i = 1; $i <= 7281; $i++) { + $messages[] = Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader($i)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new IndexHeader($i)); + } + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + clock: $this->clock, + config: ['keep_index' => true], + ); + + $store->save(...$messages); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + clock: $this->clock, + ); + + $store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(7282)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-02 00:00:00'))), + ); + + /** @var list> $result */ + $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store WHERE playhead = 7282'); + + self::assertCount(1, $result); + self::assertEquals(7282, $result[0]['id']); + } + public function testSaveWithOnlyStreamName(): void { $messages = [ From 81dfdfb8a3c26287bcaebe8b013d0f53181753c3 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Wed, 30 Sep 2026 12:24:49 +0200 Subject: [PATCH 10/18] Fix mysql/mariadb lock --- src/Store/DoctrineDbalStore.php | 36 +++++++++--- src/Store/StreamDoctrineDbalStore.php | 36 +++++++++--- .../Store/DoctrineDbalStoreTest.php | 56 +++++++++++++++++++ .../Store/StreamDoctrineDbalStoreTest.php | 54 ++++++++++++++++++ tests/Unit/Store/DoctrineDbalStoreTest.php | 52 +++++++++-------- .../Store/StreamDoctrineDbalStoreTest.php | 38 ++++++------- 6 files changed, 207 insertions(+), 65 deletions(-) diff --git a/src/Store/DoctrineDbalStore.php b/src/Store/DoctrineDbalStore.php index 927058e2b..5786dd3b9 100644 --- a/src/Store/DoctrineDbalStore.php +++ b/src/Store/DoctrineDbalStore.php @@ -312,16 +312,34 @@ public function transactional(Closure $function): void { if ($this->hasLock || !$this->config['locking']) { $this->connection->transactional($function); - } else { - $this->connection->transactional(function () use ($function): void { - $this->lock(); - try { - $function(); - } finally { - $this->unlock(); - } - }); + + return; + } + + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { + // GET_LOCK is bound to the session, not the transaction. It must be released after the commit, + // otherwise other writers can commit before this transaction is visible. + $this->lock(); + + try { + $this->connection->transactional($function); + } finally { + $this->unlock(); + } + + return; } + + $this->connection->transactional(function () use ($function): void { + $this->lock(); + try { + $function(); + } finally { + $this->unlock(); + } + }); } public function configureSchema(Schema $schema, Connection $connection): void diff --git a/src/Store/StreamDoctrineDbalStore.php b/src/Store/StreamDoctrineDbalStore.php index 123ce4b00..4b520c550 100644 --- a/src/Store/StreamDoctrineDbalStore.php +++ b/src/Store/StreamDoctrineDbalStore.php @@ -352,16 +352,34 @@ public function transactional(Closure $function): void { if ($this->hasLock || !$this->config['locking']) { $this->connection->transactional($function); - } else { - $this->connection->transactional(function () use ($function): void { - $this->lock(); - try { - $function(); - } finally { - $this->unlock(); - } - }); + + return; + } + + $platform = $this->connection->getDatabasePlatform(); + + if ($platform instanceof MariaDBPlatform || $platform instanceof MySQLPlatform) { + // GET_LOCK is bound to the session, not the transaction. It must be released after the commit, + // otherwise other writers can commit before this transaction is visible. + $this->lock(); + + try { + $this->connection->transactional($function); + } finally { + $this->unlock(); + } + + return; } + + $this->connection->transactional(function () use ($function): void { + $this->lock(); + try { + $function(); + } finally { + $this->unlock(); + } + }); } /** @return list */ diff --git a/tests/Integration/Store/DoctrineDbalStoreTest.php b/tests/Integration/Store/DoctrineDbalStoreTest.php index 69451ced6..b498dd25f 100644 --- a/tests/Integration/Store/DoctrineDbalStoreTest.php +++ b/tests/Integration/Store/DoctrineDbalStoreTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Integration\Store; +use Closure; use DateTimeImmutable; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; @@ -283,6 +284,61 @@ public function testLockIsAcquiredAgainAfterTimeout(): void } } + public function testSaveHoldsLockUntilCommit(): void + { + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $this->markTestSkipped('SQLite does not support locks'); + } + + $probeConnection = DriverManager::getConnection($this->connection->getParams()); + $lockQuery = $probeConnection->getDatabasePlatform() instanceof PostgreSQLPlatform + ? "SELECT COUNT(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = 133742" + : 'SELECT IS_USED_LOCK("133742") IS NOT NULL'; + + $connection = new class ($this->connection->getParams(), $this->connection->getDriver()) extends Connection { + public Closure|null $beforeCommit = null; + + public function commit(): void + { + if ($this->beforeCommit) { + ($this->beforeCommit)(); + } + + parent::commit(); + } + }; + + // Checks from a second session whether the lock is still held right before the commit. + $lockHeldOnCommit = null; + $connection->beforeCommit = static function () use ($probeConnection, $lockQuery, &$lockHeldOnCommit): void { + $lockHeldOnCommit = $probeConnection->fetchOne($lockQuery) > 0; + }; + + $store = new DoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + $profileId = ProfileId::generate(); + + try { + $store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new AggregateHeader( + 'profile', + $profileId->toString(), + 1, + new DateTimeImmutable('2020-01-01 00:00:00'), + )), + ); + } finally { + $connection->close(); + $probeConnection->close(); + } + + self::assertTrue($lockHeldOnCommit); + } + public function testLoad(): void { $profileId = ProfileId::generate(); diff --git a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php index 7ddd13ba0..d246db923 100644 --- a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Tests\Integration\Store; +use Closure; use DateTimeImmutable; use Doctrine\DBAL\Connection; use Doctrine\DBAL\DriverManager; @@ -491,6 +492,59 @@ public function testSaveLockTimeout(): void } } + public function testSaveHoldsLockUntilCommit(): void + { + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $this->markTestSkipped('SQLite does not support locks'); + } + + $probeConnection = DriverManager::getConnection($this->connection->getParams()); + $lockQuery = $probeConnection->getDatabasePlatform() instanceof PostgreSQLPlatform + ? "SELECT COUNT(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = 133742" + : 'SELECT IS_USED_LOCK("133742") IS NOT NULL'; + + $connection = new class ($this->connection->getParams(), $this->connection->getDriver()) extends Connection { + public Closure|null $beforeCommit = null; + + public function commit(): void + { + if ($this->beforeCommit) { + ($this->beforeCommit)(); + } + + parent::commit(); + } + }; + + // Checks from a second session whether the lock is still held right before the commit. + $lockHeldOnCommit = null; + $connection->beforeCommit = static function () use ($probeConnection, $lockQuery, &$lockHeldOnCommit): void { + $lockHeldOnCommit = $probeConnection->fetchOne($lockQuery) > 0; + }; + + $store = new StreamDoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + clock: $this->clock, + ); + + $profileId = ProfileId::generate(); + + try { + $store->save( + 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'))), + ); + } finally { + $connection->close(); + $probeConnection->close(); + } + + self::assertTrue($lockHeldOnCommit); + } + public function testLoad(): void { $profileId = ProfileId::generate(); diff --git a/tests/Unit/Store/DoctrineDbalStoreTest.php b/tests/Unit/Store/DoctrineDbalStoreTest.php index fb5b80c6e..518547f80 100644 --- a/tests/Unit/Store/DoctrineDbalStoreTest.php +++ b/tests/Unit/Store/DoctrineDbalStoreTest.php @@ -522,7 +522,7 @@ public function __invoke(): void }; $connection = $this->createMock(Connection::class); - $connection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn(new SQLitePlatform()); + $connection->expects($this->exactly(3))->method('getDatabasePlatform')->willReturn(new SQLitePlatform()); $connection->expects($this->atLeastOnce())->method('transactional')->willReturnCallback( static fn (Closure $closure): mixed => $closure(), ); @@ -554,7 +554,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); $connection @@ -598,7 +598,7 @@ public function __invoke(): void }; $connection = $this->createMock(Connection::class); - $connection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn(new MariaDBPlatform()); + $connection->expects($this->exactly(3))->method('getDatabasePlatform')->willReturn(new MariaDBPlatform()); $connection ->expects($this->exactly(2)) ->method('fetchOne') @@ -636,7 +636,7 @@ public function __invoke(): void }; $connection = $this->createMock(Connection::class); - $connection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn(new PostgreSQLPlatform()); + $connection->expects($this->exactly(3))->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( @@ -669,7 +669,7 @@ public function __invoke(): void }; $connection = $this->createMock(Connection::class); - $connection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn(new PostgreSQLPlatform()); + $connection->expects($this->exactly(3))->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( @@ -704,7 +704,7 @@ public function __invoke(): void }; $connection = $this->createMock(Connection::class); - $connection->expects($this->exactly(4))->method('getDatabasePlatform')->willReturn(new PostgreSQLPlatform()); + $connection->expects($this->exactly(6))->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( @@ -736,7 +736,7 @@ public function __invoke(): void }; $connection = $this->createMock(Connection::class); - $connection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn(new MariaDBPlatform()); + $connection->expects($this->exactly(3))->method('getDatabasePlatform')->willReturn(new MariaDBPlatform()); $connection ->expects($this->exactly(2)) ->method('fetchOne') @@ -776,7 +776,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -821,7 +821,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -866,7 +866,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -877,9 +877,8 @@ public function __invoke(): void ->willReturn(0); $connection - ->expects($this->once()) - ->method('transactional') - ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + ->expects($this->never()) + ->method('transactional'); $eventSerializer = $this->createMock(EventSerializer::class); $headersSerializer = $this->createMock(HeadersSerializer::class); @@ -910,7 +909,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -921,9 +920,8 @@ public function __invoke(): void ->willReturn(null); $connection - ->expects($this->once()) - ->method('transactional') - ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + ->expects($this->never()) + ->method('transactional'); $eventSerializer = $this->createMock(EventSerializer::class); $headersSerializer = $this->createMock(HeadersSerializer::class); @@ -953,7 +951,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -998,7 +996,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -1051,7 +1049,7 @@ public function testSaveWithOneEvent(): void $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->exactly(3))->method('getDatabasePlatform')->willReturn(new SQLitePlatform()); $mockedConnection->expects($this->once())->method('transactional')->willReturnCallback( static fn (Closure $closure): mixed => $closure(), ); @@ -1094,7 +1092,7 @@ public function testSaveWithoutAggregateHeader(): void ->willReturn('[]'); $mockedConnection = $this->createMock(Connection::class); - $mockedConnection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn(new SQLitePlatform()); + $mockedConnection->expects($this->exactly(3))->method('getDatabasePlatform')->willReturn(new SQLitePlatform()); $mockedConnection->expects($this->once())->method('transactional')->willReturnCallback( static fn (Closure $closure): mixed => $closure(), ); @@ -1158,7 +1156,7 @@ public function testSaveWithTwoEvents(): void $headersSerializer->expects($this->exactly(2))->method('serialize')->with([])->willReturn('[]'); $mockedConnection = $this->createMock(Connection::class); - $mockedConnection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn(new SQLitePlatform()); + $mockedConnection->expects($this->exactly(3))->method('getDatabasePlatform')->willReturn(new SQLitePlatform()); $mockedConnection->expects($this->once())->method('transactional')->willReturnCallback( static fn (Closure $closure): mixed => $closure(), ); @@ -1240,7 +1238,7 @@ public function testSaveWithUniqueConstraintViolation(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -1325,7 +1323,7 @@ public function testSaveWithThousandEvents(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -1376,7 +1374,7 @@ public function testSaveWithCustomHeaders(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -1849,7 +1847,7 @@ public function testArchiveMessagesDifferentAggregates(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -1976,7 +1974,7 @@ public function testArchiveMessagesSameAggregate(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection diff --git a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php index acf3e16f0..fe93bc855 100644 --- a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php @@ -621,7 +621,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $connection @@ -656,7 +656,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -700,7 +700,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -744,7 +744,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new PostgreSQLPlatform()); @@ -785,7 +785,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new PostgreSQLPlatform()); @@ -828,7 +828,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(4)) + ->expects($this->exactly(6)) ->method('getDatabasePlatform') ->willReturn(new PostgreSQLPlatform()); $connection @@ -867,7 +867,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -911,7 +911,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -956,7 +956,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -1001,7 +1001,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -1012,9 +1012,8 @@ public function __invoke(): void ->willReturn(0); $connection - ->expects($this->once()) - ->method('transactional') - ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + ->expects($this->never()) + ->method('transactional'); $eventSerializer = $this->createMock(EventSerializer::class); $headersSerializer = $this->createMock(HeadersSerializer::class); @@ -1045,7 +1044,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -1056,9 +1055,8 @@ public function __invoke(): void ->willReturn(null); $connection - ->expects($this->once()) - ->method('transactional') - ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + ->expects($this->never()) + ->method('transactional'); $eventSerializer = $this->createMock(EventSerializer::class); $headersSerializer = $this->createMock(HeadersSerializer::class); @@ -1088,7 +1086,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -1133,7 +1131,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -1265,7 +1263,7 @@ public function testSaveWithTwoEvents(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection From fddbfaa21628467107536a73722109ba43a5e886 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Wed, 30 Sep 2026 18:33:37 +0200 Subject: [PATCH 11/18] When a gap in the index was accepted after all retries, `$currentIndex` was only incremented by one instead of set to the yielded index. The next event was then treated as a new gap, the loader reloaded from the old position and yielded the event after the gap again. Batching subscribers handled that event twice. --- .../Engine/GapResolverStoreMessageLoader.php | 2 +- .../EventFilteredStoreMessageLoaderTest.php | 275 ++++++++++++++++++ .../GapResolverStoreMessageLoaderTest.php | 239 +++++++++++++++ .../Subscription/StoreMessageLoaderTest.php | 157 ++++++++++ 4 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Subscription/EventFilteredStoreMessageLoaderTest.php create mode 100644 tests/Integration/Subscription/GapResolverStoreMessageLoaderTest.php create mode 100644 tests/Integration/Subscription/StoreMessageLoaderTest.php diff --git a/src/Subscription/Engine/GapResolverStoreMessageLoader.php b/src/Subscription/Engine/GapResolverStoreMessageLoader.php index 1eb809944..8b628b972 100644 --- a/src/Subscription/Engine/GapResolverStoreMessageLoader.php +++ b/src/Subscription/Engine/GapResolverStoreMessageLoader.php @@ -66,7 +66,7 @@ private function generator(int $startIndex, int $retry = 0): Generator } $retry = 0; - $currentIndex++; + $currentIndex = $stream->index() + 1; yield $stream->index() => $message; } diff --git a/tests/Integration/Subscription/EventFilteredStoreMessageLoaderTest.php b/tests/Integration/Subscription/EventFilteredStoreMessageLoaderTest.php new file mode 100644 index 000000000..35642ceac --- /dev/null +++ b/tests/Integration/Subscription/EventFilteredStoreMessageLoaderTest.php @@ -0,0 +1,275 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testLoadOnlySubscribedEvents(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jim')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(3)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new ProfileCreated(ProfileId::generate(), 'Tom')) + ->withHeader(new StreamNameHeader('profile-2')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + ); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + new MetadataSubscriberAccessorRepository([new ProfileProjection($this->connection)]), + ); + + $stream = $loader->load(0, [new Subscription('profile_1')]); + + $indexes = []; + + foreach ($stream as $message) { + $indexes[] = $stream->index(); + } + + self::assertSame([1, 4], $indexes); + } + + public function testLoadOnlySubscribedEventsFromStartIndex(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new ProfileCreated(ProfileId::generate(), 'Tom')) + ->withHeader(new StreamNameHeader('profile-2')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + ); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + new MetadataSubscriberAccessorRepository([new ProfileProjection($this->connection)]), + ); + + $stream = $loader->load(1, [new Subscription('profile_1')]); + + $indexes = []; + + foreach ($stream as $message) { + $indexes[] = $stream->index(); + } + + self::assertSame([3], $indexes); + } + + public function testLoadSubscribedEventsOfMultipleSubscriptions(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new AdminPromoted(ProfileId::generate())) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(3)) + ->withHeader(new RecordedOnHeader($recordedOn)), + ); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + new MetadataSubscriberAccessorRepository([ + new ProfileProjection($this->connection), + new LookupSubscriber($this->connection), + ]), + ); + + $stream = $loader->load(0, [new Subscription('profile_1'), new Subscription('lookup')]); + + $indexes = []; + + foreach ($stream as $message) { + $indexes[] = $stream->index(); + } + + self::assertSame([1, 3], $indexes); + } + + public function testLoadAllEventsIfSubscriberListensToAll(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new AdminPromoted(ProfileId::generate())) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(3)) + ->withHeader(new RecordedOnHeader($recordedOn)), + ); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + new MetadataSubscriberAccessorRepository([ + new ProfileProjection($this->connection), + new ErrorProducerSubscriber(), + ]), + ); + + $stream = $loader->load(0, [new Subscription('profile_1'), new Subscription('error_producer')]); + + $indexes = []; + + foreach ($stream as $message) { + $indexes[] = $stream->index(); + } + + self::assertSame([1, 2, 3], $indexes); + } + + public function testLastIndexIgnoresSubscribedEvents(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)), + ); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + new MetadataSubscriberAccessorRepository([new ProfileProjection($this->connection)]), + ); + + self::assertSame(2, $loader->lastIndex()); + } + + public function testLastIndexOnEmptyStore(): void + { + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + new MetadataSubscriberAccessorRepository([new ProfileProjection($this->connection)]), + ); + + self::assertSame(0, $loader->lastIndex()); + } +} diff --git a/tests/Integration/Subscription/GapResolverStoreMessageLoaderTest.php b/tests/Integration/Subscription/GapResolverStoreMessageLoaderTest.php new file mode 100644 index 000000000..165c614d3 --- /dev/null +++ b/tests/Integration/Subscription/GapResolverStoreMessageLoaderTest.php @@ -0,0 +1,239 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testLoadAllEventsWithoutGaps(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new ProfileCreated(ProfileId::generate(), 'Tom')) + ->withHeader(new StreamNameHeader('profile-2')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + ); + + $loader = new GapResolverStoreMessageLoader( + $store, + new FrozenClock($recordedOn), + [0, 0], + ); + + $stream = $loader->load(1, [new Subscription('profile_1')]); + + $indexes = []; + + foreach ($stream as $message) { + $indexes[] = $stream->index(); + } + + self::assertSame([2, 3], $indexes); + } + + public function testEventWrittenIntoGapIsNotSkipped(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + config: ['keep_index' => true], + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(1)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(2)), + Message::create(new ProfileCreated(ProfileId::generate(), 'Tom')) + ->withHeader(new StreamNameHeader('profile-4')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(4)), + ); + + $loader = new GapResolverStoreMessageLoader( + $store, + new FrozenClock($recordedOn), + [0, 0], + ); + + $stream = $loader->load(0, [new Subscription('profile_1')]); + + $indexes = []; + + foreach ($stream as $message) { + $indexes[] = $stream->index(); + + if ($stream->index() !== 2) { + continue; + } + + // simulates a transaction that commits after the stream was loaded + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'Late')) + ->withHeader(new StreamNameHeader('profile-3')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(3)), + ); + } + + self::assertSame([1, 2, 3, 4], $indexes); + } + + public function testPermanentGapIsAcceptedWithoutDuplicates(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + config: ['keep_index' => true], + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(1)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(2)), + Message::create(new ProfileCreated(ProfileId::generate(), 'Tom')) + ->withHeader(new StreamNameHeader('profile-4')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(4)), + Message::create(new ProfileCreated(ProfileId::generate(), 'Anna')) + ->withHeader(new StreamNameHeader('profile-5')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(5)), + ); + + $loader = new GapResolverStoreMessageLoader( + $store, + new FrozenClock($recordedOn), + [0, 0], + ); + + $stream = $loader->load(0, [new Subscription('profile_1')]); + + $indexes = []; + + foreach ($stream as $message) { + $indexes[] = $stream->index(); + } + + self::assertSame([1, 2, 4, 5], $indexes); + } + + public function testLastIndex(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + config: ['keep_index' => true], + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(1)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)) + ->withHeader(new IndexHeader(5)), + ); + + $loader = new GapResolverStoreMessageLoader($store, new FrozenClock($recordedOn)); + + self::assertSame(5, $loader->lastIndex()); + } + + public function testLastIndexOnEmptyStore(): void + { + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $loader = new GapResolverStoreMessageLoader($store, new FrozenClock(new DateTimeImmutable('2020-01-01 00:00:00'))); + + self::assertSame(0, $loader->lastIndex()); + } +} diff --git a/tests/Integration/Subscription/StoreMessageLoaderTest.php b/tests/Integration/Subscription/StoreMessageLoaderTest.php new file mode 100644 index 000000000..dd497eb15 --- /dev/null +++ b/tests/Integration/Subscription/StoreMessageLoaderTest.php @@ -0,0 +1,157 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testLoadAllEvents(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new ProfileCreated(ProfileId::generate(), 'Tom')) + ->withHeader(new StreamNameHeader('profile-2')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + ); + + $loader = new StoreMessageLoader($store); + + $stream = $loader->load(0, [new Subscription('profile_1')]); + + $indexes = []; + + foreach ($stream as $message) { + $indexes[] = $stream->index(); + } + + self::assertSame([1, 2, 3], $indexes); + } + + public function testLoadFromStartIndex(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new ProfileCreated(ProfileId::generate(), 'Tom')) + ->withHeader(new StreamNameHeader('profile-2')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + ); + + $loader = new StoreMessageLoader($store); + + $stream = $loader->load(1, [new Subscription('profile_1')]); + + $indexes = []; + + foreach ($stream as $message) { + $indexes[] = $stream->index(); + } + + self::assertSame([2, 3], $indexes); + } + + public function testLastIndex(): void + { + $recordedOn = new DateTimeImmutable('2020-01-01 00:00:00'); + + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'John')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader($recordedOn)), + Message::create(new NameChanged(ProfileId::generate(), 'Jane')) + ->withHeader(new StreamNameHeader('profile-1')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader($recordedOn)), + ); + + $loader = new StoreMessageLoader($store); + + self::assertSame(2, $loader->lastIndex()); + } + + public function testLastIndexOnEmptyStore(): void + { + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $loader = new StoreMessageLoader($store); + + self::assertSame(0, $loader->lastIndex()); + } +} From 6f20f89590f14ad444072a8aa48778406d09e5ae Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Thu, 1 Oct 2026 18:26:52 +0200 Subject: [PATCH 12/18] Fix subscription status command ignoring the id and group filter --- .../Command/SubscriptionStatusCommand.php | 9 +- .../Command/SubscriptionStatusCommandTest.php | 124 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Console/Command/SubscriptionStatusCommandTest.php diff --git a/src/Console/Command/SubscriptionStatusCommand.php b/src/Console/Command/SubscriptionStatusCommand.php index 42176d167..648de136a 100644 --- a/src/Console/Command/SubscriptionStatusCommand.php +++ b/src/Console/Command/SubscriptionStatusCommand.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Console\InputHelper; use Patchlevel\EventSourcing\Console\OutputStyle; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Subscription\SubscriptionError; @@ -41,9 +42,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new OutputStyle($input, $output); $id = InputHelper::nullableString($input->getArgument('id')); - $subscriptions = $this->engine->subscriptions(); if ($id === null) { + $subscriptions = $this->engine->subscriptions($this->subscriptionEngineCriteria($input)); + $io->table( [ 'id', @@ -69,7 +71,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - $subscription = $this->findSubscription($subscriptions, $id); + $subscription = $this->findSubscription( + $this->engine->subscriptions(new SubscriptionEngineCriteria([$id])), + $id, + ); $io->horizontalTable( [ diff --git a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php new file mode 100644 index 000000000..7cbe3e8ee --- /dev/null +++ b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php @@ -0,0 +1,124 @@ +createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria()) + ->willReturn([ + new Subscription('profile_1', 'projector', status: Status::Active, position: 10), + new Subscription('welcome_email', 'processor', status: Status::Paused, position: 5), + ]); + + $command = new SubscriptionStatusCommand($engine); + + $input = new ArrayInput([]); + $output = new BufferedOutput(); + + $exitCode = $command->run($input, $output); + + self::assertSame(0, $exitCode); + + $content = $output->fetch(); + + self::assertStringContainsString('profile_1', $content); + self::assertStringContainsString('active', $content); + self::assertStringContainsString('welcome_email', $content); + self::assertStringContainsString('paused', $content); + } + + public function testListFiltered(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(['profile_1', 'profile_2'], ['projector'])) + ->willReturn([ + new Subscription('profile_1', 'projector', status: Status::Active, position: 10), + ]); + + $command = new SubscriptionStatusCommand($engine); + + $input = new ArrayInput([ + '--id' => ['profile_1', 'profile_2'], + '--group' => ['projector'], + ]); + $output = new BufferedOutput(); + + $exitCode = $command->run($input, $output); + + self::assertSame(0, $exitCode); + + $content = $output->fetch(); + + self::assertStringContainsString('profile_1', $content); + self::assertStringNotContainsString('welcome_email', $content); + } + + public function testShowOne(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(['profile_1'])) + ->willReturn([ + new Subscription('profile_1', 'projector', status: Status::Active, position: 10), + ]); + + $command = new SubscriptionStatusCommand($engine); + + $input = new ArrayInput(['id' => 'profile_1']); + $output = new BufferedOutput(); + + $exitCode = $command->run($input, $output); + + self::assertSame(0, $exitCode); + + $content = $output->fetch(); + + self::assertStringContainsString('profile_1', $content); + self::assertStringContainsString('projector', $content); + self::assertStringContainsString('active', $content); + } + + public function testShowOneNotFound(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(['profile_1'])) + ->willReturn([]); + + $command = new SubscriptionStatusCommand($engine); + + $input = new ArrayInput(['id' => 'profile_1']); + $output = new BufferedOutput(); + + $this->expectException(SubscriptionNotFound::class); + + $command->run($input, $output); + } +} From 829c68a418229ff6db7b4e37b63b0a2dbd7eb26e Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Thu, 1 Oct 2026 18:26:52 +0200 Subject: [PATCH 13/18] Improve docs by adding explanations about every command --- docs/cli.md | 380 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 348 insertions(+), 32 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 43ba954c7..cfb980a88 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,8 @@ # CLI This library provides a `cli` to manage the `event-sourcing` functionalities. +The commands are [symfony console](https://symfony.com/doc/current/components/console.html) commands, +so you can register them in any symfony console application. You can: @@ -10,51 +12,342 @@ You can: * Inspect your `aggregates`, `events` and `subscribers` * Migrate events from one `store` to another +:::note +The examples on this page use `bin/console` as the entry point. +How to build such a cli file is shown in the [CLI example](#cli-example). +::: + ## Database commands -There are two commands for creating and deleting a database. +The database commands create and delete the database configured in your doctrine connection. +They are mostly useful for local development and test environments. + +### Create database + +The `event-sourcing:database:create` command creates the database. + +```bash +bin/console event-sourcing:database:create +``` +With the `--if-not-exists` option, the command does not fail if the database already exists. + +```bash +bin/console event-sourcing:database:create --if-not-exists +``` +### Drop database + +The `event-sourcing:database:drop` command deletes the database. +Without the `--force` option, the command only prints which database would be dropped. + +```bash +bin/console event-sourcing:database:drop --force +``` +With the `--if-exists` option, the command does not fail if the database does not exist. -* DatabaseCreateCommand: `event-sourcing:database:create` -* DatabaseDropCommand: `event-sourcing:database:drop` +:::danger +Dropping the database deletes all your events. They cannot be restored. +::: ## Schema commands -The database schema can also be created, updated and dropped. +The schema commands manage the tables the library needs, +for example the event store table and the subscription store table. +They use the [schema director](store.md#schema), which knows all tables of the configured stores. -* SchemaCreateCommand: `event-sourcing:schema:create` -* SchemaUpdateCommand: `event-sourcing:schema:update` -* SchemaDropCommand: `event-sourcing:schema:drop` +All schema commands support the `--dry-run` option. +Instead of executing anything, the command prints the SQL queries it would run, +so you can review the changes before applying them. -:::note -You can also register doctrine migration commands. +```bash +bin/console event-sourcing:schema:update --dry-run +``` +### Create schema + +The `event-sourcing:schema:create` command creates all tables. +Use it once when you set up a new database. + +```bash +bin/console event-sourcing:schema:create +``` +### Update schema + +The `event-sourcing:schema:update` command compares the existing tables with the expected schema +and applies the differences, for example after a library update. +Without the `--force` option, the command does nothing. + +```bash +bin/console event-sourcing:schema:update --force +``` +### Drop schema + +The `event-sourcing:schema:drop` command deletes all tables of the library. +Without the `--force` option, the command does nothing. + +```bash +bin/console event-sourcing:schema:drop --force +``` +:::danger +Dropping the schema deletes all your events. They cannot be restored. +::: + +:::tip +In production, we recommend [doctrine migrations](#doctrine-migrations) instead of the schema commands, +so that every schema change is versioned and reviewed. ::: ## Subscription commands -To manage your subscriptions there are the following cli commands. +The subscription commands control the [subscription engine](subscription.md). +They move [subscriptions](subscription.md) through their lifecycle and show their current status. -* SubscriptionBootCommand: `event-sourcing:subscription:boot` -* SubscriptionPauseCommand: `event-sourcing:subscription:pause` -* SubscriptionReactivateCommand: `event-sourcing:subscription:reactivate` -* SubscriptionRefreshCommand: `event-sourcing:subscription:refresh` -* SubscriptionRemoveCommand: `event-sourcing:subscription:remove` -* SubscriptionRunCommand: `event-sourcing:subscription:run` -* SubscriptionSetupCommand: `event-sourcing:subscription:setup` -* SubscriptionStatusCommand: `event-sourcing:subscription:status` -* SubscriptionTeardownCommand: `event-sourcing:subscription:teardown` +### Filter subscriptions +Most subscription commands can be limited to certain subscriptions. +Use `--id` to select subscriptions by their subscriber ID and `--group` to select them by their group. +Both options can be passed multiple times. + +```bash +bin/console event-sourcing:subscription:boot --id=profile_1 --id=welcome_email +bin/console event-sourcing:subscription:run --group=projector +``` :::note -You can find out more about [subscriptions](subscription.md). +Ids are combined with `OR`, groups are combined with `OR`, and both options together are combined with `AND`. +Without any filter, the command applies to all subscriptions. +::: + +### Setup + +The `event-sourcing:subscription:setup` command sets up all new subscriptions. +The subscription engine calls the `setup` method of the subscriber, if available, +for example to create the table of a projection. +Afterwards the subscription is booting or, depending on the run mode, already active. + +```bash +bin/console event-sourcing:subscription:setup +``` +With `--skip-booting`, the subscriptions skip the booting status and become active right away. +They then catch up with the event store in the [run command](#run) instead of the boot command. + +### Boot + +The `event-sourcing:subscription:boot` command lets all booting subscriptions catch up with the event store. +After that, they are active, or finished for subscribers with `RunMode::Once`. +The command stops as soon as all subscriptions have caught up. + +```bash +bin/console event-sourcing:subscription:boot +``` +With `--setup`, the command first sets up new subscriptions, so you can do both steps in one call. + +```bash +bin/console event-sourcing:subscription:boot --setup +``` +The command processes the events in chunks. The `--message-limit` option (default `1000`) +defines how many events are processed per run. +If the boot is aborted by a [worker limit](#worker-options) before all subscriptions caught up, +the command exits with code `1`. + +### Run + +The `event-sourcing:subscription:run` command is the long-running worker. +It keeps the active subscriptions up to date by processing new events as they are recorded. + +```bash +bin/console event-sourcing:subscription:run +``` +By default, the command looks for new events every second (`--sleep=1000`) +and processes up to 100 events per run (`--message-limit=100`). +If your store supports it (`DoctrineDbalStore` and `StreamDoctrineDbalStore` with PostgreSQL), +the worker does not poll but waits for a notification from the database, so new events are processed right away. + +:::warning +The run command only handles active subscriptions. +Subscriptions that are new or booting have to be set up and booted first. +After adding a new subscriber, run the setup and boot step and restart the run command. +::: + +### Worker options + +The boot and run commands are workers and support the following options: + +| Option | Description | +|-------------------|-----------------------------------------------------------------------------| +| `--run-limit` | Stop the worker after this number of runs. | +| `--message-limit` | How many events are processed per run. | +| `--memory-limit` | Stop the worker if it uses more memory than this, e.g. `250MB`. | +| `--time-limit` | Stop the worker after this number of seconds. | +| `--sleep` | How many milliseconds the worker waits between two runs. | + +```bash +bin/console event-sourcing:subscription:run --memory-limit=250MB --time-limit=3600 +``` +:::tip +Use the memory and time limits together with a process manager like supervisor or systemd, +which restarts the worker after it stopped. This prevents memory leaks from piling up +and makes sure that a deployment is picked up. +::: + +### Status + +The `event-sourcing:subscription:status` command shows a table of all subscriptions +with their group, run mode, position, status and error message. +Use it to check whether your subscriptions are up to date or have an error. + +```bash +bin/console event-sourcing:subscription:status +bin/console event-sourcing:subscription:status --group=projector +``` +Pass a subscription ID to show the details of one subscription. +If the subscription has an error, the command also prints the error message and the stack trace. + +```bash +bin/console event-sourcing:subscription:status profile_1 +``` +### Pause + +The `event-sourcing:subscription:pause` command pauses subscriptions. +The subscription engine no longer processes events for them until they are reactivated. +This is useful, for example, if a third party system used by a processor is down. + +```bash +bin/console event-sourcing:subscription:pause --id=welcome_email +``` +### Reactivate + +The `event-sourcing:subscription:reactivate` command reactivates paused, finished, detached, +error or failed subscriptions. The subscription then continues from its current position. +A subscription with an error returns to the status it had before the error, +all others become active. +Use it after you fixed the cause of an error. + +```bash +bin/console event-sourcing:subscription:reactivate --id=welcome_email +``` +:::note +Only subscriptions whose subscriber exists in the code can be reactivated. +::: + +### Refresh + +The `event-sourcing:subscription:refresh` command updates the stored subscriptions +if you changed the metadata of a subscriber in the code, for example the run mode or the group. +The position and status of the subscriptions stay untouched. + +```bash +bin/console event-sourcing:subscription:refresh +``` +:::note +This command needs a subscription engine that supports refreshing, like the `DefaultSubscriptionEngine`. +::: + +### Teardown + +The `event-sourcing:subscription:teardown` command cleans up detached subscriptions. +A subscription is detached if its subscriber no longer exists in the code, +for example because you deleted it or created a new version with a new subscriber ID. +The subscription engine calls the `teardown` method or runs the cleanup tasks of the subscriber, if available, +and removes the subscription afterwards. + +```bash +bin/console event-sourcing:subscription:teardown +``` +:::note +Without [cleanup tasks](subscription.md#cleanup), a teardown is only possible if the code of the subscriber still exists. +Otherwise, the subscription stays detached and you can remove it with the [remove command](#remove). +::: + +### Remove + +The `event-sourcing:subscription:remove` command removes subscriptions regardless of their status. +The subscription engine tries to call the `teardown` method, if available, +but removes the subscription even if this fails. +If the subscriber still exists, the subscription is new again and can be set up and booted from scratch. +This is the way to rebuild a projection. + +```bash +bin/console event-sourcing:subscription:remove --id=profile_1 +``` +Without any filter, the command asks for confirmation before it removes all subscriptions. +Use `--force` to skip the question. + +:::danger +Removing a subscription deletes the data of its projection, if the subscriber has a `teardown` method. +::: + +### Typical workflow + +On every deployment, stop the old run command, set up and boot new subscriptions and start the run command again. +Once the old version of your application no longer serves requests, clean up the outdated subscriptions. + +```bash +bin/console event-sourcing:subscription:boot --setup +# start bin/console event-sourcing:subscription:run +# wait until the old application is gone +bin/console event-sourcing:subscription:teardown +``` +If a subscription has an error, look at the details and reactivate it after you fixed the problem. + +```bash +bin/console event-sourcing:subscription:status welcome_email +bin/console event-sourcing:subscription:reactivate --id=welcome_email +``` +:::note +You can find out more about the [subscription lifecycle](subscription.md#subscription-status). ::: ## Inspector commands -The inspector is a tool to inspect the event streams. +The inspector commands display the events in your store. +They help you to debug your application and to understand what happened in an aggregate. + +### Show events -* ShowCommand: `event-sourcing:show` -* ShowAggregateCommand: `event-sourcing:show-aggregate` -* WatchCommand: `event-sourcing:watch` +The `event-sourcing:show` command shows the latest events of the store, newest first. +After each page, it asks whether to show the next events. +```bash +bin/console event-sourcing:show +``` +| Option | Description | +|-------------|-----------------------------------------------------------------------------| +| `--limit` | How many events are displayed per page (default `10`, `0` shows all). | +| `--forward` | Start with the oldest event instead of the newest. | +| `--stream` | Only show events of the given stream, wildcards like `profile-*` are allowed. | + +```bash +bin/console event-sourcing:show --stream="profile-*" --limit=20 +``` +:::note +The `--stream` option needs the [StreamDoctrineDbalStore](store.md#streamdoctrinedbalstore). +::: + +### Show aggregate + +The `event-sourcing:show-aggregate` command shows all events of one aggregate. +Pass the aggregate name and the aggregate ID as arguments. +If you leave them out, the command asks for them. + +```bash +bin/console event-sourcing:show-aggregate profile 018d6a1c-5f2b-7f3e-9c4a-2b6a1f0e8d7c +``` +### Watch events + +The `event-sourcing:watch` command prints new events live as they are saved. +It is a worker like the subscription run command and supports the same +[worker options](#worker-options), except `--message-limit`. + +```bash +bin/console event-sourcing:watch +``` +You can limit the output to one stream, or to an aggregate name and aggregate ID. +The stream and the aggregate options cannot be combined. +The `--stream` option needs the [StreamDoctrineDbalStore](store.md#streamdoctrinedbalstore), +the `--aggregate` and `--aggregate-id` options need the `DoctrineDbalStore`. + +```bash +bin/console event-sourcing:watch --stream="profile-*" +bin/console event-sourcing:watch --aggregate=profile --aggregate-id=018d6a1c-5f2b-7f3e-9c4a-2b6a1f0e8d7c +``` ## Debug command The debug command prints everything the library knows about your application: @@ -143,9 +436,14 @@ A cli php file can look like this: use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Console\Command; use Patchlevel\EventSourcing\Console\DoctrineHelper; +use Patchlevel\EventSourcing\Message\Serializer\HeadersSerializer; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; +use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; +use Patchlevel\EventSourcing\Serializer\EventSerializer; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Symfony\Component\Console\Application; $cli = new Application('Event-Sourcing CLI'); @@ -159,25 +457,42 @@ $doctrineHelper = new DoctrineHelper(); */ $schemaDirector = new DoctrineSchemaDirector($connection, $store); -/** @var SubscriptionEngine $subscriptionEngine */ +/** + * @var SubscriptionEngine $subscriptionEngine + * @var EventSerializer $eventSerializer + * @var HeadersSerializer $headersSerializer + * @var AggregateRootRegistry $aggregateRootRegistry + * @var EventRegistry $eventRegistry + * @var SubscriberAccessorRepository $subscriberAccessorRepository + */ $cli->addCommands([ new Command\DatabaseCreateCommand($connection, $doctrineHelper), new Command\DatabaseDropCommand($connection, $doctrineHelper), + new Command\SchemaCreateCommand($schemaDirector), + new Command\SchemaDropCommand($schemaDirector), + new Command\SchemaUpdateCommand($schemaDirector), + new Command\SubscriptionSetupCommand($subscriptionEngine), new Command\SubscriptionBootCommand($subscriptionEngine), - new Command\SubscriptionPauseCommand($subscriptionEngine), new Command\SubscriptionRunCommand($subscriptionEngine, $store), + new Command\SubscriptionStatusCommand($subscriptionEngine), + new Command\SubscriptionPauseCommand($subscriptionEngine), + new Command\SubscriptionReactivateCommand($subscriptionEngine), + new Command\SubscriptionRefreshCommand($subscriptionEngine), new Command\SubscriptionTeardownCommand($subscriptionEngine), new Command\SubscriptionRemoveCommand($subscriptionEngine), - new Command\SubscriptionReactivateCommand($subscriptionEngine), - new Command\SubscriptionSetupCommand($subscriptionEngine), - new Command\SubscriptionStatusCommand($subscriptionEngine), - new Command\SchemaCreateCommand($schemaDirector), - new Command\SchemaDropCommand($schemaDirector), - new Command\SchemaUpdateCommand($schemaDirector), + new Command\ShowCommand($store, $eventSerializer, $headersSerializer), + new Command\ShowAggregateCommand($store, $eventSerializer, $headersSerializer, $aggregateRootRegistry), + new Command\WatchCommand($store, $eventSerializer, $headersSerializer), + new Command\DebugCommand($aggregateRootRegistry, $eventRegistry, $subscriberAccessorRepository), ]); $cli->run(); ``` +:::note +The `SubscriptionBootCommand` and `SubscriptionRunCommand` accept a PSR-14 event dispatcher as an optional last argument. +It receives the worker events, for example to reset services between two runs. +::: + ### Doctrine Migrations If you want to use doctrine migrations, you can register the commands like this: @@ -233,3 +548,4 @@ Here you can find more information on how to * [How to configure store](store.md) * [How to configure subscription engine](subscription.md) +* [How to use subscriptions](subscription.md#usage) From 3c6cb5da6f9330f7dda83e6d9cedd2bc78331c24 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Thu, 1 Oct 2026 19:34:57 +0200 Subject: [PATCH 14/18] Update roave/backward-compatibility-check to ^8.23.0 --- tools/composer.json | 2 +- tools/composer.lock | 752 ++++++++++++++++++++++---------------------- 2 files changed, 370 insertions(+), 384 deletions(-) diff --git a/tools/composer.json b/tools/composer.json index 74d1aab33..473bd65cc 100644 --- a/tools/composer.json +++ b/tools/composer.json @@ -1,6 +1,6 @@ { "require": { "php": "~8.5.0", - "roave/backward-compatibility-check": "^8.20.0" + "roave/backward-compatibility-check": "^8.23.0" } } diff --git a/tools/composer.lock b/tools/composer.lock index 8e6c0e394..15818ea2e 100644 --- a/tools/composer.lock +++ b/tools/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "115628ecf995f1bedf175c263987938a", + "content-hash": "5dab92cb5a7b23839a5caf9e41d4dbb7", "packages": [ { "name": "beberlei/assert", - "version": "v3.3.3", + "version": "v3.3.4", "source": { "type": "git", "url": "https://github.com/beberlei/assert.git", - "reference": "b5fd8eacd8915a1b627b8bfc027803f1939734dd" + "reference": "f193f4613c7d7fbcee2c05e4daff4061d49c040e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/beberlei/assert/zipball/b5fd8eacd8915a1b627b8bfc027803f1939734dd", - "reference": "b5fd8eacd8915a1b627b8bfc027803f1939734dd", + "url": "https://api.github.com/repos/beberlei/assert/zipball/f193f4613c7d7fbcee2c05e4daff4061d49c040e", + "reference": "f193f4613c7d7fbcee2c05e4daff4061d49c040e", "shasum": "" }, "require": { @@ -69,22 +69,22 @@ ], "support": { "issues": "https://github.com/beberlei/assert/issues", - "source": "https://github.com/beberlei/assert/tree/v3.3.3" + "source": "https://github.com/beberlei/assert/tree/v3.3.4" }, - "time": "2024-07-15T13:18:35+00:00" + "time": "2026-06-10T19:47:05+00:00" }, { "name": "composer/ca-bundle", - "version": "1.5.12", + "version": "1.5.14", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" + "reference": "0c8abba0634f637bd78c4e451981da368d403463" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", - "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/0c8abba0634f637bd78c4e451981da368d403463", + "reference": "0c8abba0634f637bd78c4e451981da368d403463", "shasum": "" }, "require": { @@ -131,7 +131,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.12" + "source": "https://github.com/composer/ca-bundle/tree/1.5.14" }, "funding": [ { @@ -143,20 +143,20 @@ "type": "github" } ], - "time": "2026-05-19T11:26:22+00:00" + "time": "2026-08-21T14:57:29+00:00" }, { "name": "composer/class-map-generator", - "version": "1.7.3", + "version": "1.8.0", "source": { "type": "git", "url": "https://github.com/composer/class-map-generator.git", - "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f" + "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/class-map-generator/zipball/86d8208fc3c649a3a999daf1a63c25201be2990f", - "reference": "86d8208fc3c649a3a999daf1a63c25201be2990f", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", + "reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4", "shasum": "" }, "require": { @@ -200,7 +200,7 @@ ], "support": { "issues": "https://github.com/composer/class-map-generator/issues", - "source": "https://github.com/composer/class-map-generator/tree/1.7.3" + "source": "https://github.com/composer/class-map-generator/tree/1.8.0" }, "funding": [ { @@ -212,20 +212,20 @@ "type": "github" } ], - "time": "2026-05-05T09:17:07+00:00" + "time": "2026-09-25T15:24:19+00:00" }, { "name": "composer/composer", - "version": "2.10.0", + "version": "2.10.3", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "c13824d95608b15913a7c0def0a3dea4474b71fc" + "reference": "f0de0bf90226853b841672f086d8b58b02332504" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/c13824d95608b15913a7c0def0a3dea4474b71fc", - "reference": "c13824d95608b15913a7c0def0a3dea4474b71fc", + "url": "https://api.github.com/repos/composer/composer/zipball/f0de0bf90226853b841672f086d8b58b02332504", + "reference": "f0de0bf90226853b841672f086d8b58b02332504", "shasum": "" }, "require": { @@ -236,6 +236,8 @@ "composer/semver": "^3.3", "composer/spdx-licenses": "^1.5.7", "composer/xdebug-handler": "^2.0.2 || ^3.0.3", + "ext-filter": "*", + "ext-hash": "*", "ext-json": "*", "justinrainbow/json-schema": "^6.5.1", "php": "^7.2.5 || ^8.0", @@ -313,7 +315,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.10.0" + "source": "https://github.com/composer/composer/tree/2.10.3" }, "funding": [ { @@ -325,20 +327,20 @@ "type": "github" } ], - "time": "2026-05-28T09:22:08+00:00" + "time": "2026-08-27T11:34:23+00:00" }, { "name": "composer/metadata-minifier", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/composer/metadata-minifier.git", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207" + "reference": "8e86142e3ade750b837d55e55f0cdc786d8da006" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207", + "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/8e86142e3ade750b837d55e55f0cdc786d8da006", + "reference": "8e86142e3ade750b837d55e55f0cdc786d8da006", "shasum": "" }, "require": { @@ -346,8 +348,8 @@ }, "require-dev": { "composer/composer": "^2", - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" + "phpstan/phpstan": "^1", + "symfony/phpunit-bridge": "^4.2 || ^5 || ^6 || ^7" }, "type": "library", "extra": { @@ -378,7 +380,7 @@ ], "support": { "issues": "https://github.com/composer/metadata-minifier/issues", - "source": "https://github.com/composer/metadata-minifier/tree/1.0.0" + "source": "https://github.com/composer/metadata-minifier/tree/1.0.1" }, "funding": [ { @@ -388,38 +390,35 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2021-04-07T13:37:33+00:00" + "time": "2026-06-30T11:34:59+00:00" }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -457,7 +456,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -467,26 +466,22 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "composer/semver", - "version": "3.4.4", + "version": "3.5.0", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "url": "https://api.github.com/repos/composer/semver/zipball/f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", + "reference": "f7a296f4c4cf8cb8bb83e35d6951a406bb11afa5", "shasum": "" }, "require": { @@ -528,7 +523,7 @@ "homepage": "http://robbast.nl" } ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", + "description": "Version comparison library that offers utilities, version constraint parsing and validation.", "keywords": [ "semantic", "semver", @@ -538,7 +533,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.4" + "source": "https://github.com/composer/semver/tree/3.5.0" }, "funding": [ { @@ -550,7 +545,7 @@ "type": "github" } ], - "time": "2025-08-20T19:15:30+00:00" + "time": "2026-09-24T14:38:51+00:00" }, { "name": "composer/spdx-licenses", @@ -696,16 +691,16 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.1", + "version": "v2026.2", "source": { "type": "git", "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "2cdd054c4109dfb76667c9198bf9427606354243" + "reference": "709e512210784a7c0a677b3a89d35def844a59b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/2cdd054c4109dfb76667c9198bf9427606354243", - "reference": "2cdd054c4109dfb76667c9198bf9427606354243", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", + "reference": "709e512210784a7c0a677b3a89d35def844a59b9", "shasum": "" }, "require-dev": { @@ -736,23 +731,24 @@ "stubs", "type" ], - "time": "2026-02-19T20:12:01+00:00" + "time": "2026-06-12T13:19:10+00:00" }, { "name": "justinrainbow/json-schema", - "version": "6.8.2", + "version": "6.13.1", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76" + "reference": "e54aacc4c0657b3a4af4f628428d2702146828c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/e54aacc4c0657b3a4af4f628428d2702146828c6", + "reference": "e54aacc4c0657b3a4af4f628428d2702146828c6", "shasum": "" }, "require": { + "ext-filter": "*", "ext-json": "*", "marc-mabe/php-enum": "^4.4", "php": "^7.2 || ^8.0" @@ -785,20 +781,9 @@ ], "authors": [ { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" + "name": "Danny van der Sluijs", + "email": "danny.vandersluijs@icloud.com", + "role": "Maintainer" } ], "description": "A library to validate a json schema.", @@ -809,9 +794,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.13.1" }, - "time": "2026-05-05T05:39:01+00:00" + "time": "2026-09-30T07:42:28+00:00" }, { "name": "marc-mabe/php-enum", @@ -888,20 +873,19 @@ }, { "name": "nikic/php-parser", - "version": "v5.7.0", + "version": "v5.9.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -940,9 +924,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" }, - "time": "2025-12-06T11:56:16+00:00" + "time": "2026-09-13T18:51:52+00:00" }, { "name": "nikolaposa/version", @@ -1070,16 +1054,16 @@ }, { "name": "php-standard-library/async", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/async.git", - "reference": "8c0c63d3e304318a7c78846044edc1dfef398d1d" + "reference": "3d2fbaf391cd256f5543f96889e9e738840d321a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/async/zipball/8c0c63d3e304318a7c78846044edc1dfef398d1d", - "reference": "8c0c63d3e304318a7c78846044edc1dfef398d1d", + "url": "https://api.github.com/repos/php-standard-library/async/zipball/3d2fbaf391cd256f5543f96889e9e738840d321a", + "reference": "3d2fbaf391cd256f5543f96889e9e738840d321a", "shasum": "" }, "require": { @@ -1102,7 +1086,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1136,22 +1120,22 @@ "cooperative-multitasking" ], "support": { - "source": "https://github.com/php-standard-library/async/tree/6.2.1" + "source": "https://github.com/php-standard-library/async/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/channel", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/channel.git", - "reference": "1d90ea4e262978a583ad24c2c9a97c9daa26b6f1" + "reference": "f2f5f0ee4d04dccc57d33e6620671c6296e881e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/channel/zipball/1d90ea4e262978a583ad24c2c9a97c9daa26b6f1", - "reference": "1d90ea4e262978a583ad24c2c9a97c9daa26b6f1", + "url": "https://api.github.com/repos/php-standard-library/channel/zipball/f2f5f0ee4d04dccc57d33e6620671c6296e881e2", + "reference": "f2f5f0ee4d04dccc57d33e6620671c6296e881e2", "shasum": "" }, "require": { @@ -1171,7 +1155,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1205,22 +1189,22 @@ "message-passing" ], "support": { - "source": "https://github.com/php-standard-library/channel/tree/6.2.1" + "source": "https://github.com/php-standard-library/channel/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/collection", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/collection.git", - "reference": "0e8b757a16ccb1f68a70f4ada8565099743d50b9" + "reference": "d31f6a015958ae4e13e2c1d47621d9786ee1ec1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/collection/zipball/0e8b757a16ccb1f68a70f4ada8565099743d50b9", - "reference": "0e8b757a16ccb1f68a70f4ada8565099743d50b9", + "url": "https://api.github.com/repos/php-standard-library/collection/zipball/d31f6a015958ae4e13e2c1d47621d9786ee1ec1a", + "reference": "d31f6a015958ae4e13e2c1d47621d9786ee1ec1a", "shasum": "" }, "require": { @@ -1239,7 +1223,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1271,22 +1255,22 @@ "vector" ], "support": { - "source": "https://github.com/php-standard-library/collection/tree/6.2.1" + "source": "https://github.com/php-standard-library/collection/tree/6.3.0" }, - "time": "2026-05-18T22:19:21+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/comparison", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/comparison.git", - "reference": "1884e2218c231c285b6039e9b2010f5dd956ae20" + "reference": "e77454e109feaa13732a3d556e1f8855b216b4fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/comparison/zipball/1884e2218c231c285b6039e9b2010f5dd956ae20", - "reference": "1884e2218c231c285b6039e9b2010f5dd956ae20", + "url": "https://api.github.com/repos/php-standard-library/comparison/zipball/e77454e109feaa13732a3d556e1f8855b216b4fd", + "reference": "e77454e109feaa13732a3d556e1f8855b216b4fd", "shasum": "" }, "require": { @@ -1303,7 +1287,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1336,22 +1320,22 @@ "ordering" ], "support": { - "source": "https://github.com/php-standard-library/comparison/tree/6.2.1" + "source": "https://github.com/php-standard-library/comparison/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/date-time", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/date-time.git", - "reference": "1e5e2b51eb2b27c2933859872e7c4a45abc5226e" + "reference": "4d280c6192e1b17ee8a59e07ed0fc29129bcd373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/date-time/zipball/1e5e2b51eb2b27c2933859872e7c4a45abc5226e", - "reference": "1e5e2b51eb2b27c2933859872e7c4a45abc5226e", + "url": "https://api.github.com/repos/php-standard-library/date-time/zipball/4d280c6192e1b17ee8a59e07ed0fc29129bcd373", + "reference": "4d280c6192e1b17ee8a59e07ed0fc29129bcd373", "shasum": "" }, "require": { @@ -1374,7 +1358,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1408,22 +1392,22 @@ "timezone" ], "support": { - "source": "https://github.com/php-standard-library/date-time/tree/6.2.1" + "source": "https://github.com/php-standard-library/date-time/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/default", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/default.git", - "reference": "89f05ec6e6a29e8c07de7b6755d14d05b06048e2" + "reference": "6f7daf0864b4f319dee645cb0ffd7c4be0c9907c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/default/zipball/89f05ec6e6a29e8c07de7b6755d14d05b06048e2", - "reference": "89f05ec6e6a29e8c07de7b6755d14d05b06048e2", + "url": "https://api.github.com/repos/php-standard-library/default/zipball/6f7daf0864b4f319dee645cb0ffd7c4be0c9907c", + "reference": "6f7daf0864b4f319dee645cb0ffd7c4be0c9907c", "shasum": "" }, "require": { @@ -1438,7 +1422,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1467,22 +1451,22 @@ "interface" ], "support": { - "source": "https://github.com/php-standard-library/default/tree/6.2.1" + "source": "https://github.com/php-standard-library/default/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/dict", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/dict.git", - "reference": "e06a4b7dea0f870b909e9f7d81d9dc70395f0eeb" + "reference": "0c3f7546c4dd5887e085e21663eedaa3df78a00d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/dict/zipball/e06a4b7dea0f870b909e9f7d81d9dc70395f0eeb", - "reference": "e06a4b7dea0f870b909e9f7d81d9dc70395f0eeb", + "url": "https://api.github.com/repos/php-standard-library/dict/zipball/0c3f7546c4dd5887e085e21663eedaa3df78a00d", + "reference": "0c3f7546c4dd5887e085e21663eedaa3df78a00d", "shasum": "" }, "require": { @@ -1502,7 +1486,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1536,22 +1520,22 @@ "map" ], "support": { - "source": "https://github.com/php-standard-library/dict/tree/6.2.1" + "source": "https://github.com/php-standard-library/dict/tree/6.3.0" }, - "time": "2026-03-31T16:59:27+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/either-or-both", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/either-or-both.git", - "reference": "a1f4d80ee5ee616272688941390d2eae2d1948e6" + "reference": "cd443b661dae1dc87f309e17953e9a7426690195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/either-or-both/zipball/a1f4d80ee5ee616272688941390d2eae2d1948e6", - "reference": "a1f4d80ee5ee616272688941390d2eae2d1948e6", + "url": "https://api.github.com/repos/php-standard-library/either-or-both/zipball/cd443b661dae1dc87f309e17953e9a7426690195", + "reference": "cd443b661dae1dc87f309e17953e9a7426690195", "shasum": "" }, "require": { @@ -1570,7 +1554,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1605,22 +1589,22 @@ "union-type" ], "support": { - "source": "https://github.com/php-standard-library/either-or-both/tree/6.2.1" + "source": "https://github.com/php-standard-library/either-or-both/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/env", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/env.git", - "reference": "1c6d72eef53e904506daa021141a5981a30c4403" + "reference": "093b68fbe9f1104142735c2e2a5ca42931c9a8f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/env/zipball/1c6d72eef53e904506daa021141a5981a30c4403", - "reference": "1c6d72eef53e904506daa021141a5981a30c4403", + "url": "https://api.github.com/repos/php-standard-library/env/zipball/093b68fbe9f1104142735c2e2a5ca42931c9a8f1", + "reference": "093b68fbe9f1104142735c2e2a5ca42931c9a8f1", "shasum": "" }, "require": { @@ -1637,7 +1621,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1669,22 +1653,22 @@ "environment" ], "support": { - "source": "https://github.com/php-standard-library/env/tree/6.2.1" + "source": "https://github.com/php-standard-library/env/tree/6.3.0" }, - "time": "2026-03-31T16:59:27+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/file", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/file.git", - "reference": "64c064f8a599cb6621e7b9329a4f1b7c5bc04152" + "reference": "8e6c84b64060278c833e64fb8fb3298a34d2d3af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/file/zipball/64c064f8a599cb6621e7b9329a4f1b7c5bc04152", - "reference": "64c064f8a599cb6621e7b9329a4f1b7c5bc04152", + "url": "https://api.github.com/repos/php-standard-library/file/zipball/8e6c84b64060278c833e64fb8fb3298a34d2d3af", + "reference": "8e6c84b64060278c833e64fb8fb3298a34d2d3af", "shasum": "" }, "require": { @@ -1707,7 +1691,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1740,22 +1724,22 @@ "io" ], "support": { - "source": "https://github.com/php-standard-library/file/tree/6.2.1" + "source": "https://github.com/php-standard-library/file/tree/6.3.0" }, - "time": "2026-04-06T03:33:20+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/filesystem", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/filesystem.git", - "reference": "d1e87eaee4d8180842f38d010747ee5d963a8d5b" + "reference": "65a13875f9f918a10b072a3f6975dfd9e4e412e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/filesystem/zipball/d1e87eaee4d8180842f38d010747ee5d963a8d5b", - "reference": "d1e87eaee4d8180842f38d010747ee5d963a8d5b", + "url": "https://api.github.com/repos/php-standard-library/filesystem/zipball/65a13875f9f918a10b072a3f6975dfd9e4e412e6", + "reference": "65a13875f9f918a10b072a3f6975dfd9e4e412e6", "shasum": "" }, "require": { @@ -1777,7 +1761,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1809,22 +1793,22 @@ "filesystem" ], "support": { - "source": "https://github.com/php-standard-library/filesystem/tree/6.2.1" + "source": "https://github.com/php-standard-library/filesystem/tree/6.3.0" }, - "time": "2026-03-31T16:59:27+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/foundation", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/foundation.git", - "reference": "7f65cec48c8ed3d53dc8dd7643796e2ba6de6008" + "reference": "9b464388f93b5c496bd6a17912e5cdc846b9df1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/foundation/zipball/7f65cec48c8ed3d53dc8dd7643796e2ba6de6008", - "reference": "7f65cec48c8ed3d53dc8dd7643796e2ba6de6008", + "url": "https://api.github.com/repos/php-standard-library/foundation/zipball/9b464388f93b5c496bd6a17912e5cdc846b9df1e", + "reference": "9b464388f93b5c496bd6a17912e5cdc846b9df1e", "shasum": "" }, "require": { @@ -1839,7 +1823,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1872,22 +1856,22 @@ "invariant" ], "support": { - "source": "https://github.com/php-standard-library/foundation/tree/6.2.1" + "source": "https://github.com/php-standard-library/foundation/tree/6.3.0" }, - "time": "2026-04-28T06:28:49+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/interoperability", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/interoperability.git", - "reference": "09ae66cc3e6463538c3a4592bb8762cf60113821" + "reference": "d95ae8d2502d05b7aa14e456b2c7f5e9c1510228" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/interoperability/zipball/09ae66cc3e6463538c3a4592bb8762cf60113821", - "reference": "09ae66cc3e6463538c3a4592bb8762cf60113821", + "url": "https://api.github.com/repos/php-standard-library/interoperability/zipball/d95ae8d2502d05b7aa14e456b2c7f5e9c1510228", + "reference": "d95ae8d2502d05b7aa14e456b2c7f5e9c1510228", "shasum": "" }, "require": { @@ -1902,7 +1886,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -1931,22 +1915,22 @@ "interoperability" ], "support": { - "source": "https://github.com/php-standard-library/interoperability/tree/6.2.1" + "source": "https://github.com/php-standard-library/interoperability/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/io", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/io.git", - "reference": "0b61f6b5e0a392c52d3cb2b386d8bba379b91f3c" + "reference": "cd55c50f5158871c9a77b3fc6a1fca14f3d4d082" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/io/zipball/0b61f6b5e0a392c52d3cb2b386d8bba379b91f3c", - "reference": "0b61f6b5e0a392c52d3cb2b386d8bba379b91f3c", + "url": "https://api.github.com/repos/php-standard-library/io/zipball/cd55c50f5158871c9a77b3fc6a1fca14f3d4d082", + "reference": "cd55c50f5158871c9a77b3fc6a1fca14f3d4d082", "shasum": "" }, "require": { @@ -1969,7 +1953,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2003,22 +1987,22 @@ "stream" ], "support": { - "source": "https://github.com/php-standard-library/io/tree/6.2.1" + "source": "https://github.com/php-standard-library/io/tree/6.3.0" }, - "time": "2026-04-28T03:11:45+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/iter", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/iter.git", - "reference": "d3f2db3adec4cbe5129f3428969ae8508a54aeb7" + "reference": "9a53e9e4614b6e30ac23df21bd9bf7540dad79f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/iter/zipball/d3f2db3adec4cbe5129f3428969ae8508a54aeb7", - "reference": "d3f2db3adec4cbe5129f3428969ae8508a54aeb7", + "url": "https://api.github.com/repos/php-standard-library/iter/zipball/9a53e9e4614b6e30ac23df21bd9bf7540dad79f8", + "reference": "9a53e9e4614b6e30ac23df21bd9bf7540dad79f8", "shasum": "" }, "require": { @@ -2041,7 +2025,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2075,22 +2059,22 @@ "iterator" ], "support": { - "source": "https://github.com/php-standard-library/iter/tree/6.2.1" + "source": "https://github.com/php-standard-library/iter/tree/6.3.0" }, - "time": "2026-04-28T06:28:49+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/json", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/json.git", - "reference": "03e00062efdd704d874012b5412340f3003a043d" + "reference": "ba52bbe747e91b2a1c905d3c51b8fc0b9f5af27d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/json/zipball/03e00062efdd704d874012b5412340f3003a043d", - "reference": "03e00062efdd704d874012b5412340f3003a043d", + "url": "https://api.github.com/repos/php-standard-library/json/zipball/ba52bbe747e91b2a1c905d3c51b8fc0b9f5af27d", + "reference": "ba52bbe747e91b2a1c905d3c51b8fc0b9f5af27d", "shasum": "" }, "require": { @@ -2111,7 +2095,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2144,22 +2128,22 @@ "json" ], "support": { - "source": "https://github.com/php-standard-library/json/tree/6.2.1" + "source": "https://github.com/php-standard-library/json/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/locale", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/locale.git", - "reference": "0771cfb19757e8923c66b7f46ab90280775caecd" + "reference": "9687409700fc2392046f43cc4c1494253f8cf178" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/locale/zipball/0771cfb19757e8923c66b7f46ab90280775caecd", - "reference": "0771cfb19757e8923c66b7f46ab90280775caecd", + "url": "https://api.github.com/repos/php-standard-library/locale/zipball/9687409700fc2392046f43cc4c1494253f8cf178", + "reference": "9687409700fc2392046f43cc4c1494253f8cf178", "shasum": "" }, "require": { @@ -2176,7 +2160,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2206,22 +2190,22 @@ "locale" ], "support": { - "source": "https://github.com/php-standard-library/locale/tree/6.2.1" + "source": "https://github.com/php-standard-library/locale/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/option", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/option.git", - "reference": "afe2059dbb3a9e2e3ba6edd371ccbb7d87355a66" + "reference": "d6cfc43ae2d0ce122d861065f5e6a433737b9e93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/option/zipball/afe2059dbb3a9e2e3ba6edd371ccbb7d87355a66", - "reference": "afe2059dbb3a9e2e3ba6edd371ccbb7d87355a66", + "url": "https://api.github.com/repos/php-standard-library/option/zipball/d6cfc43ae2d0ce122d861065f5e6a433737b9e93", + "reference": "d6cfc43ae2d0ce122d861065f5e6a433737b9e93", "shasum": "" }, "require": { @@ -2238,7 +2222,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2272,22 +2256,22 @@ "some" ], "support": { - "source": "https://github.com/php-standard-library/option/tree/6.2.1" + "source": "https://github.com/php-standard-library/option/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/process", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/process.git", - "reference": "ab41e8b8de07289971f447ce289ec1effde321f3" + "reference": "46895e2c1b19cdd6c48688d1c631f1c23bc524e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/process/zipball/ab41e8b8de07289971f447ce289ec1effde321f3", - "reference": "ab41e8b8de07289971f447ce289ec1effde321f3", + "url": "https://api.github.com/repos/php-standard-library/process/zipball/46895e2c1b19cdd6c48688d1c631f1c23bc524e5", + "reference": "46895e2c1b19cdd6c48688d1c631f1c23bc524e5", "shasum": "" }, "require": { @@ -2309,7 +2293,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2339,22 +2323,22 @@ "subprocess" ], "support": { - "source": "https://github.com/php-standard-library/process/tree/6.2.1" + "source": "https://github.com/php-standard-library/process/tree/6.3.0" }, - "time": "2026-05-23T20:46:16+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/promise", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/promise.git", - "reference": "a38a2694e06609874e856e68951bca24ad00009c" + "reference": "97e5c6ffac07058f0ae408c747b5f5c363f3673b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/promise/zipball/a38a2694e06609874e856e68951bca24ad00009c", - "reference": "a38a2694e06609874e856e68951bca24ad00009c", + "url": "https://api.github.com/repos/php-standard-library/promise/zipball/97e5c6ffac07058f0ae408c747b5f5c363f3673b", + "reference": "97e5c6ffac07058f0ae408c747b5f5c363f3673b", "shasum": "" }, "require": { @@ -2369,7 +2353,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2399,22 +2383,22 @@ "promise" ], "support": { - "source": "https://github.com/php-standard-library/promise/tree/6.2.1" + "source": "https://github.com/php-standard-library/promise/tree/6.3.0" }, - "time": "2026-04-26T16:26:10+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/range", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/range.git", - "reference": "34befd85fa3e048972aabac7cdf6a86e2f4b833f" + "reference": "9e08b73482c2c8ee6065e4eb1c6d138ceba56376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/range/zipball/34befd85fa3e048972aabac7cdf6a86e2f4b833f", - "reference": "34befd85fa3e048972aabac7cdf6a86e2f4b833f", + "url": "https://api.github.com/repos/php-standard-library/range/zipball/9e08b73482c2c8ee6065e4eb1c6d138ceba56376", + "reference": "9e08b73482c2c8ee6065e4eb1c6d138ceba56376", "shasum": "" }, "require": { @@ -2432,7 +2416,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2464,22 +2448,22 @@ "range" ], "support": { - "source": "https://github.com/php-standard-library/range/tree/6.2.1" + "source": "https://github.com/php-standard-library/range/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/regex", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/regex.git", - "reference": "150533c75ee7aaf83a1254837690603b22bc0191" + "reference": "199b6e8f3321e7e5978a3f55963d4bd6fbc57317" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/regex/zipball/150533c75ee7aaf83a1254837690603b22bc0191", - "reference": "150533c75ee7aaf83a1254837690603b22bc0191", + "url": "https://api.github.com/repos/php-standard-library/regex/zipball/199b6e8f3321e7e5978a3f55963d4bd6fbc57317", + "reference": "199b6e8f3321e7e5978a3f55963d4bd6fbc57317", "shasum": "" }, "require": { @@ -2496,7 +2480,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2529,22 +2513,22 @@ "regular-expression" ], "support": { - "source": "https://github.com/php-standard-library/regex/tree/6.2.1" + "source": "https://github.com/php-standard-library/regex/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/result", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/result.git", - "reference": "242bbf872662f5091a37d7db9d45f119225a01f5" + "reference": "8489291e58eb926d3ffc9e48b23d8fa6678a0ff3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/result/zipball/242bbf872662f5091a37d7db9d45f119225a01f5", - "reference": "242bbf872662f5091a37d7db9d45f119225a01f5", + "url": "https://api.github.com/repos/php-standard-library/result/zipball/8489291e58eb926d3ffc9e48b23d8fa6678a0ff3", + "reference": "8489291e58eb926d3ffc9e48b23d8fa6678a0ff3", "shasum": "" }, "require": { @@ -2562,7 +2546,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2595,22 +2579,22 @@ "result" ], "support": { - "source": "https://github.com/php-standard-library/result/tree/6.2.1" + "source": "https://github.com/php-standard-library/result/tree/6.3.0" }, - "time": "2026-04-26T16:26:10+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/shell", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/shell.git", - "reference": "6d57e1192d0ecce76e8c33e7507505b03c1bc539" + "reference": "e87e4720403c748036166102df5b825f7bb9ecd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/shell/zipball/6d57e1192d0ecce76e8c33e7507505b03c1bc539", - "reference": "6d57e1192d0ecce76e8c33e7507505b03c1bc539", + "url": "https://api.github.com/repos/php-standard-library/shell/zipball/e87e4720403c748036166102df5b825f7bb9ecd1", + "reference": "e87e4720403c748036166102df5b825f7bb9ecd1", "shasum": "" }, "require": { @@ -2634,7 +2618,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2667,22 +2651,22 @@ "shell" ], "support": { - "source": "https://github.com/php-standard-library/shell/tree/6.2.1" + "source": "https://github.com/php-standard-library/shell/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/str", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/str.git", - "reference": "e88e7c79e1c07227aa3c50005a3a91e88425e56a" + "reference": "ab722aecf6c45531cdf0bf8ee11aa2370d71dff7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/str/zipball/e88e7c79e1c07227aa3c50005a3a91e88425e56a", - "reference": "e88e7c79e1c07227aa3c50005a3a91e88425e56a", + "url": "https://api.github.com/repos/php-standard-library/str/zipball/ab722aecf6c45531cdf0bf8ee11aa2370d71dff7", + "reference": "ab722aecf6c45531cdf0bf8ee11aa2370d71dff7", "shasum": "" }, "require": { @@ -2701,7 +2685,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2735,22 +2719,22 @@ "utf8" ], "support": { - "source": "https://github.com/php-standard-library/str/tree/6.2.1" + "source": "https://github.com/php-standard-library/str/tree/6.3.0" }, - "time": "2026-04-29T06:23:35+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/type", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/type.git", - "reference": "ffc050e32bc5def0e03aaae3d52c78dfc746e143" + "reference": "80986810f49ba476d90dfeaa9c41bee0997675eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/type/zipball/ffc050e32bc5def0e03aaae3d52c78dfc746e143", - "reference": "ffc050e32bc5def0e03aaae3d52c78dfc746e143", + "url": "https://api.github.com/repos/php-standard-library/type/zipball/80986810f49ba476d90dfeaa9c41bee0997675eb", + "reference": "80986810f49ba476d90dfeaa9c41bee0997675eb", "shasum": "" }, "require": { @@ -2764,6 +2748,7 @@ "azjezz/psl": "*" }, "require-dev": { + "ext-bcmath": "*", "php-standard-library/dict": "^6.0", "php-standard-library/math": "^6.0", "php-standard-library/result": "^6.0", @@ -2774,7 +2759,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2808,22 +2793,22 @@ "validation" ], "support": { - "source": "https://github.com/php-standard-library/type/tree/6.2.1" + "source": "https://github.com/php-standard-library/type/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/vec", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/vec.git", - "reference": "ac119fa952177c10eeb3d5d6bca04130491c7f2e" + "reference": "5251a921c3d06b2045b4371c191bb60ff0ce47d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/vec/zipball/ac119fa952177c10eeb3d5d6bca04130491c7f2e", - "reference": "ac119fa952177c10eeb3d5d6bca04130491c7f2e", + "url": "https://api.github.com/repos/php-standard-library/vec/zipball/5251a921c3d06b2045b4371c191bb60ff0ce47d3", + "reference": "5251a921c3d06b2045b4371c191bb60ff0ce47d3", "shasum": "" }, "require": { @@ -2843,7 +2828,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2877,9 +2862,9 @@ "vector" ], "support": { - "source": "https://github.com/php-standard-library/vec/tree/6.2.1" + "source": "https://github.com/php-standard-library/vec/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "psr/container", @@ -3131,16 +3116,16 @@ }, { "name": "roave/backward-compatibility-check", - "version": "8.21.0", + "version": "8.23.0", "source": { "type": "git", "url": "https://github.com/Roave/BackwardCompatibilityCheck.git", - "reference": "ee85fb8879cfe939814d84e18ec6cb5c33904b75" + "reference": "f7573e3d1e3d012096f04aa7fe4de99b42aaf01d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/BackwardCompatibilityCheck/zipball/ee85fb8879cfe939814d84e18ec6cb5c33904b75", - "reference": "ee85fb8879cfe939814d84e18ec6cb5c33904b75", + "url": "https://api.github.com/repos/Roave/BackwardCompatibilityCheck/zipball/f7573e3d1e3d012096f04aa7fe4de99b42aaf01d", + "reference": "f7573e3d1e3d012096f04aa7fe4de99b42aaf01d", "shasum": "" }, "require": { @@ -3213,36 +3198,36 @@ "description": "Tool to compare two revisions of a public API to check for BC breaks", "support": { "issues": "https://github.com/Roave/BackwardCompatibilityCheck/issues", - "source": "https://github.com/Roave/BackwardCompatibilityCheck/tree/8.21.0" + "source": "https://github.com/Roave/BackwardCompatibilityCheck/tree/8.23.0" }, - "time": "2026-05-15T14:19:15+00:00" + "time": "2026-09-30T20:43:24+00:00" }, { "name": "roave/better-reflection", - "version": "6.71.0", + "version": "6.73.0", "source": { "type": "git", "url": "https://github.com/Roave/BetterReflection.git", - "reference": "3ec176d4a161b4e8764edc625d69ff624ca390b1" + "reference": "a6a93a3bd7681627f2750bfa8f41e586d32dc67d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/BetterReflection/zipball/3ec176d4a161b4e8764edc625d69ff624ca390b1", - "reference": "3ec176d4a161b4e8764edc625d69ff624ca390b1", + "url": "https://api.github.com/repos/Roave/BetterReflection/zipball/a6a93a3bd7681627f2750bfa8f41e586d32dc67d", + "reference": "a6a93a3bd7681627f2750bfa8f41e586d32dc67d", "shasum": "" }, "require": { "ext-json": "*", - "jetbrains/phpstorm-stubs": "2026.1", - "nikic/php-parser": "^5.7.0", + "jetbrains/phpstorm-stubs": "2026.2", + "nikic/php-parser": "^5.8.0", "php": "~8.4.1 || ~8.5.0" }, "conflict": { "thecodingmachine/safe": "<1.1.3" }, "require-dev": { - "phpbench/phpbench": "^1.6.1", - "phpunit/phpunit": "^13.1.8" + "phpbench/phpbench": "^1.7.0", + "phpunit/phpunit": "^13.3.2" }, "suggest": { "composer/composer": "Required to use the ComposerSourceLocator" @@ -3282,22 +3267,22 @@ "description": "Better Reflection - an improved code reflection API", "support": { "issues": "https://github.com/Roave/BetterReflection/issues", - "source": "https://github.com/Roave/BetterReflection/tree/6.71.0" + "source": "https://github.com/Roave/BetterReflection/tree/6.73.0" }, - "time": "2026-05-02T10:56:00+00:00" + "time": "2026-09-09T19:57:09+00:00" }, { "name": "seld/jsonlint", - "version": "1.11.0", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2" + "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/1748aaf847fc731cfad7725aec413ee46f0cc3a2", - "reference": "1748aaf847fc731cfad7725aec413ee46f0cc3a2", + "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9a90eb5d32d5a500296bf43f946d60246444d5f7", + "reference": "9a90eb5d32d5a500296bf43f946d60246444d5f7", "shasum": "" }, "require": { @@ -3336,7 +3321,7 @@ ], "support": { "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" + "source": "https://github.com/Seldaek/jsonlint/tree/1.12.1" }, "funding": [ { @@ -3348,20 +3333,20 @@ "type": "tidelift" } ], - "time": "2024-07-11T14:55:45+00:00" + "time": "2026-06-12T11:32:29+00:00" }, { "name": "seld/phar-utils", - "version": "1.2.1", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/Seldaek/phar-utils.git", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + "reference": "65fb381613fd31745d39a740a112a233f978d2d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/65fb381613fd31745d39a740a112a233f978d2d9", + "reference": "65fb381613fd31745d39a740a112a233f978d2d9", "shasum": "" }, "require": { @@ -3394,9 +3379,9 @@ ], "support": { "issues": "https://github.com/Seldaek/phar-utils/issues", - "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.3" }, - "time": "2022-08-31T10:31:18+00:00" + "time": "2026-10-01T08:16:23+00:00" }, { "name": "seld/signal-handler", @@ -3461,16 +3446,16 @@ }, { "name": "symfony/console", - "version": "v7.4.13", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", - "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", "shasum": "" }, "require": { @@ -3535,7 +3520,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.13" + "source": "https://github.com/symfony/console/tree/v7.4.20" }, "funding": [ { @@ -3555,20 +3540,20 @@ "type": "tidelift" } ], - "time": "2026-05-24T08:56:14+00:00" + "time": "2026-09-18T15:55:00+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -3606,7 +3591,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -3626,24 +3611,25 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v8.0.11", + "version": "v8.1.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", - "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/7599ebb855fede59413ddb7f15198d67bfcae7ba", + "reference": "7599ebb855fede59413ddb7f15198d67bfcae7ba", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, @@ -3676,7 +3662,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.11" + "source": "https://github.com/symfony/filesystem/tree/v8.1.6" }, "funding": [ { @@ -3696,24 +3682,24 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:39:47+00:00" + "time": "2026-08-23T10:06:25+00:00" }, { "name": "symfony/finder", - "version": "v8.0.8", + "version": "v8.1.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8da41214757b87d97f181e3d14a4179286151007" + "reference": "bd4fed193a3132c91c5c93595cbfc78988915822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", - "reference": "8da41214757b87d97f181e3d14a4179286151007", + "url": "https://api.github.com/repos/symfony/finder/zipball/bd4fed193a3132c91c5c93595cbfc78988915822", + "reference": "bd4fed193a3132c91c5c93595cbfc78988915822", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "require-dev": { "symfony/filesystem": "^7.4|^8.0" @@ -3744,7 +3730,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.8" + "source": "https://github.com/symfony/finder/tree/v8.1.8" }, "funding": [ { @@ -3764,7 +3750,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-09-26T06:33:49+00:00" }, { "name": "symfony/polyfill-ctype", @@ -3851,16 +3837,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", "shasum": "" }, "require": { @@ -3909,7 +3895,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" }, "funding": [ { @@ -3929,20 +3915,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", "shasum": "" }, "require": { @@ -3994,7 +3980,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" }, "funding": [ { @@ -4014,20 +4000,20 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-09-23T22:02:09+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", "shasum": "" }, "require": { @@ -4079,7 +4065,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" }, "funding": [ { @@ -4099,7 +4085,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-09-24T15:19:04+00:00" }, { "name": "symfony/polyfill-php73", @@ -4183,16 +4169,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", "shasum": "" }, "require": { @@ -4243,7 +4229,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" }, "funding": [ { @@ -4263,7 +4249,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { "name": "symfony/polyfill-php81", @@ -4347,16 +4333,16 @@ }, { "name": "symfony/polyfill-php84", - "version": "v1.38.1", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", - "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", "shasum": "" }, "require": { @@ -4403,7 +4389,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" }, "funding": [ { @@ -4423,24 +4409,24 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-09-23T22:01:09+00:00" }, { "name": "symfony/process", - "version": "v8.0.13", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4" + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", - "reference": "4f23d9c7637ead9ed19f697fe93cb87fd9b379d4", + "url": "https://api.github.com/repos/symfony/process/zipball/10823b09358e690df4ff943e24e8492bb1019fc3", + "reference": "10823b09358e690df4ff943e24e8492bb1019fc3", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -4468,7 +4454,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.13" + "source": "https://github.com/symfony/process/tree/v8.1.7" }, "funding": [ { @@ -4488,20 +4474,20 @@ "type": "tidelift" } ], - "time": "2026-05-23T18:05:53+00:00" + "time": "2026-09-02T12:40:29+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.7.0", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", - "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -4555,7 +4541,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -4575,24 +4561,24 @@ "type": "tidelift" } ], - "time": "2026-03-28T09:44:51+00:00" + "time": "2026-07-27T15:39:01+00:00" }, { "name": "symfony/string", - "version": "v8.0.13", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc" + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f2e3e4d33579350d1b12001ef2872f86b27ed3dc", - "reference": "f2e3e4d33579350d1b12001ef2872f86b27ed3dc", + "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -4645,7 +4631,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.13" + "source": "https://github.com/symfony/string/tree/v8.1.7" }, "funding": [ { @@ -4665,7 +4651,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T18:05:53+00:00" + "time": "2026-09-11T14:51:16+00:00" } ], "packages-dev": [], From 3e42d7db55ae7e95197ec83fd5ba01da621e20a6 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Thu, 1 Oct 2026 20:09:38 +0200 Subject: [PATCH 15/18] Add header aliases. This show our current design flaw with header localisation / registration Ref: https://github.com/patchlevel/event-sourcing/issues/524 --- docs/message.md | 24 +++++++ src/Attribute/Header.php | 2 + .../AttributeMessageHeaderRegistryFactory.php | 26 +++++-- .../Message/HeaderAlreadyInRegistry.php | 20 ++++++ .../Message/MessageHeaderRegistry.php | 3 +- tests/Unit/Fixture/Header/FooHeader.php | 2 +- .../DefaultHeadersSerializerTest.php | 17 +++++ ...ributeMessageHeaderRegistryFactoryTest.php | 57 +++++++++++++++ .../ArchivedHeader.php | 13 ++++ .../AliasUsedAsName/ApplicationHeader.php | 13 ++++ .../Fixture/AliasUsedAsName/TenantHeader.php | 13 ++++ .../DuplicateAlias/ApplicationHeader.php | 13 ++++ .../Fixture/DuplicateAlias/TenantHeader.php | 13 ++++ .../Message/MessageHeaderRegistryTest.php | 72 +++++++++++++++++++ 14 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 src/Metadata/Message/HeaderAlreadyInRegistry.php create mode 100644 tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php create mode 100644 tests/Unit/Metadata/Message/Fixture/AliasUsedAsInternalName/ArchivedHeader.php create mode 100644 tests/Unit/Metadata/Message/Fixture/AliasUsedAsName/ApplicationHeader.php create mode 100644 tests/Unit/Metadata/Message/Fixture/AliasUsedAsName/TenantHeader.php create mode 100644 tests/Unit/Metadata/Message/Fixture/DuplicateAlias/ApplicationHeader.php create mode 100644 tests/Unit/Metadata/Message/Fixture/DuplicateAlias/TenantHeader.php create mode 100644 tests/Unit/Metadata/Message/MessageHeaderRegistryTest.php diff --git a/docs/message.md b/docs/message.md index fbd0f5324..888cbf788 100644 --- a/docs/message.md +++ b/docs/message.md @@ -139,6 +139,30 @@ use Patchlevel\EventSourcing\Message\Message; /** @var Message $message */ $message->header(ApplicationHeader::class); ``` +### Alias + +You can also define aliases for headers. +This can be useful when you want to rename a header but still need to read the messages with the old name. + +```php +use Patchlevel\EventSourcing\Attribute\Header; + +#[Header('app', aliases: ['application'])] +class ApplicationHeader +{ + public function __construct( + private readonly string $id, + ) { + } +} +``` +When saving, the name will always be used. However, when loading, aliases will also be taken into account. + +:::note +An alias must not be used as a header name or as an alias of another header. +Otherwise a `HeaderAlreadyInRegistry` exception is thrown. +::: + ## Missing headers When a message is deserialized, every header name is resolved to its registered header class. diff --git a/src/Attribute/Header.php b/src/Attribute/Header.php index cb0e85824..4b6e0d8f9 100644 --- a/src/Attribute/Header.php +++ b/src/Attribute/Header.php @@ -11,6 +11,8 @@ final class Header { public function __construct( public readonly string $name, + /** @var list */ + public readonly array $aliases = [], ) { } } diff --git a/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php b/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php index 103e5d432..bc5fd31e4 100644 --- a/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php +++ b/src/Metadata/Message/AttributeMessageHeaderRegistryFactory.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Metadata\ClassFinder; use ReflectionClass; +use function array_key_exists; use function count; final class AttributeMessageHeaderRegistryFactory implements MessageHeaderRegistryFactory @@ -17,7 +18,8 @@ public function create(array $paths): MessageHeaderRegistry { $classes = (new ClassFinder())->findClassNames($paths); - $result = []; + $names = []; + $aliases = []; foreach ($classes as $class) { $reflection = new ReflectionClass($class); @@ -27,10 +29,26 @@ public function create(array $paths): MessageHeaderRegistry continue; } - $aggregateName = $attributes[0]->newInstance()->name; - $result[$aggregateName] = $class; + $attribute = $attributes[0]->newInstance(); + $names[$attribute->name] = $class; + + foreach ($attribute->aliases as $alias) { + if (array_key_exists($alias, $aliases)) { + throw new HeaderAlreadyInRegistry($alias); + } + + $aliases[$alias] = $class; + } + } + + $registry = MessageHeaderRegistry::createWithInternalHeaders($names); + + foreach ($aliases as $alias => $class) { + if ($registry->hasHeaderName($alias)) { + throw new HeaderAlreadyInRegistry($alias); + } } - return MessageHeaderRegistry::createWithInternalHeaders($result); + return new MessageHeaderRegistry($registry->headerClasses() + $aliases); } } diff --git a/src/Metadata/Message/HeaderAlreadyInRegistry.php b/src/Metadata/Message/HeaderAlreadyInRegistry.php new file mode 100644 index 000000000..892a18ba6 --- /dev/null +++ b/src/Metadata/Message/HeaderAlreadyInRegistry.php @@ -0,0 +1,20 @@ +nameToClassMap = $headerNameToClassMap; - $this->classToNameMap = array_flip($headerNameToClassMap); + $this->classToNameMap = array_flip(array_reverse($headerNameToClassMap)); } /** @param class-string $headerClass */ diff --git a/tests/Unit/Fixture/Header/FooHeader.php b/tests/Unit/Fixture/Header/FooHeader.php index ddcd7caa2..eacd60beb 100644 --- a/tests/Unit/Fixture/Header/FooHeader.php +++ b/tests/Unit/Fixture/Header/FooHeader.php @@ -7,7 +7,7 @@ use Patchlevel\EventSourcing\Attribute\Header; /** @immutable */ -#[Header('foo')] +#[Header('foo', aliases: ['legacyFoo'])] final class FooHeader { public function __construct( diff --git a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php index 68ac94525..c2042dbb7 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -13,6 +13,7 @@ use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder; use Patchlevel\EventSourcing\Store\ArchivedHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\FooHeader; use Patchlevel\Hydrator\MetadataHydrator; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -147,4 +148,20 @@ public function testSerializeMissingHeadersRoundTrip(): void $content, ); } + + public function testDeserializeAliasAndSerializeWithHeaderName(): void + { + $serializer = new DefaultHeadersSerializer( + (new AttributeMessageHeaderRegistryFactory())->create([ + __DIR__ . '/../../Fixture', + ]), + new MetadataHydrator(), + new JsonEncoder(), + ); + + $headers = $serializer->deserialize('{"legacyFoo":{"data":"bar"}}'); + + self::assertEquals([new FooHeader('bar')], $headers); + self::assertSame('{"foo":{"data":"bar"}}', $serializer->serialize($headers)); + } } diff --git a/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php new file mode 100644 index 000000000..ee7a28268 --- /dev/null +++ b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php @@ -0,0 +1,57 @@ +create([__DIR__ . '/../../Fixture/Header']); + + self::assertTrue($registry->hasHeaderClass(FooHeader::class)); + self::assertTrue($registry->hasHeaderClass(BazHeader::class)); + self::assertTrue($registry->hasHeaderClass(ArchivedHeader::class)); + self::assertSame('foo', $registry->headerName(FooHeader::class)); + self::assertSame(FooHeader::class, $registry->headerClass('foo')); + self::assertSame(FooHeader::class, $registry->headerClass('legacyFoo')); + } + + public function testCreateRegistryWithDuplicateAlias(): void + { + $this->expectException(HeaderAlreadyInRegistry::class); + $this->expectExceptionMessage('The header name "legacy" is already used in the registry. Maybe you defined an alias which is already used as header name or alias.'); + + $factory = new AttributeMessageHeaderRegistryFactory(); + $factory->create([__DIR__ . '/Fixture/DuplicateAlias']); + } + + public function testCreateRegistryWithAliasUsedAsHeaderName(): void + { + $this->expectException(HeaderAlreadyInRegistry::class); + $this->expectExceptionMessage('The header name "application" is already used in the registry. Maybe you defined an alias which is already used as header name or alias.'); + + $factory = new AttributeMessageHeaderRegistryFactory(); + $factory->create([__DIR__ . '/Fixture/AliasUsedAsName']); + } + + public function testCreateRegistryWithAliasUsedAsInternalHeaderName(): void + { + $this->expectException(HeaderAlreadyInRegistry::class); + $this->expectExceptionMessage('The header name "archived" is already used in the registry. Maybe you defined an alias which is already used as header name or alias.'); + + $factory = new AttributeMessageHeaderRegistryFactory(); + $factory->create([__DIR__ . '/Fixture/AliasUsedAsInternalName']); + } +} diff --git a/tests/Unit/Metadata/Message/Fixture/AliasUsedAsInternalName/ArchivedHeader.php b/tests/Unit/Metadata/Message/Fixture/AliasUsedAsInternalName/ArchivedHeader.php new file mode 100644 index 000000000..d2c32895b --- /dev/null +++ b/tests/Unit/Metadata/Message/Fixture/AliasUsedAsInternalName/ArchivedHeader.php @@ -0,0 +1,13 @@ +hasHeaderClass(FooHeader::class)); + self::assertCount(0, $registry->headerClasses()); + } + + public function testMapping(): void + { + $registry = new MessageHeaderRegistry(['foo' => FooHeader::class]); + + self::assertTrue($registry->hasHeaderClass(FooHeader::class)); + self::assertTrue($registry->hasHeaderName('foo')); + self::assertSame('foo', $registry->headerName(FooHeader::class)); + self::assertSame(FooHeader::class, $registry->headerClass('foo')); + self::assertSame(['foo' => FooHeader::class], $registry->headerClasses()); + self::assertSame([FooHeader::class => 'foo'], $registry->headerNames()); + } + + public function testMappingWithAliases(): void + { + $registry = new MessageHeaderRegistry([ + 'foo' => FooHeader::class, + 'legacyFoo' => FooHeader::class, + ]); + + self::assertTrue($registry->hasHeaderClass(FooHeader::class)); + self::assertTrue($registry->hasHeaderName('foo')); + self::assertTrue($registry->hasHeaderName('legacyFoo')); + self::assertSame('foo', $registry->headerName(FooHeader::class)); + self::assertSame(FooHeader::class, $registry->headerClass('foo')); + self::assertSame(FooHeader::class, $registry->headerClass('legacyFoo')); + self::assertSame([ + 'foo' => FooHeader::class, + 'legacyFoo' => FooHeader::class, + ], $registry->headerClasses()); + self::assertSame([FooHeader::class => 'foo'], $registry->headerNames()); + } + + public function testHeaderClassNotRegistered(): void + { + $this->expectException(HeaderClassNotRegistered::class); + + $registry = new MessageHeaderRegistry([]); + $registry->headerName(FooHeader::class); + } + + public function testHeaderNameNotRegistered(): void + { + $this->expectException(HeaderNameNotRegistered::class); + + $registry = new MessageHeaderRegistry([]); + $registry->headerClass('foo'); + } +} From 9b50c24be17cea1c2cd981825d8d7b315c43389d Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Thu, 1 Oct 2026 23:42:19 +0200 Subject: [PATCH 16/18] Add custom stream docs --- docs/message.md | 2 +- docs/store.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/docs/message.md b/docs/message.md index fbd0f5324..1108e726b 100644 --- a/docs/message.md +++ b/docs/message.md @@ -86,7 +86,7 @@ $message->header(EventIdHeader::class)->eventId; // 'a4a4a4a4-4a4a-...' ``` :::warning The `PlayheadHeader` is only added if the stream is playhead based. -Streams that are written without a playhead, for example custom streams, +Streams that are written without a playhead, for example [custom streams](store.md#custom-streams), do not have this header. Use `hasHeader` before you access it. ::: diff --git a/docs/store.md b/docs/store.md index e59a6eced..9a7b33270 100644 --- a/docs/store.md +++ b/docs/store.md @@ -494,6 +494,91 @@ The store locks the table for writing during each save by default. Use the transactional method if you want to call multiple save methods in one transaction. ::: +#### Custom streams + +The `StreamDoctrineDbalStore` is not limited to aggregates. +You can save any event into your own stream, for example integration events +that are derived from your domain events and consumed by other bounded contexts. +For this, the message needs a `StreamNameHeader` with the name of the stream. + +```php +use Patchlevel\EventSourcing\Attribute\Event; + +#[Event('hotel.integration.guest_checked_in')] +final class GuestCheckedInIntegrationEvent +{ + public function __construct( + public readonly string $hotelId, + public readonly string $guestName, + ) { + } +} +``` +```php +use Patchlevel\EventSourcing\Attribute\Processor; +use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\Store; + +#[Processor('hotel_integration_publisher')] +final class HotelIntegrationPublisher +{ + public function __construct( + private readonly Store $store, + ) { + } + + #[Subscribe(GuestIsCheckedIn::class)] + public function onGuestIsCheckedIn(GuestIsCheckedIn $event): void + { + $this->store->save( + Message::create(new GuestCheckedInIntegrationEvent( + $event->hotelId->toString(), + $event->guestName, + ))->withHeader(new StreamNameHeader('integration-hotel')), + ); + } +} +``` +These messages are part of the store like any other message. +Every [subscriber](subscription.md) can subscribe to them, +and you can load them with the `StreamCriterion`. + +```php +use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; +use Patchlevel\EventSourcing\Store\Store; + +/** @var Store $store */ +$stream = $store->load( + new Criteria(new StreamCriterion('integration-hotel')), +); +``` +Only the `StreamNameHeader` is required, the other headers are optional: + +| Header | If missing | +|--------------------|-------------------------------------------------| +| `StreamNameHeader` | A `MissingDataForStorage` exception is thrown | +| `PlayheadHeader` | The playhead is saved as `null` | +| `EventIdHeader` | A new UUIDv7 is generated | +| `RecordedOnHeader` | The current time of the clock is used | + +:::warning +Choose a stream name that cannot collide with your aggregate streams, which are named +like `profile-e3e3e3e3-3e3e-3e3e-3e3e-3e3e3e3e3e3e` by default. A prefix like `integration-` helps. +::: + +:::note +Without a `PlayheadHeader` there is no optimistic locking for this stream, +because the unique index on `stream` and `playhead` does not apply to `null` values. +::: + +:::note +Custom streams are only supported by the `StreamDoctrineDbalStore`. +The event class still needs the `#[Event]` attribute and must be known by the event serializer. +::: + ### Update It is not possible to update events. From dd29cd2b060447c86ee315a8edb5eb4c164d2301 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:35:54 +0000 Subject: [PATCH 17/18] Update dependency league/commonmark to v2.10.2 [SECURITY] (#890) | datasource | package | from | to | | ---------- | ----------------- | ----- | ------ | | packagist | league/commonmark | 2.9.0 | 2.10.2 | Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 23429ca17..1847099fe 100644 --- a/composer.lock +++ b/composer.lock @@ -3622,16 +3622,16 @@ }, { "name": "league/commonmark", - "version": "2.9.0", + "version": "2.10.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529" + "reference": "692e90b901877bdfd727e51270ce4394633c6e74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", - "reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/692e90b901877bdfd727e51270ce4394633c6e74", + "reference": "692e90b901877bdfd727e51270ce4394633c6e74", "shasum": "" }, "require": { @@ -3668,7 +3668,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -3725,7 +3725,7 @@ "type": "tidelift" } ], - "time": "2026-08-03T13:42:31+00:00" + "time": "2026-09-21T12:28:44+00:00" }, { "name": "league/config", From e332cf095cace8d52df6a0845806d13391049e69 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 3 Oct 2026 12:21:17 +0200 Subject: [PATCH 18/18] Port store fixes from StreamDoctrineDbalStore to TaggableDoctrineDbalStore The fixes that came in with the 3.22 upmerge only touched the stream store. The taggable store had the same problems: the event id criterion was bound as array parameter, the mysql/mariadb lock was released before the commit, and a failed lock acquisition left hasLock set so later calls skipped locking. configureSchema also compared connection instances instead of checking for the same database. The integration tests added for the stream store are ported as well. --- src/Store/TaggableDoctrineDbalStore.php | 47 +- .../Store/TaggableDoctrineDbalStoreTest.php | 665 ++++++++++++++++++ .../Store/TaggableDoctrineDbalStoreTest.php | 19 +- 3 files changed, 712 insertions(+), 19 deletions(-) diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index 4d3dcc7dc..13c935b3c 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -28,6 +28,7 @@ use Patchlevel\EventSourcing\Message\Serializer\HeadersSerializer; use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; +use Patchlevel\EventSourcing\Schema\DoctrineHelper; use Patchlevel\EventSourcing\Schema\DoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Serializer\EventSerializer; use Patchlevel\EventSourcing\Serializer\SerializedEvent; @@ -250,7 +251,7 @@ private function applyCriteria(QueryBuilder $builder, Criteria $criteria): void break; case EventIdCriterion::class: $builder->andWhere('event_id = :event_id'); - $builder->setParameter('event_id', $criterion->eventId, ArrayParameterType::STRING); + $builder->setParameter('event_id', $criterion->eventId); break; case TagCriterion::class: if ($this->isSQLite) { @@ -551,16 +552,32 @@ public function transactional(Closure $function): void { if ($this->hasLock || !$this->config['locking']) { $this->connection->transactional($function); - } else { - $this->connection->transactional(function () use ($function): void { - $this->lock(); - try { - $function(); - } finally { - $this->unlock(); - } - }); + + return; } + + if ($this->isMariaDb || $this->isMysql) { + // GET_LOCK is bound to the session, not the transaction. It must be released after the commit, + // otherwise other writers can commit before this transaction is visible. + $this->lock(); + + try { + $this->connection->transactional($function); + } finally { + $this->unlock(); + } + + return; + } + + $this->connection->transactional(function () use ($function): void { + $this->lock(); + try { + $function(); + } finally { + $this->unlock(); + } + }); } /** @return list */ @@ -605,7 +622,7 @@ public function archive(Criteria|null $criteria = null): void public function configureSchema(Schema $schema, Connection $connection): void { - if ($this->connection !== $connection) { + if (!DoctrineHelper::sameDatabase($this->connection, $connection)) { return; } @@ -764,8 +781,6 @@ private function executeSave( private function lock(): void { - $this->hasLock = true; - if ($this->isPostgres) { $this->connection->executeStatement( sprintf( @@ -774,6 +789,8 @@ private function lock(): void ), ); + $this->hasLock = true; + return; } @@ -800,10 +817,14 @@ private function lock(): void throw LockCouldNotBeAcquired::byError($this->config['lock_id']); } + $this->hasLock = true; + return; } if ($this->isSQLite) { + $this->hasLock = true; + return; // sql locking is not needed because of file locking } diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index cf4afb530..e800a04c3 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -4,21 +4,30 @@ namespace Patchlevel\EventSourcing\Tests\Integration\Store; +use Closure; 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; +use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\AppendCondition; use Patchlevel\EventSourcing\Store\AppendConditionNotMet; +use Patchlevel\EventSourcing\Store\Criteria\ArchivedCriterion; use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\EventIdCriterion; +use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; +use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; +use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Criteria\TagCriterion; +use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\ToPlayheadCriterion; use Patchlevel\EventSourcing\Store\Header\EventIdHeader; use Patchlevel\EventSourcing\Store\Header\IndexHeader; @@ -31,14 +40,20 @@ use Patchlevel\EventSourcing\Store\SubQuery; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; use Patchlevel\EventSourcing\Store\UniqueConstraintViolation; +use Patchlevel\EventSourcing\Store\UnsupportedCriterion; use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\Store\Events\ExternEvent; use Patchlevel\EventSourcing\Tests\Integration\Store\Events\ProfileCreated; +use Patchlevel\EventSourcing\Tests\Integration\Store\Header\TraceHeader; use Patchlevel\EventSourcing\Tests\PhpunitHelper; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use Psr\Clock\ClockInterface; +use RuntimeException; +use stdClass; +use Throwable; +use function array_map; use function iterator_to_array; use function json_decode; use function sprintf; @@ -1028,4 +1043,654 @@ public function testRemove(): void self::assertEquals(['foo'], $streams); } + + public function testCount(): void + { + $profileId = ProfileId::generate(); + + $this->store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(2)), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')), + ); + + self::assertSame(3, $this->store->count()); + self::assertSame(2, $this->store->count(new Criteria(new StreamCriterion('profile-*')))); + self::assertSame(1, $this->store->count(new Criteria(new StreamCriterion('foo')))); + } + + public function testLoadEmptyStore(): void + { + $stream = null; + + try { + $stream = $this->store->load(); + + self::assertSame([], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + self::assertSame(0, $this->store->count()); + } + + public function testLoadMultipleStreams(): void + { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-b')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-c')) + ->withHeader(new PlayheadHeader(3)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + + $stream = null; + + try { + $stream = $this->store->load(new Criteria(new StreamCriterion('profile-a', 'profile-b'))); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testLoadWithArchivedCriterion(): void + { + $profileId = ProfileId::generate(); + $streamName = sprintf('profile-%s', $profileId->toString()); + + $this->store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader(1)), + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader(2)), + ); + + $this->store->archive(new Criteria(new StreamCriterion($streamName), new ToPlayheadCriterion(2))); + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new ArchivedCriterion(false))); + + self::assertSame([2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new ArchivedCriterion(true))); + + self::assertSame([1], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testLoadWithEventCriteria(): void + { + $profileId = ProfileId::generate(); + $eventId = '0190e47e-77e9-7b90-bf62-08bbf0ab9b4b'; + + $this->store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new EventIdHeader($eventId)), + Message::create(new ExternEvent('test message')) + ->withHeader(new StreamNameHeader('foo')), + ); + + $stream = null; + + try { + $stream = $this->store->load(new Criteria(new EventsCriterion(['profile.created']))); + + self::assertCount(1, iterator_to_array($stream)); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(new Criteria(new EventIdCriterion($eventId))); + $messages = iterator_to_array($stream, false); + + self::assertCount(1, $messages); + self::assertSame($eventId, $messages[0]->header(EventIdHeader::class)->eventId); + } finally { + $stream?->close(); + } + } + + public function testLoadWithIndexCriteria(): void + { + $profileId = ProfileId::generate(); + $streamName = sprintf('profile-%s', $profileId->toString()); + + $messages = []; + + for ($playhead = 1; $playhead <= 5; $playhead++) { + $messages[] = Message::create(new ProfileCreated($profileId, 'name-' . $playhead)) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader($playhead)); + } + + $this->store->save(...$messages); + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new FromIndexCriterion(3))); + + self::assertSame([4, 5], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new ToIndexCriterion(3))); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new FromPlayheadCriterion(2))); + + self::assertSame([3, 4, 5], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new FromPlayheadCriterion(2), new ToPlayheadCriterion(5))); + + self::assertSame([3, 4], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testLoadWithLimitOffsetAndBackwards(): void + { + $profileId = ProfileId::generate(); + $streamName = sprintf('profile-%s', $profileId->toString()); + + $messages = []; + + for ($playhead = 1; $playhead <= 5; $playhead++) { + $messages[] = Message::create(new ProfileCreated($profileId, 'name-' . $playhead)) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader($playhead)); + } + + $this->store->save(...$messages); + + $stream = null; + + try { + $stream = $this->store->load(limit: 2); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(limit: 2, offset: 2); + + self::assertSame([3, 4], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(limit: 3, offset: 0); + + self::assertSame([1, 2, 3], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + $stream = null; + + try { + $stream = $this->store->load(backwards: true); + + self::assertSame([5, 4, 3, 2, 1], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testLockIsAcquiredAgainAfterTimeout(): void + { + $this->skipIfNoLockTimeout(); + + $otherConnection = DriverManager::getConnection($this->connection->getParams()); + + try { + self::assertSame(1, $otherConnection->fetchOne('SELECT GET_LOCK("133742", 1)')); + + $exception = null; + + try { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + } catch (Throwable $e) { + $exception = $e; + } + + self::assertInstanceOf(LockCouldNotBeAcquired::class, $exception); + + // the lock is still held by the other connection, so the second save must also wait for it + $this->expectException(LockCouldNotBeAcquired::class); + + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + } finally { + $otherConnection->close(); + } + } + + public function testLockIsReleasedAfterException(): void + { + $this->skipIfNoLockTimeout(); + + try { + $this->store->transactional(static function (): void { + throw new RuntimeException('error'); + }); + } catch (RuntimeException) { + // expected + } + + $otherConnection = DriverManager::getConnection($this->connection->getParams()); + + try { + $otherStore = new TaggableDoctrineDbalStore( + $otherConnection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), + clock: $this->clock, + config: ['lock_timeout' => 1], + ); + + $otherStore->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + + self::assertSame(1, $this->store->count()); + } finally { + $otherConnection->close(); + } + } + + public function testSaveHoldsLockUntilCommit(): void + { + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $this->markTestSkipped('SQLite does not support locks'); + } + + $probeConnection = DriverManager::getConnection($this->connection->getParams()); + $lockQuery = $probeConnection->getDatabasePlatform() instanceof PostgreSQLPlatform + ? "SELECT COUNT(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = 133742" + : 'SELECT IS_USED_LOCK("133742") IS NOT NULL'; + + $connection = new class ($this->connection->getParams(), $this->connection->getDriver()) extends Connection { + public Closure|null $beforeCommit = null; + + public function commit(): void + { + if ($this->beforeCommit) { + ($this->beforeCommit)(); + } + + parent::commit(); + } + }; + + // Checks from a second session whether the lock is still held right before the commit. + $lockHeldOnCommit = null; + $connection->beforeCommit = static function () use ($probeConnection, $lockQuery, &$lockHeldOnCommit): void { + $lockHeldOnCommit = $probeConnection->fetchOne($lockQuery) > 0; + }; + + $store = new TaggableDoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), + clock: $this->clock, + ); + + $profileId = ProfileId::generate(); + + try { + $store->save( + 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'))), + ); + } finally { + $connection->close(); + $probeConnection->close(); + } + + self::assertTrue($lockHeldOnCommit); + } + + public function testSaveWithCustomHeaders(): void + { + $store = new TaggableDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), + DefaultHeadersSerializer::createFromPaths([__DIR__ . '/Header']), + clock: $this->clock, + ); + + $store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00')))->withHeader(new TraceHeader('trace-1')), + ); + + $stream = null; + + try { + $stream = $store->load(); + $loaded = $stream->current(); + + self::assertInstanceOf(Message::class, $loaded); + self::assertEquals(new TraceHeader('trace-1'), $loaded->header(TraceHeader::class)); + } finally { + $stream?->close(); + } + } + + public function testSaveWithIndexExactBatchSize(): void + { + $profileId = ProfileId::generate(); + + $messages = []; + + // 65535 max parameters / 10 columns = 6553 messages per batch + for ($i = 1; $i <= 6553; $i++) { + $messages[] = Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader($i)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))) + ->withHeader(new IndexHeader($i)); + } + + $store = new TaggableDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), + clock: $this->clock, + config: ['keep_index' => true], + ); + + $store->save(...$messages); + + $store = new TaggableDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), + clock: $this->clock, + ); + + $store->save( + Message::create(new ProfileCreated($profileId, 'test')) + ->withHeader(new StreamNameHeader(sprintf('profile-%s', $profileId->toString()))) + ->withHeader(new PlayheadHeader(6554)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-02 00:00:00'))), + ); + + /** @var list> $result */ + $result = $this->connection->fetchAllAssociative('SELECT * FROM event_store WHERE playhead = 6554'); + + self::assertCount(1, $result); + self::assertEquals(6554, $result[0]['id']); + } + + public function testTransactionalNested(): void + { + $this->store->transactional(function (): void { + $this->store->transactional(function (): void { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + }); + + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + }); + + $stream = null; + + try { + $stream = $this->store->load(); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + + self::assertSame(0, $this->connection->getTransactionNestingLevel()); + } + + public function testTransactionalRollsBackOnException(): void + { + $exception = null; + + try { + $this->store->transactional(function (): void { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + + throw new RuntimeException('error'); + }); + } catch (RuntimeException $e) { + $exception = $e; + } + + self::assertNotNull($exception); + self::assertSame(0, $this->store->count()); + self::assertSame(0, $this->connection->getTransactionNestingLevel()); + } + + public function testTransactionalTwice(): void + { + $this->store->transactional(function (): void { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(1)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + }); + + $this->store->transactional(function (): void { + $this->store->save( + Message::create(new ProfileCreated(ProfileId::generate(), 'test')) + ->withHeader(new StreamNameHeader('profile-a')) + ->withHeader(new PlayheadHeader(2)) + ->withHeader(new RecordedOnHeader(new DateTimeImmutable('2020-01-01 00:00:00'))), + ); + }); + + $stream = null; + + try { + $stream = $this->store->load(); + + self::assertSame([1, 2], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } + + public function testUnsupportedCriterion(): void + { + $this->expectException(UnsupportedCriterion::class); + + $this->store->count(new Criteria(new stdClass())); + } + + public function testWaitWithoutNotification(): void + { + // without postgres it just sleeps, with postgres no notification arrives within the timeout + $this->store->wait(10); + $this->store->wait(10); + + self::assertSame(0, $this->store->count()); + } + + public function testConfigureSchemaSameDatabase(): void + { + $connection = DbalManager::createConnection(); + $otherConnection = DbalManager::createConnection(); + + $store = new TaggableDoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), + clock: $this->clock, + ); + + $schema = new Schema(); + + $store->configureSchema($schema, $otherConnection); + + self::assertTrue($schema->hasTable('event_store')); + } + + public function testConfigureSchemaNotSameDatabase(): void + { + $connection = DbalManager::createConnection(); + $otherConnection = DbalManager::createConnection('other'); + + $store = new TaggableDoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + (new AttributeEventRegistryFactory())->create([__DIR__ . '/Events']), + clock: $this->clock, + ); + + $schema = new Schema(); + + $store->configureSchema($schema, $otherConnection); + + self::assertFalse($schema->hasTable('event_store')); + } + + private function skipIfNoLockTimeout(): void + { + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $this->markTestSkipped('SQLite does not support locks'); + } + + if (!($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform)) { + return; + } + + $this->markTestSkipped('PostgreSQL does lock indefinitely'); + } } diff --git a/tests/Unit/Store/TaggableDoctrineDbalStoreTest.php b/tests/Unit/Store/TaggableDoctrineDbalStoreTest.php index 1bec0ff65..06077ff66 100644 --- a/tests/Unit/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Unit/Store/TaggableDoctrineDbalStoreTest.php @@ -1238,9 +1238,8 @@ public function __invoke(): void ->willReturn(0); $connection - ->expects($this->once()) - ->method('transactional') - ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + ->expects($this->never()) + ->method('transactional'); $eventSerializer = $this->createMock(EventSerializer::class); $eventRegistry = new EventRegistry([]); @@ -1284,9 +1283,8 @@ public function __invoke(): void ->willReturn(null); $connection - ->expects($this->once()) - ->method('transactional') - ->willReturnCallback(static fn (Closure $closure): mixed => $closure()); + ->expects($this->never()) + ->method('transactional'); $eventSerializer = $this->createMock(EventSerializer::class); $eventRegistry = new EventRegistry([]); @@ -4780,7 +4778,16 @@ public function testConfigureSchemaWithDifferentConnection(): void $headersSerializer, ); + $connection + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db']); + $differentConnection = $this->createMock(Connection::class); + $differentConnection + ->expects($this->once()) + ->method('getParams') + ->willReturn(['dbname' => 'db2']); $schema = new Schema(); $doctrineDbalStore->configureSchema($schema, $differentConnection);