diff --git a/.github/workflows/config-shape.yml b/.github/workflows/config-shape.yml new file mode 100644 index 000000000..6956bd0c4 --- /dev/null +++ b/.github/workflows/config-shape.yml @@ -0,0 +1,50 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Check Configuration Shape" + +on: + pull_request: + push: + branches: + - "[0-9]+.[0-9]+.x" + - "renovate/*" + +jobs: + config-shape: + name: "Configuration shape is up to date" + + runs-on: ${{ matrix.operating-system }} + + strategy: + matrix: + dependencies: + - "locked" + php-version: + - "8.5" + operating-system: + - "ubuntu-latest" + + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Install PHP" + uses: "shivammathur/setup-php@2.37.2" + with: + coverage: none + php-version: "${{ matrix.php-version }}" + ini-values: memory_limit=-1, opcache.enable_cli=1 + + - uses: ramsey/composer-install@4.0.0 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: "Generate configuration shape" + run: "bin/generate-config-shape" + + - name: "Check for changes" + run: | + if ! git diff --exit-code -- src/Container/Configuration.php; then + echo "::error file=src/Container/Configuration.php::The configuration shape is outdated, run \"make config-shape\" and commit the changes." + exit 1 + fi diff --git a/.github/workflows/container-integration.yml b/.github/workflows/container-integration.yml new file mode 100644 index 000000000..0e36fd75d --- /dev/null +++ b/.github/workflows/container-integration.yml @@ -0,0 +1,61 @@ +# https://help.github.com/en/categories/automating-your-workflow-with-github-actions + +name: "Container integration tests" + +on: + pull_request: + push: + branches: + - "[0-9]+.[0-9]+.x" + - "renovate/*" + +jobs: + container: + name: "${{ matrix.container }}" + + runs-on: ${{ matrix.operating-system }} + + strategy: + fail-fast: false + matrix: + dependencies: + - "locked" + php-version: + - "8.5" + operating-system: + - "ubuntu-latest" + container: + - "PHP-DI" + - "Laminas ServiceManager" + - "League Container" + include: + - container: "PHP-DI" + package: "php-di/php-di:^7.0" + test: "PhpDiContainerTest" + - container: "Laminas ServiceManager" + package: "laminas/laminas-servicemanager:^4.0" + test: "LaminasServiceManagerTest" + - container: "League Container" + package: "league/container:^5.0" + test: "LeagueContainerTest" + + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + - name: "Install PHP" + uses: "shivammathur/setup-php@2.37.2" + with: + php-version: "${{ matrix.php-version }}" + ini-values: memory_limit=-1 + extensions: pdo_sqlite + + - uses: ramsey/composer-install@4.0.0 + with: + dependency-versions: ${{ matrix.dependencies }} + + - name: "Install ${{ matrix.container }}" + run: "composer require --dev --no-interaction --no-progress '${{ matrix.package }}'" + + - name: "Tests" + run: "vendor/bin/phpunit tests/Integration/Container/${{ matrix.test }}.php" diff --git a/Makefile b/Makefile index fc1a58d59..4efd8814f 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,11 @@ phpunit-integration-postgres: vendor phpunit-integration-mysql: vendor ## run phpunit integration tests on mysql DB_URL="pdo-mysql://root@127.0.0.1:3306/eventstore?charset=utf8" vendor/bin/phpunit --testsuite=integration +.PHONY: phpunit-container +phpunit-container: vendor ## run phpunit integration tests with third party containers (changes composer.json) + composer require --dev php-di/php-di laminas/laminas-servicemanager league/container + vendor/bin/phpunit --no-coverage tests/Integration/Container + .PHONY: phpunit-unit phpunit-unit: vendor ## run phpunit unit tests XDEBUG_MODE=coverage vendor/bin/phpunit --testsuite=unit @@ -74,6 +79,10 @@ benchmark-diff-test: benchmark-base benchmark-diff .PHONY: dev dev: static test ## run dev tools +.PHONY: config-shape +config-shape: vendor ## generate the array shapes of the container configuration + bin/generate-config-shape + .PHONY: docs-extract-php docs-extract-php: bin/docs-extract-php-code diff --git a/bin/generate-config-shape b/bin/generate-config-shape new file mode 100755 index 000000000..05d3f189e --- /dev/null +++ b/bin/generate-config-shape @@ -0,0 +1,16 @@ +#!/usr/bin/env php +generate(Configuration::tree(), (string)file_get_contents($file)), +); diff --git a/docs/cli.md b/docs/cli.md index c53070072..43aa4b4d3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -428,6 +428,10 @@ You can create it with the [schema commands](#schema-commands). ## CLI example +:::tip +If you use the [container](container.md), `Factory::commands()` returns all commands already configured. +::: + A cli php file can look like this: ```php @@ -545,5 +549,6 @@ Here you can find more information on how to ## Learn more * [How to configure store](store.md) +* [How to configure the container](container.md) * [How to configure subscription engine](subscription.md) * [How to use subscriptions](subscription.md#usage) diff --git a/docs/container.md b/docs/container.md new file mode 100644 index 000000000..faf34fa3e --- /dev/null +++ b/docs/container.md @@ -0,0 +1,489 @@ +# Container + +The library consists of many small services: the store, the serializer, the repository manager, +the subscription engine and more. Wiring them by hand is flexible, but a lot of work. +The container does this for you. You describe your setup in one configuration array +and get a [PSR-11](https://www.php-fig.org/psr/psr-11/) container with all services ready to use. + +:::tip +If you use symfony, you can use our [symfony bundle](https://patchlevel.dev/docs/event-sourcing-bundle/latest) instead. +The configuration of the container follows the configuration of the bundle. +::: + +## Create the container + +You create the container with the `Factory`. +The only required option is the database connection, +everything else has a default. + +```php +use Patchlevel\EventSourcing\Container\Factory; +use Patchlevel\EventSourcing\Repository\RepositoryManager; + +$container = Factory::create([ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app'], + 'aggregates' => ['src/Domain/Hotel'], + 'events' => ['src/Domain/Hotel/Event'], + 'subscription' => [ + 'subscribers' => [new HotelProjector($projectionConnection)], + 'sync' => true, + ], +]); + +$hotelRepository = $container->get(RepositoryManager::class)->get(Hotel::class); +``` +The services are created lazily, the first time you request them. +You request them by their interface, for example `RepositoryManager`, `CommandBus`, `QueryBus`, +`Store`, `SubscriptionEngine` or `SchemaDirector`. + +:::note +The configuration is described as a PHP array shape, +so your IDE and static analysers like PHPStan autocomplete and validate every option. +::: + +## Configuration + +Every option is optional except the connection. +You only configure what differs from the defaults. +Some rules apply to all options: + +* Sections with an `enabled` key can also be set to a bool: `'event_bus' => true` enables the event bus with its defaults. + If you configure such a section with an array, it is enabled automatically. +* Lists of services like `subscribers`, `upcasters` or `message_decorators` accept objects as well as service ids. + Service ids are resolved from the container or from an [external container](#framework-integration). +* An invalid configuration, like an unknown option or a wrong type, throws an `InvalidConfiguration` exception + that names the exact path, for example `Unrecognized option "typ" under "event_sourcing.store"`. + +### Connection + +The container either creates the doctrine connection from a url or uses an existing connection service. + +```php +use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Tools\DsnParser; +use Patchlevel\EventSourcing\Container\Factory; + +$connection = DriverManager::getConnection( + (new DsnParser())->parse('pdo-pgsql://user:secret@localhost/app'), +); + +$container = Factory::create([ + 'connection' => ['service' => 'app.connection'], + 'services' => ['app.connection' => $connection], +]); +``` +With `provide_dedicated_connection`, the container creates a second connection from the url, +which is available as `Doctrine\DBAL\Connection` for your projections. +The store keeps its own connection, so your projections don't interfere with its transactions. + +### Store + +By default the `StreamDoctrineDbalStore` is used. +You can switch to the `TaggableDoctrineDbalStore`, the `InMemoryStore` or your own store service. + +```php +use Patchlevel\EventSourcing\Container\Factory; + +$container = Factory::create([ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app'], + 'store' => [ + 'type' => 'dbal_taggable', + 'options' => ['table_name' => 'hotel_events'], + ], +]); +``` +The `options` are passed to the store, the available options are listed on the [store](store.md) page. +With `read_only`, the stream store is wrapped in a `ReadOnlyStore`. + +:::tip +With `dbal_taggable`, the container adds the `PostgreSQLPlatformMiddleware` to the connection created from the `url`, +so a GIN index on the `tags` column is created on PostgreSQL. +If you pass your own connection `service`, you have to register the middleware yourself. +::: + +To move your events into another store, enable `migrate_to_new_store`. +This registers the `event-sourcing:store:migrate` [command](cli.md#store-migration-command). + +```php +use Patchlevel\EventSourcing\Container\Factory; +use Patchlevel\EventSourcing\Message\Translator\ExtractEventTagTranslator; + +$container = Factory::create([ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app'], + 'store' => [ + 'migrate_to_new_store' => [ + 'type' => 'dbal_taggable', + 'options' => ['table_name' => 'new_event_store'], + 'translators' => [new ExtractEventTagTranslator()], + ], + ], +]); +``` +### Subscriptions + +The `subscription` section configures the [subscription engine](subscription.md) and its subscribers. + +```php +use Patchlevel\EventSourcing\Container\Factory; + +$container = Factory::create([ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app'], + 'subscription' => [ + 'subscribers' => [ + new HotelProjector($projectionConnection), + new SendCheckInEmailProcessor($mailer), + ], + 'sync' => [ + 'groups' => ['projector'], + 'throw_on_error' => true, + ], + 'gap_detection' => true, + ], +]); +``` +With `sync`, the subscriptions run directly after an aggregate was saved, +so your projections are up to date in the same request. +For this, a separate catch-up engine is used, the `SubscriptionEngine` used by your workers is not affected. +You can limit it to some subscriptions with `ids` and `groups`. + +:::tip +Use `sync` for development and small applications. +For production, run the subscriptions in a [worker](cli.md#run) instead. +::: + +With `event_emitter`, subscribers can emit new events into their own `subscription_` stream +with the [EventEmitter](subscription.md#event-emitter-resolver). It is disabled by default. + +:::warning +If a subscription is removed, its `subscription_` stream is removed from the event store as well. +::: + +:::note +The event emitter does not work with a read only store. +::: + +### Hydrator + +The `hydrator` section configures how events, headers and snapshots are hydrated. +You can enable the [cryptography](sensitive-data.md) for personal data, +register [upcasters](upcasting.md) and add your own hydrator extensions. + +```php +use Patchlevel\EventSourcing\Container\Factory; + +$container = Factory::create([ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app'], + 'hydrator' => [ + 'cryptography' => true, + 'upcasters' => [ + 'before_transform' => [new ProfileCreatedEmailLowerCastUpcaster()], + ], + ], +]); +``` +With the cryptography enabled, the cipher keys are stored in the `cryptography_keys` table. +You can also use your own cipher key store with `cipher_key_store`. + +### Services and parameters + +With `services` you add your own services to the container. +A closure is called with the container the first time the service is requested, +so your service can use the event sourcing services. + +```php +use Patchlevel\EventSourcing\Container\Container; +use Patchlevel\EventSourcing\Container\Factory; +use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; +use Patchlevel\EventSourcing\DecisionModel\EventAppender; + +$container = Factory::create([ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app'], + 'store' => ['type' => 'dbal_taggable'], + 'dcb' => true, + 'command_bus' => [ + 'handlers' => [CreateHotelHandler::class], + ], + 'services' => [ + CreateHotelHandler::class => static fn (Container $container): CreateHotelHandler => new CreateHotelHandler( + $container->get(DecisionModelBuilder::class), + $container->get(EventAppender::class), + ), + ], +]); +``` +Your services are registered last, so a service with the id of a built-in service replaces it. +`parameters` are plain values, for example for the `#[Inject]` attribute of [aggregate handlers](command-bus.md). + +### Reference + +These are all options with their default values. + +```php +use Patchlevel\EventSourcing\Container\Factory; +use Patchlevel\EventSourcing\Repository\AggregateOutdated; + +$container = Factory::create([ + 'connection' => [ + 'url' => null, // a doctrine dbal url + 'service' => null, // or the id of an existing connection service + 'provide_dedicated_connection' => false, + ], + 'store' => [ + 'type' => 'dbal_stream', // dbal_stream, dbal_taggable, in_memory or custom + 'service' => null, // the store service for the custom type + 'options' => [], // table_name, locking, lock_id, lock_timeout, keep_index, default_stream_name + 'read_only' => false, + 'migrate_to_new_store' => [ + 'enabled' => false, + 'type' => 'dbal_stream', + 'service' => null, + 'options' => [], + 'translators' => [], + ], + ], + 'aggregates' => [], // paths to your aggregates + 'events' => [], // paths to your events + 'headers' => [], // paths to your custom headers + 'event_bus' => [ + 'enabled' => false, + 'type' => 'default', // default, psr14 or custom + 'service' => null, // the psr-14 event dispatcher or the custom event bus + 'listeners' => [], + ], + 'command_bus' => [ + 'enabled' => true, + 'register_aggregate_handlers' => true, + 'handlers' => [], + 'handler_providers' => [], + 'instant_retry' => [ + 'enabled' => true, + 'default_max_retries' => 3, + 'default_exceptions' => [AggregateOutdated::class], + ], + ], + 'query_bus' => [ + 'enabled' => true, + 'handlers' => [], + 'handler_providers' => [], + ], + 'clock' => [ + 'freeze' => null, // a date, e.g. "2025-01-01 12:00:00", for a frozen clock + 'service' => null, // or the id of your own PSR-20 clock + ], + 'logger' => [ + 'service' => null, // the id of a PSR-3 logger + ], + 'migration' => [ + 'enabled' => true, + 'namespace' => 'EventSourcingMigrations', + 'path' => 'migrations', + ], + 'snapshot_stores' => [], // name => snapshot adapter or ['type' => 'psr6|psr16|custom', 'service' => '...'] + 'subscription' => [ + 'subscribers' => [], + 'store' => [ + 'type' => 'dbal', // dbal, in_memory, static_in_memory or custom + 'service' => null, + 'options' => ['table_name' => 'subscriptions'], + ], + 'retry_strategies' => [ + 'default' => [ + 'type' => 'clock_based', + 'options' => ['base_delay' => 5, 'delay_factor' => 2.0, 'max_attempts' => 5], + ], + 'no_retry' => ['type' => 'no_retry'], + ], + 'default_retry_strategy' => 'default', + 'sync' => [ + 'enabled' => false, + 'ids' => [], + 'groups' => [], + 'catch_up_limit' => null, + 'throw_on_error' => false, + ], + 'gap_detection' => [ + 'enabled' => false, + 'retries_in_ms' => [0, 5, 50, 500], + 'detection_window' => 'PT5M', + ], + 'event_filtered_message_loader' => false, + 'event_emitter' => false, + 'argument_resolvers' => [], + 'cleanup_task_handlers' => [], + ], + 'dcb' => false, + 'hydrator' => [ + 'default_lazy' => false, + 'cryptography' => [ + 'enabled' => false, + 'algorithm' => 'aes-128-gcm', + 'cipher_key_store' => null, + ], + 'lifecycle' => false, + 'extensions' => [], + 'guessers' => [], + 'upcasters' => [ + 'before_encoding' => [], + 'before_transform' => [], + ], + ], + 'message_decorators' => [], + 'services' => [], + 'parameters' => [], +]); +``` +:::note +The `retry_strategies` replace the defaults as a whole. +If you configure your own strategies, the default strategy has to be one of them. +::: + +## Framework integration + +If you use a framework without an official integration, like Laminas or CakePHP, +or a container like PHP-DI, you connect both containers in two directions: + +* Pass your application container as external container. + Every service id that the event sourcing container doesn't know is looked up there, + so your subscribers, handlers, logger or connection can come from your application. +* Register the event sourcing services in your application container with `Factory::registerBridges()`, + so your controllers and services can get the `CommandBus` or `RepositoryManager` injected. + +### PHP-DI + +```php +use DI\ContainerBuilder; +use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\Container\Factory; +use Psr\Log\LoggerInterface; + +use function DI\factory; + +$container = (new ContainerBuilder()) + ->useAutowiring(true) + ->addDefinitions([ + Connection::class => $connection, + LoggerInterface::class => $logger, + ]) + ->build(); + +$eventSourcing = Factory::create( + [ + 'connection' => ['service' => Connection::class], + 'logger' => ['service' => LoggerInterface::class], + 'aggregates' => ['src/Domain/Hotel'], + 'events' => ['src/Domain/Hotel/Event'], + 'subscription' => [ + 'subscribers' => [HotelProjector::class], + ], + ], + $container, +); + +Factory::registerBridges( + $eventSourcing, + static function (string $id, callable $service) use ($container): void { + $container->set($id, factory($service)); + }, +); +``` +The `HotelProjector` is created by PHP-DI with its dependencies, +and every service of your application can get the event sourcing services injected. + +### Laminas + +```php +use Doctrine\DBAL\Connection; +use Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory; +use Laminas\ServiceManager\ServiceManager; +use Patchlevel\EventSourcing\Container\Factory; + +$container = new ServiceManager([ + 'services' => [Connection::class => $connection], + 'abstract_factories' => [ReflectionBasedAbstractFactory::class], +]); + +$eventSourcing = Factory::create( + [ + 'connection' => ['service' => Connection::class], + 'aggregates' => ['src/Domain/Hotel'], + 'events' => ['src/Domain/Hotel/Event'], + 'subscription' => [ + 'subscribers' => [HotelProjector::class], + ], + ], + $container, +); + +Factory::registerBridges( + $eventSourcing, + static function (string $id, callable $service) use ($container): void { + $container->setFactory($id, static fn (): object => $service()); + }, +); +``` +### League Container and CakePHP + +The container of CakePHP is based on the [League Container](https://container.thephpleague.com/), +so the integration looks the same for both. + +```php +use Doctrine\DBAL\Connection; +use League\Container\Container; +use League\Container\ReflectionContainer; +use Patchlevel\EventSourcing\Container\Factory; + +$container = new Container(); +$container->delegate(new ReflectionContainer(true)); +$container->addShared(Connection::class, $connection); + +$eventSourcing = Factory::create( + [ + 'connection' => ['service' => Connection::class], + 'aggregates' => ['src/Domain/Hotel'], + 'events' => ['src/Domain/Hotel/Event'], + 'subscription' => [ + 'subscribers' => [HotelProjector::class], + ], + ], + $container, +); + +Factory::registerBridges( + $eventSourcing, + static function (string $id, callable $service) use ($container): void { + $container->addShared($id, static fn (): object => $service()); + }, +); +``` +:::note +Only services created by the event sourcing container are registered as bridges. +Services that come from your application container, like the connection in these examples, stay untouched. +::: + +## Console commands + +`Factory::commands()` returns all [cli commands](cli.md) of the configured features, +so you can add them to your symfony console application. + +```php +use Patchlevel\EventSourcing\Container\Factory; +use Symfony\Component\Console\Application; + +$eventSourcing = Factory::create([ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app'], + 'aggregates' => ['src/Domain/Hotel'], + 'events' => ['src/Domain/Hotel/Event'], +]); + +$cli = new Application('Event-Sourcing CLI'); +$cli->addCommands(Factory::commands($eventSourcing)); +$cli->run(); +``` +The [doctrine migrations](cli.md#doctrine-migrations) commands are included as `event-sourcing:migration:*`. + +## Learn more + +* [How to get started](getting-started.md) +* [How to configure the store](store.md) +* [How to use subscriptions](subscription.md) +* [How to use the cli commands](cli.md) diff --git a/docs/dynamic-consistency-boundary.md b/docs/dynamic-consistency-boundary.md index 53a1ab436..40ebdd58a 100644 --- a/docs/dynamic-consistency-boundary.md +++ b/docs/dynamic-consistency-boundary.md @@ -477,6 +477,10 @@ same tag query and conditional append, so the decision model and the append cond way without a database. ::: +:::note +With the [container](container.md) you enable all of this with `'store' => ['type' => 'dbal_taggable']` and `'dcb' => true`. +::: + ## Database setup The last step is to create the database schema. diff --git a/docs/getting-started.md b/docs/getting-started.md index dcf86d1ef..4eeb50498 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -271,18 +271,8 @@ If you use symfony, you can use our [symfony bundle](https://patchlevel.dev/docs ```php use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\Tools\DsnParser; -use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootRegistryFactory; -use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; -use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; -use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; -use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; -use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; -use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; - -$connection = DriverManager::getConnection( - (new DsnParser())->parse('pdo-pgsql://user:secret@localhost/app'), -); +use Patchlevel\EventSourcing\Container\Factory; +use Patchlevel\EventSourcing\Repository\RepositoryManager; $projectionConnection = DriverManager::getConnection( (new DsnParser())->parse('pdo-pgsql://user:secret@localhost/projection'), @@ -291,40 +281,24 @@ $projectionConnection = DriverManager::getConnection( /* your own mailer */ $mailer; -$serializer = DefaultEventSerializer::createFromPaths(['src/Domain/Hotel/Event']); -$aggregateRegistry = (new AttributeAggregateRootRegistryFactory())->create(['src/Domain/Hotel']); - -$eventStore = new StreamDoctrineDbalStore( - $connection, - $serializer, -); - -$hotelProjector = new HotelProjector($projectionConnection); - -$subscriberRepository = new MetadataSubscriberAccessorRepository([ - $hotelProjector, - new SendCheckInEmailProcessor($mailer), +$container = Factory::create([ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app'], + 'aggregates' => ['src/Domain/Hotel'], + 'events' => ['src/Domain/Hotel/Event'], + 'subscription' => [ + 'subscribers' => [ + new HotelProjector($projectionConnection), + new SendCheckInEmailProcessor($mailer), + ], + // run the subscriptions directly after an aggregate was saved + 'sync' => true, + ], ]); -$subscriptionStore = new DoctrineSubscriptionStore($connection); - -$engine = new DefaultSubscriptionEngine( - $eventStore, - $subscriptionStore, - $subscriberRepository, -); - -$repositoryManager = new RunSubscriptionEngineRepositoryManager( - new DefaultRepositoryManager( - $aggregateRegistry, - $eventStore, - ), - $engine, -); - -$hotelRepository = $repositoryManager->get(Hotel::class); +$hotelRepository = $container->get(RepositoryManager::class)->get(Hotel::class); ``` :::note +All options of the configuration are described on the [container](container.md) page. You can find out more about the [store](store.md). ::: @@ -334,31 +308,14 @@ So that we can actually write the data to a database, we need the associated schema and databases. ```php -use Doctrine\DBAL\Connection; -use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; -use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; -use Patchlevel\EventSourcing\Store\Store; +use Patchlevel\EventSourcing\Container\Container; +use Patchlevel\EventSourcing\Schema\SchemaDirector; use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; - -/** - * @var Connection $connection - * @var Store $eventStore - * @var SubscriptionStore $subscriptionStore - */ -$schemaDirector = new DoctrineSchemaDirector( - $connection, - new ChainDoctrineSchemaConfigurator([ - $eventStore, - $subscriptionStore, - ]), -); - -$schemaDirector->create(); -/** @var SubscriptionEngine $engine */ -$engine->execute(new Setup(skipBooting: true)); +/** @var Container $container */ +$container->get(SchemaDirector::class)->create(); +$container->get(SubscriptionEngine::class)->execute(new Setup(skipBooting: true)); ``` :::note You can use the predefined [cli commands](cli.md) for this. @@ -407,3 +364,4 @@ If there are still open questions, create a ticket on Github and we will try to * [How to store aggregates](repository.md) * [How to create a projection and processors](subscription.md) * [How to setup the database](store.md) +* [How to configure the container](container.md) diff --git a/docs/introduction.md b/docs/introduction.md index eb1890aff..e124e9c63 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -15,6 +15,7 @@ powered by the reliable Doctrine ecosystem and focused on developer experience. * Smooth [upcasting](upcasting.md) of old events * Simple setup with [schema management](store.md) and [doctrine migration](store.md) * Built in [cli commands](cli.md) with [symfony](https://symfony.com/) +* Ready to use [PSR-11 container](container.md) for plain PHP and any framework * and much more... ## Installation diff --git a/docs/project.json b/docs/project.json index 3ba963303..1e3ff96d8 100644 --- a/docs/project.json +++ b/docs/project.json @@ -46,6 +46,10 @@ { "title": "Query Bus", "file": "query-bus.md" + }, + { + "title": "Container", + "file": "container.md" } ] }, diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index aa5f7e8ed..6343c3cd3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -66,12 +66,6 @@ parameters: count: 1 path: src/Message/Message.php - - - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\, array\ given\.$#' - identifier: argument.type - count: 1 - path: src/Message/Serializer/DefaultHeadersSerializer.php - - message: '#^Property Patchlevel\\EventSourcing\\Message\\Stream\:\:\$iterator \(Iterator\\|null\) does not accept IteratorIterator\\>\.$#' identifier: assign.propertyType @@ -168,12 +162,6 @@ parameters: count: 1 path: src/Serializer/Normalizer/IdNormalizer.php - - - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Hydrator\:\:hydrate\(\) expects array\, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Snapshot/DefaultSnapshotStore.php - - message: '#^Patchlevel\\EventSourcing\\Snapshot\\DefaultSnapshotStore should not depend on Patchlevel\\EventSourcing\\Identifier\\Identifier$#' identifier: phpat.testSnapshotCanOnlyDependOnAllowedLayers @@ -375,19 +363,19 @@ parameters: - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertArrayHasKey\(\) with 0 and array\{array\\} will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType - count: 4 + count: 1 path: tests/Integration/PersonalData/PersonalDataTest.php - message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Tests\\\\Integration\\\\PersonalData\\\\Profile'' and Patchlevel\\EventSourcing\\Tests\\Integration\\PersonalData\\Profile will always evaluate to true\.$#' identifier: staticMethod.alreadyNarrowedType - count: 8 + count: 6 path: tests/Integration/PersonalData/PersonalDataTest.php - message: '#^Parameter \#2 \$haystack of static method PHPUnit\\Framework\\Assert\:\:assertStringNotContainsString\(\) expects string, mixed given\.$#' identifier: argument.type - count: 3 + count: 1 path: tests/Integration/PersonalData/PersonalDataTest.php - @@ -444,6 +432,126 @@ parameters: count: 5 path: tests/Unit/CommandBus/InstantRetryCommandBusTest.php + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\DebugCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\DebugCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\ShowAggregateCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\ShowAggregateCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\ShowCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\ShowCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\SubscriptionBootCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\SubscriptionBootCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\SubscriptionPauseCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\SubscriptionPauseCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\SubscriptionReactivateCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\SubscriptionReactivateCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\SubscriptionRemoveCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\SubscriptionRemoveCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\SubscriptionRunCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\SubscriptionRunCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\SubscriptionSetupCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\SubscriptionSetupCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\SubscriptionStatusCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\SubscriptionStatusCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\SubscriptionTeardownCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\SubscriptionTeardownCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Console\\\\Command\\\\WatchCommand'' and Patchlevel\\EventSourcing\\Console\\Command\\WatchCommand will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Metadata\\\\AggregateRoot\\\\AggregateRootRegistry'' and Patchlevel\\EventSourcing\\Metadata\\AggregateRoot\\AggregateRootRegistry will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Metadata\\\\Event\\\\EventRegistry'' and Patchlevel\\EventSourcing\\Metadata\\Event\\EventRegistry will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Metadata\\\\Message\\\\MessageHeaderRegistry'' and Patchlevel\\EventSourcing\\Metadata\\Message\\MessageHeaderRegistry will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Repository\\\\MessageDecorator\\\\SplitStreamDecorator'' and Patchlevel\\EventSourcing\\Repository\\MessageDecorator\\SplitStreamDecorator will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Serializer\\\\DefaultEventSerializer'' and Patchlevel\\EventSourcing\\Serializer\\DefaultEventSerializer will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Store\\\\StreamDoctrineDbalStore'' and Patchlevel\\EventSourcing\\Store\\StreamDoctrineDbalStore will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Patchlevel\\\\EventSourcing\\\\Subscription\\\\RetryStrategy\\\\RetryStrategyRepository'' and Patchlevel\\EventSourcing\\Subscription\\RetryStrategy\\RetryStrategyRepository will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 8 + path: tests/Unit/Container/FactoryTest.php + + - + message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\BrokenAutoInitializableProfile\:\:\$id is unused\.$#' + identifier: property.unused + count: 1 + path: tests/Unit/Fixture/BrokenAutoInitializableProfile.php + - message: '#^Parameter \#1 \$data of static method Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\Message\:\:fromArray\(\) expects array\{id\: string, text\: string, createdAt\: string\}, array\ given\.$#' identifier: argument.type @@ -462,12 +570,6 @@ parameters: count: 1 path: tests/Unit/Fixture/ProfileInvalid.php - - - message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\BrokenAutoInitializableProfile\:\:\$id is unused\.$#' - identifier: property.unused - count: 1 - path: tests/Unit/Fixture/BrokenAutoInitializableProfile.php - - message: '#^Property Patchlevel\\EventSourcing\\Tests\\Unit\\Fixture\\ProfileWithAggregateStream\:\:\$id is unused\.$#' identifier: property.unused diff --git a/phpstan.neon.dist b/phpstan.neon.dist index f9cfcc06c..a24f0a633 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -9,6 +9,9 @@ parameters: paths: - src - tests + excludePaths: + # needs third party containers, see .github/workflows/container-integration.yml + - tests/Integration/Container/*Test.php ignoreErrors: - identifier: missingType.generics diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3bc60af1a..cdbe92528 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -19,6 +19,8 @@ ./tests/Integration + + ./tests/Integration/Container diff --git a/src/Container/Configuration.php b/src/Container/Configuration.php new file mode 100644 index 000000000..4e2d6eee5 --- /dev/null +++ b/src/Container/Configuration.php @@ -0,0 +1,502 @@ +, + * read_only?: bool, + * migrate_to_new_store?: bool|array{ + * enabled?: bool, + * type?: StoreType, + * service?: string|null, + * options?: array, + * translators?: list, + * }, + * }, + * aggregates?: list, + * events?: list, + * headers?: list, + * event_bus?: bool|array{ + * enabled?: bool, + * type?: 'default'|'psr14'|'custom', + * service?: string|null, + * listeners?: list, + * }, + * command_bus?: bool|array{ + * enabled?: bool, + * register_aggregate_handlers?: bool, + * handlers?: list, + * handler_providers?: list, + * instant_retry?: bool|array{ + * enabled?: bool, + * default_max_retries?: positive-int, + * default_exceptions?: list>, + * }, + * }, + * query_bus?: bool|array{ + * enabled?: bool, + * handlers?: list, + * handler_providers?: list, + * }, + * clock?: array{freeze?: string|null, service?: string|null}, + * logger?: array{service?: string|null}, + * migration?: bool|array{enabled?: bool, namespace?: string, path?: string}, + * snapshot_stores?: array, + * subscription?: array{ + * subscribers?: list, + * store?: array{ + * type?: 'dbal'|'in_memory'|'static_in_memory'|'custom', + * service?: string|null, + * options?: array{table_name?: string}, + * }, + * retry_strategies?: array, + * }>, + * default_retry_strategy?: string, + * sync?: bool|array{ + * enabled?: bool, + * ids?: list, + * groups?: list, + * catch_up_limit?: positive-int|null, + * throw_on_error?: bool, + * }, + * gap_detection?: bool|array{enabled?: bool, retries_in_ms?: list, detection_window?: string|null}, + * event_filtered_message_loader?: bool|array{enabled?: bool}, + * event_emitter?: bool|array{enabled?: bool}, + * argument_resolvers?: list, + * cleanup_task_handlers?: list, + * }, + * dcb?: bool|array{enabled?: bool}, + * hydrator?: array{ + * default_lazy?: bool, + * cryptography?: bool|array{ + * enabled?: bool, + * algorithm?: non-empty-string, + * cipher_key_store?: string|null, + * }, + * lifecycle?: bool|array{enabled?: bool}, + * extensions?: list, + * guessers?: list, + * upcasters?: array{ + * before_encoding?: list, + * before_transform?: list, + * }, + * }, + * message_decorators?: list, + * services?: array, + * parameters?: array, + * } + * @phpstan-type NormalizedConfig array{ + * connection: array{ + * url: string|null, + * service: string|null, + * provide_dedicated_connection: bool, + * }, + * store: array{ + * type: StoreType, + * service: string|null, + * options: array, + * read_only: bool, + * migrate_to_new_store: array{ + * enabled: bool, + * type: StoreType, + * service: string|null, + * options: array, + * translators: list, + * }, + * }, + * aggregates: list, + * events: list, + * headers: list, + * event_bus: array{ + * enabled: bool, + * type: 'default'|'psr14'|'custom', + * service: string|null, + * listeners: list, + * }, + * command_bus: array{ + * enabled: bool, + * register_aggregate_handlers: bool, + * handlers: list, + * handler_providers: list, + * instant_retry: array{ + * enabled: bool, + * default_max_retries: positive-int, + * default_exceptions: list>, + * }, + * }, + * query_bus: array{ + * enabled: bool, + * handlers: list, + * handler_providers: list, + * }, + * clock: array{freeze: string|null, service: string|null}, + * logger: array{service: string|null}, + * migration: array{enabled: bool, namespace: string, path: string}, + * snapshot_stores: array, + * subscription: array{ + * subscribers: list, + * store: array{ + * type: 'dbal'|'in_memory'|'static_in_memory'|'custom', + * service: string|null, + * options: array{table_name: string}, + * }, + * retry_strategies: array, + * }>, + * default_retry_strategy: string, + * sync: array{ + * enabled: bool, + * ids: list, + * groups: list, + * catch_up_limit: positive-int|null, + * throw_on_error: bool, + * }, + * gap_detection: array{enabled: bool, retries_in_ms: list, detection_window: string|null}, + * event_filtered_message_loader: array{enabled: bool}, + * event_emitter: array{enabled: bool}, + * argument_resolvers: list, + * cleanup_task_handlers: list, + * }, + * dcb: array{enabled: bool}, + * hydrator: array{ + * default_lazy: bool, + * cryptography: array{enabled: bool, algorithm: non-empty-string, cipher_key_store: string|null}, + * lifecycle: array{enabled: bool}, + * extensions: list, + * guessers: list, + * upcasters: array{ + * before_encoding: list, + * before_transform: list, + * }, + * }, + * message_decorators: list, + * services: array, + * parameters: array, + * } + * @phpstan-import-type Node from ConfigurationNormalizer + */ +final class Configuration +{ + public const STORE_DBAL_STREAM = 'dbal_stream'; + public const STORE_DBAL_TAGGABLE = 'dbal_taggable'; + public const STORE_IN_MEMORY = 'in_memory'; + public const STORE_CUSTOM = 'custom'; + + public const EVENT_BUS_DEFAULT = 'default'; + public const EVENT_BUS_PSR14 = 'psr14'; + public const EVENT_BUS_CUSTOM = 'custom'; + + public const SUBSCRIPTION_STORE_DBAL = 'dbal'; + public const SUBSCRIPTION_STORE_IN_MEMORY = 'in_memory'; + public const SUBSCRIPTION_STORE_STATIC_IN_MEMORY = 'static_in_memory'; + public const SUBSCRIPTION_STORE_CUSTOM = 'custom'; + + public const SUBSCRIPTION_RETRY_CLOCK_BASED = 'clock_based'; + public const SUBSCRIPTION_RETRY_NO_RETRY = 'no_retry'; + public const SUBSCRIPTION_RETRY_CUSTOM = 'custom'; + + private const STORE_TYPES = [self::STORE_DBAL_STREAM, self::STORE_DBAL_TAGGABLE, self::STORE_IN_MEMORY, self::STORE_CUSTOM]; + + /** + * Merges the defaults into the given configuration and validates it. + * + * @param Config $config + * + * @return NormalizedConfig + * + * @throws InvalidConfiguration + */ + public static function normalize(array $config): array + { + /** @var NormalizedConfig $normalized */ + $normalized = ConfigurationNormalizer::normalize(self::tree(), $config, 'event_sourcing'); + + self::validate($normalized); + + return $normalized; + } + + /** + * @internal + * + * @return Node + */ + public static function tree(): array + { + $storeType = ConfigurationNormalizer::named( + 'StoreType', + ConfigurationNormalizer::enum(self::STORE_TYPES, self::STORE_DBAL_STREAM), + ); + + // the options depend on the store, so they are passed through as they are + $storeOptions = ConfigurationNormalizer::map(ConfigurationNormalizer::variable()); + + return ConfigurationNormalizer::named('Config', ConfigurationNormalizer::struct([ + 'connection' => ConfigurationNormalizer::struct([ + 'url' => ConfigurationNormalizer::string(null), + 'service' => ConfigurationNormalizer::string(null), + 'provide_dedicated_connection' => ConfigurationNormalizer::bool(false), + ]), + 'store' => ConfigurationNormalizer::struct([ + 'type' => $storeType, + 'service' => ConfigurationNormalizer::string(null), + 'options' => $storeOptions, + 'read_only' => ConfigurationNormalizer::bool(false), + 'migrate_to_new_store' => ConfigurationNormalizer::toggle(false, [ + 'type' => $storeType, + 'service' => ConfigurationNormalizer::string(null), + 'options' => $storeOptions, + 'translators' => ConfigurationNormalizer::services(Translator::class), + ]), + ]), + 'aggregates' => ConfigurationNormalizer::stringList(), + 'events' => ConfigurationNormalizer::stringList(), + 'headers' => ConfigurationNormalizer::stringList(), + 'event_bus' => ConfigurationNormalizer::toggle(false, [ + 'type' => ConfigurationNormalizer::enum( + [self::EVENT_BUS_DEFAULT, self::EVENT_BUS_PSR14, self::EVENT_BUS_CUSTOM], + self::EVENT_BUS_DEFAULT, + ), + 'service' => ConfigurationNormalizer::string(null), + 'listeners' => ConfigurationNormalizer::services(), + ]), + 'command_bus' => ConfigurationNormalizer::toggle(true, [ + 'register_aggregate_handlers' => ConfigurationNormalizer::bool(true), + 'handlers' => ConfigurationNormalizer::services(), + 'handler_providers' => ConfigurationNormalizer::services(CommandBusHandlerProvider::class), + 'instant_retry' => ConfigurationNormalizer::toggle(true, [ + 'default_max_retries' => ConfigurationNormalizer::int(3, 1), + 'default_exceptions' => ConfigurationNormalizer::typed( + 'list>', + ConfigurationNormalizer::stringList([AggregateOutdated::class]), + ), + ]), + ]), + 'query_bus' => ConfigurationNormalizer::toggle(true, [ + 'handlers' => ConfigurationNormalizer::services(), + 'handler_providers' => ConfigurationNormalizer::services(QueryBusHandlerProvider::class), + ]), + 'clock' => ConfigurationNormalizer::struct([ + 'freeze' => ConfigurationNormalizer::string(null), + 'service' => ConfigurationNormalizer::string(null), + ]), + 'logger' => ConfigurationNormalizer::struct([ + 'service' => ConfigurationNormalizer::string(null), + ]), + 'migration' => ConfigurationNormalizer::toggle(true, [ + 'namespace' => ConfigurationNormalizer::string('EventSourcingMigrations', false), + 'path' => ConfigurationNormalizer::string('migrations', false), + ]), + 'snapshot_stores' => ConfigurationNormalizer::map( + ConfigurationNormalizer::objectOr( + SnapshotAdapter::class, + ConfigurationNormalizer::named('SnapshotStoreDefinition', ConfigurationNormalizer::struct([ + 'type' => ConfigurationNormalizer::enum(['psr6', 'psr16', 'custom'], 'psr6'), + 'service' => ConfigurationNormalizer::requiredString(), + ])), + ), + ), + 'subscription' => ConfigurationNormalizer::struct([ + 'subscribers' => ConfigurationNormalizer::services(), + 'store' => ConfigurationNormalizer::struct([ + 'type' => ConfigurationNormalizer::enum( + [ + self::SUBSCRIPTION_STORE_DBAL, + self::SUBSCRIPTION_STORE_IN_MEMORY, + self::SUBSCRIPTION_STORE_STATIC_IN_MEMORY, + self::SUBSCRIPTION_STORE_CUSTOM, + ], + self::SUBSCRIPTION_STORE_DBAL, + ), + 'service' => ConfigurationNormalizer::string(null), + 'options' => ConfigurationNormalizer::struct([ + 'table_name' => ConfigurationNormalizer::string('subscriptions', false), + ]), + ]), + 'retry_strategies' => ConfigurationNormalizer::map( + ConfigurationNormalizer::struct([ + 'type' => ConfigurationNormalizer::requiredEnum([ + self::SUBSCRIPTION_RETRY_CLOCK_BASED, + self::SUBSCRIPTION_RETRY_NO_RETRY, + self::SUBSCRIPTION_RETRY_CUSTOM, + ]), + 'service' => ConfigurationNormalizer::string(null), + 'options' => ConfigurationNormalizer::map(ConfigurationNormalizer::variable()), + ]), + [ + 'default' => [ + 'type' => self::SUBSCRIPTION_RETRY_CLOCK_BASED, + 'service' => null, + 'options' => ['base_delay' => 5, 'delay_factor' => 2.0, 'max_attempts' => 5], + ], + 'no_retry' => [ + 'type' => self::SUBSCRIPTION_RETRY_NO_RETRY, + 'service' => null, + 'options' => [], + ], + ], + ), + 'default_retry_strategy' => ConfigurationNormalizer::string('default', false), + 'sync' => ConfigurationNormalizer::toggle(false, [ + 'ids' => ConfigurationNormalizer::stringList(), + 'groups' => ConfigurationNormalizer::stringList(), + 'catch_up_limit' => ConfigurationNormalizer::int(null, 1), + 'throw_on_error' => ConfigurationNormalizer::bool(false), + ]), + 'gap_detection' => ConfigurationNormalizer::toggle(false, [ + 'retries_in_ms' => ConfigurationNormalizer::intList([0, 5, 50, 500]), + 'detection_window' => ConfigurationNormalizer::string('PT5M'), + ]), + 'event_filtered_message_loader' => ConfigurationNormalizer::toggle(false, []), + 'event_emitter' => ConfigurationNormalizer::toggle(false, []), + 'argument_resolvers' => ConfigurationNormalizer::services(ArgumentResolver::class), + 'cleanup_task_handlers' => ConfigurationNormalizer::services(CleanupTaskHandler::class), + ]), + 'dcb' => ConfigurationNormalizer::toggle(false, []), + 'hydrator' => ConfigurationNormalizer::struct([ + 'default_lazy' => ConfigurationNormalizer::bool(false), + 'cryptography' => ConfigurationNormalizer::toggle(false, [ + 'algorithm' => ConfigurationNormalizer::typed( + 'non-empty-string', + ConfigurationNormalizer::string(OpensslCipherKeyFactory::DEFAULT_METHOD, false), + ), + 'cipher_key_store' => ConfigurationNormalizer::string(null), + ]), + 'lifecycle' => ConfigurationNormalizer::toggle(false, []), + 'extensions' => ConfigurationNormalizer::services(Extension::class), + 'guessers' => ConfigurationNormalizer::services(Guesser::class), + 'upcasters' => ConfigurationNormalizer::struct([ + 'before_encoding' => ConfigurationNormalizer::services(Upcaster::class), + 'before_transform' => ConfigurationNormalizer::services(Upcaster::class), + ]), + ]), + 'message_decorators' => ConfigurationNormalizer::services(MessageDecorator::class), + 'services' => ConfigurationNormalizer::map(ConfigurationNormalizer::typed( + 'object|\\Closure(\\Patchlevel\\EventSourcing\\Container\\Container): object', + ConfigurationNormalizer::object(), + )), + 'parameters' => ConfigurationNormalizer::map(ConfigurationNormalizer::variable()), + ])); + } + + /** @param NormalizedConfig $config */ + private static function validate(array $config): void + { + $connection = $config['connection']; + + if ($connection['url'] === null && $connection['service'] === null) { + throw new InvalidConfiguration('Either "event_sourcing.connection.url" or "event_sourcing.connection.service" is required.'); + } + + if ($connection['url'] !== null && $connection['service'] !== null) { + throw new InvalidConfiguration('Only one of "event_sourcing.connection.url" and "event_sourcing.connection.service" can be set.'); + } + + if ($connection['provide_dedicated_connection'] && $connection['url'] === null) { + throw new InvalidConfiguration('"event_sourcing.connection.provide_dedicated_connection" is only possible with "url".'); + } + + $requiresService = [ + 'store' => $config['store']['type'] === self::STORE_CUSTOM, + 'store.migrate_to_new_store' => $config['store']['migrate_to_new_store']['enabled'] + && $config['store']['migrate_to_new_store']['type'] === self::STORE_CUSTOM, + 'event_bus' => $config['event_bus']['enabled'] && $config['event_bus']['type'] !== self::EVENT_BUS_DEFAULT, + 'subscription.store' => $config['subscription']['store']['type'] === self::SUBSCRIPTION_STORE_CUSTOM, + ]; + + $services = [ + 'store' => $config['store']['service'], + 'store.migrate_to_new_store' => $config['store']['migrate_to_new_store']['service'], + 'event_bus' => $config['event_bus']['service'], + 'subscription.store' => $config['subscription']['store']['service'], + ]; + + foreach ($requiresService as $path => $required) { + if ($required && $services[$path] === null) { + throw new InvalidConfiguration(sprintf('"event_sourcing.%s.service" is required for this type.', $path)); + } + } + + foreach ($config['subscription']['retry_strategies'] as $name => $retryStrategy) { + if ($retryStrategy['type'] === self::SUBSCRIPTION_RETRY_CUSTOM && $retryStrategy['service'] === null) { + throw new InvalidConfiguration(sprintf( + '"event_sourcing.subscription.retry_strategies.%s.service" is required for this type.', + $name, + )); + } + } + + if (!array_key_exists($config['subscription']['default_retry_strategy'], $config['subscription']['retry_strategies'])) { + throw new InvalidConfiguration(sprintf( + 'The default retry strategy "%s" is not defined in "event_sourcing.subscription.retry_strategies".', + $config['subscription']['default_retry_strategy'], + )); + } + + if ($config['store']['read_only'] && $config['store']['type'] !== self::STORE_DBAL_STREAM) { + throw new InvalidConfiguration('"event_sourcing.store.read_only" is only supported by the "dbal_stream" store.'); + } + + if ($config['subscription']['event_emitter']['enabled'] && $config['store']['read_only']) { + throw new InvalidConfiguration('"event_sourcing.subscription.event_emitter" does not support a read only store.'); + } + + if ($config['dcb']['enabled'] && $config['store']['type'] === self::STORE_DBAL_STREAM) { + throw new InvalidConfiguration('"event_sourcing.dcb" requires a store with tag support, e.g. "dbal_taggable".'); + } + + if ( + $config['subscription']['gap_detection']['enabled'] + && $config['subscription']['event_filtered_message_loader']['enabled'] + ) { + throw new InvalidConfiguration( + '"event_sourcing.subscription.gap_detection" and "event_sourcing.subscription.event_filtered_message_loader" can not be combined.', + ); + } + } +} diff --git a/src/Container/ConfigurationNormalizer.php b/src/Container/ConfigurationNormalizer.php new file mode 100644 index 000000000..8ba9f9a4f --- /dev/null +++ b/src/Container/ConfigurationNormalizer.php @@ -0,0 +1,494 @@ + + */ +final class ConfigurationNormalizer +{ + /** + * @param Node $node + * + * @throws InvalidConfiguration + */ + public static function normalize(array $node, mixed $value, string $path): mixed + { + return self::normalizeNode($node, $value, true, $path); + } + + /** + * @param array $children + * + * @return Node + */ + public static function struct(array $children, bool $omitMissing = false): array + { + return ['kind' => 'struct', 'children' => $children, 'omit_missing' => $omitMissing]; + } + + /** + * A struct with an "enabled" key, which can also be configured with a bool. + * + * @param array $children + * + * @return Node + */ + public static function toggle(bool $enabled, array $children): array + { + return ['kind' => 'toggle', 'enabled' => $enabled, 'children' => $children]; + } + + /** + * Without a default, the value is optional and null is allowed, unless nullable is set to false. + * + * @return Node + */ + public static function bool(bool|null $default, bool|null $nullable = null): array + { + return ['kind' => 'bool', 'default' => $default, 'nullable' => $nullable ?? $default === null]; + } + + /** @return Node */ + public static function int(int|null $default, int|null $min = null, bool|null $nullable = null): array + { + return ['kind' => 'int', 'default' => $default, 'min' => $min, 'nullable' => $nullable ?? $default === null]; + } + + /** @return Node */ + public static function float(float|null $default, bool|null $nullable = null): array + { + return ['kind' => 'float', 'default' => $default, 'nullable' => $nullable ?? $default === null]; + } + + /** @return Node */ + public static function string(string|null $default, bool $nullable = true): array + { + return ['kind' => 'string', 'default' => $default, 'nullable' => $nullable]; + } + + /** @return Node */ + public static function requiredString(): array + { + return ['kind' => 'string', 'default' => null, 'nullable' => false, 'required' => true]; + } + + /** + * @param list $values + * + * @return Node + */ + public static function enum(array $values, string $default): array + { + return ['kind' => 'enum', 'values' => $values, 'default' => $default]; + } + + /** + * @param list $values + * + * @return Node + */ + public static function requiredEnum(array $values): array + { + return ['kind' => 'enum', 'values' => $values, 'default' => null, 'required' => true]; + } + + /** + * @param list $default + * + * @return Node + */ + public static function stringList(array $default = []): array + { + return ['kind' => 'list', 'item' => 'string', 'default' => $default]; + } + + /** + * @param list $default + * + * @return Node + */ + public static function intList(array $default = []): array + { + return ['kind' => 'list', 'item' => 'int', 'default' => $default]; + } + + /** + * A list of objects or service ids. + * + * @param class-string|null $class + * + * @return Node + */ + public static function services(string|null $class = null): array + { + return ['kind' => 'list', 'item' => 'service', 'class' => $class, 'default' => []]; + } + + /** + * @param Node $prototype + * @param array $default + * + * @return Node + */ + public static function map(array $prototype, array $default = []): array + { + return ['kind' => 'map', 'prototype' => $prototype, 'default' => $default]; + } + + /** + * @param class-string $class + * @param Node $node + * + * @return Node + */ + public static function objectOr(string $class, array $node): array + { + return ['kind' => 'object_or', 'class' => $class, 'node' => $node]; + } + + /** @return Node */ + public static function object(): array + { + return ['kind' => 'object']; + } + + /** @return Node */ + public static function variable(): array + { + return ['kind' => 'variable', 'default' => null]; + } + + /** + * Gives the node its own type alias in the generated configuration shape. + * + * @param Node $node + * + * @return Node + */ + public static function named(string $name, array $node): array + { + return [...$node, 'name' => $name]; + } + + /** + * Overrides the type of the node in the generated configuration shape, for types the node can not express. + * + * @param Node $node + * + * @return Node + */ + public static function typed(string $type, array $node): array + { + return [...$node, 'type' => $type]; + } + + /** @param Node $node */ + private static function normalizeNode(array $node, mixed $value, bool $present, string $path): mixed + { + if (!$present && ($node['required'] ?? false) === true) { + throw new InvalidConfiguration(sprintf('The child config "%s" must be configured.', $path)); + } + + return match ($node['kind']) { + 'struct' => self::normalizeStruct($node, $present ? $value : [], $path), + 'toggle' => self::normalizeToggle($node, $value, $present, $path), + 'bool' => $present ? self::assertType($value, 'bool', is_bool($value) || ($value === null && $node['nullable'] === true), $path) : $node['default'], + 'int' => $present ? self::normalizeInt($node, $value, $path) : $node['default'], + 'float' => $present ? self::normalizeFloat($node, $value, $path) : $node['default'], + 'string' => $present ? self::normalizeString($node, $value, $path) : $node['default'], + 'enum' => $present ? self::normalizeEnum($node, $value, $path) : $node['default'], + 'list' => $present ? self::normalizeList($node, $value, $path) : $node['default'], + 'map' => $present ? self::normalizeMap($node, $value, $path) : $node['default'], + 'object_or' => self::normalizeObjectOr($node, $value, $path), + 'object' => self::assertType($value, 'object', is_object($value), $path), + 'variable' => $present ? $value : $node['default'], + default => throw new LogicException('Unknown configuration node.'), + }; + } + + /** + * @param Node $node + * + * @return array + */ + private static function normalizeStruct(array $node, mixed $value, string $path): array + { + if ($value === null) { + $value = []; + } + + if (!is_array($value)) { + self::invalidType($value, 'array', $path); + } + + /** @var array $children */ + $children = $node['children']; + + foreach (array_keys($value) as $key) { + if (is_string($key) && array_key_exists($key, $children)) { + continue; + } + + throw new InvalidConfiguration(sprintf( + 'Unrecognized option "%s" under "%s". Available options are "%s".', + $key, + $path, + implode('", "', array_keys($children)), + )); + } + + $result = []; + + foreach ($children as $key => $child) { + $present = array_key_exists($key, $value); + + if (!$present && $node['omit_missing'] === true) { + continue; + } + + $result[$key] = self::normalizeNode($child, $present ? $value[$key] : null, $present, $path . '.' . $key); + } + + return $result; + } + + /** + * @param Node $node + * + * @return array + */ + private static function normalizeToggle(array $node, mixed $value, bool $present, string $path): array + { + if (!$present) { + $value = ['enabled' => $node['enabled']]; + } + + if (is_bool($value)) { + $value = ['enabled' => $value]; + } + + if (!is_array($value)) { + self::invalidType($value, 'bool" or "array', $path); + } + + $enabled = $value['enabled'] ?? true; + + if (!is_bool($enabled)) { + self::invalidType($enabled, 'bool', $path . '.enabled'); + } + + unset($value['enabled']); + + /** @var array $children */ + $children = $node['children']; + + return [ + 'enabled' => $enabled, + ...self::normalizeStruct(self::struct($children), $value, $path), + ]; + } + + /** @param Node $node */ + private static function normalizeInt(array $node, mixed $value, string $path): int|null + { + if ($value === null && $node['nullable'] === true) { + return null; + } + + if (!is_int($value)) { + self::invalidType($value, 'int', $path); + } + + if (is_int($node['min']) && $value < $node['min']) { + throw new InvalidConfiguration(sprintf( + 'The value %d is too small for path "%s". Should be greater than or equal to %d.', + $value, + $path, + $node['min'], + )); + } + + return $value; + } + + /** @param Node $node */ + private static function normalizeFloat(array $node, mixed $value, string $path): float|null + { + if ($value === null && $node['nullable'] === true) { + return null; + } + + if (is_float($value)) { + return $value; + } + + if (is_int($value)) { + return (float)$value; + } + + self::invalidType($value, 'float', $path); + } + + /** @param Node $node */ + private static function normalizeString(array $node, mixed $value, string $path): string|null + { + if ($value === null && $node['nullable'] === true) { + return null; + } + + if (!is_string($value)) { + self::invalidType($value, 'string', $path); + } + + return $value; + } + + /** @param Node $node */ + private static function normalizeEnum(array $node, mixed $value, string $path): string + { + /** @var list $values */ + $values = $node['values']; + + if (!is_string($value) || !in_array($value, $values, true)) { + throw new InvalidConfiguration(sprintf( + 'The value %s is not allowed for path "%s". Permissible values: "%s".', + is_string($value) ? '"' . $value . '"' : get_debug_type($value), + $path, + implode('", "', $values), + )); + } + + return $value; + } + + /** + * @param Node $node + * + * @return list + */ + private static function normalizeList(array $node, mixed $value, string $path): array + { + if (!is_array($value) || !array_is_list($value)) { + self::invalidType($value, 'list', $path); + } + + foreach ($value as $index => $item) { + $itemPath = sprintf('%s.%d', $path, $index); + + match ($node['item']) { + 'string' => self::assertType($item, 'string', is_string($item), $itemPath), + 'int' => self::assertType($item, 'int', is_int($item), $itemPath), + 'service' => self::assertService($node, $item, $itemPath), + default => throw new LogicException('Unknown configuration list item.'), + }; + } + + return $value; + } + + /** + * @param Node $node + * + * @return array + */ + private static function normalizeMap(array $node, mixed $value, string $path): array + { + if (!is_array($value)) { + self::invalidType($value, 'array', $path); + } + + /** @var Node $prototype */ + $prototype = $node['prototype']; + $result = []; + + foreach ($value as $key => $item) { + if (!is_string($key)) { + throw new InvalidConfiguration(sprintf('The keys of "%s" must be strings, got "%s".', $path, $key)); + } + + $result[$key] = self::normalizeNode($prototype, $item, true, $path . '.' . $key); + } + + return $result; + } + + /** @param Node $node */ + private static function normalizeObjectOr(array $node, mixed $value, string $path): mixed + { + if (!is_object($value)) { + /** @var Node $inner */ + $inner = $node['node']; + + return self::normalizeNode($inner, $value, true, $path); + } + + /** @var class-string $class */ + $class = $node['class']; + + if (!$value instanceof $class) { + self::invalidType($value, $class, $path); + } + + return $value; + } + + /** @param Node $node */ + private static function assertService(array $node, mixed $value, string $path): mixed + { + if (is_string($value)) { + return $value; + } + + /** @var class-string|null $class */ + $class = $node['class']; + + if ($class === null) { + return self::assertType($value, 'object" or "string', is_object($value), $path); + } + + return self::assertType($value, $class . '" or "string', $value instanceof $class, $path); + } + + private static function assertType(mixed $value, string $expected, bool $valid, string $path): mixed + { + if (!$valid) { + self::invalidType($value, $expected, $path); + } + + return $value; + } + + private static function invalidType(mixed $value, string $expected, string $path): never + { + throw new InvalidConfiguration(sprintf( + 'Invalid type for path "%s". Expected "%s", but got "%s".', + $path, + $expected, + get_debug_type($value), + )); + } +} diff --git a/src/Container/ConfigurationShapeGenerator.php b/src/Container/ConfigurationShapeGenerator.php new file mode 100644 index 000000000..8ca4792f7 --- /dev/null +++ b/src/Container/ConfigurationShapeGenerator.php @@ -0,0 +1,478 @@ + */ + private array $aliases = []; + + /** + * class => name used in the file + * + * @var array + */ + private array $imports = []; + + /** + * Returns the source with the generated array shapes. + * + * @param Node $tree + */ + public function generate(array $tree, string $source): string + { + $this->namespace = preg_match('/^namespace ([^;]+);$/m', $source, $match) === 1 ? $match[1] : ''; + $this->aliases = []; + $this->imports = $this->parseImports($source); + + $imports = $this->imports; + + $this->alias($tree); + + $source = $this->replaceDocBlock($source); + + // only touch the imports if classes were added, so the use block stays as it is otherwise + if ($this->imports === $imports) { + return $source; + } + + return $this->replaceImports($source); + } + + /** @return array */ + private function parseImports(string $source): array + { + preg_match_all('/^use (?!function |const )([^;\s]+)(?: as (\w+))?;$/m', $source, $matches, PREG_SET_ORDER); + + $imports = []; + + foreach ($matches as $match) { + $imports[$match[1]] = ($match[2] ?? '') !== '' ? $match[2] : $this->shortName($match[1]); + } + + return $imports; + } + + private function replaceDocBlock(string $source): string + { + if (preg_match('/\n(final )?class \w+/', $source, $match, PREG_OFFSET_CAPTURE) !== 1) { + throw new LogicException('No class found.'); + } + + $head = substr($source, 0, $match[0][1]); + $start = strrpos($head, '/**'); + $end = strrpos($head, '*/'); + + if ($start === false || $end === false || $end < $start) { + throw new LogicException('The class has no docblock.'); + } + + return substr($source, 0, $start) + . $this->docBlock(substr($source, $start, $end + 2 - $start)) + . substr($source, $end + 2); + } + + /** + * Keeps the description and all tags except "@phpstan-type" and puts the generated type aliases in front of them. + */ + private function docBlock(string $docBlock): string + { + $description = []; + $tags = []; + + foreach (array_slice(explode("\n", $docBlock), 1, -1) as $line) { + $content = substr(ltrim($line), 2); + + if (str_starts_with($content, '@')) { + $tags[] = [$content]; + + continue; + } + + if ($tags === []) { + $description[] = $content; + + continue; + } + + $tags[count($tags) - 1][] = $content; + } + + while ($description !== [] && trim($description[count($description) - 1]) === '') { + unset($description[count($description) - 1]); + } + + $lines = array_values($description); + + if ($lines !== []) { + $lines[] = ''; + } + + foreach ($this->aliases as $name => $type) { + $lines = [...$lines, ...explode("\n", sprintf('@phpstan-type %s %s', $name, $type))]; + } + + foreach ($tags as $tag) { + if (str_starts_with($tag[0], '@phpstan-type ')) { + continue; + } + + $lines = [...$lines, ...$tag]; + } + + return sprintf("/**\n%s\n */", implode("\n", array_map( + static fn (string $line): string => $line === '' ? ' *' : ' * ' . $line, + $lines, + ))); + } + + private function replaceImports(string $source): string + { + $imports = $this->imports; + uksort($imports, static fn (string $a, string $b): int => strcasecmp($a, $b)); + + $uses = []; + + foreach ($imports as $class => $name) { + $uses[] = $this->shortName($class) === $name + ? sprintf('use %s;', $class) + : sprintf('use %s as %s;', $class, $name); + } + + $block = implode("\n", $uses); + + preg_match_all('/^use (?!function |const )[^;]+;$/m', $source, $matches, PREG_OFFSET_CAPTURE); + + if ($matches[0] === []) { + $position = (int)strpos($source, "\n", (int)strpos($source, 'namespace ')) + 1; + + return substr($source, 0, $position) . "\n" . $block . "\n" . substr($source, $position); + } + + $first = $matches[0][0][1]; + $last = $matches[0][count($matches[0]) - 1]; + + return substr($source, 0, $first) . $block . substr($source, $last[1] + strlen($last[0])); + } + + /** + * Registers the type aliases of a named node and returns the alias for the requested mode. + * + * @param Node $node + */ + private function alias(array $node, string $mode = self::INPUT): string + { + $name = $node['name'] ?? null; + + if (!is_string($name)) { + throw new LogicException('Only named nodes have an alias.'); + } + + unset($node['name']); + + $normalizedName = 'Normalized' . $name; + + if (!array_key_exists($name, $this->aliases)) { + $input = $this->type($node, self::INPUT, 0); + $normalized = $this->type($node, self::NORMALIZED, 0); + + $this->aliases[$name] = $input; + + if ($input !== $normalized) { + $this->aliases[$normalizedName] = $normalized; + } + } + + if ($mode === self::NORMALIZED && array_key_exists($normalizedName, $this->aliases)) { + return $normalizedName; + } + + return $name; + } + + /** @param Node $node */ + private function type(array $node, string $mode, int $level): string + { + if (array_key_exists('name', $node)) { + return $this->alias($node, $mode); + } + + if (is_string($node['type'] ?? null)) { + return $this->importClasses($node['type']); + } + + return match ($node['kind']) { + 'struct' => $this->struct($this->nodes($node, 'children'), $mode, $level, $node['omit_missing'] === true), + 'toggle' => $this->toggle($this->nodes($node, 'children'), $mode, $level), + 'bool' => $this->nullable('bool', $node), + 'int' => $this->nullable($this->int($node['min']), $node), + 'float' => $this->nullable('float', $node), + 'string' => $this->nullable('string', $node), + 'enum' => $this->enum($node['values']), + 'list' => $this->list($node), + 'map' => sprintf('array', $this->type($this->node($node, 'prototype'), $mode, $level)), + 'object_or' => sprintf( + '%s|%s', + $this->import($this->className($node)), + $this->type($this->node($node, 'node'), $mode, $level), + ), + 'object' => 'object', + 'variable' => 'mixed', + default => throw new LogicException('Unknown configuration node.'), + }; + } + + /** @param array $children */ + private function struct(array $children, string $mode, int $level, bool $omitMissing): string + { + $entries = []; + + foreach ($children as $key => $child) { + $optional = $mode === self::INPUT + ? ($child['required'] ?? false) !== true + : $omitMissing; + + $entries[] = sprintf('%s%s: %s', $key, $optional ? '?' : '', $this->type($child, $mode, $level + 1)); + } + + return $this->shape($entries, $level); + } + + /** @param array $children */ + private function toggle(array $children, string $mode, int $level): string + { + $entries = [$mode === self::INPUT ? 'enabled?: bool' : 'enabled: bool']; + + foreach ($children as $key => $child) { + $optional = $mode === self::INPUT && ($child['required'] ?? false) !== true; + + $entries[] = sprintf('%s%s: %s', $key, $optional ? '?' : '', $this->type($child, $mode, $level + 1)); + } + + $shape = $this->shape($entries, $level); + + return $mode === self::INPUT ? 'bool|' . $shape : $shape; + } + + /** @param list $entries */ + private function shape(array $entries, int $level): string + { + $inline = sprintf('array{%s}', implode(', ', $entries)); + + if (!str_contains($inline, "\n") && strlen($inline) <= self::MAX_INLINE_LENGTH) { + return $inline; + } + + $indent = str_repeat(self::INDENT, $level + 1); + $lines = array_map(static fn (string $entry): string => $indent . $entry . ',', $entries); + + return sprintf("array{\n%s\n%s}", implode("\n", $lines), str_repeat(self::INDENT, $level)); + } + + private function int(mixed $min): string + { + if ($min === null) { + return 'int'; + } + + if ($min === 1) { + return 'positive-int'; + } + + if (!is_int($min)) { + throw new LogicException('The min value of an int node has to be an int.'); + } + + return sprintf('int<%d, max>', $min); + } + + /** @param Node $node */ + private function nullable(string $type, array $node): string + { + return ($node['nullable'] ?? false) === true ? $type . '|null' : $type; + } + + private function enum(mixed $values): string + { + if (!is_array($values)) { + throw new LogicException('The values of an enum node have to be a list.'); + } + + return implode('|', array_map( + static fn (mixed $value): string => is_string($value) + ? sprintf("'%s'", $value) + : throw new LogicException('The values of an enum node have to be strings.'), + $values, + )); + } + + /** @param Node $node */ + private function list(array $node): string + { + return match ($node['item']) { + 'string' => 'list', + 'int' => 'list', + 'service' => is_string($node['class']) + ? sprintf('list<%s|string>', $this->import($node['class'])) + : 'list', + default => throw new LogicException('Unknown configuration list item.'), + }; + } + + /** + * Replaces the fully qualified class names in a type with the names used in the file. + */ + private function importClasses(string $type): string + { + return (string)preg_replace_callback( + '/\\\\[A-Za-z_][A-Za-z0-9_]*(?:\\\\[A-Za-z_][A-Za-z0-9_]*)*/', + fn (array $match): string => $this->import(ltrim($match[0], '\\')), + $type, + ); + } + + /** + * Imports the class if needed and returns the name to reference it with. + */ + private function import(string $class): string + { + if (array_key_exists($class, $this->imports)) { + return $this->imports[$class]; + } + + $shortName = $this->shortName($class); + + if ($this->classNamespace($class) === $this->namespace) { + return $shortName; + } + + if (in_array($shortName, $this->imports, true)) { + $segments = explode('\\', $class); + $shortName = $segments[count($segments) - 2] . $shortName; + } + + $this->imports[$class] = $shortName; + + return $shortName; + } + + private function shortName(string $class): string + { + $segments = explode('\\', $class); + + return $segments[count($segments) - 1]; + } + + private function classNamespace(string $class): string + { + $segments = explode('\\', $class); + + return implode('\\', array_slice($segments, 0, -1)); + } + + /** @param Node $node */ + private function className(array $node): string + { + $class = $node['class'] ?? null; + + if (!is_string($class)) { + throw new LogicException('The node has no class.'); + } + + return $class; + } + + /** + * @param Node $node + * + * @return Node + */ + private function node(array $node, string $key): array + { + $child = $node[$key] ?? null; + + if (!is_array($child)) { + throw new LogicException(sprintf('The node has no "%s".', $key)); + } + + /** @var Node $result */ + $result = $child; + + return $result; + } + + /** + * @param Node $node + * + * @return array + */ + private function nodes(array $node, string $key): array + { + $children = $node[$key] ?? null; + + if (!is_array($children)) { + throw new LogicException(sprintf('The node has no "%s".', $key)); + } + + /** @var array $result */ + $result = $children; + + return $result; + } +} diff --git a/src/Container/Container.php b/src/Container/Container.php new file mode 100644 index 000000000..d81cc1e40 --- /dev/null +++ b/src/Container/Container.php @@ -0,0 +1,173 @@ + + * @phpstan-type FactoryMap array + * @phpstan-type AliasMap array + */ +final class Container implements ContainerInterface +{ + /** @var array */ + private array $building = []; + + /** + * @param ServiceMap $services + * @param FactoryMap $factories + * @param AliasMap $aliases + */ + public function __construct( + private array $services = [], + private array $factories = [], + private array $aliases = [], + private ContainerInterface|null $externalContainer = null, + ) { + } + + /** + * @param string|class-string $id + * + * @return ($id is class-string ? T : object) + * + * @template T of object + */ + public function get(string $id): mixed + { + $id = $this->resolveAlias($id); + + if (array_key_exists($id, $this->services)) { + return $this->services[$id]; + } + + if (!array_key_exists($id, $this->factories)) { + if ($this->externalContainer?->has($id) === true) { + return $this->externalService($id); + } + + throw new ServiceNotFound($id); + } + + if (array_key_exists($id, $this->building)) { + throw new ServiceCreationFailed(sprintf('Circular service reference detected for "%s".', $id)); + } + + try { + $this->building[$id] = true; + $this->services[$id] = ($this->factories[$id])($this); + } catch (ServiceNotFound | ServiceCreationFailed $exception) { + throw $exception; + } catch (Throwable $exception) { + throw new ServiceCreationFailed( + sprintf('Could not create service "%s": %s', $id, $exception->getMessage()), + 0, + $exception, + ); + } finally { + unset($this->building[$id]); + } + + return $this->services[$id]; + } + + /** + * Whether the service is provided by this container itself and not by the external container. + */ + public function provides(string $id): bool + { + $id = $this->resolveAlias($id); + + return array_key_exists($id, $this->services) + || array_key_exists($id, $this->factories); + } + + public function has(string $id): bool + { + $id = $this->resolveAlias($id); + + return array_key_exists($id, $this->services) + || array_key_exists($id, $this->factories) + || $this->externalContainer?->has($id) === true; + } + + private function externalService(string $id): object + { + $service = $this->externalContainer?->get($id); + + if (!is_object($service)) { + throw new ServiceCreationFailed(sprintf( + 'External service "%s" must be an object, got "%s".', + $id, + get_debug_type($service), + )); + } + + return $service; + } + + private function resolveAlias(string $id): string + { + $resolved = $id; + $visited = []; + + while (array_key_exists($resolved, $this->aliases)) { + if (array_key_exists($resolved, $visited)) { + throw new ServiceCreationFailed(sprintf('Circular alias detected for "%s".', $id)); + } + + $visited[$resolved] = true; + $resolved = $this->aliases[$resolved]; + } + + return $resolved; + } + + /** @param object|Closure(ContainerInterface): object $service */ + public function bind(string $id, object $service): void + { + unset($this->aliases[$id], $this->services[$id], $this->factories[$id]); + + if ($service instanceof Closure) { + $this->factories[$id] = $service; + + return; + } + + $this->services[$id] = $service; + } + + public function alias(string $alias, string $id): void + { + unset($this->services[$alias], $this->factories[$alias]); + + // an alias to itself means the service is provided by the external container + if ($alias === $id) { + unset($this->aliases[$alias]); + + return; + } + + $this->aliases[$alias] = $id; + } + + public function decorate(string $id, string $decoratorId, callable $factory): void + { + $innerId = $this->resolveAlias($id); + $this->bind( + $decoratorId, + static fn (ContainerInterface $container) => $factory($container, $container->get($innerId)), + ); + $this->alias($id, $decoratorId); + } +} diff --git a/src/Container/Factory.php b/src/Container/Factory.php new file mode 100644 index 000000000..39bcd8443 --- /dev/null +++ b/src/Container/Factory.php @@ -0,0 +1,1476 @@ + $service) { + $container->bind($id, $service); + } + + foreach ($config['parameters'] as $id => $parameter) { + $container->bind($id, static fn () => $parameter); + } + + return $container; + } + + /** + * Registers the public event sourcing services into an application container. + * + * The given callable is invoked once per available public service with the + * service id and a lazy factory, so any PSR-11 implementation can map them + * to its own definition format. + * + * @param callable(string, callable(): object): void $register + */ + public static function registerBridges(Container $container, callable $register): void + { + foreach (self::PUBLIC_SERVICE_IDS as $id) { + // services from the external container are already known by the application container + if (!$container->provides($id)) { + continue; + } + + $register($id, static fn (): object => $container->get($id)); + } + } + + /** @return list */ + public static function commands(Container $container): array + { + $commands = []; + + foreach (self::COMMAND_SERVICE_IDS as $id) { + if (!$container->provides($id)) { + continue; + } + + $commands[] = self::resolveService($container, $id, Command::class); + } + + return $commands; + } + + /** @param NormalizedConfig $config */ + private static function configureClock(array $config, Container $container): void + { + $container->bind(SystemClock::class, new SystemClock()); + $container->alias(ClockInterface::class, SystemClock::class); + + if ($config['clock']['service'] !== null) { + $container->alias(ClockInterface::class, $config['clock']['service']); + + return; + } + + if ($config['clock']['freeze'] === null) { + return; + } + + $container->bind(FrozenClock::class, new FrozenClock(new DateTimeImmutable($config['clock']['freeze']))); + $container->alias(ClockInterface::class, FrozenClock::class); + } + + /** @param NormalizedConfig $config */ + private static function configureConnection(array $config, Container $container): void + { + $url = $config['connection']['url']; + + if ($url === null) { + /** @var string $service validated by the configuration */ + $service = $config['connection']['service']; + + $container->alias(self::CONNECTION_ID, $service); + $container->alias(Connection::class, $service); + + return; + } + + $factory = new class { + /** + * Mapping was taken from Doctrine Bundle. + * + * @see https://github.com/doctrine/DoctrineBundle/blob/7564fa72ab4a87316660347ccd226cefc8fb0ea9/src/ConnectionFactory.php#L35 + */ + private const DEFAULT_SCHEME_MAP = [ + 'db2' => 'ibm_db2', + 'mssql' => 'pdo_sqlsrv', + 'mysql' => 'pdo_mysql', + 'mysql2' => 'pdo_mysql', // Amazon RDS, for some weird reason + 'postgres' => 'pdo_pgsql', + 'postgresql' => 'pdo_pgsql', + 'pgsql' => 'pdo_pgsql', + 'sqlite' => 'pdo_sqlite', + 'sqlite3' => 'pdo_sqlite', + ]; + + /** @param list $middlewares */ + public static function createConnection(string $url, array $middlewares): Connection + { + return DriverManager::getConnection( + (new DsnParser(self::DEFAULT_SCHEME_MAP))->parse($url), + (new DbalConfiguration())->setMiddlewares($middlewares), + ); + } + }; + + // the taggable store needs the platform middleware to create a gin index for the tags on postgres + $middlewares = self::usesTaggableStore($config) ? [new PostgreSQLPlatformMiddleware()] : []; + + $container->bind( + self::CONNECTION_ID, + static fn (): Connection => $factory::createConnection($url, $middlewares), + ); + + if (!$config['connection']['provide_dedicated_connection']) { + $container->alias(Connection::class, self::CONNECTION_ID); + + return; + } + + $container->bind(self::PUBLIC_CONNECTION_ID, static fn (): Connection => $factory::createConnection($url, [])); + $container->alias(Connection::class, self::PUBLIC_CONNECTION_ID); + } + + /** @param NormalizedConfig $config */ + private static function usesTaggableStore(array $config): bool + { + return $config['store']['type'] === Configuration::STORE_DBAL_TAGGABLE + || ( + $config['store']['migrate_to_new_store']['enabled'] + && $config['store']['migrate_to_new_store']['type'] === Configuration::STORE_DBAL_TAGGABLE + ); + } + + /** @param NormalizedConfig $config */ + private static function configureLogger(array $config, Container $container): void + { + if ($config['logger']['service'] === null) { + return; + } + + $container->alias(LoggerInterface::class, $config['logger']['service']); + } + + /** @param NormalizedConfig $config */ + private static function configureHydrator(array $config, Container $container): void + { + $hydrator = $config['hydrator']; + + if ($hydrator['cryptography']['enabled']) { + if ($hydrator['cryptography']['cipher_key_store'] !== null) { + $container->alias(CipherKeyStore::class, $hydrator['cryptography']['cipher_key_store']); + } else { + $container->bind( + DoctrineCipherKeyStore::class, + static fn (Container $container): DoctrineCipherKeyStore => new DoctrineCipherKeyStore( + self::resolveService($container, self::CONNECTION_ID, Connection::class), + ), + ); + $container->alias(CipherKeyStore::class, DoctrineCipherKeyStore::class); + } + + $container->bind( + BaseCryptographer::class, + static fn (Container $container): BaseCryptographer => BaseCryptographer::createWithOpenssl( + self::resolveService($container, CipherKeyStore::class, CipherKeyStore::class), + $hydrator['cryptography']['algorithm'], + ), + ); + $container->alias(Cryptographer::class, BaseCryptographer::class); + } + + $container->bind( + StackHydrator::class, + static function (Container $container) use ($hydrator): StackHydrator { + $builder = new StackHydratorBuilder(); + + if ($hydrator['cryptography']['enabled']) { + $builder->useExtension(new CryptographyExtension( + self::resolveService($container, Cryptographer::class, Cryptographer::class), + )); + } + + $beforeEncoding = self::resolveServices($container, $hydrator['upcasters']['before_encoding'], Upcaster::class); + $beforeTransform = self::resolveServices($container, $hydrator['upcasters']['before_transform'], Upcaster::class); + + if ($beforeEncoding !== [] || $beforeTransform !== []) { + $builder->useExtension(new UpcastExtension($beforeEncoding, $beforeTransform)); + } + + foreach (self::resolveServices($container, $hydrator['extensions'], Extension::class) as $extension) { + $builder->useExtension($extension); + } + + foreach (self::resolveServices($container, $hydrator['guessers'], Guesser::class) as $guesser) { + $builder->addGuesser($guesser); + } + + $builder->useExtension(new CoreExtension()); + + if ($hydrator['lifecycle']['enabled']) { + $builder->useExtension(new LifecycleExtension()); + } + + $builder->enableDefaultLazy($hydrator['default_lazy']); + + return $builder->build(); + }, + ); + $container->alias(Hydrator::class, StackHydrator::class); + } + + /** @param NormalizedConfig $config */ + private static function configureSerializer(array $config, Container $container): void + { + $container->bind( + EventRegistry::class, + static fn (): EventRegistry => (new AttributeEventRegistryFactory())->create($config['events']), + ); + + $container->bind(AttributeEventMetadataFactory::class, new AttributeEventMetadataFactory()); + $container->alias(EventMetadataFactory::class, AttributeEventMetadataFactory::class); + + $container->bind(JsonEncoder::class, new JsonEncoder()); + $container->alias(Encoder::class, JsonEncoder::class); + + $container->bind( + DefaultEventSerializer::class, + static fn (Container $container): DefaultEventSerializer => new DefaultEventSerializer( + $container->get(EventRegistry::class), + $container->get(Hydrator::class), + $container->get(Encoder::class), + ), + ); + $container->alias(EventSerializer::class, DefaultEventSerializer::class); + + $container->bind(AttributeEventTagExtractor::class, new AttributeEventTagExtractor()); + $container->alias(EventTagExtractor::class, AttributeEventTagExtractor::class); + + $container->bind(AttributeMessageHeaderRegistryFactory::class, new AttributeMessageHeaderRegistryFactory()); + $container->alias(MessageHeaderRegistryFactory::class, AttributeMessageHeaderRegistryFactory::class); + + $container->bind( + MessageHeaderRegistry::class, + static fn (Container $container): MessageHeaderRegistry => $container + ->get(MessageHeaderRegistryFactory::class) + ->create($config['headers']), + ); + + $container->bind( + DefaultHeadersSerializer::class, + static fn (Container $container): DefaultHeadersSerializer => new DefaultHeadersSerializer( + $container->get(MessageHeaderRegistry::class), + $container->get(Hydrator::class), + $container->get(Encoder::class), + ), + ); + $container->alias(HeadersSerializer::class, DefaultHeadersSerializer::class); + } + + /** @param NormalizedConfig $config */ + private static function configureMessageDecorator(array $config, Container $container): void + { + $container->bind( + SplitStreamDecorator::class, + static fn (Container $container): SplitStreamDecorator => new SplitStreamDecorator( + $container->get(EventMetadataFactory::class), + ), + ); + + // events saved by aggregates need tags to be found by the dynamic consistency boundary + $tagEvents = $config['store']['type'] === Configuration::STORE_DBAL_TAGGABLE || $config['dcb']['enabled']; + + $container->bind( + ChainMessageDecorator::class, + static fn (Container $container): ChainMessageDecorator => new ChainMessageDecorator([ + $container->get(SplitStreamDecorator::class), + ...$tagEvents ? + [new EventTagDecorator($container->get(EventTagExtractor::class))] : + [], + ...self::resolveServices($container, $config['message_decorators'], MessageDecorator::class), + ]), + ); + $container->alias(MessageDecorator::class, ChainMessageDecorator::class); + } + + /** @param NormalizedConfig $config */ + private static function configureCommandBus(array $config, Container $container): void + { + $commandBus = $config['command_bus']; + + if (!$commandBus['enabled']) { + return; + } + + $container->bind( + ChainHandlerProvider::class, + static fn (Container $container): ChainHandlerProvider => new ChainHandlerProvider([ + ...$commandBus['register_aggregate_handlers'] ? + [ + new AggregateHandlerProvider( + $container->get(AggregateRootRegistry::class), + $container->get(RepositoryManager::class), + $container, + ), + ] : + [], + new CommandServiceHandlerProvider(self::resolveObjects($container, $commandBus['handlers'])), + ...self::resolveServices($container, $commandBus['handler_providers'], HandlerProvider::class), + ]), + ); + $container->alias(HandlerProvider::class, ChainHandlerProvider::class); + + $container->bind( + SyncCommandBus::class, + static fn (Container $container): SyncCommandBus => new SyncCommandBus( + $container->get(HandlerProvider::class), + ), + ); + $container->alias(CommandBus::class, SyncCommandBus::class); + + if (!$commandBus['instant_retry']['enabled']) { + return; + } + + $container->decorate( + CommandBus::class, + InstantRetryCommandBus::class, + static fn (Container $container, CommandBus $inner): InstantRetryCommandBus => new InstantRetryCommandBus( + $inner, + $commandBus['instant_retry']['default_max_retries'], + $commandBus['instant_retry']['default_exceptions'], + ), + ); + } + + /** @param NormalizedConfig $config */ + private static function configureEventBus(array $config, Container $container): void + { + $eventBus = $config['event_bus']; + + if (!$eventBus['enabled']) { + return; + } + + if ($eventBus['type'] === Configuration::EVENT_BUS_DEFAULT) { + $container->bind( + AttributeListenerProvider::class, + static fn (Container $container): AttributeListenerProvider => new AttributeListenerProvider( + self::resolveObjects($container, $eventBus['listeners']), + ), + ); + $container->alias(ListenerProvider::class, AttributeListenerProvider::class); + + $container->bind( + DefaultConsumer::class, + static fn (Container $container): DefaultConsumer => new DefaultConsumer( + $container->get(ListenerProvider::class), + self::logger($container), + ), + ); + $container->alias(Consumer::class, DefaultConsumer::class); + + $container->bind( + DefaultEventBus::class, + static fn (Container $container): DefaultEventBus => new DefaultEventBus( + $container->get(Consumer::class), + self::logger($container), + ), + ); + $container->alias(EventBus::class, DefaultEventBus::class); + + return; + } + + /** @var string $service validated by the configuration */ + $service = $eventBus['service']; + + if ($eventBus['type'] === Configuration::EVENT_BUS_PSR14) { + $container->bind( + Psr14EventBus::class, + static fn (Container $container): Psr14EventBus => new Psr14EventBus( + self::resolveService($container, $service, PsrEventDispatcherInterface::class), + ), + ); + $container->alias(EventBus::class, Psr14EventBus::class); + + return; + } + + $container->alias(EventBus::class, $service); + } + + /** @param NormalizedConfig $config */ + private static function configureQueryBus(array $config, Container $container): void + { + $queryBus = $config['query_bus']; + + if (!$queryBus['enabled']) { + return; + } + + $container->bind( + QueryChainHandlerProvider::class, + static fn (Container $container): QueryChainHandlerProvider => new QueryChainHandlerProvider([ + // subscribers can answer queries, e.g. a projector which holds the read model + new QueryServiceHandlerProvider([ + ...self::resolveObjects($container, $config['subscription']['subscribers']), + ...self::resolveObjects($container, $queryBus['handlers']), + ]), + ...self::resolveServices($container, $queryBus['handler_providers'], QueryHandlerProvider::class), + ]), + ); + $container->alias(QueryHandlerProvider::class, QueryChainHandlerProvider::class); + + $container->bind( + SyncQueryBus::class, + static fn (Container $container): SyncQueryBus => new SyncQueryBus( + $container->get(QueryHandlerProvider::class), + ), + ); + $container->alias(QueryBus::class, SyncQueryBus::class); + } + + /** @param NormalizedConfig $config */ + private static function configureStore(array $config, Container $container): void + { + $store = $config['store']; + + if ($store['type'] === Configuration::STORE_CUSTOM) { + /** @var string $service validated by the configuration */ + $service = $store['service']; + + $container->alias(Store::class, $service); + + return; + } + + $class = self::bindStore($container, $store['type'], $store['options']); + $container->alias(Store::class, $class); + + if (!$store['read_only']) { + return; + } + + $container->decorate( + Store::class, + ReadOnlyStore::class, + static fn (Container $container, Store $inner): ReadOnlyStore => new ReadOnlyStore( + $inner, + self::logger($container), + ), + ); + } + + /** + * Binds a store and returns its id. + * + * @param array $options + */ + private static function bindStore(Container $container, string $type, array $options, string|null $id = null): string + { + if ($type === Configuration::STORE_IN_MEMORY) { + $id ??= InMemoryStore::class; + + $container->bind( + $id, + static fn (Container $container): InMemoryStore => new InMemoryStore( + [], + $container->get(EventRegistry::class), + $container->get(ClockInterface::class), + ), + ); + + return $id; + } + + if ($type === Configuration::STORE_DBAL_TAGGABLE) { + $id ??= TaggableDoctrineDbalStore::class; + + /** @var array{table_name?: string, locking?: bool, lock_id?: int, lock_timeout?: int, keep_index?: bool, default_stream_name?: string} $storeConfig the store is responsible for its options */ + $storeConfig = $options; + + $container->bind( + $id, + static fn (Container $container): TaggableDoctrineDbalStore => new TaggableDoctrineDbalStore( + self::resolveService($container, self::CONNECTION_ID, Connection::class), + $container->get(EventSerializer::class), + $container->get(EventRegistry::class), + $container->get(HeadersSerializer::class), + $container->get(ClockInterface::class), + $storeConfig, + ), + ); + + return $id; + } + + $id ??= StreamDoctrineDbalStore::class; + + // the stream store has no default stream, every message has a stream header + unset($options['default_stream_name']); + + /** @var array{table_name?: string, locking?: bool, lock_id?: int, lock_timeout?: int, keep_index?: bool} $storeConfig the store is responsible for its options */ + $storeConfig = $options; + + $container->bind( + $id, + static fn (Container $container): StreamDoctrineDbalStore => new StreamDoctrineDbalStore( + self::resolveService($container, self::CONNECTION_ID, Connection::class), + $container->get(EventSerializer::class), + $container->get(HeadersSerializer::class), + $container->get(ClockInterface::class), + $storeConfig, + ), + ); + + return $id; + } + + /** @param NormalizedConfig $config */ + private static function configureDecisionModel(array $config, Container $container): void + { + if (!$config['dcb']['enabled']) { + return; + } + + $container->bind( + StoreDecisionModelBuilder::class, + static fn (Container $container): StoreDecisionModelBuilder => new StoreDecisionModelBuilder( + self::resolveService($container, Store::class, AppendStore::class), + ), + ); + $container->alias(DecisionModelBuilder::class, StoreDecisionModelBuilder::class); + + /** @var string $defaultStreamName the store is responsible for its options */ + $defaultStreamName = $config['store']['options']['default_stream_name'] ?? 'main'; + + $container->bind( + StoreEventAppender::class, + static fn (Container $container): StoreEventAppender => new StoreEventAppender( + self::resolveService($container, Store::class, AppendStore::class), + $container->get(EventTagExtractor::class), + $defaultStreamName, + ), + ); + $container->alias(EventAppender::class, StoreEventAppender::class); + + $container->bind( + StoreProjectionBuilder::class, + static fn (Container $container): StoreProjectionBuilder => new StoreProjectionBuilder( + self::resolveService($container, Store::class, AppendStore::class), + ), + ); + $container->alias(ProjectionBuilder::class, StoreProjectionBuilder::class); + } + + /** @param NormalizedConfig $config */ + private static function configureSnapshots(array $config, Container $container): void + { + if ($config['snapshot_stores'] === []) { + return; + } + + $container->bind( + AdapterRepository::class, + static function (Container $container) use ($config): ArrayAdapterRepository { + $adapters = []; + + foreach ($config['snapshot_stores'] as $name => $definition) { + if ($definition instanceof SnapshotAdapter) { + $adapters[$name] = $definition; + + continue; + } + + $adapters[$name] = match ($definition['type']) { + 'psr6' => new Psr6SnapshotAdapter( + self::resolveService($container, $definition['service'], CacheItemPoolInterface::class), + ), + 'psr16' => new Psr16SnapshotAdapter( + self::resolveService($container, $definition['service'], CacheInterface::class), + ), + 'custom' => self::resolveService($container, $definition['service'], SnapshotAdapter::class), + }; + } + + return new ArrayAdapterRepository($adapters); + }, + ); + + $container->bind( + DefaultSnapshotStore::class, + static fn (Container $container): DefaultSnapshotStore => new DefaultSnapshotStore( + $container->get(AdapterRepository::class), + $container->get(Hydrator::class), + $container->get(AggregateRootMetadataFactory::class), + ), + ); + $container->alias(SnapshotStore::class, DefaultSnapshotStore::class); + } + + /** @param NormalizedConfig $config */ + private static function configureAggregates(array $config, Container $container): void + { + $container->bind( + AggregateRootMetadataAwareMetadataFactory::class, + new AggregateRootMetadataAwareMetadataFactory(), + ); + $container->alias(AggregateRootMetadataFactory::class, AggregateRootMetadataAwareMetadataFactory::class); + + $container->bind( + AggregateRootRegistry::class, + static fn (): AggregateRootRegistry => (new AttributeAggregateRootRegistryFactory())->create( + $config['aggregates'], + ), + ); + + $container->bind( + DefaultRepositoryManager::class, + static fn (Container $container): DefaultRepositoryManager => new DefaultRepositoryManager( + $container->get(AggregateRootRegistry::class), + $container->get(Store::class), + $container->has(EventBus::class) ? $container->get(EventBus::class) : null, + $container->has(SnapshotStore::class) ? $container->get(SnapshotStore::class) : null, + $container->get(MessageDecorator::class), + $container->get(ClockInterface::class), + $container->get(AggregateRootMetadataFactory::class), + self::logger($container), + ), + ); + $container->alias(RepositoryManager::class, DefaultRepositoryManager::class); + } + + private static function configureCommands(Container $container): void + { + $container->bind( + ShowCommand::class, + static fn (Container $container): ShowCommand => new ShowCommand( + $container->get(Store::class), + $container->get(EventSerializer::class), + $container->get(HeadersSerializer::class), + ), + ); + $container->bind( + ShowAggregateCommand::class, + static fn (Container $container): ShowAggregateCommand => new ShowAggregateCommand( + $container->get(Store::class), + $container->get(EventSerializer::class), + $container->get(HeadersSerializer::class), + $container->get(AggregateRootRegistry::class), + $container->get(AggregateRootMetadataFactory::class), + ), + ); + $container->bind( + WatchCommand::class, + static fn (Container $container): WatchCommand => new WatchCommand( + $container->get(Store::class), + $container->get(EventSerializer::class), + $container->get(HeadersSerializer::class), + ), + ); + $container->bind( + DebugCommand::class, + static fn (Container $container): DebugCommand => new DebugCommand( + $container->get(AggregateRootRegistry::class), + $container->get(EventRegistry::class), + $container->get(SubscriberAccessorRepository::class), + ), + ); + $container->bind( + SubscriptionSetupCommand::class, + static fn (Container $container): SubscriptionSetupCommand => new SubscriptionSetupCommand( + $container->get(SubscriptionEngine::class), + ), + ); + $container->bind( + SubscriptionBootCommand::class, + static fn (Container $container): SubscriptionBootCommand => new SubscriptionBootCommand( + $container->get(SubscriptionEngine::class), + self::eventDispatcher($container), + ), + ); + $container->bind( + SubscriptionRunCommand::class, + static fn (Container $container): SubscriptionRunCommand => new SubscriptionRunCommand( + $container->get(SubscriptionEngine::class), + $container->get(Store::class), + self::eventDispatcher($container), + ), + ); + $container->bind( + SubscriptionTeardownCommand::class, + static fn (Container $container): SubscriptionTeardownCommand => new SubscriptionTeardownCommand( + $container->get(SubscriptionEngine::class), + ), + ); + $container->bind( + SubscriptionRemoveCommand::class, + static fn (Container $container): SubscriptionRemoveCommand => new SubscriptionRemoveCommand( + $container->get(SubscriptionEngine::class), + ), + ); + $container->bind( + SubscriptionStatusCommand::class, + static fn (Container $container): SubscriptionStatusCommand => new SubscriptionStatusCommand( + $container->get(SubscriptionEngine::class), + ), + ); + $container->bind( + SubscriptionPauseCommand::class, + static fn (Container $container): SubscriptionPauseCommand => new SubscriptionPauseCommand( + $container->get(SubscriptionEngine::class), + ), + ); + $container->bind( + SubscriptionReactivateCommand::class, + static fn (Container $container): SubscriptionReactivateCommand => new SubscriptionReactivateCommand( + $container->get(SubscriptionEngine::class), + ), + ); + $container->bind( + SubscriptionRefreshCommand::class, + static fn (Container $container): SubscriptionRefreshCommand => new SubscriptionRefreshCommand( + $container->get(SubscriptionEngine::class), + ), + ); + } + + private static function configureSchema(Container $container): void + { + $container->bind( + ChainDoctrineSchemaConfigurator::class, + static function (Container $container): ChainDoctrineSchemaConfigurator { + $services = []; + + foreach ([Store::class, SubscriptionStore::class, self::NEW_STORE_ID, CipherKeyStore::class] as $id) { + if (!$container->has($id)) { + continue; + } + + $services[] = $container->get($id); + } + + return new ChainDoctrineSchemaConfigurator(array_filter( + $services, + static fn (object $service): bool => $service instanceof DoctrineSchemaConfigurator, + )); + }, + ); + $container->alias(DoctrineSchemaConfigurator::class, ChainDoctrineSchemaConfigurator::class); + + $container->bind( + DoctrineSchemaListener::class, + static fn (Container $container): DoctrineSchemaListener => new DoctrineSchemaListener( + $container->get(DoctrineSchemaConfigurator::class), + ), + ); + + $container->bind( + DoctrineSchemaDirector::class, + static fn (Container $container): DoctrineSchemaDirector => new DoctrineSchemaDirector( + self::resolveService($container, self::CONNECTION_ID, Connection::class), + $container->get(DoctrineSchemaConfigurator::class), + ), + ); + $container->alias(DoctrineSchemaProvider::class, DoctrineSchemaDirector::class); + $container->alias(SchemaDirector::class, DoctrineSchemaDirector::class); + + $container->bind(DoctrineHelper::class, new DoctrineHelper()); + + $container->bind( + DatabaseCreateCommand::class, + static fn (Container $container): DatabaseCreateCommand => new DatabaseCreateCommand( + self::resolveService($container, self::CONNECTION_ID, Connection::class), + $container->get(DoctrineHelper::class), + ), + ); + $container->bind( + DatabaseDropCommand::class, + static fn (Container $container): DatabaseDropCommand => new DatabaseDropCommand( + self::resolveService($container, self::CONNECTION_ID, Connection::class), + $container->get(DoctrineHelper::class), + ), + ); + $container->bind( + SchemaCreateCommand::class, + static fn (Container $container): SchemaCreateCommand => new SchemaCreateCommand( + $container->get(SchemaDirector::class), + ), + ); + $container->bind( + SchemaUpdateCommand::class, + static fn (Container $container): SchemaUpdateCommand => new SchemaUpdateCommand( + $container->get(SchemaDirector::class), + ), + ); + $container->bind( + SchemaDropCommand::class, + static fn (Container $container): SchemaDropCommand => new SchemaDropCommand( + $container->get(SchemaDirector::class), + ), + ); + } + + /** @param NormalizedConfig $config */ + private static function configureMessageLoader(array $config, Container $container): void + { + $container->bind( + StoreMessageLoader::class, + static fn (Container $container): StoreMessageLoader => new StoreMessageLoader( + $container->get(Store::class), + ), + ); + $container->alias(MessageLoader::class, StoreMessageLoader::class); + + if ($config['subscription']['event_filtered_message_loader']['enabled']) { + $container->bind( + EventFilteredStoreMessageLoader::class, + static fn (Container $container): EventFilteredStoreMessageLoader => new EventFilteredStoreMessageLoader( + $container->get(Store::class), + $container->get(EventMetadataFactory::class), + $container->get(SubscriberAccessorRepository::class), + ), + ); + $container->alias(MessageLoader::class, EventFilteredStoreMessageLoader::class); + + return; + } + + $gapDetection = $config['subscription']['gap_detection']; + + if (!$gapDetection['enabled']) { + return; + } + + $container->bind( + GapResolverStoreMessageLoader::class, + static fn (Container $container): GapResolverStoreMessageLoader => new GapResolverStoreMessageLoader( + $container->get(Store::class), + $container->get(ClockInterface::class), + $gapDetection['retries_in_ms'], + $gapDetection['detection_window'] !== null ? new DateInterval($gapDetection['detection_window']) : null, + ), + ); + $container->alias(MessageLoader::class, GapResolverStoreMessageLoader::class); + } + + /** @param NormalizedConfig $config */ + private static function configureSubscription(array $config, Container $container): void + { + $subscription = $config['subscription']; + + $container->bind(AttributeSubscriberMetadataFactory::class, new AttributeSubscriberMetadataFactory()); + $container->alias(SubscriberMetadataFactory::class, AttributeSubscriberMetadataFactory::class); + + $container->bind( + RetryStrategyRepository::class, + static function (Container $container) use ($subscription): RetryStrategyRepository { + $strategies = []; + + foreach ($subscription['retry_strategies'] as $name => $definition) { + /** @var array{base_delay?: int, delay_factor?: float, max_attempts?: positive-int} $options */ + $options = $definition['options']; + + $strategies[$name] = match ($definition['type']) { + Configuration::SUBSCRIPTION_RETRY_CLOCK_BASED => new ClockBasedRetryStrategy( + $container->get(ClockInterface::class), + $options['base_delay'] ?? ClockBasedRetryStrategy::DEFAULT_BASE_DELAY, + $options['delay_factor'] ?? ClockBasedRetryStrategy::DEFAULT_DELAY_FACTOR, + $options['max_attempts'] ?? ClockBasedRetryStrategy::DEFAULT_MAX_ATTEMPTS, + ), + Configuration::SUBSCRIPTION_RETRY_NO_RETRY => new NoRetryStrategy(), + Configuration::SUBSCRIPTION_RETRY_CUSTOM => self::resolveService( + $container, + (string)$definition['service'], + RetryStrategy::class, + ), + }; + } + + return new RetryStrategyRepository($strategies, $subscription['default_retry_strategy']); + }, + ); + + self::configureSubscriptionStore($config, $container); + + $container->bind( + LookupResolver::class, + static fn (Container $container): LookupResolver => new LookupResolver( + $container->get(Store::class), + $container->get(EventRegistry::class), + ), + ); + + $container->bind( + MetadataSubscriberAccessorRepository::class, + static fn (Container $container): MetadataSubscriberAccessorRepository => new MetadataSubscriberAccessorRepository( + self::resolveObjects($container, $subscription['subscribers']), + $container->get(SubscriberMetadataFactory::class), + ), + ); + $container->alias(SubscriberAccessorRepository::class, MetadataSubscriberAccessorRepository::class); + + $container->bind( + DefaultCleaner::class, + static fn (Container $container): DefaultCleaner => new DefaultCleaner([ + new DbalCleanupTaskHandler(self::resolveService($container, self::CONNECTION_ID, Connection::class)), + ...self::resolveServices($container, $subscription['cleanup_task_handlers'], CleanupTaskHandler::class), + ]), + ); + $container->alias(Cleaner::class, DefaultCleaner::class); + + $eventEmitter = $subscription['event_emitter']['enabled']; + + $container->bind( + self::SUBSCRIPTION_EVENT_DISPATCHER_ID, + static function (Container $container) use ($eventEmitter): EventDispatcher { + $eventDispatcher = new EventDispatcher(); + + if ($eventEmitter) { + $eventDispatcher->addListener( + OnSubscriptionRemoved::class, + new RemoveSubscriptionStreamListener($container->get(Store::class), self::logger($container)), + ); + } + + return $eventDispatcher; + }, + ); + + $container->bind( + DefaultSubscriptionEngine::class, + static fn (Container $container): DefaultSubscriptionEngine => new DefaultSubscriptionEngine( + $container->get(MessageLoader::class), + $container->get(SubscriptionStore::class), + $container->get(SubscriberAccessorRepository::class), + $container->get(RetryStrategyRepository::class), + self::logger($container), + $container->get(Cleaner::class), + self::resolveService($container, self::SUBSCRIPTION_EVENT_DISPATCHER_ID, EventDispatcherInterface::class), + [ + $container->get(LookupResolver::class), + ...$eventEmitter ? + [new EventEmitterResolver($container->get(Store::class))] : + [], + ...self::resolveServices($container, $subscription['argument_resolvers'], ArgumentResolver::class), + ], + ), + ); + $container->alias(SubscriptionEngine::class, DefaultSubscriptionEngine::class); + + $sync = $subscription['sync']; + + if (!$sync['enabled']) { + return; + } + + // the sync engine is only used to run the subscriptions after an aggregate was saved + $container->bind( + self::SUBSCRIPTION_SYNC_ENGINE_ID, + static function (Container $container) use ($sync): SubscriptionEngine { + $engine = new CatchUpSubscriptionEngine( + $container->get(DefaultSubscriptionEngine::class), + $sync['catch_up_limit'], + ); + + return $sync['throw_on_error'] ? new ThrowOnErrorSubscriptionEngine($engine) : $engine; + }, + ); + + $container->decorate( + RepositoryManager::class, + RunSubscriptionEngineRepositoryManager::class, + static fn (Container $container, RepositoryManager $inner): RunSubscriptionEngineRepositoryManager => new RunSubscriptionEngineRepositoryManager( + $inner, + self::resolveService($container, self::SUBSCRIPTION_SYNC_ENGINE_ID, SubscriptionEngine::class), + $sync['ids'] ?: null, + $sync['groups'] ?: null, + ), + ); + } + + /** @param NormalizedConfig $config */ + private static function configureSubscriptionStore(array $config, Container $container): void + { + $store = $config['subscription']['store']; + + if ($store['type'] === Configuration::SUBSCRIPTION_STORE_CUSTOM) { + /** @var string $service validated by the configuration */ + $service = $store['service']; + + $container->alias(SubscriptionStore::class, $service); + + return; + } + + if ($store['type'] === Configuration::SUBSCRIPTION_STORE_DBAL) { + $container->bind( + DoctrineSubscriptionStore::class, + static fn (Container $container): DoctrineSubscriptionStore => new DoctrineSubscriptionStore( + self::resolveService($container, self::CONNECTION_ID, Connection::class), + $container->get(ClockInterface::class), + $store['options']['table_name'], + ), + ); + $container->alias(SubscriptionStore::class, DoctrineSubscriptionStore::class); + + return; + } + + if ($store['type'] === Configuration::SUBSCRIPTION_STORE_IN_MEMORY) { + $container->bind( + InMemorySubscriptionStore::class, + static fn (Container $container): InMemorySubscriptionStore => new InMemorySubscriptionStore( + [], + $container->get(ClockInterface::class), + ), + ); + $container->alias(SubscriptionStore::class, InMemorySubscriptionStore::class); + + return; + } + + // shared by all containers of the process, meant for tests only + $factory = new class { + private static InMemorySubscriptionStore|null $store = null; + + public static function create(): InMemorySubscriptionStore + { + return self::$store ??= new InMemorySubscriptionStore(); + } + }; + + $container->bind(InMemorySubscriptionStore::class, static fn (): InMemorySubscriptionStore => $factory::create()); + $container->alias(SubscriptionStore::class, InMemorySubscriptionStore::class); + } + + /** @param NormalizedConfig $config */ + private static function configureMigration(array $config, Container $container): void + { + $migration = $config['migration']; + + if (!$migration['enabled']) { + return; + } + + $container->bind( + DoctrineMigrationSchemaProvider::class, + static fn (Container $container): DoctrineMigrationSchemaProvider => new DoctrineMigrationSchemaProvider( + $container->get(DoctrineSchemaProvider::class), + ), + ); + + $container->bind( + self::MIGRATION_DEPENDENCY_FACTORY_ID, + static function (Container $container) use ($migration): DependencyFactory { + $dependencyFactory = DependencyFactory::fromConnection( + new ConfigurationArray([ + 'migrations_paths' => [$migration['namespace'] => $migration['path']], + ]), + new ExistingConnection(self::resolveService($container, self::CONNECTION_ID, Connection::class)), + self::logger($container), + ); + + $dependencyFactory->setService( + SchemaProvider::class, + $container->get(DoctrineMigrationSchemaProvider::class), + ); + + return $dependencyFactory; + }, + ); + + $commands = [ + self::MIGRATION_DIFF_COMMAND_ID => [DiffCommand::class, 'event-sourcing:migration:diff'], + self::MIGRATION_MIGRATE_COMMAND_ID => [MigrateCommand::class, 'event-sourcing:migration:migrate'], + self::MIGRATION_CURRENT_COMMAND_ID => [CurrentCommand::class, 'event-sourcing:migration:current'], + self::MIGRATION_EXECUTE_COMMAND_ID => [ExecuteCommand::class, 'event-sourcing:migration:execute'], + self::MIGRATION_STATUS_COMMAND_ID => [StatusCommand::class, 'event-sourcing:migration:status'], + ]; + + foreach ($commands as $id => [$class, $name]) { + $container->bind( + $id, + static fn (Container $container): Command => new $class( + self::resolveService($container, self::MIGRATION_DEPENDENCY_FACTORY_ID, DependencyFactory::class), + $name, + ), + ); + } + } + + /** @param NormalizedConfig $config */ + private static function configureStoreMigration(array $config, Container $container): void + { + $migration = $config['store']['migrate_to_new_store']; + + if (!$migration['enabled']) { + return; + } + + if ($migration['type'] === Configuration::STORE_CUSTOM) { + /** @var string $service validated by the configuration */ + $service = $migration['service']; + + $container->alias(self::NEW_STORE_ID, $service); + } else { + self::bindStore($container, $migration['type'], $migration['options'], self::NEW_STORE_ID); + } + + $container->bind( + StoreMigrateCommand::class, + static fn (Container $container): StoreMigrateCommand => new StoreMigrateCommand( + $container->get(Store::class), + self::resolveService($container, self::NEW_STORE_ID, Store::class), + self::resolveServices($container, $migration['translators'], Translator::class), + ), + ); + } + + private static function logger(Container $container): LoggerInterface|null + { + return $container->has(LoggerInterface::class) ? $container->get(LoggerInterface::class) : null; + } + + private static function eventDispatcher(Container $container): EventDispatcherInterface|null + { + return $container->has(EventDispatcherInterface::class) ? $container->get(EventDispatcherInterface::class) : null; + } + + /** + * Resolves a list of objects and service ids into objects. + * + * @param list $services + * + * @return list + */ + private static function resolveObjects(Container $container, array $services): array + { + return array_map( + static fn (object|string $service): object => is_string($service) ? $container->get($service) : $service, + $services, + ); + } + + /** + * Resolves a list of objects and service ids into objects of the given type. + * + * @param list $services + * @param class-string $class + * + * @return list + * + * @template T of object + */ + private static function resolveServices(Container $container, array $services, string $class): array + { + return array_map( + static fn (object|string $service): object => is_string($service) + ? self::resolveService($container, $service, $class) + : $service, + $services, + ); + } + + /** + * @param class-string $class + * + * @return T + * + * @template T of object + */ + private static function resolveService(Container $container, string $id, string $class): object + { + $service = $container->get($id); + + if (!$service instanceof $class) { + throw new ServiceCreationFailed(sprintf( + 'Service "%s" must be an instance of "%s", got "%s".', + $id, + $class, + get_debug_type($service), + )); + } + + return $service; + } +} diff --git a/src/Container/InvalidConfiguration.php b/src/Container/InvalidConfiguration.php new file mode 100644 index 000000000..476d58c62 --- /dev/null +++ b/src/Container/InvalidConfiguration.php @@ -0,0 +1,11 @@ +connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), - DefaultHeadersSerializer::createFromPaths([ - __DIR__ . '/Header', - ]), - ); - $profileProjector = new ProfileProjector($this->connection); - $engine = new DefaultSubscriptionEngine( - new StoreMessageLoader($store), - new InMemorySubscriptionStore(), - new MetadataSubscriberAccessorRepository([ - $profileProjector, - new SendEmailProcessor(), - ]), - ); - - $manager = new RunSubscriptionEngineRepositoryManager( - new DefaultRepositoryManager( - new AggregateRootRegistry(['profile' => Profile::class]), - $store, - null, - null, - new FooMessageDecorator(), - ), - $engine, - ); + $configuration = [ + 'connection' => ['service' => 'app.connection'], + 'aggregates' => [__DIR__], + 'events' => [__DIR__ . '/Events'], + 'headers' => [__DIR__ . '/Header'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => [ + 'subscribers' => [$profileProjector, new SendEmailProcessor()], + 'store' => ['type' => 'in_memory'], + 'gap_detection' => true, + 'sync' => ['throw_on_error' => true], + ], + 'message_decorators' => [new FooMessageDecorator()], + 'services' => ['app.connection' => $this->connection], + ]; + $container = Factory::create($configuration); + + $manager = $container->get(RepositoryManager::class); + $engine = $container->get(SubscriptionEngine::class); $repository = $manager->get(Profile::class); - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, - ); - + $schemaDirector = $container->get(SchemaDirector::class); $schemaDirector->create(); $engine->execute(new Setup(skipBooting: true)); @@ -129,43 +106,31 @@ public function testSuccessful(): void public function testSnapshot(): void { - $store = new StreamDoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), - DefaultHeadersSerializer::createFromPaths([ - __DIR__ . '/Header', - ]), - ); - $profileProjection = new ProfileProjector($this->connection); - - $engine = new DefaultSubscriptionEngine( - new StoreMessageLoader($store), - new InMemorySubscriptionStore(), - new MetadataSubscriberAccessorRepository([ - $profileProjection, - new SendEmailProcessor(), - ]), - ); - - $manager = new RunSubscriptionEngineRepositoryManager( - new DefaultRepositoryManager( - new AggregateRootRegistry(['profile' => Profile::class]), - $store, - null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), - new FooMessageDecorator(), - ), - $engine, - ); + $configuration = [ + 'connection' => ['service' => 'app.connection'], + 'aggregates' => [__DIR__], + 'events' => [__DIR__ . '/Events'], + 'headers' => [__DIR__ . '/Header'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'snapshot_stores' => ['default' => new InMemorySnapshotAdapter()], + 'subscription' => [ + 'subscribers' => [$profileProjection, new SendEmailProcessor()], + 'store' => ['type' => 'in_memory'], + 'gap_detection' => true, + 'sync' => ['throw_on_error' => true], + ], + 'message_decorators' => [new FooMessageDecorator()], + 'services' => ['app.connection' => $this->connection], + ]; + $container = Factory::create($configuration); + + $manager = $container->get(RepositoryManager::class); + $engine = $container->get(SubscriptionEngine::class); + $schemaDirector = $container->get(SchemaDirector::class); $repository = $manager->get(Profile::class); - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, - ); - $schemaDirector->create(); $engine->execute(new Setup(skipBooting: true)); @@ -195,29 +160,26 @@ public function testSnapshot(): void public function testTempProjection(): void { - $store = new StreamDoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), - DefaultHeadersSerializer::createFromPaths([ - __DIR__ . '/Header', - ]), - ); - - $manager = new DefaultRepositoryManager( - new AggregateRootRegistry(['profile' => Profile::class]), - $store, - null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), - new FooMessageDecorator(), - ); - + $configuration = [ + 'connection' => ['service' => 'app.connection'], + 'aggregates' => [__DIR__], + 'events' => [__DIR__ . '/Events'], + 'headers' => [__DIR__ . '/Header'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => [ + 'store' => ['type' => 'in_memory'], + 'gap_detection' => true, + 'sync' => ['throw_on_error' => true], + ], + 'services' => ['app.connection' => $this->connection], + ]; + $container = Factory::create($configuration); + + $manager = $container->get(RepositoryManager::class); + $store = $container->get(Store::class); $repository = $manager->get(Profile::class); - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, - ); - + $schemaDirector = $container->get(SchemaDirector::class); $schemaDirector->create(); $profileId = ProfileId::generate(); @@ -252,54 +214,29 @@ public function testTempProjection(): void public function testCommandBus(): void { - $store = new StreamDoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), - DefaultHeadersSerializer::createFromPaths([ - __DIR__ . '/Header', - ]), - ); - - $aggregateRootRegistry = new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]); - - $manager = new DefaultRepositoryManager( - new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]), - $store, - null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), - new FooMessageDecorator(), - ); - $profileProjection = new ProfileProjector($this->connection); - - $engine = new DefaultSubscriptionEngine( - new StoreMessageLoader($store), - new InMemorySubscriptionStore(), - new MetadataSubscriberAccessorRepository([ - $profileProjection, - new SendEmailProcessor(), - ]), - ); - - $manager = new RunSubscriptionEngineRepositoryManager( - $manager, - $engine, - ); - - $commandBus = SyncCommandBus::createForAggregateHandlers( - $aggregateRootRegistry, - $manager, - new ServiceLocator([ - ClockInterface::class => new SystemClock(), - 'env' => 'test', - ]), - ); - - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, - ); - + $configuration = [ + 'connection' => ['service' => 'app.connection'], + 'aggregates' => [__DIR__], + 'events' => [__DIR__ . '/Events'], + 'headers' => [__DIR__ . '/Header'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => [ + 'subscribers' => [$profileProjection, new SendEmailProcessor()], + 'store' => ['type' => 'in_memory'], + 'gap_detection' => true, + 'sync' => ['throw_on_error' => true], + ], + 'services' => ['app.connection' => $this->connection], + 'parameters' => ['env' => 'test'], + ]; + $container = Factory::create($configuration); + + $manager = $container->get(RepositoryManager::class); + $engine = $container->get(SubscriptionEngine::class); + $commandBus = $container->get(CommandBus::class); + + $schemaDirector = $container->get(SchemaDirector::class); $schemaDirector->create(); $engine->execute(new Setup(skipBooting: true)); @@ -330,55 +267,28 @@ public function testCommandBus(): void public function testQueryBus(): void { - $store = new StreamDoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), - DefaultHeadersSerializer::createFromPaths([ - __DIR__ . '/Header', - ]), - ); - - $aggregateRootRegistry = new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]); - - $manager = new DefaultRepositoryManager( - new AggregateRootRegistry(['profile_with_commands' => ProfileWithCommands::class]), - $store, - null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), - new FooMessageDecorator(), - ); - $profileProjection = new ProfileProjector($this->connection); - - $engine = new DefaultSubscriptionEngine( - new StoreMessageLoader($store), - new InMemorySubscriptionStore(), - new MetadataSubscriberAccessorRepository([ - $profileProjection, - new SendEmailProcessor(), - ]), - ); - - $manager = new RunSubscriptionEngineRepositoryManager( - $manager, - $engine, - ); - - $commandBus = SyncCommandBus::createForAggregateHandlers( - $aggregateRootRegistry, - $manager, - new ServiceLocator([ - ClockInterface::class => new SystemClock(), - 'env' => 'test', - ]), - ); - - $queryBus = new SyncQueryBus(new ServiceHandlerProvider([$profileProjection])); - - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, - ); + $configuration = [ + 'connection' => ['service' => 'app.connection'], + 'aggregates' => [__DIR__], + 'events' => [__DIR__ . '/Events'], + 'headers' => [__DIR__ . '/Header'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => [ + 'subscribers' => [$profileProjection], + 'store' => ['type' => 'in_memory'], + 'gap_detection' => true, + 'sync' => ['throw_on_error' => true], + ], + 'services' => ['app.connection' => $this->connection], + 'parameters' => ['env' => 'test'], + ]; + $container = Factory::create($configuration); + + $engine = $container->get(SubscriptionEngine::class); + $commandBus = $container->get(CommandBus::class); + $queryBus = $container->get(QueryBus::class); + $schemaDirector = $container->get(SchemaDirector::class); $schemaDirector->create(); $engine->execute(new Setup(skipBooting: true)); @@ -395,33 +305,24 @@ public function testQueryBus(): void public function testAggregateInitialization(): void { - $store = new StreamDoctrineDbalStore( - $this->connection, - DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), - DefaultHeadersSerializer::createFromPaths([ - __DIR__ . '/Header', - ]), - ); - - $aggregateRootRegistry = new AggregateRootRegistry(['stock' => Stock::class]); - - $manager = new DefaultRepositoryManager( - $aggregateRootRegistry, - $store, - null, - new DefaultSnapshotStore(['default' => new InMemorySnapshotAdapter()]), - new FooMessageDecorator(), - ); - - $commandBus = SyncCommandBus::createForAggregateHandlers( - $aggregateRootRegistry, - $manager, - ); - - $schemaDirector = new DoctrineSchemaDirector( - $this->connection, - $store, - ); + $configuration = [ + 'connection' => ['service' => 'app.connection'], + 'aggregates' => [__DIR__], + 'events' => [__DIR__ . '/Events'], + 'headers' => [__DIR__ . '/Header'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => [ + 'store' => ['type' => 'in_memory'], + 'gap_detection' => true, + 'sync' => ['throw_on_error' => true], + ], + 'services' => ['app.connection' => $this->connection], + ]; + $container = Factory::create($configuration); + + $manager = $container->get(RepositoryManager::class); + $commandBus = $container->get(CommandBus::class); + $schemaDirector = $container->get(SchemaDirector::class); $schemaDirector->create(); diff --git a/tests/Integration/Container/Fixture/ProfileRegistration.php b/tests/Integration/Container/Fixture/ProfileRegistration.php new file mode 100644 index 000000000..0d46faf4e --- /dev/null +++ b/tests/Integration/Container/Fixture/ProfileRegistration.php @@ -0,0 +1,35 @@ +commandBus->dispatch(new CreateProfile($profileId, $name)); + + $name = $this->queryBus->dispatch(new QueryProfileName($profileId)); + + return is_string($name) ? $name : ''; + } +} diff --git a/tests/Integration/Container/LaminasServiceManagerTest.php b/tests/Integration/Container/LaminasServiceManagerTest.php new file mode 100644 index 000000000..4c5046113 --- /dev/null +++ b/tests/Integration/Container/LaminasServiceManagerTest.php @@ -0,0 +1,110 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testIntegration(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2026-01-01 12:00:00')); + + $container = new ServiceManager([ + 'services' => [ + Connection::class => $this->connection, + ClockInterface::class => $clock, + LoggerInterface::class => new NullLogger(), + ], + 'abstract_factories' => [ReflectionBasedAbstractFactory::class], + ]); + + $eventSourcing = Factory::create( + [ + 'connection' => ['service' => Connection::class], + 'aggregates' => [__DIR__ . '/../BasicImplementation'], + 'events' => [__DIR__ . '/../BasicImplementation/Events'], + 'headers' => [__DIR__ . '/../BasicImplementation/Header'], + 'clock' => ['service' => ClockInterface::class], + 'logger' => ['service' => LoggerInterface::class], + 'subscription' => [ + 'subscribers' => [ProfileProjector::class], + 'sync' => ['throw_on_error' => true], + ], + 'parameters' => ['env' => 'test'], + ], + $container, + ); + + Factory::registerBridges( + $eventSourcing, + static function (string $id, callable $service) use ($container): void { + $container->setFactory($id, static fn (): object => $service()); + }, + ); + + $schemaDirector = $container->get(SchemaDirector::class); + $subscriptionEngine = $container->get(SubscriptionEngine::class); + + $schemaDirector->create(); + $subscriptionEngine->execute(new Setup(skipBooting: true)); + + $profileId = ProfileId::generate(); + $registration = $container->get(ProfileRegistration::class); + + self::assertSame('John', $registration->register($profileId, 'John')); + + self::assertSame($clock, $container->get(ClockInterface::class)); + self::assertSame($eventSourcing->get(RepositoryManager::class), $container->get(RepositoryManager::class)); + + $profile = $eventSourcing->get(RepositoryManager::class)->get(ProfileWithCommands::class)->load($profileId); + + self::assertSame('John', $profile->name()); + + $messages = array_values($eventSourcing->get(Store::class)->load()->toArray()); + + self::assertCount(1, $messages); + self::assertEquals( + new RecordedOnHeader(new DateTimeImmutable('2026-01-01 12:00:00')), + $messages[0]->header(RecordedOnHeader::class), + ); + } +} diff --git a/tests/Integration/Container/LeagueContainerTest.php b/tests/Integration/Container/LeagueContainerTest.php new file mode 100644 index 000000000..921d6ceae --- /dev/null +++ b/tests/Integration/Container/LeagueContainerTest.php @@ -0,0 +1,111 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testIntegration(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2026-01-01 12:00:00')); + + $container = new LeagueContainer(); + $container->delegate(new ReflectionContainer(true)); + $container->addShared(Connection::class, $this->connection); + $container->addShared(ClockInterface::class, $clock); + $container->addShared(LoggerInterface::class, new NullLogger()); + + $eventSourcing = Factory::create( + [ + 'connection' => ['service' => Connection::class], + 'aggregates' => [__DIR__ . '/../BasicImplementation'], + 'events' => [__DIR__ . '/../BasicImplementation/Events'], + 'headers' => [__DIR__ . '/../BasicImplementation/Header'], + 'clock' => ['service' => ClockInterface::class], + 'logger' => ['service' => LoggerInterface::class], + 'subscription' => [ + 'subscribers' => [ProfileProjector::class], + 'sync' => ['throw_on_error' => true], + ], + 'parameters' => ['env' => 'test'], + ], + $container, + ); + + Factory::registerBridges( + $eventSourcing, + static function (string $id, callable $service) use ($container): void { + $container->addShared($id, static fn (): object => $service()); + }, + ); + + $schemaDirector = $container->get(SchemaDirector::class); + $subscriptionEngine = $container->get(SubscriptionEngine::class); + + self::assertInstanceOf(SchemaDirector::class, $schemaDirector); + self::assertInstanceOf(SubscriptionEngine::class, $subscriptionEngine); + + $schemaDirector->create(); + $subscriptionEngine->execute(new Setup(skipBooting: true)); + + $profileId = ProfileId::generate(); + $registration = $container->get(ProfileRegistration::class); + + self::assertInstanceOf(ProfileRegistration::class, $registration); + self::assertSame('John', $registration->register($profileId, 'John')); + + self::assertSame($clock, $container->get(ClockInterface::class)); + self::assertSame($eventSourcing->get(RepositoryManager::class), $container->get(RepositoryManager::class)); + + $profile = $eventSourcing->get(RepositoryManager::class)->get(ProfileWithCommands::class)->load($profileId); + + self::assertSame('John', $profile->name()); + + $messages = array_values($eventSourcing->get(Store::class)->load()->toArray()); + + self::assertCount(1, $messages); + self::assertEquals( + new RecordedOnHeader(new DateTimeImmutable('2026-01-01 12:00:00')), + $messages[0]->header(RecordedOnHeader::class), + ); + } +} diff --git a/tests/Integration/Container/PhpDiContainerTest.php b/tests/Integration/Container/PhpDiContainerTest.php new file mode 100644 index 000000000..fec4b4394 --- /dev/null +++ b/tests/Integration/Container/PhpDiContainerTest.php @@ -0,0 +1,114 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testIntegration(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2026-01-01 12:00:00')); + + $container = (new ContainerBuilder()) + ->useAutowiring(true) + ->addDefinitions([ + Connection::class => $this->connection, + ClockInterface::class => $clock, + LoggerInterface::class => new NullLogger(), + ]) + ->build(); + + $eventSourcing = Factory::create( + [ + 'connection' => ['service' => Connection::class], + 'aggregates' => [__DIR__ . '/../BasicImplementation'], + 'events' => [__DIR__ . '/../BasicImplementation/Events'], + 'headers' => [__DIR__ . '/../BasicImplementation/Header'], + 'clock' => ['service' => ClockInterface::class], + 'logger' => ['service' => LoggerInterface::class], + 'subscription' => [ + 'subscribers' => [ProfileProjector::class], + 'sync' => ['throw_on_error' => true], + ], + 'parameters' => ['env' => 'test'], + ], + $container, + ); + + Factory::registerBridges( + $eventSourcing, + static function (string $id, callable $service) use ($container): void { + $container->set($id, factory($service)); + }, + ); + + $schemaDirector = $container->get(SchemaDirector::class); + $subscriptionEngine = $container->get(SubscriptionEngine::class); + + self::assertInstanceOf(SchemaDirector::class, $schemaDirector); + self::assertInstanceOf(SubscriptionEngine::class, $subscriptionEngine); + + $schemaDirector->create(); + $subscriptionEngine->execute(new Setup(skipBooting: true)); + + $profileId = ProfileId::generate(); + $registration = $container->get(ProfileRegistration::class); + + self::assertInstanceOf(ProfileRegistration::class, $registration); + self::assertSame('John', $registration->register($profileId, 'John')); + + self::assertSame($clock, $container->get(ClockInterface::class)); + self::assertSame($eventSourcing->get(RepositoryManager::class), $container->get(RepositoryManager::class)); + + $profile = $eventSourcing->get(RepositoryManager::class)->get(ProfileWithCommands::class)->load($profileId); + + self::assertSame('John', $profile->name()); + + $messages = array_values($eventSourcing->get(Store::class)->load()->toArray()); + + self::assertCount(1, $messages); + self::assertEquals( + new RecordedOnHeader(new DateTimeImmutable('2026-01-01 12:00:00')), + $messages[0]->header(RecordedOnHeader::class), + ); + } +} diff --git a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php index 63e5a4f2d..7a1990094 100644 --- a/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php +++ b/tests/Integration/DynamicConsistencyBoundary/DynamicConsistencyBoundaryTest.php @@ -5,15 +5,22 @@ namespace Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary; use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\CommandBus\CommandBus; use Patchlevel\EventSourcing\CommandBus\ServiceHandlerProvider; use Patchlevel\EventSourcing\CommandBus\SyncCommandBus; +use Patchlevel\EventSourcing\Container\Container; +use Patchlevel\EventSourcing\Container\Factory; +use Patchlevel\EventSourcing\DecisionModel\DecisionModelBuilder; +use Patchlevel\EventSourcing\DecisionModel\EventAppender; use Patchlevel\EventSourcing\DecisionModel\StoreDecisionModelBuilder; use Patchlevel\EventSourcing\DecisionModel\StoreEventAppender; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; +use Patchlevel\EventSourcing\Schema\SchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\TaggableDoctrineDbalStore; use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\DynamicConsistencyBoundary\Course\Command\ChangeCourseCapacity; @@ -141,4 +148,61 @@ public function testInvoice(): void ->withHeader(new StreamNameHeader('main')), ], $store->load()); } + + public function testCourseWithContainer(): void + { + $configuration = [ + 'connection' => ['service' => 'app.connection'], + 'store' => ['type' => 'dbal_taggable', 'options' => ['default_stream_name' => 'course']], + 'events' => [__DIR__ . '/Course/Event'], + 'dcb' => true, + 'command_bus' => [ + 'handlers' => [ + DefineCourseHandler::class, + ChangeCourseCapacityHandler::class, + SubscribeStudentToCourseHandler::class, + ], + ], + 'services' => [ + 'app.connection' => $this->connection, + DefineCourseHandler::class => static fn (Container $container): DefineCourseHandler => new DefineCourseHandler( + $container->get(DecisionModelBuilder::class), + $container->get(EventAppender::class), + ), + ChangeCourseCapacityHandler::class => static fn (Container $container): ChangeCourseCapacityHandler => new ChangeCourseCapacityHandler( + $container->get(DecisionModelBuilder::class), + $container->get(EventAppender::class), + ), + SubscribeStudentToCourseHandler::class => static fn (Container $container): SubscribeStudentToCourseHandler => new SubscribeStudentToCourseHandler( + $container->get(DecisionModelBuilder::class), + $container->get(EventAppender::class), + ), + ], + ]; + $container = Factory::create($configuration); + + $container->get(SchemaDirector::class)->create(); + + $commandBus = $container->get(CommandBus::class); + + $courseId = CourseId::generate(); + $student1Id = StudentId::generate(); + $student2Id = StudentId::generate(); + + $commandBus->dispatch(new DefineCourse($courseId, 10)); + $commandBus->dispatch(new ChangeCourseCapacity($courseId, 2)); + $commandBus->dispatch(new SubscribeStudentToCourse($student1Id, $courseId)); + $commandBus->dispatch(new SubscribeStudentToCourse($student2Id, $courseId)); + + self::assertStreamEquals([ + Message::create(new CourseDefined($courseId, 10)) + ->withHeader(new StreamNameHeader('course')), + Message::create(new CourseCapacityChanged($courseId, 2)) + ->withHeader(new StreamNameHeader('course')), + Message::create(new StudentSubscribedToCourse($student1Id, $courseId)) + ->withHeader(new StreamNameHeader('course')), + Message::create(new StudentSubscribedToCourse($student2Id, $courseId)) + ->withHeader(new StreamNameHeader('course')), + ], $container->get(Store::class)->load()); + } } diff --git a/tests/Integration/Subscription/SubscriptionTest.php b/tests/Integration/Subscription/SubscriptionTest.php index 8bb2e6cde..40369e62d 100644 --- a/tests/Integration/Subscription/SubscriptionTest.php +++ b/tests/Integration/Subscription/SubscriptionTest.php @@ -12,16 +12,20 @@ use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\Clock\FrozenClock; +use Patchlevel\EventSourcing\Container\Factory; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; +use Patchlevel\EventSourcing\Repository\RepositoryManager; use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; +use Patchlevel\EventSourcing\Schema\SchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; +use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DbalCleanupTaskHandler; use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropTableTask; @@ -43,6 +47,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\RunMode; @@ -1871,4 +1876,49 @@ private static function findSubscription(array $subscriptions, string $id): Subs self::fail('subscription not found'); } + + public function testEventEmitterWithContainer(): void + { + $collector = new NotificationCollector(); + + $configuration = [ + 'connection' => ['service' => 'app.connection'], + 'aggregates' => [__DIR__], + 'events' => [__DIR__ . '/Events'], + 'clock' => ['freeze' => '2021-01-01T00:00:00'], + 'subscription' => [ + 'subscribers' => [new NotificationEmittingProjection(), $collector], + 'event_emitter' => true, + ], + 'services' => ['app.connection' => $this->connection], + ]; + $container = Factory::create($configuration); + + $container->get(SchemaDirector::class)->create(); + + $store = $container->get(Store::class); + $engine = $container->get(SubscriptionEngine::class); + $repository = $container->get(RepositoryManager::class)->get(Profile::class); + + $engine->execute(new SetupCommand()); + $engine->execute(new Boot()); + + $profileId = ProfileId::generate(); + $repository->save(Profile::create($profileId, 'John')); + + do { + $result = $engine->execute(new Run()); + + self::assertInstanceOf(ProcessedResult::class, $result); + self::assertEquals([], $result->errors); + } while ($result->processedMessages > 0); + + self::assertSame(1, $store->count(new Criteria(new StreamCriterion('subscription_emitting')))); + self::assertCount(1, $collector->notifications); + self::assertEquals($profileId, $collector->notifications[0]->profileId); + + $engine->execute(new Remove()); + + self::assertNotContains('subscription_emitting', $store->streams()); + } } diff --git a/tests/Unit/Container/ConfigurationShapeGeneratorTest.php b/tests/Unit/Container/ConfigurationShapeGeneratorTest.php new file mode 100644 index 000000000..fd193ffc3 --- /dev/null +++ b/tests/Unit/Container/ConfigurationShapeGeneratorTest.php @@ -0,0 +1,113 @@ + ConfigurationNormalizer::named('Type', ConfigurationNormalizer::enum(['a', 'b'], 'a')), + 'definition' => ConfigurationNormalizer::named('Definition', ConfigurationNormalizer::struct([ + 'service' => ConfigurationNormalizer::requiredString(), + 'limit' => ConfigurationNormalizer::int(null, 1), + ])), + 'feature' => ConfigurationNormalizer::toggle(false, [ + 'decorators' => ConfigurationNormalizer::services(MessageDecorator::class), + 'providers' => ConfigurationNormalizer::services(HandlerProvider::class), + 'exceptions' => ConfigurationNormalizer::typed( + 'list>', + ConfigurationNormalizer::stringList(), + ), + ]), + 'parameters' => ConfigurationNormalizer::map(ConfigurationNormalizer::variable()), + ])); + + $source = <<<'PHP' + , + * providers?: list, + * exceptions?: list>, + * }, + * parameters?: array, + * } + * @phpstan-type NormalizedConfig array{ + * type: Type, + * definition: NormalizedDefinition, + * feature: array{ + * enabled: bool, + * decorators: list, + * providers: list, + * exceptions: list>, + * }, + * parameters: array, + * } + * @phpstan-import-type Node from ConfigurationNormalizer + */ + final class ExampleConfiguration + { + } + + PHP, + (new ConfigurationShapeGenerator())->generate($tree, $source), + ); + } +} diff --git a/tests/Unit/Container/ConfigurationTest.php b/tests/Unit/Container/ConfigurationTest.php new file mode 100644 index 000000000..826b9f2d5 --- /dev/null +++ b/tests/Unit/Container/ConfigurationTest.php @@ -0,0 +1,293 @@ + ['url' => 'sqlite3:///:memory:', 'service' => null, 'provide_dedicated_connection' => false], + 'store' => [ + 'type' => 'dbal_stream', + 'service' => null, + 'options' => [], + 'read_only' => false, + 'migrate_to_new_store' => [ + 'enabled' => false, + 'type' => 'dbal_stream', + 'service' => null, + 'options' => [], + 'translators' => [], + ], + ], + 'aggregates' => [], + 'events' => [], + 'headers' => [], + 'event_bus' => ['enabled' => false, 'type' => 'default', 'service' => null, 'listeners' => []], + 'command_bus' => [ + 'enabled' => true, + 'register_aggregate_handlers' => true, + 'handlers' => [], + 'handler_providers' => [], + 'instant_retry' => [ + 'enabled' => true, + 'default_max_retries' => 3, + 'default_exceptions' => [AggregateOutdated::class], + ], + ], + 'query_bus' => ['enabled' => true, 'handlers' => [], 'handler_providers' => []], + 'clock' => ['freeze' => null, 'service' => null], + 'logger' => ['service' => null], + 'migration' => ['enabled' => true, 'namespace' => 'EventSourcingMigrations', 'path' => 'migrations'], + 'snapshot_stores' => [], + 'subscription' => [ + 'subscribers' => [], + 'store' => ['type' => 'dbal', 'service' => null, 'options' => ['table_name' => 'subscriptions']], + 'retry_strategies' => [ + 'default' => [ + 'type' => 'clock_based', + 'service' => null, + 'options' => ['base_delay' => 5, 'delay_factor' => 2.0, 'max_attempts' => 5], + ], + 'no_retry' => ['type' => 'no_retry', 'service' => null, 'options' => []], + ], + 'default_retry_strategy' => 'default', + 'sync' => [ + 'enabled' => false, + 'ids' => [], + 'groups' => [], + 'catch_up_limit' => null, + 'throw_on_error' => false, + ], + 'gap_detection' => ['enabled' => false, 'retries_in_ms' => [0, 5, 50, 500], 'detection_window' => 'PT5M'], + 'event_filtered_message_loader' => ['enabled' => false], + 'event_emitter' => ['enabled' => false], + 'argument_resolvers' => [], + 'cleanup_task_handlers' => [], + ], + 'dcb' => ['enabled' => false], + 'hydrator' => [ + 'default_lazy' => false, + 'cryptography' => ['enabled' => false, 'algorithm' => 'aes-128-gcm', 'cipher_key_store' => null], + 'lifecycle' => ['enabled' => false], + 'extensions' => [], + 'guessers' => [], + 'upcasters' => ['before_encoding' => [], 'before_transform' => []], + ], + 'message_decorators' => [], + 'services' => [], + 'parameters' => [], + ], + Configuration::normalize(['connection' => ['url' => 'sqlite3:///:memory:']]), + ); + } + + public function testBoolShorthandForToggles(): void + { + $config = Configuration::normalize([ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'event_bus' => true, + 'command_bus' => false, + 'subscription' => ['gap_detection' => true], + ]); + + self::assertSame(['enabled' => true, 'type' => 'default', 'service' => null, 'listeners' => []], $config['event_bus']); + self::assertFalse($config['command_bus']['enabled']); + self::assertTrue($config['subscription']['gap_detection']['enabled']); + self::assertSame([0, 5, 50, 500], $config['subscription']['gap_detection']['retries_in_ms']); + } + + public function testConfiguredToggleIsEnabled(): void + { + $config = Configuration::normalize([ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'subscription' => ['sync' => ['ids' => ['profile']]], + ]); + + self::assertSame( + ['enabled' => true, 'ids' => ['profile'], 'groups' => [], 'catch_up_limit' => null, 'throw_on_error' => false], + $config['subscription']['sync'], + ); + } + + public function testRetryStrategiesReplaceDefaults(): void + { + $config = Configuration::normalize([ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'subscription' => [ + 'retry_strategies' => ['default' => ['type' => 'clock_based', 'options' => ['max_attempts' => 10]]], + ], + ]); + + self::assertSame( + ['default' => ['type' => 'clock_based', 'service' => null, 'options' => ['max_attempts' => 10]]], + $config['subscription']['retry_strategies'], + ); + } + + public function testStoreOptionsArePassedThrough(): void + { + $config = Configuration::normalize([ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => [ + 'type' => 'custom', + 'service' => 'app.store', + 'options' => ['table_name' => 'my_event_store', 'custom_option' => ['foo' => 'bar']], + ], + ]); + + self::assertSame( + ['table_name' => 'my_event_store', 'custom_option' => ['foo' => 'bar']], + $config['store']['options'], + ); + } + + public function testServicesAcceptObjectsAndIds(): void + { + $decorator = new SplitStreamDecorator(new AttributeEventMetadataFactory()); + + $config = Configuration::normalize([ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'message_decorators' => [$decorator, 'app.message_decorator'], + ]); + + self::assertSame([$decorator, 'app.message_decorator'], $config['message_decorators']); + } + + /** @return iterable, string}> */ + public static function invalidConfigurationProvider(): iterable + { + yield 'unknown option' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'store' => ['typ' => 'in_memory']], + 'Unrecognized option "typ" under "event_sourcing.store". Available options are "type", "service", "options", "read_only", "migrate_to_new_store".', + ]; + + yield 'unknown root option' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'foo' => true], + 'Unrecognized option "foo" under "event_sourcing".', + ]; + + yield 'invalid type' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'store' => ['read_only' => 'yes']], + 'Invalid type for path "event_sourcing.store.read_only". Expected "bool", but got "string".', + ]; + + yield 'invalid enum value' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'store' => ['type' => 'dbal']], + 'The value "dbal" is not allowed for path "event_sourcing.store.type". Permissible values: "dbal_stream", "dbal_taggable", "in_memory", "custom".', + ]; + + yield 'invalid service' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'message_decorators' => [new stdClass()]], + 'Invalid type for path "event_sourcing.message_decorators.0". Expected "Patchlevel\EventSourcing\Repository\MessageDecorator\MessageDecorator" or "string", but got "stdClass".', + ]; + + yield 'invalid list' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'aggregates' => ['src' => 'src/Domain']], + 'Invalid type for path "event_sourcing.aggregates". Expected "list", but got "array".', + ]; + + yield 'too small int' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'subscription' => ['sync' => ['catch_up_limit' => 0]]], + 'The value 0 is too small for path "event_sourcing.subscription.sync.catch_up_limit". Should be greater than or equal to 1.', + ]; + + yield 'missing required value' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'snapshot_stores' => ['default' => ['type' => 'psr6']]], + 'The child config "event_sourcing.snapshot_stores.default.service" must be configured.', + ]; + + yield 'missing connection' => [ + [], + 'Either "event_sourcing.connection.url" or "event_sourcing.connection.service" is required.', + ]; + + yield 'url and service' => [ + ['connection' => ['url' => 'sqlite3:///:memory:', 'service' => 'app.connection']], + 'Only one of "event_sourcing.connection.url" and "event_sourcing.connection.service" can be set.', + ]; + + yield 'dedicated connection with service' => [ + ['connection' => ['service' => 'app.connection', 'provide_dedicated_connection' => true]], + '"event_sourcing.connection.provide_dedicated_connection" is only possible with "url".', + ]; + + yield 'custom store without service' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'store' => ['type' => 'custom']], + '"event_sourcing.store.service" is required for this type.', + ]; + + yield 'custom event bus without service' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'event_bus' => ['type' => 'psr14']], + '"event_sourcing.event_bus.service" is required for this type.', + ]; + + yield 'custom retry strategy without service' => [ + [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'subscription' => ['retry_strategies' => ['default' => ['type' => 'custom']]], + ], + '"event_sourcing.subscription.retry_strategies.default.service" is required for this type.', + ]; + + yield 'unknown default retry strategy' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'subscription' => ['default_retry_strategy' => 'foo']], + 'The default retry strategy "foo" is not defined in "event_sourcing.subscription.retry_strategies".', + ]; + + yield 'read only taggable store' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'store' => ['type' => 'dbal_taggable', 'read_only' => true]], + '"event_sourcing.store.read_only" is only supported by the "dbal_stream" store.', + ]; + + yield 'event emitter with read only store' => [ + [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['read_only' => true], + 'subscription' => ['event_emitter' => true], + ], + '"event_sourcing.subscription.event_emitter" does not support a read only store.', + ]; + + yield 'dcb with stream store' => [ + ['connection' => ['url' => 'sqlite3:///:memory:'], 'dcb' => true], + '"event_sourcing.dcb" requires a store with tag support, e.g. "dbal_taggable".', + ]; + + yield 'gap detection with event filtered message loader' => [ + [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'subscription' => ['gap_detection' => true, 'event_filtered_message_loader' => true], + ], + '"event_sourcing.subscription.gap_detection" and "event_sourcing.subscription.event_filtered_message_loader" can not be combined.', + ]; + } + + /** @param array $config */ + #[DataProvider('invalidConfigurationProvider')] + public function testInvalidConfiguration(array $config, string $message): void + { + $this->expectException(InvalidConfiguration::class); + $this->expectExceptionMessage($message); + + /** @phpstan-ignore argument.type */ + Configuration::normalize($config); + } +} diff --git a/tests/Unit/Container/ContainerTest.php b/tests/Unit/Container/ContainerTest.php new file mode 100644 index 000000000..2fc56d11f --- /dev/null +++ b/tests/Unit/Container/ContainerTest.php @@ -0,0 +1,179 @@ + $externalService])); + + self::assertSame($externalService, $container->get('app.service')); + } + + public function testLocalServiceWinsOverExternalContainer(): void + { + $localService = new stdClass(); + $container = new Container( + services: ['app.service' => $localService], + externalContainer: new ArrayContainer(['app.service' => new stdClass()]), + ); + + self::assertSame($localService, $container->get('app.service')); + } + + public function testHasConsultsExternalContainer(): void + { + $container = new Container(externalContainer: new ArrayContainer(['app.service' => new stdClass()])); + + self::assertTrue($container->has('app.service')); + self::assertFalse($container->has('app.unknown')); + } + + public function testServiceNotFoundWhenMissingEverywhere(): void + { + $container = new Container(externalContainer: new ArrayContainer()); + + $this->expectException(ServiceNotFound::class); + $container->get('app.unknown'); + } + + public function testServiceNotFoundWithoutExternalContainer(): void + { + $container = new Container(); + + $this->expectException(ServiceNotFound::class); + $container->get('app.unknown'); + } + + public function testNonObjectExternalServiceFails(): void + { + $container = new Container(externalContainer: new ArrayContainer(['app.parameter' => 'a-string'])); + + $this->expectException(ServiceCreationFailed::class); + $container->get('app.parameter'); + } + + public function testAliasResolvesIntoExternalContainer(): void + { + $externalService = new SystemClock(); + $container = new Container(externalContainer: new ArrayContainer(['app.clock' => $externalService])); + $container->alias('clock', 'app.clock'); + + self::assertTrue($container->has('clock')); + self::assertSame($externalService, $container->get('clock')); + } + + public function testExternalContainerIsResolvedLazily(): void + { + $external = new ArrayContainer(['app.service' => new stdClass()]); + $container = new Container(externalContainer: $external); + $container->bind('local', static fn (): stdClass => new stdClass()); + + $container->get('local'); + + self::assertSame([], $external->resolvedIds); + + $container->get('app.service'); + + self::assertSame(['app.service'], $external->resolvedIds); + } + + public function testBindReplacesAlias(): void + { + $service = new stdClass(); + $container = new Container(); + $container->bind('original', new stdClass()); + $container->alias('service', 'original'); + + $container->bind('service', $service); + + self::assertSame($service, $container->get('service')); + } + + public function testBindReplacesFactory(): void + { + $service = new stdClass(); + $container = new Container(); + $container->bind('service', static fn (): stdClass => new stdClass()); + + $container->bind('service', $service); + + self::assertSame($service, $container->get('service')); + } + + public function testBindInvokableObjectAsService(): void + { + $service = new class () { + public function __invoke(): stdClass + { + return new stdClass(); + } + }; + $container = new Container(); + + $container->bind('service', $service); + + self::assertSame($service, $container->get('service')); + } + + public function testProvidesOnlyLocalServices(): void + { + $container = new Container(externalContainer: new ArrayContainer(['app.service' => new stdClass()])); + $container->bind('local', new stdClass()); + $container->alias('local.alias', 'local'); + $container->alias('external.alias', 'app.service'); + + self::assertTrue($container->provides('local')); + self::assertTrue($container->provides('local.alias')); + self::assertFalse($container->provides('app.service')); + self::assertFalse($container->provides('external.alias')); + self::assertTrue($container->has('external.alias')); + } + + public function testAliasToItselfFallsBackToExternalContainer(): void + { + $externalService = new SystemClock(); + $container = new Container(externalContainer: new ArrayContainer(['clock' => $externalService])); + $container->bind('clock', new SystemClock()); + + $container->alias('clock', 'clock'); + + self::assertFalse($container->provides('clock')); + self::assertSame($externalService, $container->get('clock')); + } + + public function testCircularAliasFails(): void + { + $container = new Container(); + $container->alias('a', 'b'); + $container->alias('b', 'a'); + + $this->expectException(ServiceCreationFailed::class); + + $container->get('a'); + } + + public function testAliasReplacesService(): void + { + $service = new stdClass(); + $container = new Container(); + $container->bind('service', new stdClass()); + $container->bind('other', $service); + + $container->alias('service', 'other'); + + self::assertSame($service, $container->get('service')); + } +} diff --git a/tests/Unit/Container/FactoryTest.php b/tests/Unit/Container/FactoryTest.php new file mode 100644 index 000000000..de193412a --- /dev/null +++ b/tests/Unit/Container/FactoryTest.php @@ -0,0 +1,1216 @@ + ['url' => 'sqlite3:///:memory:'], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(DefaultRepositoryManager::class, $container->get(RepositoryManager::class)); + self::assertInstanceOf(AttributeEventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + self::assertInstanceOf(JsonEncoder::class, $container->get(Encoder::class)); + self::assertInstanceOf(AttributeMessageHeaderRegistryFactory::class, $container->get(MessageHeaderRegistryFactory::class)); + self::assertInstanceOf(AggregateRootMetadataAwareMetadataFactory::class, $container->get(AggregateRootMetadataFactory::class)); + self::assertInstanceOf(AttributeSubscriberMetadataFactory::class, $container->get(SubscriberMetadataFactory::class)); + self::assertInstanceOf(Connection::class, $container->get(Factory::CONNECTION_ID)); + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(EventRegistry::class, $container->get(EventRegistry::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(MessageHeaderRegistry::class, $container->get(MessageHeaderRegistry::class)); + self::assertInstanceOf(DefaultHeadersSerializer::class, $container->get(HeadersSerializer::class)); + self::assertInstanceOf(StackHydrator::class, $container->get(Hydrator::class)); + self::assertInstanceOf(SystemClock::class, $container->get(ClockInterface::class)); + self::assertInstanceOf(AggregateRootRegistry::class, $container->get(AggregateRootRegistry::class)); + self::assertInstanceOf(ShowCommand::class, $container->get(ShowCommand::class)); + self::assertInstanceOf(ShowAggregateCommand::class, $container->get(ShowAggregateCommand::class)); + self::assertInstanceOf(WatchCommand::class, $container->get(WatchCommand::class)); + self::assertInstanceOf(DebugCommand::class, $container->get(DebugCommand::class)); + self::assertInstanceOf(ChainMessageDecorator::class, $container->get(MessageDecorator::class)); + self::assertInstanceOf(SplitStreamDecorator::class, $container->get(SplitStreamDecorator::class)); + self::assertInstanceOf(InstantRetryCommandBus::class, $container->get(CommandBus::class)); + self::assertInstanceOf(SyncQueryBus::class, $container->get(QueryBus::class)); + self::assertInstanceOf(StoreMessageLoader::class, $container->get(MessageLoader::class)); + self::assertFalse($container->has(ListenerProvider::class)); + self::assertFalse($container->has(Consumer::class)); + self::assertFalse($container->has(EventBus::class)); + self::assertFalse($container->has(SnapshotStore::class)); + self::assertInstanceOf(RetryStrategyRepository::class, $container->get(RetryStrategyRepository::class)); + self::assertInstanceOf(DoctrineSubscriptionStore::class, $container->get(SubscriptionStore::class)); + self::assertInstanceOf(MetadataSubscriberAccessorRepository::class, $container->get(SubscriberAccessorRepository::class)); + self::assertInstanceOf(DefaultSubscriptionEngine::class, $container->get(SubscriptionEngine::class)); + self::assertInstanceOf(SubscriptionSetupCommand::class, $container->get(SubscriptionSetupCommand::class)); + self::assertInstanceOf(SubscriptionBootCommand::class, $container->get(SubscriptionBootCommand::class)); + self::assertInstanceOf(SubscriptionRunCommand::class, $container->get(SubscriptionRunCommand::class)); + self::assertInstanceOf(SubscriptionTeardownCommand::class, $container->get(SubscriptionTeardownCommand::class)); + self::assertInstanceOf(SubscriptionRemoveCommand::class, $container->get(SubscriptionRemoveCommand::class)); + self::assertInstanceOf(SubscriptionStatusCommand::class, $container->get(SubscriptionStatusCommand::class)); + self::assertInstanceOf(SubscriptionPauseCommand::class, $container->get(SubscriptionPauseCommand::class)); + self::assertInstanceOf(SubscriptionReactivateCommand::class, $container->get(SubscriptionReactivateCommand::class)); + self::assertFalse($container->has(CipherKeyFactory::class)); + self::assertFalse($container->has(CipherKeyStore::class)); + self::assertFalse($container->has(Cryptographer::class)); + } + + public function testCreateConnectionServiceContainer(): void + { + $connection = DriverManager::getConnection((new DsnParser())->parse('sqlite3:///:memory:')); + $configuration = [ + 'connection' => ['service' => 'app.connection'], + 'services' => ['app.connection' => $connection], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(DefaultRepositoryManager::class, $container->get(RepositoryManager::class)); + self::assertInstanceOf(AttributeEventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + self::assertInstanceOf(JsonEncoder::class, $container->get(Encoder::class)); + self::assertInstanceOf(AttributeMessageHeaderRegistryFactory::class, $container->get(MessageHeaderRegistryFactory::class)); + self::assertInstanceOf(AggregateRootMetadataAwareMetadataFactory::class, $container->get(AggregateRootMetadataFactory::class)); + self::assertInstanceOf(AttributeSubscriberMetadataFactory::class, $container->get(SubscriberMetadataFactory::class)); + self::assertInstanceOf(Connection::class, $container->get(Factory::CONNECTION_ID)); + self::assertSame($connection, $container->get(Factory::CONNECTION_ID)); + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(EventRegistry::class, $container->get(EventRegistry::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(MessageHeaderRegistry::class, $container->get(MessageHeaderRegistry::class)); + self::assertInstanceOf(DefaultHeadersSerializer::class, $container->get(HeadersSerializer::class)); + self::assertInstanceOf(StackHydrator::class, $container->get(Hydrator::class)); + self::assertInstanceOf(SystemClock::class, $container->get(ClockInterface::class)); + self::assertInstanceOf(AggregateRootRegistry::class, $container->get(AggregateRootRegistry::class)); + self::assertInstanceOf(ShowCommand::class, $container->get(ShowCommand::class)); + self::assertInstanceOf(ShowAggregateCommand::class, $container->get(ShowAggregateCommand::class)); + self::assertInstanceOf(WatchCommand::class, $container->get(WatchCommand::class)); + self::assertInstanceOf(DebugCommand::class, $container->get(DebugCommand::class)); + self::assertInstanceOf(ChainMessageDecorator::class, $container->get(MessageDecorator::class)); + self::assertInstanceOf(SplitStreamDecorator::class, $container->get(SplitStreamDecorator::class)); + self::assertInstanceOf(InstantRetryCommandBus::class, $container->get(CommandBus::class)); + self::assertInstanceOf(SyncQueryBus::class, $container->get(QueryBus::class)); + self::assertInstanceOf(StoreMessageLoader::class, $container->get(MessageLoader::class)); + self::assertFalse($container->has(ListenerProvider::class)); + self::assertFalse($container->has(Consumer::class)); + self::assertFalse($container->has(EventBus::class)); + self::assertFalse($container->has(SnapshotStore::class)); + self::assertInstanceOf(RetryStrategyRepository::class, $container->get(RetryStrategyRepository::class)); + self::assertInstanceOf(DoctrineSubscriptionStore::class, $container->get(SubscriptionStore::class)); + self::assertInstanceOf(MetadataSubscriberAccessorRepository::class, $container->get(SubscriberAccessorRepository::class)); + self::assertInstanceOf(DefaultSubscriptionEngine::class, $container->get(SubscriptionEngine::class)); + self::assertInstanceOf(SubscriptionSetupCommand::class, $container->get(SubscriptionSetupCommand::class)); + self::assertInstanceOf(SubscriptionBootCommand::class, $container->get(SubscriptionBootCommand::class)); + self::assertInstanceOf(SubscriptionRunCommand::class, $container->get(SubscriptionRunCommand::class)); + self::assertInstanceOf(SubscriptionTeardownCommand::class, $container->get(SubscriptionTeardownCommand::class)); + self::assertInstanceOf(SubscriptionRemoveCommand::class, $container->get(SubscriptionRemoveCommand::class)); + self::assertInstanceOf(SubscriptionStatusCommand::class, $container->get(SubscriptionStatusCommand::class)); + self::assertInstanceOf(SubscriptionPauseCommand::class, $container->get(SubscriptionPauseCommand::class)); + self::assertInstanceOf(SubscriptionReactivateCommand::class, $container->get(SubscriptionReactivateCommand::class)); + self::assertFalse($container->has(CipherKeyFactory::class)); + self::assertFalse($container->has(CipherKeyStore::class)); + self::assertFalse($container->has(Cryptographer::class)); + } + + public function testCreateWithFeatures(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => ['gap_detection' => true], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(DefaultRepositoryManager::class, $container->get(RepositoryManager::class)); + self::assertInstanceOf(AttributeEventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + self::assertInstanceOf(JsonEncoder::class, $container->get(Encoder::class)); + self::assertInstanceOf(AttributeMessageHeaderRegistryFactory::class, $container->get(MessageHeaderRegistryFactory::class)); + self::assertInstanceOf(AggregateRootMetadataAwareMetadataFactory::class, $container->get(AggregateRootMetadataFactory::class)); + self::assertInstanceOf(AttributeSubscriberMetadataFactory::class, $container->get(SubscriberMetadataFactory::class)); + self::assertInstanceOf(Connection::class, $container->get(Factory::CONNECTION_ID)); + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(EventRegistry::class, $container->get(EventRegistry::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(MessageHeaderRegistry::class, $container->get(MessageHeaderRegistry::class)); + self::assertInstanceOf(DefaultHeadersSerializer::class, $container->get(HeadersSerializer::class)); + self::assertInstanceOf(StackHydrator::class, $container->get(Hydrator::class)); + self::assertInstanceOf(SystemClock::class, $container->get(ClockInterface::class)); + self::assertInstanceOf(AggregateRootRegistry::class, $container->get(AggregateRootRegistry::class)); + self::assertInstanceOf(ShowCommand::class, $container->get(ShowCommand::class)); + self::assertInstanceOf(ShowAggregateCommand::class, $container->get(ShowAggregateCommand::class)); + self::assertInstanceOf(WatchCommand::class, $container->get(WatchCommand::class)); + self::assertInstanceOf(DebugCommand::class, $container->get(DebugCommand::class)); + self::assertInstanceOf(ChainMessageDecorator::class, $container->get(MessageDecorator::class)); + self::assertInstanceOf(SplitStreamDecorator::class, $container->get(SplitStreamDecorator::class)); + self::assertInstanceOf(InstantRetryCommandBus::class, $container->get(CommandBus::class)); + self::assertInstanceOf(SyncQueryBus::class, $container->get(QueryBus::class)); + self::assertInstanceOf(GapResolverStoreMessageLoader::class, $container->get(MessageLoader::class)); + self::assertFalse($container->has(ListenerProvider::class)); + self::assertFalse($container->has(Consumer::class)); + self::assertFalse($container->has(EventBus::class)); + self::assertFalse($container->has(SnapshotStore::class)); + self::assertInstanceOf(RetryStrategyRepository::class, $container->get(RetryStrategyRepository::class)); + self::assertInstanceOf(DoctrineSubscriptionStore::class, $container->get(SubscriptionStore::class)); + self::assertInstanceOf(MetadataSubscriberAccessorRepository::class, $container->get(SubscriberAccessorRepository::class)); + self::assertInstanceOf(DefaultSubscriptionEngine::class, $container->get(SubscriptionEngine::class)); + self::assertInstanceOf(SubscriptionSetupCommand::class, $container->get(SubscriptionSetupCommand::class)); + self::assertInstanceOf(SubscriptionBootCommand::class, $container->get(SubscriptionBootCommand::class)); + self::assertInstanceOf(SubscriptionRunCommand::class, $container->get(SubscriptionRunCommand::class)); + self::assertInstanceOf(SubscriptionTeardownCommand::class, $container->get(SubscriptionTeardownCommand::class)); + self::assertInstanceOf(SubscriptionRemoveCommand::class, $container->get(SubscriptionRemoveCommand::class)); + self::assertInstanceOf(SubscriptionStatusCommand::class, $container->get(SubscriptionStatusCommand::class)); + self::assertInstanceOf(SubscriptionPauseCommand::class, $container->get(SubscriptionPauseCommand::class)); + self::assertInstanceOf(SubscriptionReactivateCommand::class, $container->get(SubscriptionReactivateCommand::class)); + self::assertInstanceOf(DoctrineCipherKeyStore::class, $container->get(CipherKeyStore::class)); + self::assertInstanceOf(BaseCryptographer::class, $container->get(Cryptographer::class)); + } + + public function testCreateWithSyncSubscriptionsAndThrowOnError(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => ['gap_detection' => true, 'sync' => ['throw_on_error' => true]], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(AttributeEventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + self::assertInstanceOf(JsonEncoder::class, $container->get(Encoder::class)); + self::assertInstanceOf(AttributeMessageHeaderRegistryFactory::class, $container->get(MessageHeaderRegistryFactory::class)); + self::assertInstanceOf(AggregateRootMetadataAwareMetadataFactory::class, $container->get(AggregateRootMetadataFactory::class)); + self::assertInstanceOf(AttributeSubscriberMetadataFactory::class, $container->get(SubscriberMetadataFactory::class)); + self::assertInstanceOf(Connection::class, $container->get(Factory::CONNECTION_ID)); + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(EventRegistry::class, $container->get(EventRegistry::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(MessageHeaderRegistry::class, $container->get(MessageHeaderRegistry::class)); + self::assertInstanceOf(DefaultHeadersSerializer::class, $container->get(HeadersSerializer::class)); + self::assertInstanceOf(StackHydrator::class, $container->get(Hydrator::class)); + self::assertInstanceOf(SystemClock::class, $container->get(ClockInterface::class)); + self::assertInstanceOf(AggregateRootRegistry::class, $container->get(AggregateRootRegistry::class)); + self::assertInstanceOf(ShowCommand::class, $container->get(ShowCommand::class)); + self::assertInstanceOf(ShowAggregateCommand::class, $container->get(ShowAggregateCommand::class)); + self::assertInstanceOf(WatchCommand::class, $container->get(WatchCommand::class)); + self::assertInstanceOf(DebugCommand::class, $container->get(DebugCommand::class)); + self::assertInstanceOf(ChainMessageDecorator::class, $container->get(MessageDecorator::class)); + self::assertInstanceOf(SplitStreamDecorator::class, $container->get(SplitStreamDecorator::class)); + self::assertInstanceOf(InstantRetryCommandBus::class, $container->get(CommandBus::class)); + self::assertInstanceOf(SyncQueryBus::class, $container->get(QueryBus::class)); + self::assertInstanceOf(GapResolverStoreMessageLoader::class, $container->get(MessageLoader::class)); + self::assertFalse($container->has(ListenerProvider::class)); + self::assertFalse($container->has(Consumer::class)); + self::assertFalse($container->has(EventBus::class)); + self::assertFalse($container->has(SnapshotStore::class)); + self::assertInstanceOf(RetryStrategyRepository::class, $container->get(RetryStrategyRepository::class)); + self::assertInstanceOf(DoctrineSubscriptionStore::class, $container->get(SubscriptionStore::class)); + self::assertInstanceOf(MetadataSubscriberAccessorRepository::class, $container->get(SubscriberAccessorRepository::class)); + self::assertInstanceOf(DefaultSubscriptionEngine::class, $container->get(SubscriptionEngine::class)); + self::assertInstanceOf(ThrowOnErrorSubscriptionEngine::class, $container->get(Factory::SUBSCRIPTION_SYNC_ENGINE_ID)); + self::assertInstanceOf(RunSubscriptionEngineRepositoryManager::class, $container->get(RepositoryManager::class)); + self::assertInstanceOf(SubscriptionSetupCommand::class, $container->get(SubscriptionSetupCommand::class)); + self::assertInstanceOf(SubscriptionBootCommand::class, $container->get(SubscriptionBootCommand::class)); + self::assertInstanceOf(SubscriptionRunCommand::class, $container->get(SubscriptionRunCommand::class)); + self::assertInstanceOf(SubscriptionTeardownCommand::class, $container->get(SubscriptionTeardownCommand::class)); + self::assertInstanceOf(SubscriptionRemoveCommand::class, $container->get(SubscriptionRemoveCommand::class)); + self::assertInstanceOf(SubscriptionStatusCommand::class, $container->get(SubscriptionStatusCommand::class)); + self::assertInstanceOf(SubscriptionPauseCommand::class, $container->get(SubscriptionPauseCommand::class)); + self::assertInstanceOf(SubscriptionReactivateCommand::class, $container->get(SubscriptionReactivateCommand::class)); + self::assertInstanceOf(DoctrineCipherKeyStore::class, $container->get(CipherKeyStore::class)); + self::assertInstanceOf(BaseCryptographer::class, $container->get(Cryptographer::class)); + } + + public function testCreateWithSyncSubscriptionsAndCatchUpLimit(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => ['gap_detection' => true, 'sync' => ['throw_on_error' => true, 'catch_up_limit' => 10]], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(AttributeEventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + self::assertInstanceOf(JsonEncoder::class, $container->get(Encoder::class)); + self::assertInstanceOf(AttributeMessageHeaderRegistryFactory::class, $container->get(MessageHeaderRegistryFactory::class)); + self::assertInstanceOf(AggregateRootMetadataAwareMetadataFactory::class, $container->get(AggregateRootMetadataFactory::class)); + self::assertInstanceOf(AttributeSubscriberMetadataFactory::class, $container->get(SubscriberMetadataFactory::class)); + self::assertInstanceOf(Connection::class, $container->get(Factory::CONNECTION_ID)); + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(EventRegistry::class, $container->get(EventRegistry::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(MessageHeaderRegistry::class, $container->get(MessageHeaderRegistry::class)); + self::assertInstanceOf(DefaultHeadersSerializer::class, $container->get(HeadersSerializer::class)); + self::assertInstanceOf(StackHydrator::class, $container->get(Hydrator::class)); + self::assertInstanceOf(SystemClock::class, $container->get(ClockInterface::class)); + self::assertInstanceOf(AggregateRootRegistry::class, $container->get(AggregateRootRegistry::class)); + self::assertInstanceOf(ShowCommand::class, $container->get(ShowCommand::class)); + self::assertInstanceOf(ShowAggregateCommand::class, $container->get(ShowAggregateCommand::class)); + self::assertInstanceOf(WatchCommand::class, $container->get(WatchCommand::class)); + self::assertInstanceOf(DebugCommand::class, $container->get(DebugCommand::class)); + self::assertInstanceOf(ChainMessageDecorator::class, $container->get(MessageDecorator::class)); + self::assertInstanceOf(SplitStreamDecorator::class, $container->get(SplitStreamDecorator::class)); + self::assertInstanceOf(InstantRetryCommandBus::class, $container->get(CommandBus::class)); + self::assertInstanceOf(SyncQueryBus::class, $container->get(QueryBus::class)); + self::assertInstanceOf(GapResolverStoreMessageLoader::class, $container->get(MessageLoader::class)); + self::assertFalse($container->has(ListenerProvider::class)); + self::assertFalse($container->has(Consumer::class)); + self::assertFalse($container->has(EventBus::class)); + self::assertFalse($container->has(SnapshotStore::class)); + self::assertInstanceOf(RetryStrategyRepository::class, $container->get(RetryStrategyRepository::class)); + self::assertInstanceOf(DoctrineSubscriptionStore::class, $container->get(SubscriptionStore::class)); + self::assertInstanceOf(MetadataSubscriberAccessorRepository::class, $container->get(SubscriberAccessorRepository::class)); + self::assertInstanceOf(DefaultSubscriptionEngine::class, $container->get(SubscriptionEngine::class)); + self::assertInstanceOf(ThrowOnErrorSubscriptionEngine::class, $container->get(Factory::SUBSCRIPTION_SYNC_ENGINE_ID)); + self::assertInstanceOf(RunSubscriptionEngineRepositoryManager::class, $container->get(RepositoryManager::class)); + self::assertInstanceOf(SubscriptionSetupCommand::class, $container->get(SubscriptionSetupCommand::class)); + self::assertInstanceOf(SubscriptionBootCommand::class, $container->get(SubscriptionBootCommand::class)); + self::assertInstanceOf(SubscriptionRunCommand::class, $container->get(SubscriptionRunCommand::class)); + self::assertInstanceOf(SubscriptionTeardownCommand::class, $container->get(SubscriptionTeardownCommand::class)); + self::assertInstanceOf(SubscriptionRemoveCommand::class, $container->get(SubscriptionRemoveCommand::class)); + self::assertInstanceOf(SubscriptionStatusCommand::class, $container->get(SubscriptionStatusCommand::class)); + self::assertInstanceOf(SubscriptionPauseCommand::class, $container->get(SubscriptionPauseCommand::class)); + self::assertInstanceOf(SubscriptionReactivateCommand::class, $container->get(SubscriptionReactivateCommand::class)); + self::assertInstanceOf(DoctrineCipherKeyStore::class, $container->get(CipherKeyStore::class)); + self::assertInstanceOf(BaseCryptographer::class, $container->get(Cryptographer::class)); + } + + public function testCreateWithEventBus(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => ['gap_detection' => true], + 'event_bus' => true, + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(DefaultRepositoryManager::class, $container->get(RepositoryManager::class)); + self::assertInstanceOf(AttributeEventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + self::assertInstanceOf(JsonEncoder::class, $container->get(Encoder::class)); + self::assertInstanceOf(AttributeMessageHeaderRegistryFactory::class, $container->get(MessageHeaderRegistryFactory::class)); + self::assertInstanceOf(AggregateRootMetadataAwareMetadataFactory::class, $container->get(AggregateRootMetadataFactory::class)); + self::assertInstanceOf(AttributeSubscriberMetadataFactory::class, $container->get(SubscriberMetadataFactory::class)); + self::assertInstanceOf(Connection::class, $container->get(Factory::CONNECTION_ID)); + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(EventRegistry::class, $container->get(EventRegistry::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(MessageHeaderRegistry::class, $container->get(MessageHeaderRegistry::class)); + self::assertInstanceOf(DefaultHeadersSerializer::class, $container->get(HeadersSerializer::class)); + self::assertInstanceOf(StackHydrator::class, $container->get(Hydrator::class)); + self::assertInstanceOf(SystemClock::class, $container->get(ClockInterface::class)); + self::assertInstanceOf(AggregateRootRegistry::class, $container->get(AggregateRootRegistry::class)); + self::assertInstanceOf(ShowCommand::class, $container->get(ShowCommand::class)); + self::assertInstanceOf(ShowAggregateCommand::class, $container->get(ShowAggregateCommand::class)); + self::assertInstanceOf(WatchCommand::class, $container->get(WatchCommand::class)); + self::assertInstanceOf(DebugCommand::class, $container->get(DebugCommand::class)); + self::assertInstanceOf(ChainMessageDecorator::class, $container->get(MessageDecorator::class)); + self::assertInstanceOf(SplitStreamDecorator::class, $container->get(SplitStreamDecorator::class)); + self::assertInstanceOf(InstantRetryCommandBus::class, $container->get(CommandBus::class)); + self::assertInstanceOf(SyncQueryBus::class, $container->get(QueryBus::class)); + self::assertInstanceOf(GapResolverStoreMessageLoader::class, $container->get(MessageLoader::class)); + self::assertInstanceOf(AttributeListenerProvider::class, $container->get(ListenerProvider::class)); + self::assertInstanceOf(DefaultConsumer::class, $container->get(Consumer::class)); + self::assertInstanceOf(DefaultEventBus::class, $container->get(EventBus::class)); + self::assertFalse($container->has(SnapshotStore::class)); + self::assertInstanceOf(RetryStrategyRepository::class, $container->get(RetryStrategyRepository::class)); + self::assertInstanceOf(DoctrineSubscriptionStore::class, $container->get(SubscriptionStore::class)); + self::assertInstanceOf(MetadataSubscriberAccessorRepository::class, $container->get(SubscriberAccessorRepository::class)); + self::assertInstanceOf(DefaultSubscriptionEngine::class, $container->get(SubscriptionEngine::class)); + self::assertInstanceOf(SubscriptionSetupCommand::class, $container->get(SubscriptionSetupCommand::class)); + self::assertInstanceOf(SubscriptionBootCommand::class, $container->get(SubscriptionBootCommand::class)); + self::assertInstanceOf(SubscriptionRunCommand::class, $container->get(SubscriptionRunCommand::class)); + self::assertInstanceOf(SubscriptionTeardownCommand::class, $container->get(SubscriptionTeardownCommand::class)); + self::assertInstanceOf(SubscriptionRemoveCommand::class, $container->get(SubscriptionRemoveCommand::class)); + self::assertInstanceOf(SubscriptionStatusCommand::class, $container->get(SubscriptionStatusCommand::class)); + self::assertInstanceOf(SubscriptionPauseCommand::class, $container->get(SubscriptionPauseCommand::class)); + self::assertInstanceOf(SubscriptionReactivateCommand::class, $container->get(SubscriptionReactivateCommand::class)); + self::assertInstanceOf(DoctrineCipherKeyStore::class, $container->get(CipherKeyStore::class)); + self::assertInstanceOf(BaseCryptographer::class, $container->get(Cryptographer::class)); + } + + public function testCreateWithSnapshots(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => ['gap_detection' => true], + 'snapshot_stores' => ['default' => new InMemorySnapshotAdapter()], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(DefaultRepositoryManager::class, $container->get(RepositoryManager::class)); + self::assertInstanceOf(AttributeEventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + self::assertInstanceOf(JsonEncoder::class, $container->get(Encoder::class)); + self::assertInstanceOf(AttributeMessageHeaderRegistryFactory::class, $container->get(MessageHeaderRegistryFactory::class)); + self::assertInstanceOf(AggregateRootMetadataAwareMetadataFactory::class, $container->get(AggregateRootMetadataFactory::class)); + self::assertInstanceOf(AttributeSubscriberMetadataFactory::class, $container->get(SubscriberMetadataFactory::class)); + self::assertInstanceOf(Connection::class, $container->get(Factory::CONNECTION_ID)); + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(EventRegistry::class, $container->get(EventRegistry::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(MessageHeaderRegistry::class, $container->get(MessageHeaderRegistry::class)); + self::assertInstanceOf(DefaultHeadersSerializer::class, $container->get(HeadersSerializer::class)); + self::assertInstanceOf(StackHydrator::class, $container->get(Hydrator::class)); + self::assertInstanceOf(SystemClock::class, $container->get(ClockInterface::class)); + self::assertInstanceOf(AggregateRootRegistry::class, $container->get(AggregateRootRegistry::class)); + self::assertInstanceOf(ShowCommand::class, $container->get(ShowCommand::class)); + self::assertInstanceOf(ShowAggregateCommand::class, $container->get(ShowAggregateCommand::class)); + self::assertInstanceOf(WatchCommand::class, $container->get(WatchCommand::class)); + self::assertInstanceOf(DebugCommand::class, $container->get(DebugCommand::class)); + self::assertInstanceOf(ChainMessageDecorator::class, $container->get(MessageDecorator::class)); + self::assertInstanceOf(SplitStreamDecorator::class, $container->get(SplitStreamDecorator::class)); + self::assertInstanceOf(InstantRetryCommandBus::class, $container->get(CommandBus::class)); + self::assertInstanceOf(SyncQueryBus::class, $container->get(QueryBus::class)); + self::assertInstanceOf(GapResolverStoreMessageLoader::class, $container->get(MessageLoader::class)); + self::assertFalse($container->has(ListenerProvider::class)); + self::assertFalse($container->has(Consumer::class)); + self::assertFalse($container->has(EventBus::class)); + self::assertInstanceOf(DefaultSnapshotStore::class, $container->get(SnapshotStore::class)); + self::assertInstanceOf(RetryStrategyRepository::class, $container->get(RetryStrategyRepository::class)); + self::assertInstanceOf(DoctrineSubscriptionStore::class, $container->get(SubscriptionStore::class)); + self::assertInstanceOf(MetadataSubscriberAccessorRepository::class, $container->get(SubscriberAccessorRepository::class)); + self::assertInstanceOf(DefaultSubscriptionEngine::class, $container->get(SubscriptionEngine::class)); + self::assertInstanceOf(SubscriptionSetupCommand::class, $container->get(SubscriptionSetupCommand::class)); + self::assertInstanceOf(SubscriptionBootCommand::class, $container->get(SubscriptionBootCommand::class)); + self::assertInstanceOf(SubscriptionRunCommand::class, $container->get(SubscriptionRunCommand::class)); + self::assertInstanceOf(SubscriptionTeardownCommand::class, $container->get(SubscriptionTeardownCommand::class)); + self::assertInstanceOf(SubscriptionRemoveCommand::class, $container->get(SubscriptionRemoveCommand::class)); + self::assertInstanceOf(SubscriptionStatusCommand::class, $container->get(SubscriptionStatusCommand::class)); + self::assertInstanceOf(SubscriptionPauseCommand::class, $container->get(SubscriptionPauseCommand::class)); + self::assertInstanceOf(SubscriptionReactivateCommand::class, $container->get(SubscriptionReactivateCommand::class)); + self::assertInstanceOf(DoctrineCipherKeyStore::class, $container->get(CipherKeyStore::class)); + self::assertInstanceOf(BaseCryptographer::class, $container->get(Cryptographer::class)); + } + + public function testCreateWithRetryStrategy(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => ['gap_detection' => true, 'retry_strategies' => ['default' => ['type' => 'clock_based', 'options' => ['base_delay' => 10]]]], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(DefaultRepositoryManager::class, $container->get(RepositoryManager::class)); + self::assertInstanceOf(AttributeEventMetadataFactory::class, $container->get(EventMetadataFactory::class)); + self::assertInstanceOf(JsonEncoder::class, $container->get(Encoder::class)); + self::assertInstanceOf(AttributeMessageHeaderRegistryFactory::class, $container->get(MessageHeaderRegistryFactory::class)); + self::assertInstanceOf(AggregateRootMetadataAwareMetadataFactory::class, $container->get(AggregateRootMetadataFactory::class)); + self::assertInstanceOf(AttributeSubscriberMetadataFactory::class, $container->get(SubscriberMetadataFactory::class)); + self::assertInstanceOf(Connection::class, $container->get(Factory::CONNECTION_ID)); + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(EventRegistry::class, $container->get(EventRegistry::class)); + self::assertInstanceOf(DefaultEventSerializer::class, $container->get(EventSerializer::class)); + self::assertInstanceOf(MessageHeaderRegistry::class, $container->get(MessageHeaderRegistry::class)); + self::assertInstanceOf(DefaultHeadersSerializer::class, $container->get(HeadersSerializer::class)); + self::assertInstanceOf(StackHydrator::class, $container->get(Hydrator::class)); + self::assertInstanceOf(SystemClock::class, $container->get(ClockInterface::class)); + self::assertInstanceOf(AggregateRootRegistry::class, $container->get(AggregateRootRegistry::class)); + self::assertInstanceOf(ShowCommand::class, $container->get(ShowCommand::class)); + self::assertInstanceOf(ShowAggregateCommand::class, $container->get(ShowAggregateCommand::class)); + self::assertInstanceOf(WatchCommand::class, $container->get(WatchCommand::class)); + self::assertInstanceOf(DebugCommand::class, $container->get(DebugCommand::class)); + self::assertInstanceOf(ChainMessageDecorator::class, $container->get(MessageDecorator::class)); + self::assertInstanceOf(SplitStreamDecorator::class, $container->get(SplitStreamDecorator::class)); + self::assertInstanceOf(InstantRetryCommandBus::class, $container->get(CommandBus::class)); + self::assertInstanceOf(SyncQueryBus::class, $container->get(QueryBus::class)); + self::assertInstanceOf(GapResolverStoreMessageLoader::class, $container->get(MessageLoader::class)); + self::assertFalse($container->has(ListenerProvider::class)); + self::assertFalse($container->has(Consumer::class)); + self::assertFalse($container->has(EventBus::class)); + self::assertFalse($container->has(SnapshotStore::class)); + self::assertInstanceOf(RetryStrategyRepository::class, $container->get(RetryStrategyRepository::class)); + self::assertInstanceOf(DoctrineSubscriptionStore::class, $container->get(SubscriptionStore::class)); + self::assertInstanceOf(MetadataSubscriberAccessorRepository::class, $container->get(SubscriberAccessorRepository::class)); + self::assertInstanceOf(DefaultSubscriptionEngine::class, $container->get(SubscriptionEngine::class)); + self::assertInstanceOf(SubscriptionSetupCommand::class, $container->get(SubscriptionSetupCommand::class)); + self::assertInstanceOf(SubscriptionBootCommand::class, $container->get(SubscriptionBootCommand::class)); + self::assertInstanceOf(SubscriptionRunCommand::class, $container->get(SubscriptionRunCommand::class)); + self::assertInstanceOf(SubscriptionTeardownCommand::class, $container->get(SubscriptionTeardownCommand::class)); + self::assertInstanceOf(SubscriptionRemoveCommand::class, $container->get(SubscriptionRemoveCommand::class)); + self::assertInstanceOf(SubscriptionStatusCommand::class, $container->get(SubscriptionStatusCommand::class)); + self::assertInstanceOf(SubscriptionPauseCommand::class, $container->get(SubscriptionPauseCommand::class)); + self::assertInstanceOf(SubscriptionReactivateCommand::class, $container->get(SubscriptionReactivateCommand::class)); + self::assertInstanceOf(DoctrineCipherKeyStore::class, $container->get(CipherKeyStore::class)); + self::assertInstanceOf(BaseCryptographer::class, $container->get(Cryptographer::class)); + } + + public function testCreateWithReadOnlyStore(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['read_only' => true], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(ReadOnlyStore::class, $container->get(Store::class)); + } + + public function testCreateWithUpcasters(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'hydrator' => [ + 'upcasters' => [ + 'before_encoding' => [ + CallbackUpcaster::forClass( + ProfileCreated::class, + static function (array $data): array { + $data['email'] = $data['mail']; + unset($data['mail']); + + return $data; + }, + ), + ], + 'before_transform' => ['upcaster.lower_email'], + ], + ], + 'services' => [ + 'upcaster.lower_email' => CallbackUpcaster::forClass( + ProfileCreated::class, + static function (array $data): array { + if (!is_string($data['email'])) { + return $data; + } + + $data['email'] = strtolower($data['email']); + + return $data; + }, + ), + ], + ]; + $container = Factory::create($configuration); + + $event = $container->get(Hydrator::class)->hydrate( + ProfileCreated::class, + ['profileId' => '1', 'mail' => 'Info@Patchlevel.de'], + ); + + self::assertSame('info@patchlevel.de', $event->email->toString()); + } + + public function testUserServiceReplacesDefaultService(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2020-01-01 00:00:00')); + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'services' => [ClockInterface::class => $clock], + ]; + $container = Factory::create($configuration); + + self::assertSame($clock, $container->get(ClockInterface::class)); + } + + public function testUserServiceReplacesAliasedDefaultService(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2020-01-01 00:00:00')); + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'services' => [SystemClock::class => $clock], + ]; + $container = Factory::create($configuration); + + self::assertSame($clock, $container->get(ClockInterface::class)); + } + + public function testCustomEventBusService(): void + { + $eventBus = new DefaultEventBus(new DefaultConsumer(new AttributeListenerProvider([]))); + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'event_bus' => ['type' => 'custom', 'service' => 'app.event_bus'], + 'services' => ['app.event_bus' => $eventBus], + ]; + $container = Factory::create($configuration); + + self::assertSame($eventBus, $container->get(EventBus::class)); + } + + public function testConnectionIsAliasedForConnectionUrl(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + ]; + $container = Factory::create($configuration); + + self::assertSame($container->get(Factory::CONNECTION_ID), $container->get(Connection::class)); + } + + public function testRegisterBridges(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'hydrator' => ['default_lazy' => true, 'cryptography' => true, 'lifecycle' => true], + 'subscription' => ['gap_detection' => true], + ]; + $container = Factory::create($configuration); + + $services = []; + Factory::registerBridges( + $container, + static function (string $id, callable $factory) use (&$services): void { + $services[$id] = $factory(); + }, + ); + + self::assertSame( + [ + RepositoryManager::class, + CommandBus::class, + QueryBus::class, + Store::class, + SubscriptionEngine::class, + SubscriptionStore::class, + SchemaDirector::class, + DoctrineSchemaListener::class, + Connection::class, + ClockInterface::class, + EventSerializer::class, + HeadersSerializer::class, + EventTagExtractor::class, + Hydrator::class, + AggregateRootRegistry::class, + EventRegistry::class, + CipherKeyStore::class, + Cryptographer::class, + ], + array_keys($services), + ); + self::assertSame($container->get(CipherKeyStore::class), $services[CipherKeyStore::class]); + } + + public function testSnapshotAdapterDefinitions(): void + { + $customAdapter = new InMemorySnapshotAdapter(); + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'snapshot_stores' => [ + 'memory' => new InMemorySnapshotAdapter(), + 'psr6' => ['type' => 'psr6', 'service' => 'app.cache_pool'], + 'psr16' => ['type' => 'psr16', 'service' => 'app.simple_cache'], + 'custom' => ['type' => 'custom', 'service' => 'app.snapshot_adapter'], + ], + 'services' => [ + 'app.cache_pool' => new ArrayAdapter(), + 'app.simple_cache' => new Psr16Cache(new ArrayAdapter()), + 'app.snapshot_adapter' => $customAdapter, + ], + ]; + $container = Factory::create($configuration); + + $adapterRepository = $container->get(AdapterRepository::class); + + self::assertInstanceOf(DefaultSnapshotStore::class, $container->get(SnapshotStore::class)); + self::assertInstanceOf(InMemorySnapshotAdapter::class, $adapterRepository->get('memory')); + self::assertInstanceOf(Psr6SnapshotAdapter::class, $adapterRepository->get('psr6')); + self::assertInstanceOf(Psr16SnapshotAdapter::class, $adapterRepository->get('psr16')); + self::assertSame($customAdapter, $adapterRepository->get('custom')); + } + + public function testCommandHandlers(): void + { + $handler = new class () { + public CreateProfile|null $command = null; + + #[Handle] + public function handle(CreateProfile $command): void + { + $this->command = $command; + } + }; + + $serviceHandler = new class () { + public QueryProfile|null $command = null; + + #[Handle] + public function handle(QueryProfile $command): void + { + $this->command = $command; + } + }; + + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'command_bus' => ['handlers' => [$handler, 'app.command_handler']], + 'services' => ['app.command_handler' => $serviceHandler], + ]; + $container = Factory::create($configuration); + + $createProfile = new CreateProfile(ProfileId::fromString('1'), 'John'); + $queryProfile = new QueryProfile(ProfileId::fromString('1')); + + $container->get(CommandBus::class)->dispatch($createProfile); + $container->get(CommandBus::class)->dispatch($queryProfile); + + self::assertSame($createProfile, $handler->command); + self::assertSame($queryProfile, $serviceHandler->command); + } + + public function testQueryHandlers(): void + { + $handler = new class () { + #[Answer] + public function answer(OtherQueryProfile $query): string + { + return 'other'; + } + }; + + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'query_bus' => ['handlers' => [$handler, 'app.query_handler']], + 'services' => ['app.query_handler' => new QueryAnsweringProjection()], + ]; + $container = Factory::create($configuration); + + $queryBus = $container->get(QueryBus::class); + + self::assertSame('found', $queryBus->dispatch(new QueryProfile(ProfileId::fromString('1')))); + self::assertSame('other', $queryBus->dispatch(new OtherQueryProfile(ProfileId::fromString('1')))); + } + + public function testCommands(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['migrate_to_new_store' => true], + ]; + $container = Factory::create($configuration); + + self::assertSame( + [ + 'event-sourcing:show', + 'event-sourcing:show-aggregate', + 'event-sourcing:watch', + 'event-sourcing:debug', + 'event-sourcing:database:create', + 'event-sourcing:database:drop', + 'event-sourcing:schema:create', + 'event-sourcing:schema:update', + 'event-sourcing:schema:drop', + 'event-sourcing:subscription:setup', + 'event-sourcing:subscription:boot', + 'event-sourcing:subscription:run', + 'event-sourcing:subscription:teardown', + 'event-sourcing:subscription:remove', + 'event-sourcing:subscription:status', + 'event-sourcing:subscription:pause', + 'event-sourcing:subscription:reactivate', + 'event-sourcing:subscription:refresh', + 'event-sourcing:store:migrate', + 'event-sourcing:migration:diff', + 'event-sourcing:migration:migrate', + 'event-sourcing:migration:current', + 'event-sourcing:migration:execute', + 'event-sourcing:migration:status', + ], + array_map(static fn (Command $command): string|null => $command->getName(), Factory::commands($container)), + ); + } + + public function testRetryStrategyDefinitionWithoutOptions(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'subscription' => [ + 'retry_strategies' => [ + 'default' => ['type' => 'clock_based'], + 'no_retry' => ['type' => 'no_retry'], + ], + ], + ]; + $container = Factory::create($configuration); + + $repository = $container->get(RetryStrategyRepository::class); + + self::assertEquals( + new ClockBasedRetryStrategy($container->get(ClockInterface::class)), + $repository->get('default'), + ); + self::assertInstanceOf(NoRetryStrategy::class, $repository->get('no_retry')); + } + + public function testCleanerDropsDbalTables(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + ]; + $container = Factory::create($configuration); + + $connection = $container->get(Connection::class); + $connection->executeStatement('CREATE TABLE projection_profile (id VARCHAR(36) NOT NULL)'); + + $container->get(Cleaner::class)->cleanup( + new Subscription('profile', cleanupTasks: [new DropTableTask('projection_profile')]), + ); + + self::assertFalse($connection->createSchemaManager()->tablesExist(['projection_profile'])); + } + + public function testStoreMigrationIntoAnotherStreamStore(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => [ + 'migrate_to_new_store' => [ + 'type' => Configuration::STORE_DBAL_STREAM, + 'options' => ['table_name' => 'new_event_store'], + 'translators' => [new ExcludeEventTranslator([ProfileCreated::class])], + ], + ], + ]; + $container = Factory::create($configuration); + + $newStore = $container->get(Factory::NEW_STORE_ID); + + self::assertInstanceOf(StreamDoctrineDbalStore::class, $newStore); + self::assertNotSame($container->get(Store::class), $newStore); + self::assertTrue($container->has(StoreMigrateCommand::class)); + } + + public function testCreateWithTaggableStore(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['type' => 'dbal_taggable', 'options' => ['default_stream_name' => 'course']], + 'dcb' => true, + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(TaggableDoctrineDbalStore::class, $container->get(Store::class)); + self::assertInstanceOf(StoreDecisionModelBuilder::class, $container->get(DecisionModelBuilder::class)); + self::assertInstanceOf(StoreEventAppender::class, $container->get(EventAppender::class)); + self::assertInstanceOf(AttributeEventTagExtractor::class, $container->get(EventTagExtractor::class)); + } + + public function testTaggableStoreTagsAggregateEvents(): void + { + $event = new #[Event('tagged')] + class ('1') { + public function __construct( + #[EventTag(prefix: 'profile')] + public string $id, + ) { + } + }; + + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['type' => 'dbal_taggable'], + 'dcb' => true, + ]; + $container = Factory::create($configuration); + + $message = $container->get(MessageDecorator::class)(Message::create($event)); + + self::assertEquals(new TagsHeader(['profile:1']), $message->header(TagsHeader::class)); + } + + public function testStreamStoreDoesNotTagAggregateEvents(): void + { + $event = new #[Event('tagged')] + class ('1') { + public function __construct( + #[EventTag(prefix: 'profile')] + public string $id, + ) { + } + }; + + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + ]; + $container = Factory::create($configuration); + + $message = $container->get(MessageDecorator::class)(Message::create($event)); + + self::assertFalse($message->hasHeader(TagsHeader::class)); + self::assertFalse($container->has(DecisionModelBuilder::class)); + self::assertFalse($container->has(EventAppender::class)); + } + + public function testInMemoryStoreProvidesDecisionModel(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['type' => 'in_memory'], + 'dcb' => true, + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StoreDecisionModelBuilder::class, $container->get(DecisionModelBuilder::class)); + self::assertInstanceOf(StoreEventAppender::class, $container->get(EventAppender::class)); + } + + public function testStoreMigrationIntoTaggableStoreIsPartOfSchema(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => [ + 'migrate_to_new_store' => [ + 'type' => Configuration::STORE_DBAL_TAGGABLE, + 'options' => ['table_name' => 'new_event_store'], + 'translators' => [new ExtractEventTagTranslator()], + ], + ], + ]; + $container = Factory::create($configuration); + + $container->get(SchemaDirector::class)->create(); + + self::assertInstanceOf(TaggableDoctrineDbalStore::class, $container->get(Factory::NEW_STORE_ID)); + self::assertTrue( + $container->get(Connection::class)->createSchemaManager()->tablesExist(['event_store', 'new_event_store']), + ); + } + + public function testServiceFactory(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'services' => [ + 'app.clock' => static fn (Container $container): FrozenClock => new FrozenClock( + $container->get(ClockInterface::class)->now(), + ), + ], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(FrozenClock::class, $container->get('app.clock')); + self::assertSame($container->get('app.clock'), $container->get('app.clock')); + } + + public function testCreateWithEventFilteredMessageLoader(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'subscription' => ['event_filtered_message_loader' => true], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(EventFilteredStoreMessageLoader::class, $container->get(MessageLoader::class)); + } + + public function testEventFilteredMessageLoaderCanNotBeCombinedWithGapDetection(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'subscription' => ['gap_detection' => true, 'event_filtered_message_loader' => true], + ]; + + $this->expectException(InvalidConfiguration::class); + + Factory::create($configuration); + } + + public function testCreateWithProjectionBuilder(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['type' => 'dbal_taggable'], + 'dcb' => true, + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf(StoreProjectionBuilder::class, $container->get(ProjectionBuilder::class)); + } + + public function testSubscriptionStreamIsRemovedWithSubscription(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['type' => 'in_memory'], + 'subscription' => ['event_emitter' => true], + ]; + $container = Factory::create($configuration); + + $store = $container->get(Store::class); + $store->save( + Message::create(new ProfileVisited(ProfileId::fromString('1'))) + ->withHeader(new StreamNameHeader('subscription_profile')), + ); + + $eventDispatcher = $container->get(Factory::SUBSCRIPTION_EVENT_DISPATCHER_ID); + self::assertInstanceOf(EventDispatcherInterface::class, $eventDispatcher); + + $eventDispatcher->dispatch(new OnSubscriptionRemoved(new Subscription('profile'))); + + self::assertSame([], $store->streams()); + } + + public function testSubscriptionStreamIsKeptWithoutEventEmitter(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['type' => 'in_memory'], + ]; + $container = Factory::create($configuration); + + $store = $container->get(Store::class); + $store->save( + Message::create(new ProfileVisited(ProfileId::fromString('1'))) + ->withHeader(new StreamNameHeader('subscription_profile')), + ); + + $eventDispatcher = $container->get(Factory::SUBSCRIPTION_EVENT_DISPATCHER_ID); + self::assertInstanceOf(EventDispatcherInterface::class, $eventDispatcher); + + $eventDispatcher->dispatch(new OnSubscriptionRemoved(new Subscription('profile'))); + + self::assertSame(['subscription_profile'], $store->streams()); + } + + public function testEventEmitterCanNotBeCombinedWithReadOnlyStore(): void + { + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'store' => ['read_only' => true], + 'subscription' => ['event_emitter' => true], + ]; + + $this->expectException(InvalidConfiguration::class); + + Factory::create($configuration); + } + + public function testTaggableStoreConnectionUsesPostgreSQLPlatform(): void + { + $configuration = [ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app?serverVersion=16'], + 'store' => ['type' => 'dbal_taggable'], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf( + PostgreSQLPlatform::class, + $container->get(Connection::class)->getDatabasePlatform(), + ); + } + + public function testStoreMigrationIntoTaggableStoreConnectionUsesPostgreSQLPlatform(): void + { + $configuration = [ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app?serverVersion=16'], + 'store' => [ + 'migrate_to_new_store' => ['type' => Configuration::STORE_DBAL_TAGGABLE], + ], + ]; + $container = Factory::create($configuration); + + self::assertInstanceOf( + PostgreSQLPlatform::class, + $container->get(Connection::class)->getDatabasePlatform(), + ); + } + + public function testStreamStoreConnectionUsesDefaultPlatform(): void + { + $configuration = [ + 'connection' => ['url' => 'pdo-pgsql://user:secret@localhost/app?serverVersion=16'], + ]; + $container = Factory::create($configuration); + + self::assertNotInstanceOf( + PostgreSQLPlatform::class, + $container->get(Connection::class)->getDatabasePlatform(), + ); + } + + public function testServicesWithInterfaceIdsFromExternalContainer(): void + { + $connection = DriverManager::getConnection((new DsnParser())->parse('sqlite3:///:memory:')); + $clock = new FrozenClock(new DateTimeImmutable('2020-01-01 00:00:00')); + $logger = new NullLogger(); + + $configuration = [ + 'connection' => ['service' => Connection::class], + 'clock' => ['service' => ClockInterface::class], + 'logger' => ['service' => LoggerInterface::class], + ]; + $container = Factory::create( + $configuration, + new ArrayContainer([ + Connection::class => $connection, + ClockInterface::class => $clock, + LoggerInterface::class => $logger, + ]), + ); + + self::assertSame($connection, $container->get(Factory::CONNECTION_ID)); + self::assertSame($connection, $container->get(Connection::class)); + self::assertSame($clock, $container->get(ClockInterface::class)); + self::assertSame($logger, $container->get(LoggerInterface::class)); + self::assertInstanceOf(StreamDoctrineDbalStore::class, $container->get(Store::class)); + } + + public function testRegisterBridgesSkipsServicesOfExternalContainer(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2020-01-01 00:00:00')); + $configuration = [ + 'connection' => ['url' => 'sqlite3:///:memory:'], + 'clock' => ['service' => 'app.clock'], + ]; + $container = Factory::create($configuration, new ArrayContainer(['app.clock' => $clock])); + + $services = []; + Factory::registerBridges($container, static function (string $id, callable $factory) use (&$services): void { + $services[$id] = $factory(); + }); + + self::assertSame($clock, $container->get(ClockInterface::class)); + self::assertArrayNotHasKey(ClockInterface::class, $services); + self::assertArrayHasKey(Connection::class, $services); + } +} diff --git a/tests/Unit/Container/Fixture/ArrayContainer.php b/tests/Unit/Container/Fixture/ArrayContainer.php new file mode 100644 index 000000000..4adab2837 --- /dev/null +++ b/tests/Unit/Container/Fixture/ArrayContainer.php @@ -0,0 +1,38 @@ + */ + public array $resolvedIds = []; + + /** @param array $entries */ + public function __construct( + private readonly array $entries = [], + ) { + } + + public function get(string $id): mixed + { + if (!$this->has($id)) { + throw new ServiceNotFound($id); + } + + $this->resolvedIds[] = $id; + + return $this->entries[$id]; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->entries); + } +}