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"}' 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" diff --git a/composer.lock b/composer.lock index c75201dc5..73dfd4d01 100644 --- a/composer.lock +++ b/composer.lock @@ -3804,16 +3804,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": { @@ -3850,7 +3850,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.10-dev" + "dev-main": "2.11-dev" } }, "autoload": { @@ -3907,7 +3907,7 @@ "type": "tidelift" } ], - "time": "2026-08-03T13:42:31+00:00" + "time": "2026-09-21T12:28:44+00:00" }, { "name": "league/config", diff --git a/docker-compose.yaml b/docker-compose.yaml index 4677fcec0..ef91ee66f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,7 @@ services: - 5432:5432 mysql: - image: mysql:8 + image: mysql:26 environment: - MYSQL_ALLOW_EMPTY_PASSWORD="yes" - MYSQL_DATABASE=eventstore @@ -27,7 +27,7 @@ services: - 3306:3306 mariadb: - image: mariadb:12 + image: mariadb:13 environment: - MYSQL_ALLOW_EMPTY_PASSWORD="yes" - MYSQL_DATABASE=eventstore diff --git a/docs/cli.md b/docs/cli.md index 277a73802..c53070072 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,338 @@ 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 -* DatabaseCreateCommand: `event-sourcing:database:create` -* DatabaseDropCommand: `event-sourcing:database:drop` +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. + +:::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. + +### Filter subscriptions -* 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` +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 (`StreamDoctrineDbalStore` and `TaggableDoctrineDbalStore` 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 + +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. -* ShowCommand: `event-sourcing:show` -* ShowAggregateCommand: `event-sourcing:show-aggregate` -* WatchCommand: `event-sourcing:watch` +```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 with the `--stream` option. +```bash +bin/console event-sourcing:watch --stream="profile-*" +``` ## Debug command The debug command prints everything the library knows about your application: @@ -145,9 +434,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'); @@ -161,26 +455,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\SubscriptionTeardownCommand($subscriptionEngine), - new Command\SubscriptionRemoveCommand($subscriptionEngine), + new Command\SubscriptionStatusCommand($subscriptionEngine), + new Command\SubscriptionPauseCommand($subscriptionEngine), new Command\SubscriptionReactivateCommand($subscriptionEngine), new Command\SubscriptionRefreshCommand($subscriptionEngine), - new Command\SubscriptionSetupCommand($subscriptionEngine), - new Command\SubscriptionStatusCommand($subscriptionEngine), - new Command\SchemaCreateCommand($schemaDirector), - new Command\SchemaDropCommand($schemaDirector), - new Command\SchemaUpdateCommand($schemaDirector), + new Command\SubscriptionTeardownCommand($subscriptionEngine), + new Command\SubscriptionRemoveCommand($subscriptionEngine), + 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: @@ -236,3 +546,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) diff --git a/docs/message.md b/docs/message.md index 54b631cd2..7eb4a3dbf 100644 --- a/docs/message.md +++ b/docs/message.md @@ -76,7 +76,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. ::: @@ -122,6 +122,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/docs/store.md b/docs/store.md index 859113410..c9a899c85 100644 --- a/docs/store.md +++ b/docs/store.md @@ -451,6 +451,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. 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/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/src/Cryptography/DoctrineCipherKeyStore.php b/src/Cryptography/DoctrineCipherKeyStore.php index 389ebeba0..ef4d403cb 100644 --- a/src/Cryptography/DoctrineCipherKeyStore.php +++ b/src/Cryptography/DoctrineCipherKeyStore.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/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/src/Store/StreamDoctrineDbalStore.php b/src/Store/StreamDoctrineDbalStore.php index e06628cbe..20fcbd9c8 100644 --- a/src/Store/StreamDoctrineDbalStore.php +++ b/src/Store/StreamDoctrineDbalStore.php @@ -222,7 +222,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); @@ -361,16 +361,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 */ @@ -564,8 +582,6 @@ private function executeSave( private function lock(): void { - $this->hasLock = true; - $platform = $this->connection->getDatabasePlatform(); if ($platform instanceof PostgreSQLPlatform) { @@ -576,6 +592,8 @@ private function lock(): void ), ); + $this->hasLock = true; + return; } @@ -602,10 +620,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/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/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/src/Subscription/Engine/GapResolverStoreMessageLoader.php b/src/Subscription/Engine/GapResolverStoreMessageLoader.php index 89116ea2e..4f1016f55 100644 --- a/src/Subscription/Engine/GapResolverStoreMessageLoader.php +++ b/src/Subscription/Engine/GapResolverStoreMessageLoader.php @@ -71,11 +71,7 @@ private function generator(int|null $startIndex, int $retry = 0): Generator } } - if ($expectedNextIndex === null) { - $expectedNextIndex = $currentIndex + 1; - } else { - $expectedNextIndex++; - } + $expectedNextIndex = $currentIndex + 1; $retry = 0; diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 4571fce5d..98ae14848 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\DriverException; use Doctrine\DBAL\Exception\RetryableException; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode; @@ -159,25 +160,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 @@ -208,7 +213,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/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/Cryptography/DoctrineCipherKeyStoreTest.php b/tests/Integration/Cryptography/DoctrineCipherKeyStoreTest.php new file mode 100644 index 000000000..61266615a --- /dev/null +++ b/tests/Integration/Cryptography/DoctrineCipherKeyStoreTest.php @@ -0,0 +1,365 @@ +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('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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($this->connection))->get('key-1'); + + self::assertSame($key->key, $loaded->key); + } + + public function testStoredKeyDecryptsData(): void + { + $store = new DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($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 DoctrineCipherKeyStore($this->connection); + + $schema = new Schema(); + + $store->configureSchema($schema, $otherConnection); + + self::assertFalse($schema->hasTable('cryptography_keys')); + + $otherConnection->close(); + } +} 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 @@ +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 = [ @@ -403,13 +461,7 @@ public function testSave10000Messages(): void public function testSaveLockTimeout(): void { - if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { - $this->markTestSkipped('SQLite does not support locks'); - } - - if ($this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform) { - $this->markTestSkipped('PostgreSQL does lock indefinitely'); - } + $this->skipIfNoLockTimeout(); $profileId = ProfileId::generate(); @@ -440,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(); @@ -586,6 +691,514 @@ 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, false); + + 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 TagCriterion(['profile: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 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(); @@ -621,4 +1234,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/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/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()); + } +} diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php new file mode 100644 index 000000000..623aebf51 --- /dev/null +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -0,0 +1,1093 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testUpdate(): 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->active(); + $subscription->changePosition(42); + + $store->update($subscription); + + $loaded = $store->get('foo'); + + self::assertSame(Status::Active, $loaded->status()); + self::assertSame(42, $loaded->position()); + } + + public function testUpdateWithoutChanges(): 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')); + + // the clock is frozen, so all values stay the same and mysql reports no affected rows + $store->update($store->get('foo')); + $store->update($store->get('foo')); + + 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::assertNull($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); + + $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::assertNull($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/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()); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php index 48d043fc5..e34a97ef0 100644 --- a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php +++ b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Console\Command\SubscriptionStatusCommand; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; @@ -25,6 +26,7 @@ public function testStatusList(): void $engine ->expects($this->once()) ->method('subscriptions') + ->with(new SubscriptionEngineCriteria()) ->willReturn([ new Subscription('foo'), new Subscription('bar', 'other', RunMode::Once, Status::Active, 42), @@ -42,12 +44,37 @@ public function testStatusList(): void self::assertStringContainsString('42', $display); } + public function testStatusListFiltered(): void + { + $engine = $this->createMock(SubscriptionEngine::class); + $engine + ->expects($this->once()) + ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(['foo', 'bar'], ['projector'])) + ->willReturn([ + new Subscription('foo', 'projector', RunMode::FromBeginning, Status::Active, 42), + ]); + + $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); + $commandTester->execute([ + '--id' => ['foo', 'bar'], + '--group' => ['projector'], + ]); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('foo', $display); + self::assertStringContainsString('projector', $display); + } + public function testStatusDetail(): void { $engine = $this->createMock(SubscriptionEngine::class); $engine ->expects($this->once()) ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(['foo'])) ->willReturn([ new Subscription('foo', 'default', RunMode::FromBeginning, Status::Active, 42), ]); @@ -69,6 +96,7 @@ public function testStatusDetailWithError(): void $engine ->expects($this->once()) ->method('subscriptions') + ->with(new SubscriptionEngineCriteria(['foo'])) ->willReturn([ new Subscription( 'foo', @@ -100,7 +128,8 @@ public function testStatusNotFound(): void $engine ->expects($this->once()) ->method('subscriptions') - ->willReturn([new Subscription('foo')]); + ->with(new SubscriptionEngineCriteria(['bar'])) + ->willReturn([]); $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); diff --git a/tests/Unit/Cryptography/DoctrineCipherKeyStoreTest.php b/tests/Unit/Cryptography/DoctrineCipherKeyStoreTest.php index b56b9a48f..f9e5b8a24 100644 --- a/tests/Unit/Cryptography/DoctrineCipherKeyStoreTest.php +++ b/tests/Unit/Cryptography/DoctrineCipherKeyStoreTest.php @@ -7,6 +7,8 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\SQLitePlatform; +use Doctrine\DBAL\Query\QueryBuilder; +use Doctrine\DBAL\Result; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; @@ -74,9 +76,15 @@ public function testCurrentKeyFor(): void { $connection = $this->createMock(Connection::class); $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $result = $this->createMock(Result::class); + $result ->expects($this->once()) ->method('fetchAssociative') - ->with('SELECT * FROM cryptography_keys WHERE subject_id = :subject_id', ['subject_id' => 'profile-1']) ->willReturn([ 'id' => 'foo', 'subject_id' => 'profile-1', @@ -86,6 +94,15 @@ public function testCurrentKeyFor(): void ]); $connection ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM cryptography_keys WHERE subject_id = :subject_id ORDER BY created_at DESC, id DESC LIMIT 1', + ['subject_id' => 'profile-1'], + $this->isArray(), + ) + ->willReturn($result); + $connection + ->expects($this->exactly(2)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); @@ -107,10 +124,28 @@ public function testCurrentKeyForNotFound(): void { $connection = $this->createMock(Connection::class); $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $result = $this->createMock(Result::class); + $result ->expects($this->once()) ->method('fetchAssociative') - ->with('SELECT * FROM cryptography_keys WHERE subject_id = :subject_id', ['subject_id' => 'profile-1']) ->willReturn(false); + $connection + ->expects($this->once()) + ->method('executeQuery') + ->with( + 'SELECT * FROM cryptography_keys WHERE subject_id = :subject_id ORDER BY created_at DESC, id DESC LIMIT 1', + ['subject_id' => 'profile-1'], + $this->isArray(), + ) + ->willReturn($result); + $connection + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); $store = new DoctrineCipherKeyStore($connection); 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 beb3f95d9..c8906f90a 100644 --- a/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php +++ b/tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php @@ -15,6 +15,7 @@ use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\FooHeader; use Patchlevel\Hydrator\CoreExtension; use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster; use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension; @@ -230,4 +231,20 @@ public function testCreateDefaultWithCustomHydrator(): void $serializer->deserialize('{"playhead":{"playhead":1}}'), ); } + + public function testDeserializeAliasAndSerializeWithHeaderName(): void + { + $serializer = new DefaultHeadersSerializer( + (new AttributeMessageHeaderRegistryFactory())->create([ + __DIR__ . '/../../Fixture', + ]), + new StackHydrator(), + 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 index e90ee0543..fbe5280a3 100644 --- a/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php +++ b/tests/Unit/Metadata/Message/AttributeMessageHeaderRegistryFactoryTest.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Metadata\Message; use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory; +use Patchlevel\EventSourcing\Metadata\Message\HeaderAlreadyInRegistry; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\BazHeader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Header\FooHeader; @@ -22,6 +23,34 @@ public function testCreate(): void self::assertSame(FooHeader::class, $registry->headerClass('foo')); self::assertSame(BazHeader::class, $registry->headerClass('baz')); + self::assertSame(FooHeader::class, $registry->headerClass('legacyFoo')); self::assertSame(StreamNameHeader::class, $registry->headerClass('streamName')); } + + 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 @@ + 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 testHeaderName(): void { $registry = new MessageHeaderRegistry(['foo' => FooHeader::class]); diff --git a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php index fc040a9ba..2a5223e97 100644 --- a/tests/Unit/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Unit/Store/StreamDoctrineDbalStoreTest.php @@ -632,7 +632,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 @@ -667,7 +667,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -711,7 +711,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -755,7 +755,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new PostgreSQLPlatform()); @@ -796,7 +796,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new PostgreSQLPlatform()); @@ -839,7 +839,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 @@ -878,7 +878,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -922,7 +922,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -967,7 +967,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MariaDBPlatform()); @@ -1012,7 +1012,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -1023,9 +1023,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); @@ -1056,7 +1055,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -1067,9 +1066,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); @@ -1099,7 +1097,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -1144,7 +1142,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -1276,7 +1274,7 @@ public function testSaveWithTwoEvents(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(3)) + ->expects($this->exactly(4)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -2503,7 +2501,7 @@ public function testSaveWithHeaderFallbacks(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(3)) + ->expects($this->exactly(4)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -2574,7 +2572,7 @@ public function testSaveWithArchivedHeader(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(3)) + ->expects($this->exactly(4)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -2629,7 +2627,7 @@ public function testSaveWithCustomTableName(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(3)) + ->expects($this->exactly(4)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -2688,7 +2686,7 @@ public function testSaveWithExactBatchSize(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(3)) + ->expects($this->exactly(4)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -2740,7 +2738,7 @@ public function testSaveWithKeepIndex(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(4)) + ->expects($this->exactly(5)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -2796,7 +2794,7 @@ public function testSaveWithKeepIndexMissingHeader(): void $mockedConnection = $this->createMock(Connection::class); $mockedConnection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new SQLitePlatform()); $mockedConnection @@ -2952,7 +2950,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new MySQLPlatform()); @@ -2997,7 +2995,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->exactly(2)) + ->expects($this->exactly(3)) ->method('getDatabasePlatform') ->willReturn(new PostgreSQLPlatform()); @@ -3041,7 +3039,7 @@ public function __invoke(): void $connection = $this->createMock(Connection::class); $connection - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getDatabasePlatform') ->willReturn($abstractPlatform); $connection 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); diff --git a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php index cb6c1b4c5..d53c70dbe 100644 --- a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php @@ -450,6 +450,20 @@ public function testUpdateNotFound(): void ->expects($this->once()) ->method('update') ->willReturn(0); + $connection + ->method('getDatabasePlatform') + ->willReturn(new SQLitePlatform()); + $connection + ->expects($this->once()) + ->method('createQueryBuilder') + ->willReturnCallback( + static fn (): QueryBuilder => new QueryBuilder($connection), + ); + $connection + ->expects($this->once()) + ->method('fetchOne') + ->with('SELECT 1 FROM subscriptions WHERE id = :id', ['id' => 'foo']) + ->willReturn(false); $store = new DoctrineSubscriptionStore($connection, $clock); 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()); + } } 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": [],