diff --git a/packages/Dbal/src/Configuration/DbalConfiguration.php b/packages/Dbal/src/Configuration/DbalConfiguration.php index ee6ade7e5..534ecbcf7 100644 --- a/packages/Dbal/src/Configuration/DbalConfiguration.php +++ b/packages/Dbal/src/Configuration/DbalConfiguration.php @@ -51,6 +51,8 @@ class DbalConfiguration private int $minimumTimeToRemoveMessageInMilliseconds = DeduplicationModule::REMOVE_MESSAGE_AFTER_7_DAYS; private int $deduplicationRemovalBatchSize = 1000; + private bool $initializeDatabaseTables = true; + private function __construct() { } @@ -357,4 +359,21 @@ public function getConsumerPositionTrackingConnectionReference(): string { return $this->consumerPositionTrackingConnectionReference; } + + /** + * Controls whether database tables are automatically initialized on first use. + * When set to false, tables must be created manually using `ecotone:migration:database:setup --initialize`. + */ + public function withAutomaticTableInitialization(bool $enabled): self + { + $self = clone $this; + $self->initializeDatabaseTables = $enabled; + + return $self; + } + + public function isAutomaticTableInitializationEnabled(): bool + { + return $this->initializeDatabaseTables; + } } diff --git a/packages/Dbal/src/Configuration/DbalPublisherModule.php b/packages/Dbal/src/Configuration/DbalPublisherModule.php index 0b96f572b..76317eb55 100644 --- a/packages/Dbal/src/Configuration/DbalPublisherModule.php +++ b/packages/Dbal/src/Configuration/DbalPublisherModule.php @@ -3,6 +3,9 @@ namespace Ecotone\Dbal\Configuration; use Ecotone\AnnotationFinder\AnnotationFinder; +use Ecotone\Dbal\Database\DbalTableManagerReference; +use Ecotone\Dbal\Database\EnqueueTableManager; +use Ecotone\Dbal\DbalBackedMessageChannelBuilder; use Ecotone\Dbal\DbalOutboundChannelAdapterBuilder; use Ecotone\Messaging\Attribute\ModuleAnnotation; use Ecotone\Messaging\Config\Annotation\AnnotationModule; @@ -44,6 +47,21 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $registeredReferences = []; $applicationConfiguration = ExtensionObjectResolver::resolveUnique(ServiceConfiguration::class, $extensionObjects, ServiceConfiguration::createWithDefaults()); + $dbalConfiguration = ExtensionObjectResolver::resolveUnique(DbalConfiguration::class, $extensionObjects, DbalConfiguration::createWithDefaults()); + $dbalMessageChannels = ExtensionObjectResolver::resolve(DbalBackedMessageChannelBuilder::class, $extensionObjects); + $dbalPublishers = ExtensionObjectResolver::resolve(DbalMessagePublisherConfiguration::class, $extensionObjects); + $hasMessageQueues = ! empty($dbalMessageChannels) || ! empty($dbalPublishers); + $shouldAutoInitialize = $dbalConfiguration->isAutomaticTableInitializationEnabled(); + + $messagingConfiguration->registerServiceDefinition( + EnqueueTableManager::class, + new \Ecotone\Messaging\Config\Container\Definition(EnqueueTableManager::class, [ + EnqueueTableManager::DEFAULT_TABLE_NAME, + $hasMessageQueues, + $shouldAutoInitialize, + ]) + ); + foreach (ExtensionObjectResolver::resolve(DbalMessagePublisherConfiguration::class, $extensionObjects) as $dbalPublisher) { if (in_array($dbalPublisher->getReferenceName(), $registeredReferences)) { throw ConfigurationException::create("Registering two publishers under same reference name {$dbalPublisher->getReferenceName()}. You need to create publisher with specific reference using `createWithReferenceName`."); @@ -109,12 +127,16 @@ public function canHandle($extensionObject): bool { return $extensionObject instanceof DbalMessagePublisherConfiguration - || $extensionObject instanceof ServiceConfiguration; + || $extensionObject instanceof ServiceConfiguration + || $extensionObject instanceof DbalBackedMessageChannelBuilder + || $extensionObject instanceof DbalConfiguration; } public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, array $serviceExtensions): array { - return []; + return [ + new DbalTableManagerReference(EnqueueTableManager::class), + ]; } public function getModulePackageName(): string diff --git a/packages/Dbal/src/Database/DatabaseDropCommand.php b/packages/Dbal/src/Database/DatabaseDropCommand.php new file mode 100644 index 000000000..34407632e --- /dev/null +++ b/packages/Dbal/src/Database/DatabaseDropCommand.php @@ -0,0 +1,51 @@ +databaseSetupManager->getFeatureNames($all); + + if (count($featureNames) === 0) { + return ConsoleCommandResultSet::create( + ['Status'], + [['No database tables registered for drop.']] + ); + } + + if ($force) { + $this->databaseSetupManager->dropAll($all); + return ConsoleCommandResultSet::create( + ['Feature', 'Status'], + array_map(fn (string $feature) => [$feature, 'Dropped'], $featureNames) + ); + } + + return ConsoleCommandResultSet::create( + ['Feature', 'Warning'], + array_map(fn (string $feature) => [$feature, 'Would be dropped (use --force to confirm)'], $featureNames) + ); + } +} + diff --git a/packages/Dbal/src/Database/DatabaseSetupCommand.php b/packages/Dbal/src/Database/DatabaseSetupCommand.php new file mode 100644 index 000000000..13bda82e9 --- /dev/null +++ b/packages/Dbal/src/Database/DatabaseSetupCommand.php @@ -0,0 +1,67 @@ +databaseSetupManager->getFeatureNames($all); + + if (count($featureNames) === 0) { + return ConsoleCommandResultSet::create( + ['Status'], + [['No database tables registered for setup.']] + ); + } + + if ($sql) { + $statements = $this->databaseSetupManager->getCreateSqlStatements($all); + return ConsoleCommandResultSet::create( + ['SQL Statement'], + array_map(fn (string $statement) => [$statement], $statements) + ); + } + + if ($initialize) { + $this->databaseSetupManager->initializeAll($all); + return ConsoleCommandResultSet::create( + ['Feature', 'Status'], + array_map(fn (string $feature) => [$feature, 'Created'], $featureNames) + ); + } + + $initializationStatus = $this->databaseSetupManager->getInitializationStatus($all); + $rows = []; + foreach ($featureNames as $featureName) { + $isInitialized = $initializationStatus[$featureName] ?? false; + $rows[] = [$featureName, $isInitialized ? 'Yes' : 'No']; + } + + return ConsoleCommandResultSet::create( + ['Feature', 'Initialized'], + $rows + ); + } +} + diff --git a/packages/Dbal/src/Database/DatabaseSetupManager.php b/packages/Dbal/src/Database/DatabaseSetupManager.php new file mode 100644 index 000000000..973a4fc00 --- /dev/null +++ b/packages/Dbal/src/Database/DatabaseSetupManager.php @@ -0,0 +1,162 @@ + $manager->getFeatureName(), + $this->getManagers($includeInactive) + ); + } + + /** + * @return string[] SQL statements to create all tables + */ + public function getCreateSqlStatements(bool $includeInactive = false): array + { + $connection = $this->getConnection(); + $statements = []; + + foreach ($this->getManagers($includeInactive) as $manager) { + $sql = $manager->getCreateTableSql($connection); + if (is_array($sql)) { + $statements = array_merge($statements, $sql); + } else { + $statements[] = $sql; + } + } + + return $statements; + } + + /** + * @return string[] SQL statements to drop all tables + */ + public function getDropSqlStatements(bool $includeInactive = false): array + { + $connection = $this->getConnection(); + $statements = []; + + foreach ($this->getManagers($includeInactive) as $manager) { + $statements[] = $manager->getDropTableSql($connection); + } + + return $statements; + } + + /** + * Creates all tables. + */ + public function initializeAll(bool $includeInactive = false): void + { + $connection = $this->getConnection(); + + foreach ($this->getManagers($includeInactive) as $manager) { + if ($manager->isInitialized($connection)) { + continue; + } + + $manager->createTable($connection); + } + } + + /** + * Drops all tables. + */ + public function dropAll(bool $includeInactive = false): void + { + $connection = $this->getConnection(); + + foreach ($this->getManagers($includeInactive) as $manager) { + $manager->dropTable($connection); + } + } + + /** + * Returns initialization status for each table manager. + * + * @return array Map of feature name to initialization status + */ + public function getInitializationStatus(bool $includeInactive = false): array + { + $connection = $this->getConnection(); + $status = []; + + foreach ($this->getManagers($includeInactive) as $manager) { + $status[$manager->getFeatureName()] = $manager->isInitialized($connection); + } + + return $status; + } + + /** + * @return DbalTableManager[] + */ + private function getManagers(bool $includeInactive): array + { + if ($includeInactive) { + return $this->tableManagers; + } + + return array_filter( + $this->tableManagers, + fn (DbalTableManager $manager) => $manager->isActive() + ); + } + + private function getConnection(): Connection + { + /** @var DbalContext $context */ + $context = $this->connectionFactory->createContext(); + + return $context->getDbalConnection(); + } + + public function getDefinition(): Definition + { + $tableManagerDefinitions = array_map( + fn (DbalTableManager $manager) => $manager->getDefinition(), + $this->tableManagers + ); + + return new Definition( + self::class, + [ + new Definition(DbalReconnectableConnectionFactory::class, [ + $this->connectionFactory, + ]), + $tableManagerDefinitions, + ] + ); + } +} + diff --git a/packages/Dbal/src/Database/DatabaseSetupModule.php b/packages/Dbal/src/Database/DatabaseSetupModule.php new file mode 100644 index 000000000..4d56dec64 --- /dev/null +++ b/packages/Dbal/src/Database/DatabaseSetupModule.php @@ -0,0 +1,130 @@ +getDefaultConnectionReferenceNames()[0] ?? \Enqueue\Dbal\DbalConnectionFactory::class; + + $tableManagerRefs = array_map( + fn (DbalTableManagerReference $ref) => new Reference($ref->getReferenceName()), + $tableManagerReferences + ); + + $messagingConfiguration->registerServiceDefinition( + DatabaseSetupManager::class, + new Definition(DatabaseSetupManager::class, [ + new Definition(DbalReconnectableConnectionFactory::class, [ + new Reference($connectionReference), + ]), + $tableManagerRefs, + ]) + ); + + $messagingConfiguration->registerServiceDefinition( + DatabaseSetupCommand::class, + new Definition(DatabaseSetupCommand::class, [ + new Reference(DatabaseSetupManager::class), + ]) + ); + + $messagingConfiguration->registerServiceDefinition( + DatabaseDropCommand::class, + new Definition(DatabaseDropCommand::class, [ + new Reference(DatabaseSetupManager::class), + ]) + ); + + $this->registerConsoleCommand( + 'setup', + 'ecotone:migration:database:setup', + DatabaseSetupCommand::class, + $messagingConfiguration, + $interfaceToCallRegistry + ); + + $this->registerConsoleCommand( + 'drop', + 'ecotone:migration:database:drop', + DatabaseDropCommand::class, + $messagingConfiguration, + $interfaceToCallRegistry + ); + } + + public function canHandle($extensionObject): bool + { + return $extensionObject instanceof DbalTableManagerReference + || $extensionObject instanceof DbalConfiguration; + } + + public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, array $serviceExtensions): array + { + return []; + } + + public function getModulePackageName(): string + { + return ModulePackageList::DBAL_PACKAGE; + } + + private function registerConsoleCommand( + string $methodName, + string $commandName, + string $className, + Configuration $configuration, + InterfaceToCallRegistry $interfaceToCallRegistry + ): void { + [$messageHandlerBuilder, $oneTimeCommandConfiguration] = ConsoleCommandModule::prepareConsoleCommandForReference( + new Reference($className), + new InterfaceToCallReference($className, $methodName), + $commandName, + true, + $interfaceToCallRegistry + ); + + $configuration + ->registerMessageHandler($messageHandlerBuilder) + ->registerConsoleCommand($oneTimeCommandConfiguration); + } +} + diff --git a/packages/Dbal/src/Database/DbalTableManager.php b/packages/Dbal/src/Database/DbalTableManager.php new file mode 100644 index 000000000..43b421848 --- /dev/null +++ b/packages/Dbal/src/Database/DbalTableManager.php @@ -0,0 +1,67 @@ + SQL statement(s) + */ + public function getCreateTableSql(Connection $connection): string|array; + + /** + * Returns the SQL statement to drop the table. + * + * @return string SQL statement + */ + public function getDropTableSql(Connection $connection): string; + + /** + * Creates the table if it doesn't exist. + */ + public function createTable(Connection $connection): void; + + /** + * Drops the table if it exists. + */ + public function dropTable(Connection $connection): void; + + /** + * Checks if the table(s) managed by this manager exist. + */ + public function isInitialized(Connection $connection): bool; + + /** + * Returns whether this table should be automatically initialized at runtime. + * This combines global DbalConfiguration setting with feature-specific config. + */ + public function shouldBeInitializedAutomatically(): bool; +} + diff --git a/packages/Dbal/src/Database/DbalTableManagerReference.php b/packages/Dbal/src/Database/DbalTableManagerReference.php new file mode 100644 index 000000000..cb407e2b4 --- /dev/null +++ b/packages/Dbal/src/Database/DbalTableManagerReference.php @@ -0,0 +1,26 @@ +referenceName; + } +} + diff --git a/packages/Dbal/src/Database/DeadLetterTableManager.php b/packages/Dbal/src/Database/DeadLetterTableManager.php new file mode 100644 index 000000000..8ca82b1cc --- /dev/null +++ b/packages/Dbal/src/Database/DeadLetterTableManager.php @@ -0,0 +1,109 @@ +isActive; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getCreateTableSql(Connection $connection): string|array + { + $table = $this->buildTableSchema(); + + return $connection->getDatabasePlatform()->getCreateTableSQL($table); + } + + public function getDropTableSql(Connection $connection): string + { + return 'DROP TABLE IF EXISTS ' . $this->tableName; + } + + public function createTable(Connection $connection): void + { + if (self::isInitialized($connection)) { + return; + } + + SchemaManagerCompatibility::getSchemaManager($connection)->createTable($this->buildTableSchema()); + } + + public function dropTable(Connection $connection): void + { + $schemaManager = $connection->createSchemaManager(); + + if (! $schemaManager->tablesExist([$this->tableName])) { + return; + } + + $schemaManager->dropTable($this->tableName); + } + + public function isInitialized(Connection $connection): bool + { + return SchemaManagerCompatibility::tableExists($connection, $this->tableName); + } + + public function shouldBeInitializedAutomatically(): bool + { + return $this->shouldAutoInitialize; + } + + public function getDefinition(): Definition + { + return new Definition( + self::class, + [$this->tableName, $this->isActive, $this->shouldAutoInitialize] + ); + } + + public function buildTableSchema(): Table + { + $table = new Table($this->tableName); + + $table->addColumn('message_id', Types::STRING, ['length' => 255]); + $table->addColumn('failed_at', Types::DATETIME_MUTABLE); + $table->addColumn('payload', Types::TEXT); + $table->addColumn('headers', Types::TEXT); + + $table->setPrimaryKey(['message_id']); + $table->addIndex(['failed_at']); + return $table; + } +} + diff --git a/packages/Dbal/src/Database/DeduplicationTableManager.php b/packages/Dbal/src/Database/DeduplicationTableManager.php new file mode 100644 index 000000000..520cfecfe --- /dev/null +++ b/packages/Dbal/src/Database/DeduplicationTableManager.php @@ -0,0 +1,106 @@ +isActive; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getCreateTableSql(Connection $connection): string|array + { + return $connection->getDatabasePlatform()->getCreateTableSQL($this->buildTableSchema()); + } + + public function getDropTableSql(Connection $connection): string + { + return "DROP TABLE IF EXISTS {$this->tableName}"; + } + + public function createTable(Connection $connection): void + { + if (self::isInitialized($connection)) { + return; + } + + SchemaManagerCompatibility::getSchemaManager($connection)->createTable($this->buildTableSchema()); + } + + public function dropTable(Connection $connection): void + { + $schemaManager = $connection->createSchemaManager(); + + if (! $schemaManager->tablesExist([$this->tableName])) { + return; + } + + $schemaManager->dropTable($this->tableName); + } + + public function isInitialized(Connection $connection): bool + { + return SchemaManagerCompatibility::tableExists($connection, $this->tableName); + } + + public function shouldBeInitializedAutomatically(): bool + { + return $this->shouldAutoInitialize; + } + + public function getDefinition(): Definition + { + return new Definition( + self::class, + [$this->tableName, $this->isActive, $this->shouldAutoInitialize] + ); + } + + private function buildTableSchema(): Table + { + $table = new Table($this->tableName); + + $table->addColumn('message_id', Types::STRING, ['length' => 255]); + $table->addColumn('consumer_endpoint_id', Types::STRING, ['length' => 255]); + $table->addColumn('routing_slip', Types::STRING, ['length' => 255]); + $table->addColumn('handled_at', Types::BIGINT); + + $table->setPrimaryKey(['message_id', 'consumer_endpoint_id', 'routing_slip']); + $table->addIndex(['handled_at']); + + return $table; + } +} + diff --git a/packages/Dbal/src/Database/DocumentStoreTableManager.php b/packages/Dbal/src/Database/DocumentStoreTableManager.php new file mode 100644 index 000000000..6cb9ed6ee --- /dev/null +++ b/packages/Dbal/src/Database/DocumentStoreTableManager.php @@ -0,0 +1,96 @@ +isActive; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getDefinition(): Definition + { + return new Definition(self::class, [$this->tableName, $this->isActive, $this->shouldAutoInitialize]); + } + + public function shouldBeInitializedAutomatically(): bool + { + return $this->shouldAutoInitialize; + } + + public function createTable(Connection $connection): void + { + if ($this->isInitialized($connection)) { + return; + } + + SchemaManagerCompatibility::getSchemaManager($connection)->createTable($this->buildTableSchema()); + } + + public function dropTable(Connection $connection): void + { + $connection->executeStatement($this->getDropTableSql($connection)); + } + + public function getCreateTableSql(Connection $connection): array + { + return $connection->getDatabasePlatform()->getCreateTableSQL($this->buildTableSchema()); + } + + private function buildTableSchema(): Table + { + $table = new Table($this->tableName); + + $table->addColumn('collection', 'string', ['length' => 255]); + $table->addColumn('document_id', 'string', ['length' => 255]); + $table->addColumn('document_type', 'text'); + $table->addColumn('document', 'json'); + $table->addColumn('updated_at', 'float', ['length' => 53]); + + $table->setPrimaryKey(['collection', 'document_id']); + + return $table; + } + + public function getDropTableSql(Connection $connection): string + { + return "DROP TABLE IF EXISTS {$this->tableName}"; + } + + public function isInitialized(Connection $connection): bool + { + return SchemaManagerCompatibility::tableExists($connection, $this->tableName); + } +} + diff --git a/packages/Dbal/src/Database/EnqueueTableManager.php b/packages/Dbal/src/Database/EnqueueTableManager.php new file mode 100644 index 000000000..15b4d9f18 --- /dev/null +++ b/packages/Dbal/src/Database/EnqueueTableManager.php @@ -0,0 +1,107 @@ +isActive; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getDefinition(): Definition + { + return new Definition(self::class, [$this->tableName, $this->isActive, $this->shouldAutoInitialize]); + } + + public function shouldBeInitializedAutomatically(): bool + { + return $this->shouldAutoInitialize; + } + + public function createTable(Connection $connection): void + { + if ($this->isInitialized($connection)) { + return; + } + + SchemaManagerCompatibility::getSchemaManager($connection)->createTable($this->buildTableSchema()); + } + + public function dropTable(Connection $connection): void + { + $connection->executeStatement($this->getDropTableSql($connection)); + } + + public function getCreateTableSql(Connection $connection): array + { + return $connection->getDatabasePlatform()->getCreateTableSQL($this->buildTableSchema()); + } + + private function buildTableSchema(): Table + { + $table = new Table($this->tableName); + + $table->addColumn('id', 'guid', ['length' => 16, 'fixed' => true]); + $table->addColumn('published_at', 'bigint'); + $table->addColumn('body', 'text', ['notnull' => false]); + $table->addColumn('headers', 'text', ['notnull' => false]); + $table->addColumn('properties', 'text', ['notnull' => false]); + $table->addColumn('redelivered', 'boolean', ['notnull' => false]); + $table->addColumn('queue', 'string', ['length' => 255]); + $table->addColumn('priority', 'integer', ['notnull' => false]); + $table->addColumn('delayed_until', 'bigint', ['notnull' => false]); + $table->addColumn('time_to_live', 'bigint', ['notnull' => false]); + $table->addColumn('delivery_id', 'guid', ['length' => 16, 'fixed' => true, 'notnull' => false]); + $table->addColumn('redeliver_after', 'bigint', ['notnull' => false]); + + $table->setPrimaryKey(['id']); + $table->addIndex(['priority', 'published_at', 'queue', 'delivery_id', 'delayed_until', 'id']); + $table->addIndex(['redeliver_after', 'delivery_id']); + $table->addIndex(['time_to_live', 'delivery_id']); + $table->addIndex(['delivery_id']); + + return $table; + } + + public function getDropTableSql(Connection $connection): string + { + return "DROP TABLE IF EXISTS {$this->tableName}"; + } + + public function isInitialized(Connection $connection): bool + { + return SchemaManagerCompatibility::tableExists($connection, $this->tableName); + } +} + diff --git a/packages/Dbal/src/DbalInboundChannelAdapter.php b/packages/Dbal/src/DbalInboundChannelAdapter.php index 80bb59074..70b3297ef 100644 --- a/packages/Dbal/src/DbalInboundChannelAdapter.php +++ b/packages/Dbal/src/DbalInboundChannelAdapter.php @@ -3,7 +3,11 @@ namespace Ecotone\Dbal; use Doctrine\DBAL\Exception\ConnectionException; +use Ecotone\Dbal\Database\EnqueueTableManager; +use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueInboundChannelAdapter; +use Ecotone\Enqueue\InboundMessageConverter; +use Ecotone\Messaging\Conversion\ConversionService; use Enqueue\Dbal\DbalContext; /** @@ -11,12 +15,28 @@ */ class DbalInboundChannelAdapter extends EnqueueInboundChannelAdapter { + public function __construct( + CachedConnectionFactory $connectionFactory, + bool $declareOnStartup, + string $queueName, + int $receiveTimeoutInMilliseconds, + InboundMessageConverter $inboundMessageConverter, + ConversionService $conversionService, + private EnqueueTableManager $tableManager, + ) { + parent::__construct($connectionFactory, $declareOnStartup, $queueName, $receiveTimeoutInMilliseconds, $inboundMessageConverter, $conversionService); + } + public function initialize(): void { /** @var DbalContext $context */ $context = $this->connectionFactory->createContext(); - $context->createDataBaseTable(); + if (! $this->tableManager->shouldBeInitializedAutomatically()) { + return; + } + + $this->tableManager->createTable($context->getDbalConnection()); } public function connectionException(): array diff --git a/packages/Dbal/src/DbalInboundChannelAdapterBuilder.php b/packages/Dbal/src/DbalInboundChannelAdapterBuilder.php index 90be5d3f6..91a5b19b2 100644 --- a/packages/Dbal/src/DbalInboundChannelAdapterBuilder.php +++ b/packages/Dbal/src/DbalInboundChannelAdapterBuilder.php @@ -2,6 +2,7 @@ namespace Ecotone\Dbal; +use Ecotone\Dbal\Database\EnqueueTableManager; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueHeader; use Ecotone\Enqueue\EnqueueInboundChannelAdapterBuilder; @@ -46,6 +47,7 @@ public function compile(MessagingContainerBuilder $builder): Definition $this->receiveTimeoutInMilliseconds, $inboundMessageConverter, new Reference(ConversionService::REFERENCE_NAME), + new Reference(EnqueueTableManager::class), ]); } } diff --git a/packages/Dbal/src/DbalOutboundChannelAdapter.php b/packages/Dbal/src/DbalOutboundChannelAdapter.php index 089d5f538..e16dd58ea 100644 --- a/packages/Dbal/src/DbalOutboundChannelAdapter.php +++ b/packages/Dbal/src/DbalOutboundChannelAdapter.php @@ -4,6 +4,7 @@ namespace Ecotone\Dbal; +use Ecotone\Dbal\Database\EnqueueTableManager; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueOutboundChannelAdapter; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; @@ -16,8 +17,14 @@ */ class DbalOutboundChannelAdapter extends EnqueueOutboundChannelAdapter { - public function __construct(CachedConnectionFactory $connectionFactory, private string $queueName, bool $autoDeclare, OutboundMessageConverter $outboundMessageConverter, ConversionService $conversionService) - { + public function __construct( + CachedConnectionFactory $connectionFactory, + private string $queueName, + bool $autoDeclare, + OutboundMessageConverter $outboundMessageConverter, + ConversionService $conversionService, + private EnqueueTableManager $tableManager, + ) { parent::__construct( $connectionFactory, new DbalDestination($this->queueName), @@ -32,7 +39,11 @@ public function initialize(): void /** @var DbalContext $context */ $context = $this->connectionFactory->createContext(); - $context->createDataBaseTable(); + if (! $this->tableManager->shouldBeInitializedAutomatically()) { + return; + } + + $this->tableManager->createTable($context->getDbalConnection()); $context->createQueue($this->queueName); } } diff --git a/packages/Dbal/src/DbalOutboundChannelAdapterBuilder.php b/packages/Dbal/src/DbalOutboundChannelAdapterBuilder.php index 209ebd241..165dd7ef6 100644 --- a/packages/Dbal/src/DbalOutboundChannelAdapterBuilder.php +++ b/packages/Dbal/src/DbalOutboundChannelAdapterBuilder.php @@ -2,6 +2,7 @@ namespace Ecotone\Dbal; +use Ecotone\Dbal\Database\EnqueueTableManager; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Enqueue\EnqueueOutboundChannelAdapterBuilder; use Ecotone\Messaging\Channel\PollableChannel\Serialization\OutboundMessageConverter; @@ -60,6 +61,7 @@ public function compile(MessagingContainerBuilder $builder): Definition $this->autoDeclare, $outboundMessageConverter, new Reference(ConversionService::REFERENCE_NAME), + new Reference(EnqueueTableManager::class), ]); } } diff --git a/packages/Dbal/src/Deduplication/DeduplicationInterceptor.php b/packages/Dbal/src/Deduplication/DeduplicationInterceptor.php index 0c8c89593..4c7ecbbdd 100644 --- a/packages/Dbal/src/Deduplication/DeduplicationInterceptor.php +++ b/packages/Dbal/src/Deduplication/DeduplicationInterceptor.php @@ -4,8 +4,8 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; -use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Types\Types; +use Ecotone\Dbal\Database\DeduplicationTableManager; use Ecotone\Dbal\DbalReconnectableConnectionFactory; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Messaging\Attribute\AsynchronousRunningEndpoint; @@ -26,11 +26,6 @@ use Throwable; -/** - * Class DbalTransactionInterceptor - * @package Ecotone\Amqp\DbalTransaction - * @author Dariusz Gafka - */ /** * licence Apache-2.0 */ @@ -40,8 +35,15 @@ class DeduplicationInterceptor private array $initialized = []; private Duration $minimumTimeToRemoveMessage; - public function __construct(private ConnectionFactory $connection, private EcotoneClockInterface $clock, int $minimumTimeToRemoveMessageInMilliseconds, private int $deduplicationRemovalBatchSize, private LoggingGateway $logger, private ExpressionEvaluationService $expressionEvaluationService) - { + public function __construct( + private ConnectionFactory $connection, + private EcotoneClockInterface $clock, + int $minimumTimeToRemoveMessageInMilliseconds, + private int $deduplicationRemovalBatchSize, + private LoggingGateway $logger, + private ExpressionEvaluationService $expressionEvaluationService, + private DeduplicationTableManager $tableManager, + ) { $this->minimumTimeToRemoveMessage = Duration::milliseconds($minimumTimeToRemoveMessageInMilliseconds); if ($this->minimumTimeToRemoveMessage->isNegativeOrZero()) { throw new Exception('Minimum time to remove message must be greater than 0'); @@ -153,29 +155,17 @@ private function insertHandledMessage(ConnectionFactory $connectionFactory, stri private function getTableName(): string { - return self::DEFAULT_DEDUPLICATION_TABLE; + return $this->tableManager->getTableName(); } private function createDataBaseTable(ConnectionFactory $connectionFactory): void { - $connection = $this->getConnection($connectionFactory); - $schemaManager = $connection->createSchemaManager(); - - if ($schemaManager->tablesExist([$this->getTableName()])) { + if (! $this->tableManager->shouldBeInitializedAutomatically()) { return; } - $table = new Table($this->getTableName()); - - $table->addColumn('message_id', Types::STRING, ['length' => 255]); - $table->addColumn('consumer_endpoint_id', Types::STRING, ['length' => 255]); - $table->addColumn('routing_slip', Types::STRING, ['length' => 255]); - $table->addColumn('handled_at', Types::BIGINT); - - $table->setPrimaryKey(['message_id', 'consumer_endpoint_id', 'routing_slip']); - $table->addIndex(['handled_at']); - - $schemaManager->createTable($table); + $connection = $this->getConnection($connectionFactory); + $this->tableManager->createTable($connection); $this->logger->info('Deduplication table was created'); } diff --git a/packages/Dbal/src/Deduplication/DeduplicationModule.php b/packages/Dbal/src/Deduplication/DeduplicationModule.php index e82443139..8b677daa9 100644 --- a/packages/Dbal/src/Deduplication/DeduplicationModule.php +++ b/packages/Dbal/src/Deduplication/DeduplicationModule.php @@ -4,6 +4,8 @@ use Ecotone\AnnotationFinder\AnnotationFinder; use Ecotone\Dbal\Configuration\DbalConfiguration; +use Ecotone\Dbal\Database\DeduplicationTableManager; +use Ecotone\Dbal\Database\DbalTableManagerReference; use Ecotone\Messaging\Attribute\AsynchronousRunningEndpoint; use Ecotone\Messaging\Attribute\Deduplicated; use Ecotone\Messaging\Attribute\ModuleAnnotation; @@ -62,6 +64,18 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $pointcut .= '||' . AsynchronousRunningEndpoint::class; } + $shouldAutoInitialize = $dbalConfiguration->isAutomaticTableInitializationEnabled(); + + // Register the DeduplicationTableManager service + $messagingConfiguration->registerServiceDefinition( + DeduplicationTableManager::class, + new Definition(DeduplicationTableManager::class, [ + DeduplicationInterceptor::DEFAULT_DEDUPLICATION_TABLE, + $isDeduplicatedEnabled, + $shouldAutoInitialize, + ]) + ); + $messagingConfiguration->registerServiceDefinition( DeduplicationInterceptor::class, new Definition( @@ -73,6 +87,7 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $dbalConfiguration->deduplicationRemovalBatchSize(), new Reference(LoggingGateway::class), new Reference(ExpressionEvaluationService::REFERENCE), + new Reference(DeduplicationTableManager::class), ] ) ); @@ -109,7 +124,9 @@ public function canHandle($extensionObject): bool public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, array $serviceExtensions): array { - return []; + return [ + new DbalTableManagerReference(DeduplicationTableManager::class), + ]; } public function getModulePackageName(): string diff --git a/packages/Dbal/src/DocumentStore/DbalDocumentStore.php b/packages/Dbal/src/DocumentStore/DbalDocumentStore.php index d1cc70383..c23a89d14 100644 --- a/packages/Dbal/src/DocumentStore/DbalDocumentStore.php +++ b/packages/Dbal/src/DocumentStore/DbalDocumentStore.php @@ -4,10 +4,9 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\DriverException; -use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Types\Types; use Ecotone\Dbal\Compatibility\QueryBuilderProxy; -use Ecotone\Dbal\Compatibility\SchemaManagerCompatibility; +use Ecotone\Dbal\Database\DocumentStoreTableManager; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Messaging\Conversion\ConversionException; use Ecotone\Messaging\Conversion\ConversionService; @@ -29,8 +28,8 @@ final class DbalDocumentStore implements DocumentStore public function __construct( private CachedConnectionFactory $cachedConnectionFactory, - private bool $autoDeclare, private ConversionService $conversionService, + private DocumentStoreTableManager $tableManager, private array $initialized = [], ) { } @@ -190,32 +189,16 @@ public function countDocuments(string $collectionName): int private function getTableName(): string { - return self::ECOTONE_DOCUMENT_STORE; + return $this->tableManager->getTableName(); } private function createDataBaseTable(): void { - if (! $this->autoDeclare) { + if (! $this->tableManager->shouldBeInitializedAutomatically()) { return; } - if ($this->doesTableExists()) { - return; - } - - $schemaManager = SchemaManagerCompatibility::getSchemaManager($this->getConnection()); - - $table = new Table($this->getTableName()); - - $table->addColumn('collection', 'string', ['length' => 255]); - $table->addColumn('document_id', 'string', ['length' => 255]); - $table->addColumn('document_type', 'text'); - $table->addColumn('document', 'json'); - $table->addColumn('updated_at', 'float', ['length' => 53]); - - $table->setPrimaryKey(['collection', 'document_id']); - - $schemaManager->createTable($table); + $this->tableManager->createTable($this->getConnection()); } private function getConnection(): Connection @@ -228,7 +211,7 @@ private function getConnection(): Connection private function doesTableExists(): bool { - if (! $this->autoDeclare) { + if (! $this->tableManager->shouldBeInitializedAutomatically()) { return true; } $connection = $this->getConnection(); diff --git a/packages/Dbal/src/DocumentStore/DbalDocumentStoreBuilder.php b/packages/Dbal/src/DocumentStore/DbalDocumentStoreBuilder.php index b203dd158..336195135 100644 --- a/packages/Dbal/src/DocumentStore/DbalDocumentStoreBuilder.php +++ b/packages/Dbal/src/DocumentStore/DbalDocumentStoreBuilder.php @@ -2,6 +2,7 @@ namespace Ecotone\Dbal\DocumentStore; +use Ecotone\Dbal\Database\DocumentStoreTableManager; use Ecotone\Dbal\DbalReconnectableConnectionFactory; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Messaging\Config\Container\Definition; @@ -24,7 +25,7 @@ final class DbalDocumentStoreBuilder extends InputOutputMessageHandlerBuilder /** * @param ParameterConverterBuilder[] $methodParameterConverterBuilders */ - public function __construct(protected string $inputMessageChannelName, private string $method, private bool $initializeDocumentStore, private string $connectionReferenceName, private bool $inMemoryEventStore, private InMemoryDocumentStore $inMemoryDocumentStore, private array $methodParameterConverterBuilders) + public function __construct(protected string $inputMessageChannelName, private string $method, private string $connectionReferenceName, private bool $inMemoryEventStore, private InMemoryDocumentStore $inMemoryDocumentStore, private array $methodParameterConverterBuilders) { } @@ -45,8 +46,8 @@ public function compile(MessagingContainerBuilder $builder): Definition new Reference($this->connectionReferenceName), ]), ], 'createFor'), - $this->initializeDocumentStore, new Reference(ConversionService::REFERENCE_NAME), + Reference::to(DocumentStoreTableManager::class), ]); $builder->register($documentStoreReference, $documentStore); diff --git a/packages/Dbal/src/DocumentStore/DbalDocumentStoreModule.php b/packages/Dbal/src/DocumentStore/DbalDocumentStoreModule.php index 696a5d392..9f9e6b6d9 100644 --- a/packages/Dbal/src/DocumentStore/DbalDocumentStoreModule.php +++ b/packages/Dbal/src/DocumentStore/DbalDocumentStoreModule.php @@ -4,7 +4,10 @@ use Ecotone\AnnotationFinder\AnnotationFinder; use Ecotone\Dbal\Configuration\DbalConfiguration; +use Ecotone\Dbal\Database\DbalTableManagerReference; +use Ecotone\Dbal\Database\DocumentStoreTableManager; use Ecotone\Messaging\Attribute\ModuleAnnotation; +use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Configuration; @@ -40,6 +43,20 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO { $dbalConfiguration = ExtensionObjectResolver::resolveUnique(DbalConfiguration::class, $extensionObjects, DbalConfiguration::createWithDefaults()); + $isDocumentStoreActive = $dbalConfiguration->isEnableDbalDocumentStore() && ! $dbalConfiguration->isInMemoryDocumentStore(); + // Combine both settings: global initialization and document store specific initialization + $shouldAutoInitialize = $dbalConfiguration->isAutomaticTableInitializationEnabled() && $dbalConfiguration->isInitializeDbalDocumentStore(); + + // Register the DocumentStoreTableManager service + $messagingConfiguration->registerServiceDefinition( + DocumentStoreTableManager::class, + new Definition(DocumentStoreTableManager::class, [ + DbalDocumentStore::ECOTONE_DOCUMENT_STORE, + $isDocumentStoreActive, + $shouldAutoInitialize, + ]) + ); + if (! $dbalConfiguration->isEnableDbalDocumentStore()) { return; } @@ -130,56 +147,56 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO ) ) ->registerMessageHandler( - new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::dropCollection($referenceName), 'dropCollection', $dbalConfiguration->isInitializeDbalDocumentStore(), $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ + new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::dropCollection($referenceName), 'dropCollection', $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ HeaderBuilder::create(self::COLLECTION_NAME_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_COLLECTION_NAME), ]) ) ->registerMessageHandler( - new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::addDocument($referenceName), 'addDocument', $dbalConfiguration->isInitializeDbalDocumentStore(), $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ + new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::addDocument($referenceName), 'addDocument', $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ HeaderBuilder::create(self::COLLECTION_NAME_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_COLLECTION_NAME), HeaderBuilder::create(self::DOCUMENT_ID_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_DOCUMENT_ID), PayloadBuilder::create(self::DOCUMENT_PARAMETER), ]) ) ->registerMessageHandler( - new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::updateDocument($referenceName), 'updateDocument', $dbalConfiguration->isInitializeDbalDocumentStore(), $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ + new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::updateDocument($referenceName), 'updateDocument', $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ HeaderBuilder::create(self::COLLECTION_NAME_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_COLLECTION_NAME), HeaderBuilder::create(self::DOCUMENT_ID_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_DOCUMENT_ID), PayloadBuilder::create(self::DOCUMENT_PARAMETER), ]) ) ->registerMessageHandler( - new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::upsertDocument($referenceName), 'upsertDocument', $dbalConfiguration->isInitializeDbalDocumentStore(), $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ + new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::upsertDocument($referenceName), 'upsertDocument', $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ HeaderBuilder::create(self::COLLECTION_NAME_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_COLLECTION_NAME), HeaderBuilder::create(self::DOCUMENT_ID_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_DOCUMENT_ID), PayloadBuilder::create(self::DOCUMENT_PARAMETER), ]) ) ->registerMessageHandler( - new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::deleteDocument($referenceName), 'deleteDocument', $dbalConfiguration->isInitializeDbalDocumentStore(), $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ + new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::deleteDocument($referenceName), 'deleteDocument', $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ HeaderBuilder::create(self::COLLECTION_NAME_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_COLLECTION_NAME), HeaderBuilder::create(self::DOCUMENT_ID_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_DOCUMENT_ID), ]) ) ->registerMessageHandler( - new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::getDocument($referenceName), 'getDocument', $dbalConfiguration->isInitializeDbalDocumentStore(), $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ + new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::getDocument($referenceName), 'getDocument', $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ HeaderBuilder::create(self::COLLECTION_NAME_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_COLLECTION_NAME), HeaderBuilder::create(self::DOCUMENT_ID_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_DOCUMENT_ID), ]) ) ->registerMessageHandler( - new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::findDocument($referenceName), 'findDocument', $dbalConfiguration->isInitializeDbalDocumentStore(), $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ + new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::findDocument($referenceName), 'findDocument', $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ HeaderBuilder::create(self::COLLECTION_NAME_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_COLLECTION_NAME), HeaderBuilder::create(self::DOCUMENT_ID_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_DOCUMENT_ID), ]) ) ->registerMessageHandler( - new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::countDocuments($referenceName), 'countDocuments', $dbalConfiguration->isInitializeDbalDocumentStore(), $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ + new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::countDocuments($referenceName), 'countDocuments', $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ HeaderBuilder::create(self::COLLECTION_NAME_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_COLLECTION_NAME), ]) ) ->registerMessageHandler( - new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::getAllDocuments($referenceName), 'getAllDocuments', $dbalConfiguration->isInitializeDbalDocumentStore(), $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ + new DbalDocumentStoreBuilder(DocumentStoreMessageChannel::getAllDocuments($referenceName), 'getAllDocuments', $dbalConfiguration->getDocumentStoreConnectionReference(), $dbalConfiguration->isInMemoryDocumentStore(), $inMemoryDocumentStore, [ HeaderBuilder::create(self::COLLECTION_NAME_PARAMETER, self::ECOTONE_DBAL_DOCUMENT_STORE_COLLECTION_NAME), ]) ); @@ -206,14 +223,18 @@ public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, { $dbalConfiguration = ExtensionObjectResolver::resolveUnique(DbalConfiguration::class, $serviceExtensions, DbalConfiguration::createWithDefaults()); + $extensions = [ + new DbalTableManagerReference(DocumentStoreTableManager::class), + ]; + if ($dbalConfiguration->isEnableDocumentStoreStandardRepository()) { - return [new DocumentStoreAggregateRepositoryBuilder( + $extensions[] = new DocumentStoreAggregateRepositoryBuilder( $dbalConfiguration->getDbalDocumentStoreReference(), $dbalConfiguration->getDocumentStoreRelatedAggregates() - )]; + ); } - return []; + return $extensions; } public function getModulePackageName(): string diff --git a/packages/Dbal/src/Recoverability/DbalDeadLetterBuilder.php b/packages/Dbal/src/Recoverability/DbalDeadLetterBuilder.php index 4e6f0af71..dd08d9309 100644 --- a/packages/Dbal/src/Recoverability/DbalDeadLetterBuilder.php +++ b/packages/Dbal/src/Recoverability/DbalDeadLetterBuilder.php @@ -2,6 +2,7 @@ namespace Ecotone\Dbal\Recoverability; +use Ecotone\Dbal\Database\DeadLetterTableManager; use Ecotone\Dbal\DbalReconnectableConnectionFactory; use Ecotone\Enqueue\CachedConnectionFactory; use Ecotone\Messaging\Config\Container\Definition; @@ -161,6 +162,7 @@ public function compile(MessagingContainerBuilder $builder): Definition DefaultHeaderMapper::createAllHeadersMapping(), Reference::to(ConversionService::REFERENCE_NAME), Reference::to(RetryRunner::class), + Reference::to(DeadLetterTableManager::class), ]); $builder->register($deadLetterHandlerReference, $deadLetterHandler); @@ -188,4 +190,5 @@ public function withEndpointId(string $endpointId): self { return $this; } + } diff --git a/packages/Dbal/src/Recoverability/DbalDeadLetterHandler.php b/packages/Dbal/src/Recoverability/DbalDeadLetterHandler.php index 20a7d6cef..199de33a9 100644 --- a/packages/Dbal/src/Recoverability/DbalDeadLetterHandler.php +++ b/packages/Dbal/src/Recoverability/DbalDeadLetterHandler.php @@ -7,9 +7,9 @@ use DateTime; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; -use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Types\Types; use Ecotone\Dbal\Compatibility\QueryBuilderProxy; +use Ecotone\Dbal\Database\DeadLetterTableManager; use Ecotone\Messaging\Conversion\ConversionService; use Ecotone\Messaging\Gateway\MessagingEntrypoint; use Ecotone\Messaging\Handler\Recoverability\ErrorContext; @@ -43,6 +43,7 @@ public function __construct( private HeaderMapper $headerMapper, private ConversionService $conversionService, private RetryRunner $retryRunner, + private DeadLetterTableManager $tableManager, ) { } @@ -51,6 +52,8 @@ public function __construct( */ public function list(int $limit, int $offset): array { + $this->initialize(); + if (! $this->doesDeadLetterTableExists()) { return []; } @@ -198,29 +201,16 @@ private function insertHandledMessage(string $payload, array $headers): void private function getTableName(): string { - return self::DEFAULT_DEAD_LETTER_TABLE; + return $this->tableManager->getTableName(); } private function createDataBaseTable(): void { - $connection = $this->getConnection(); - $schemaManager = $connection->createSchemaManager(); - - if ($this->doesDeadLetterTableExists()) { + if (! $this->tableManager->shouldBeInitializedAutomatically()) { return; } - $table = new Table($this->getTableName()); - - $table->addColumn('message_id', Types::STRING, ['length' => 255]); - $table->addColumn('failed_at', Types::DATETIME_MUTABLE); - $table->addColumn('payload', Types::TEXT); - $table->addColumn('headers', Types::TEXT); - - $table->setPrimaryKey(['message_id']); - $table->addIndex(['failed_at']); - - $schemaManager->createTable($table); + $this->tableManager->createTable($this->getConnection()); } private function doesDeadLetterTableExists(): bool diff --git a/packages/Dbal/src/Recoverability/DbalDeadLetterModule.php b/packages/Dbal/src/Recoverability/DbalDeadLetterModule.php index 97fc2188f..3cb4d5633 100644 --- a/packages/Dbal/src/Recoverability/DbalDeadLetterModule.php +++ b/packages/Dbal/src/Recoverability/DbalDeadLetterModule.php @@ -5,6 +5,8 @@ use Ecotone\AnnotationFinder\AnnotationFinder; use Ecotone\Dbal\Configuration\CustomDeadLetterGateway; use Ecotone\Dbal\Configuration\DbalConfiguration; +use Ecotone\Dbal\Database\DeadLetterTableManager; +use Ecotone\Dbal\Database\DbalTableManagerReference; use Ecotone\Messaging\Attribute\ModuleAnnotation; use Ecotone\Messaging\Config\Annotation\AnnotationModule; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ConsoleCommandModule; @@ -50,6 +52,16 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $isDeadLetterEnabled = $dbalConfiguration->isDeadLetterEnabled(); $customDeadLetterGateways = ExtensionObjectResolver::resolve(CustomDeadLetterGateway::class, $extensionObjects); $connectionFactoryReference = $dbalConfiguration->getDeadLetterConnectionReference(); + $shouldAutoInitialize = $dbalConfiguration->isAutomaticTableInitializationEnabled(); + + $messagingConfiguration->registerServiceDefinition( + DeadLetterTableManager::class, + new Definition(DeadLetterTableManager::class, [ + DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE, + $isDeadLetterEnabled, + $shouldAutoInitialize, + ]) + ); if (! $isDeadLetterEnabled) { return; @@ -79,7 +91,9 @@ public function canHandle($extensionObject): bool public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, array $serviceExtensions): array { - return []; + return [ + new DbalTableManagerReference(DeadLetterTableManager::class), + ]; } private function registerOneTimeCommand(string $methodName, string $commandName, Configuration $configuration, InterfaceToCallRegistry $interfaceToCallRegistry): void @@ -115,6 +129,14 @@ private function registerGateway(string $referenceName, string $connectionFactor ->registerMessageHandler(DbalDeadLetterBuilder::createCount($referenceName, $connectionFactoryReference)) ->registerMessageHandler(DbalDeadLetterBuilder::createReply($referenceName, $connectionFactoryReference)) ->registerMessageHandler(DbalDeadLetterBuilder::createReplyAll($referenceName, $connectionFactoryReference)) + ->registerGatewayBuilder( + GatewayProxyBuilder::create( + $referenceName, + DeadLetterGateway::class, + 'store', + DbalDeadLetterBuilder::STORE_CHANNEL + ) + ) ->registerGatewayBuilder( GatewayProxyBuilder::create( $referenceName, diff --git a/packages/Dbal/src/Recoverability/DeadLetterGateway.php b/packages/Dbal/src/Recoverability/DeadLetterGateway.php index fc5cb8df4..b2c5305ac 100644 --- a/packages/Dbal/src/Recoverability/DeadLetterGateway.php +++ b/packages/Dbal/src/Recoverability/DeadLetterGateway.php @@ -10,6 +10,8 @@ */ interface DeadLetterGateway { + public function store(Message $message): void; + /** * @return ErrorContext[] */ diff --git a/packages/Dbal/tests/Integration/ChannelAdapterTest.php b/packages/Dbal/tests/Integration/ChannelAdapterTest.php deleted file mode 100644 index 39ae37069..000000000 --- a/packages/Dbal/tests/Integration/ChannelAdapterTest.php +++ /dev/null @@ -1,65 +0,0 @@ -toString(); - $inboundChannelName = Uuid::uuid4()->toString(); - $inboundChannel = QueueChannel::create(); - $timeoutInMilliseconds = 1; - - $messaging = ComponentTestBuilder::create() - ->withChannel(SimpleMessageChannelBuilder::create($inboundChannelName, $inboundChannel)) - ->withReference(DbalConnectionFactory::class, $this->getConnectionFactory()) - ->withInboundChannelAdapter( - DbalInboundChannelAdapterBuilder::createWith( - $endpointId = Uuid::uuid4()->toString(), - $queueName, - $inboundChannelName - ) - ->withReceiveTimeout($timeoutInMilliseconds) - ) - ->withPollingMetadata( - PollingMetadata::create($endpointId)->withTestingSetup() - ) - ->withMessageHandler( - DbalOutboundChannelAdapterBuilder::create($queueName) - ->withInputChannelName($outboundChannelName = 'outboundChannel') - ) - ->build(); - - $payload = 'some'; - $messaging->sendMessageDirectToChannel( - $outboundChannelName, - MessageBuilder::withPayload($payload)->build() - ); - - $messaging->run($endpointId); - $receivedMessage = $inboundChannel->receive(); - $this->assertNotNull($receivedMessage, 'Not received message'); - $this->assertEquals($payload, $receivedMessage->getPayload()); - - $this->assertNull($inboundChannel->receive(), 'Received message twice instead of one'); - } -} diff --git a/packages/Dbal/tests/Integration/Consumer/DbalConsumerPositionTrackerTest.php b/packages/Dbal/tests/Integration/Consumer/DbalConsumerPositionTrackerTest.php index 19591f201..26b19c775 100644 --- a/packages/Dbal/tests/Integration/Consumer/DbalConsumerPositionTrackerTest.php +++ b/packages/Dbal/tests/Integration/Consumer/DbalConsumerPositionTrackerTest.php @@ -4,12 +4,13 @@ namespace Test\Ecotone\Dbal\Integration\Consumer; +use Ecotone\Dbal\Configuration\DbalConfiguration; use Ecotone\Dbal\Consumer\DbalConsumerPositionTracker; -use Ecotone\Dbal\DbalReconnectableConnectionFactory; -use Ecotone\Dbal\DocumentStore\DbalDocumentStore; -use Ecotone\Enqueue\CachedConnectionFactory; +use Ecotone\Lite\EcotoneLite; +use Ecotone\Messaging\Config\ModulePackageList; +use Ecotone\Messaging\Config\ServiceConfiguration; use Ecotone\Messaging\Store\Document\DocumentStore; -use Ecotone\Test\InMemoryConversionService; +use Enqueue\Dbal\DbalConnectionFactory; use Test\Ecotone\Dbal\DbalMessagingTestCase; /** @@ -26,12 +27,19 @@ public function setUp(): void { parent::setUp(); - $this->documentStore = new DbalDocumentStore( - CachedConnectionFactory::createFor(new DbalReconnectableConnectionFactory($this->getConnectionFactory())), - true, - InMemoryConversionService::createWithoutConversion() + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + DbalConnectionFactory::class => $this->getConnectionFactory(), + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalConfiguration::createWithDefaults() + ->withAutomaticTableInitialization(true), + ]) ); + $this->documentStore = $ecotoneLite->getGateway(DocumentStore::class); $this->tracker = new DbalConsumerPositionTracker($this->documentStore); } @@ -109,12 +117,20 @@ public function test_position_survives_connection_reconnect() // Save with first tracker instance $this->tracker->savePosition($consumerId, $position); - // Create new tracker instance (simulates reconnection) - $newDocumentStore = new DbalDocumentStore( - CachedConnectionFactory::createFor(new DbalReconnectableConnectionFactory($this->getConnectionFactory())), - true, - InMemoryConversionService::createWithoutConversion() + // Create new EcotoneLite instance (simulates reconnection) + $newEcotoneLite = EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + DbalConnectionFactory::class => $this->getConnectionFactory(), + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalConfiguration::createWithDefaults() + ->withAutomaticTableInitialization(true), + ]) ); + + $newDocumentStore = $newEcotoneLite->getGateway(DocumentStore::class); $newTracker = new DbalConsumerPositionTracker($newDocumentStore); // Position should still be there diff --git a/packages/Dbal/tests/Integration/DatabaseInitializationTest.php b/packages/Dbal/tests/Integration/DatabaseInitializationTest.php new file mode 100644 index 000000000..fdbedf2e9 --- /dev/null +++ b/packages/Dbal/tests/Integration/DatabaseInitializationTest.php @@ -0,0 +1,208 @@ +cleanUpTables(); + } + + public function tearDown(): void + { + parent::tearDown(); + $this->cleanUpTables(); + } + + public function test_database_setup_lists_features_with_initialization_status(): void + { + $ecotone = $this->bootstrapEcotone(); + + $result = $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:setup', []); + + self::assertEquals(['Feature', 'Initialized'], $result->getColumnHeaders()); + $featureNames = array_column($result->getRows(), 0); + self::assertContains('dead_letter', $featureNames); + + // Verify dead_letter shows as not initialized + $deadLetterRow = $this->findRowByFeature($result, 'dead_letter'); + self::assertEquals('No', $deadLetterRow[1]); + } + + public function test_database_setup_shows_initialized_status_after_initialization(): void + { + $ecotone = $this->bootstrapEcotone(); + + // First initialize + $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:setup', ['initialize' => true]); + + // Then check status + $result = $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:setup', []); + + $deadLetterRow = $this->findRowByFeature($result, 'dead_letter'); + self::assertEquals('Yes', $deadLetterRow[1]); + } + + public function test_database_setup_initializes_tables(): void + { + $ecotone = $this->bootstrapEcotone(); + + self::assertFalse($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + + $result = $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:setup', ['initialize' => true]); + + self::assertEquals(['Feature', 'Status'], $result->getColumnHeaders()); + self::assertTrue($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + + // Verify result contains the feature + $featureNames = array_column($result->getRows(), 0); + self::assertContains('dead_letter', $featureNames); + } + + public function test_database_setup_returns_sql_statements(): void + { + $ecotone = $this->bootstrapEcotone(); + + $result = $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:setup', ['sql' => true]); + + self::assertEquals(['SQL Statement'], $result->getColumnHeaders()); + $allSql = implode(' ', array_column($result->getRows(), 0)); + self::assertStringContainsString('CREATE TABLE', $allSql); + self::assertStringContainsString(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE, $allSql); + } + + public function test_database_drop_drops_tables(): void + { + $ecotone = $this->bootstrapEcotone(); + + // First create tables + $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:setup', ['initialize' => true]); + self::assertTrue($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + + // Drop tables + $result = $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:drop', ['force' => true]); + + self::assertEquals(['Feature', 'Status'], $result->getColumnHeaders()); + self::assertFalse($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + } + + public function test_database_drop_shows_warning_without_force(): void + { + $ecotone = $this->bootstrapEcotone(); + + // First create tables + $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:setup', ['initialize' => true]); + self::assertTrue($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + + // Try to drop without force + $result = $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:drop', []); + + self::assertEquals(['Feature', 'Warning'], $result->getColumnHeaders()); + // Tables should still exist + self::assertTrue($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + } + + public function test_tables_are_auto_created_when_auto_initialization_enabled(): void + { + $ecotone = $this->bootstrapEcotone( + DbalConfiguration::createWithDefaults() + ->withAutomaticTableInitialization(true) + ); + + self::assertFalse($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + + // Using dead letter gateway should auto-create the table + $gateway = $ecotone->getGateway(DeadLetterGateway::class); + $gateway->list(10, 0); + + self::assertTrue($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + } + + public function test_tables_are_not_auto_created_when_auto_initialization_disabled(): void + { + $ecotone = $this->bootstrapEcotone(); + + self::assertFalse($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + + // Using dead letter gateway should NOT auto-create the table when disabled + $gateway = $ecotone->getGateway(DeadLetterGateway::class); + $gateway->list(10, 0); + + // Verify the table was NOT auto-created + self::assertFalse($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + + // Manually create the table via console command + $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:setup', ['initialize' => true]); + + self::assertTrue($this->tableExists(DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)); + } + + private function executeConsoleCommand(FlowTestSupport $ecotone, string $commandName, array $parameters): ConsoleCommandResultSet + { + /** @var ConsoleCommandRunner $runner */ + $runner = $ecotone->getGateway(ConsoleCommandRunner::class); + + return $runner->execute($commandName, $parameters); + } + + private function findRowByFeature(ConsoleCommandResultSet $result, string $featureName): ?array + { + foreach ($result->getRows() as $row) { + if ($row[0] === $featureName) { + return $row; + } + } + return null; + } + + private function bootstrapEcotone(?DbalConfiguration $dbalConfiguration = null): FlowTestSupport + { + $connectionFactory = $this->getConnectionFactory(); + $dbalConfiguration ??= DbalConfiguration::createWithDefaults() + ->withAutomaticTableInitialization(false); + + return EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + DbalConnectionFactory::class => $connectionFactory, + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withEnvironment('prod') + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([$dbalConfiguration]), + pathToRootCatalog: __DIR__ . '/../../', + ); + } + + private function tableExists(string $tableName): bool + { + return self::checkIfTableExists($this->getConnection(), $tableName); + } + + private function cleanUpTables(): void + { + $connection = $this->getConnection(); + if (self::checkIfTableExists($connection, DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)) { + $connection->executeStatement('DROP TABLE ' . DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE); + } + } +} + diff --git a/packages/Dbal/tests/Integration/DbalBackedMessageChannelTest.php b/packages/Dbal/tests/Integration/DbalBackedMessageChannelTest.php index c51466579..9ead4df97 100644 --- a/packages/Dbal/tests/Integration/DbalBackedMessageChannelTest.php +++ b/packages/Dbal/tests/Integration/DbalBackedMessageChannelTest.php @@ -18,7 +18,6 @@ use Ecotone\Messaging\Scheduling\StubUTCClock; use Ecotone\Messaging\Support\MessageBuilder; use Ecotone\Test\ClockSensitiveTrait; -use Ecotone\Test\ComponentTestBuilder; use Ecotone\Test\StubLogger; use Enqueue\Dbal\DbalConnectionFactory; use Enqueue\Dbal\DbalContext; @@ -42,23 +41,28 @@ public function test_sending_and_receiving_via_channel() { $channelName = Uuid::uuid4()->toString(); - $messaging = ComponentTestBuilder::create() - ->withReference(DbalConnectionFactory::class, $this->getConnectionFactory()) - ->withChannel( + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + DbalConnectionFactory::class => $this->getConnectionFactory(), + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ DbalBackedMessageChannelBuilder::create($channelName) - ->withReceiveTimeout(1) - ) - ->build(); + ->withReceiveTimeout(1), + ]) + ); $payload = 'some'; $headerName = 'token'; - $messaging->getMessageChannel($channelName)->send( + $messageChannel = $ecotoneLite->getMessageChannel($channelName); + $messageChannel->send( MessageBuilder::withPayload($payload) ->setHeader($headerName, 123) ->build() ); - $receivedMessage = $messaging->getMessageChannel($channelName)->receive(); + $receivedMessage = $messageChannel->receive(); $this->assertNotNull($receivedMessage, 'Not received message'); $this->assertEquals($payload, $receivedMessage->getPayload(), 'Payload of received is different that sent one'); @@ -69,15 +73,20 @@ public function test_sending_and_receiving_via_channel_manager_registry() { $channelName = Uuid::uuid4()->toString(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + 'managerRegistry' => $this->getConnectionFactory(true), + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalBackedMessageChannelBuilder::create($channelName, 'managerRegistry') + ->withReceiveTimeout(1), + ]) + ); + /** @var PollableChannel $messageChannel */ - $messageChannel = ComponentTestBuilder::create() - ->withReference('managerRegistry', $this->getConnectionFactory(true)) - ->withChannel( - DbalBackedMessageChannelBuilder::create($channelName, 'managerRegistry') - ->withReceiveTimeout(1) - ) - ->build() - ->getMessageChannel($channelName); + $messageChannel = $ecotoneLite->getMessageChannel($channelName); $payload = 'some'; $headerName = 'token'; @@ -98,11 +107,19 @@ public function test_sending_and_receiving_using_already_defined_connection() { $channelName = Uuid::uuid4()->toString(); + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + DbalConnectionFactory::class => $this->getConnectionFactory(true), + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalBackedMessageChannelBuilder::create($channelName)->withReceiveTimeout(1), + ]) + ); + /** @var PollableChannel $messageChannel */ - $messageChannel = $this->getComponentTestingWithConnection(true) - ->withChannel(DbalBackedMessageChannelBuilder::create($channelName)->withReceiveTimeout(1)) - ->build() - ->getMessageChannel($channelName); + $messageChannel = $ecotoneLite->getMessageChannel($channelName); $payload = 'some'; $headerName = 'token'; @@ -122,14 +139,22 @@ public function test_sending_and_receiving_using_already_defined_connection() public function test_reconnecting_on_disconnected_channel() { $connectionFactory = $this->getConnectionFactory(); + $queueName = Uuid::uuid4()->toString(); + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + DbalConnectionFactory::class => $connectionFactory, + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalBackedMessageChannelBuilder::create($queueName) + ->withReceiveTimeout(1), + ]) + ); + /** @var PollableChannel $messageChannel */ - $messageChannel = $this->getComponentTestingWithConnection() - ->withChannel( - DbalBackedMessageChannelBuilder::create($queueName = Uuid::uuid4()->toString()) - ->withReceiveTimeout(1) - ) - ->build() - ->getMessageChannel($queueName); + $messageChannel = $ecotoneLite->getMessageChannel($queueName); /** @var DbalContext $dbalContext */ $dbalContext = $connectionFactory->createContext(); @@ -144,14 +169,22 @@ public function test_reconnecting_on_disconnected_channel() public function test_reconnecting_on_disconnected_channel_with_manager_registry() { $connectionFactory = $this->getConnectionFactory(true); + $channelName = Uuid::uuid4()->toString(); + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + DbalConnectionFactory::class => $connectionFactory, + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalBackedMessageChannelBuilder::create($channelName) + ->withReceiveTimeout(1), + ]) + ); + /** @var PollableChannel $messageChannel */ - $messageChannel = $this->getComponentTestingWithConnection() - ->withChannel( - DbalBackedMessageChannelBuilder::create($channelName = Uuid::uuid4()->toString()) - ->withReceiveTimeout(1) - ) - ->build() - ->getMessageChannel($channelName); + $messageChannel = $ecotoneLite->getMessageChannel($channelName); /** @var DbalContext $dbalContext */ $dbalContext = $connectionFactory->createContext(); @@ -165,15 +198,24 @@ public function test_reconnecting_on_disconnected_channel_with_manager_registry( public function test_delaying_the_message() { + $channelName = Uuid::uuid4()->toString(); + $clock = new StubUTCClock(); + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + DbalConnectionFactory::class => $this->getConnectionFactory(true), + ClockInterface::class => $clock, + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalBackedMessageChannelBuilder::create($channelName) + ->withReceiveTimeout(1), + ]) + ); + /** @var PollableChannel $messageChannel */ - $messageChannel = $this->getComponentTestingWithConnection(true) - ->withReference(ClockInterface::class, $clock = new StubUTCClock()) - ->withChannel( - DbalBackedMessageChannelBuilder::create($channelName = Uuid::uuid4()->toString()) - ->withReceiveTimeout(1) - ) - ->build() - ->getMessageChannel($channelName); + $messageChannel = $ecotoneLite->getMessageChannel($channelName); Clock::set($clock); @@ -195,7 +237,7 @@ public function test_sending_message() $queueName = Uuid::uuid4()->toString(); $messagePayload = 'some'; - $ecotoneLite = EcotoneLite::bootstrapForTesting( + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( containerOrAvailableServices: [ DbalConnectionFactory::class => $this->getConnectionFactory(true), ], @@ -207,7 +249,7 @@ public function test_sending_message() ); /** @var PollableChannel $messageChannel */ - $messageChannel = $ecotoneLite->getMessageChannelByName($queueName); + $messageChannel = $ecotoneLite->getMessageChannel($queueName); $messageChannel->send(MessageBuilder::withPayload($messagePayload)->build()); @@ -223,7 +265,7 @@ public function test_failing_to_receive_message_when_not_declared_and_auto_decla { $queueName = Uuid::uuid4()->toString(); - $ecotoneLite = EcotoneLite::bootstrapForTesting( + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( containerOrAvailableServices: [ DbalConnectionFactory::class => $this->getConnectionFactory(true), ], @@ -236,7 +278,7 @@ public function test_failing_to_receive_message_when_not_declared_and_auto_decla ); /** @var PollableChannel $messageChannel */ - $messageChannel = $ecotoneLite->getMessageChannelByName($queueName); + $messageChannel = $ecotoneLite->getMessageChannel($queueName); $this->expectException(TableNotFoundException::class); @@ -246,7 +288,7 @@ public function test_failing_to_receive_message_when_not_declared_and_auto_decla public function test_failing_to_consume_due_to_connection_failure() { $loggerExample = StubLogger::create(); - $ecotoneLite = EcotoneLite::bootstrapForTesting( + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( [OrderService::class], containerOrAvailableServices: [ new OrderService(), diff --git a/packages/Dbal/tests/Integration/Deduplication/DbalDeduplicationInterceptorTest.php b/packages/Dbal/tests/Integration/Deduplication/DbalDeduplicationInterceptorTest.php index 122ab8419..677e7fe15 100644 --- a/packages/Dbal/tests/Integration/Deduplication/DbalDeduplicationInterceptorTest.php +++ b/packages/Dbal/tests/Integration/Deduplication/DbalDeduplicationInterceptorTest.php @@ -1,18 +1,18 @@ getConnectionFactory(), - new NativeClock(), - 1000, - 1000, - new StubLoggingGateway(), - SymfonyExpressionEvaluationAdapter::create(InMemoryReferenceSearchService::createEmpty()) - ); - - $methodInvocation = StubMethodInvocation::create(); - - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload([])->setMultipleHeaders([MessageHeaders::MESSAGE_ID => 1])->build(), - null, - null, - new AsynchronousRunningEndpoint('endpoint1') + $handler = new class { + private int $called = 0; + + #[Deduplicated] + #[CommandHandler('endpoint1', endpointId: 'handler_endpoint1')] + public function handleEndpoint1(): void + { + $this->called++; + } + + #[Deduplicated] + #[CommandHandler('endpoint2', endpointId: 'handler_endpoint2')] + public function handleEndpoint2(): void + { + $this->called++; + } + + #[QueryHandler('getCallCount')] + public function getCallCount(): int + { + return $this->called; + } + }; + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($handler)], + containerOrAvailableServices: [$handler, DbalConnectionFactory::class => $this->getConnectionFactory(true)], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) ); - $this->assertEquals(1, $methodInvocation->getCalledTimes()); - - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload([])->setMultipleHeaders([MessageHeaders::MESSAGE_ID => 1])->build(), - null, - null, - new AsynchronousRunningEndpoint('endpoint2') - ); + $messageId = '1'; + $ecotoneLite + ->sendCommandWithRoutingKey('endpoint1', metadata: [MessageHeaders::MESSAGE_ID => $messageId]) + ->sendCommandWithRoutingKey('endpoint2', metadata: [MessageHeaders::MESSAGE_ID => $messageId]); - $this->assertEquals(2, $methodInvocation->getCalledTimes()); + $this->assertEquals(2, $ecotoneLite->sendQueryWithRouting('getCallCount')); } public function test_not_handling_same_message_twice() { - $dbalTransactionInterceptor = new DeduplicationInterceptor( - $this->getConnectionFactory(), - new NativeClock(), - 1000, - 1000, - new StubLoggingGateway(), - SymfonyExpressionEvaluationAdapter::create(InMemoryReferenceSearchService::createEmpty()) + $handler = new class { + private int $called = 0; + + #[Deduplicated] + #[CommandHandler('endpoint1', endpointId: 'handler_endpoint1')] + public function handle(): void + { + $this->called++; + } + + #[QueryHandler('getCallCount')] + public function getCallCount(): int + { + return $this->called; + } + }; + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($handler)], + containerOrAvailableServices: [$handler, DbalConnectionFactory::class => $this->getConnectionFactory(true)], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) ); - $methodInvocation = StubMethodInvocation::create(); - - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload([])->setMultipleHeaders([MessageHeaders::MESSAGE_ID => 1])->build(), - null, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); - - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload([])->setMultipleHeaders([MessageHeaders::MESSAGE_ID => 1])->build(), - null, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); + $messageId = '1'; + $ecotoneLite + ->sendCommandWithRoutingKey('endpoint1', metadata: [MessageHeaders::MESSAGE_ID => $messageId]) + ->sendCommandWithRoutingKey('endpoint1', metadata: [MessageHeaders::MESSAGE_ID => $messageId]); - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getCallCount')); } public function test_deduplicating_with_header_expression() { - $dbalTransactionInterceptor = new DeduplicationInterceptor( - $this->getConnectionFactory(), - new NativeClock(), - 1000, - 1000, - new StubLoggingGateway(), - SymfonyExpressionEvaluationAdapter::create(InMemoryReferenceSearchService::createEmpty()) + $handler = new class { + private int $called = 0; + + #[Deduplicated(expression: "headers['orderId']")] + #[CommandHandler('endpoint1', endpointId: 'handler_endpoint1')] + public function handle(): void + { + $this->called++; + } + + #[QueryHandler('getCallCount')] + public function getCallCount(): int + { + return $this->called; + } + }; + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($handler)], + containerOrAvailableServices: [$handler, DbalConnectionFactory::class => $this->getConnectionFactory(true)], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) ); - $methodInvocation = StubMethodInvocation::create(); - $deduplicatedAttribute = new Deduplicated(expression: "headers['orderId']"); - // First call with orderId header - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('test')->setHeader('orderId', 'order-123')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'test', metadata: ['orderId' => 'order-123']); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getCallCount')); // Second call with same orderId header (should be deduplicated) - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('test')->setHeader('orderId', 'order-123')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'test', metadata: ['orderId' => 'order-123']); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getCallCount')); // Third call with different orderId header (should be processed) - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('test')->setHeader('orderId', 'order-456')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(2, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'test', metadata: ['orderId' => 'order-456']); + $this->assertEquals(2, $ecotoneLite->sendQueryWithRouting('getCallCount')); } public function test_deduplicating_with_payload_expression() { - $dbalTransactionInterceptor = new DeduplicationInterceptor( - $this->getConnectionFactory(), - new NativeClock(), - 1000, - 1000, - new StubLoggingGateway(), - SymfonyExpressionEvaluationAdapter::create(InMemoryReferenceSearchService::createEmpty()) + $handler = new class { + private int $called = 0; + + #[Deduplicated(expression: 'payload')] + #[CommandHandler('endpoint1', endpointId: 'handler_endpoint1')] + public function handle(): void + { + $this->called++; + } + + #[QueryHandler('getCallCount')] + public function getCallCount(): int + { + return $this->called; + } + }; + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($handler)], + containerOrAvailableServices: [$handler, DbalConnectionFactory::class => $this->getConnectionFactory(true)], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) ); - $methodInvocation = StubMethodInvocation::create(); - $deduplicatedAttribute = new Deduplicated(expression: 'payload'); - // First call with specific payload - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('unique-payload-1')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'unique-payload-1'); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getCallCount')); // Second call with same payload (should be deduplicated) - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('unique-payload-1')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'unique-payload-1'); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getCallCount')); // Third call with different payload (should be processed) - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('unique-payload-2')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(2, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'unique-payload-2'); + $this->assertEquals(2, $ecotoneLite->sendQueryWithRouting('getCallCount')); } public function test_deduplicating_with_complex_expression() { - $dbalTransactionInterceptor = new DeduplicationInterceptor( - $this->getConnectionFactory(), - new NativeClock(), - 1000, - 1000, - new StubLoggingGateway(), - SymfonyExpressionEvaluationAdapter::create(InMemoryReferenceSearchService::createEmpty()) + $handler = new class { + private int $called = 0; + + #[Deduplicated(expression: "headers['customerId'] ~ '_' ~ payload")] + #[CommandHandler('endpoint1', endpointId: 'handler_endpoint1')] + public function handle(): void + { + $this->called++; + } + + #[QueryHandler('getCallCount')] + public function getCallCount(): int + { + return $this->called; + } + }; + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($handler)], + containerOrAvailableServices: [$handler, DbalConnectionFactory::class => $this->getConnectionFactory(true)], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) ); - $methodInvocation = StubMethodInvocation::create(); - $deduplicatedAttribute = new Deduplicated(expression: "headers['customerId'] ~ '_' ~ payload"); - // First call - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('order-data')->setHeader('customerId', 'customer-123')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'order-data', metadata: ['customerId' => 'customer-123']); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getCallCount')); // Second call with same combination (should be deduplicated) - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('order-data')->setHeader('customerId', 'customer-123')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'order-data', metadata: ['customerId' => 'customer-123']); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getCallCount')); // Third call with different customer but same payload (should be processed) - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('order-data')->setHeader('customerId', 'customer-456')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(2, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'order-data', metadata: ['customerId' => 'customer-456']); + $this->assertEquals(2, $ecotoneLite->sendQueryWithRouting('getCallCount')); } public function test_deduplicating_with_tracking_name_isolation() { - $dbalTransactionInterceptor = new DeduplicationInterceptor( - $this->getConnectionFactory(), - new NativeClock(), - 1000, - 1000, - new StubLoggingGateway(), - SymfonyExpressionEvaluationAdapter::create(InMemoryReferenceSearchService::createEmpty()) + $handler = new class { + private int $trackingOneCalled = 0; + private int $trackingTwoCalled = 0; + + #[Deduplicated(expression: "headers['orderId']", trackingName: 'tracking_one')] + #[CommandHandler('endpoint1', endpointId: 'handler_endpoint1')] + public function handleTrackingOne(): void + { + $this->trackingOneCalled++; + } + + #[Deduplicated(expression: "headers['orderId']", trackingName: 'tracking_two')] + #[CommandHandler('endpoint2', endpointId: 'handler_endpoint2')] + public function handleTrackingTwo(): void + { + $this->trackingTwoCalled++; + } + + #[QueryHandler('getTrackingOneCallCount')] + public function getTrackingOneCallCount(): int + { + return $this->trackingOneCalled; + } + + #[QueryHandler('getTrackingTwoCallCount')] + public function getTrackingTwoCallCount(): int + { + return $this->trackingTwoCalled; + } + }; + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($handler)], + containerOrAvailableServices: [$handler, DbalConnectionFactory::class => $this->getConnectionFactory(true)], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) ); - $methodInvocation = StubMethodInvocation::create(); - $deduplicatedAttributeOne = new Deduplicated(expression: "headers['orderId']", trackingName: 'tracking_one'); - $deduplicatedAttributeTwo = new Deduplicated(expression: "headers['orderId']", trackingName: 'tracking_two'); - // First call with tracking_one - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('test')->setHeader('orderId', 'order-123')->build(), - $deduplicatedAttributeOne, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'test', metadata: ['orderId' => 'order-123']); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getTrackingOneCallCount')); + $this->assertEquals(0, $ecotoneLite->sendQueryWithRouting('getTrackingTwoCallCount')); // Second call with same orderId but different tracking name (should be processed) - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('test')->setHeader('orderId', 'order-123')->build(), - $deduplicatedAttributeTwo, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(2, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint2', 'test', metadata: ['orderId' => 'order-123']); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getTrackingOneCallCount')); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getTrackingTwoCallCount')); // Third call with same orderId and same tracking name as first (should be deduplicated) - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('test')->setHeader('orderId', 'order-123')->build(), - $deduplicatedAttributeOne, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(2, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'test', metadata: ['orderId' => 'order-123']); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getTrackingOneCallCount')); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getTrackingTwoCallCount')); } - public function test_deduplicating_with_tracking_name_overrides_endpoint_id() + public function test_deduplicating_with_same_tracking_name_and_different_endpoint_id() { - $dbalTransactionInterceptor = new DeduplicationInterceptor( - $this->getConnectionFactory(), - new NativeClock(), - 1000, - 1000, - new StubLoggingGateway(), - SymfonyExpressionEvaluationAdapter::create(InMemoryReferenceSearchService::createEmpty()) + $handler = new class { + private int $called = 0; + + #[Deduplicated(expression: "headers['orderId']", trackingName: 'custom_tracking')] + #[CommandHandler('endpoint1', endpointId: 'handler_endpoint1')] + public function handleEndpoint1(): void + { + $this->called++; + } + + #[Deduplicated(expression: "headers['orderId']", trackingName: 'custom_tracking')] + #[CommandHandler('endpoint2', endpointId: 'handler_endpoint2')] + public function handleEndpoint2(): void + { + $this->called++; + } + + #[QueryHandler('getCallCount')] + public function getCallCount(): int + { + return $this->called; + } + }; + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($handler)], + containerOrAvailableServices: [$handler, DbalConnectionFactory::class => $this->getConnectionFactory(true)], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) ); - $methodInvocation = StubMethodInvocation::create(); - $deduplicatedAttribute = new Deduplicated(expression: "headers['orderId']", trackingName: 'custom_tracking'); - // First call with custom tracking name - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('test')->setHeader('orderId', 'order-123')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint1') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint1', 'test', metadata: ['orderId' => 'order-123']); + $this->assertEquals(1, $ecotoneLite->sendQueryWithRouting('getCallCount')); // Second call with same orderId and different endpoint but same tracking name (should be deduplicated) - $dbalTransactionInterceptor->deduplicate( - $methodInvocation, - MessageBuilder::withPayload('test')->setHeader('orderId', 'order-123')->build(), - $deduplicatedAttribute, - null, - new AsynchronousRunningEndpoint('endpoint2') - ); - - $this->assertEquals(1, $methodInvocation->getCalledTimes()); + $ecotoneLite->sendCommandWithRoutingKey('endpoint2', 'test', metadata: ['orderId' => 'order-123']); + $this->assertEquals(2, $ecotoneLite->sendQueryWithRouting('getCallCount')); } } diff --git a/packages/Dbal/tests/Integration/DocumentStore/DbalDocumentStoreTest.php b/packages/Dbal/tests/Integration/DocumentStore/DbalDocumentStoreTest.php index 1adb3b901..9e8696d52 100644 --- a/packages/Dbal/tests/Integration/DocumentStore/DbalDocumentStoreTest.php +++ b/packages/Dbal/tests/Integration/DocumentStore/DbalDocumentStoreTest.php @@ -2,19 +2,18 @@ namespace Test\Ecotone\Dbal\Integration\DocumentStore; -use Ecotone\Dbal\DbalReconnectableConnectionFactory; use Ecotone\Dbal\DocumentStore\DbalDocumentStore; -use Ecotone\Enqueue\CachedConnectionFactory; -use Ecotone\Messaging\Conversion\MediaType; -use Ecotone\Messaging\Handler\Type; +use Ecotone\Lite\EcotoneLite; +use Ecotone\Lite\Test\FlowTestSupport; +use Ecotone\Messaging\Config\ModulePackageList; +use Ecotone\Messaging\Config\ServiceConfiguration; use Ecotone\Messaging\Store\Document\DocumentException; use Ecotone\Messaging\Store\Document\DocumentStore; -use Ecotone\Test\InMemoryConversionService; +use Enqueue\Dbal\DbalConnectionFactory; use function json_decode; use function json_encode; -use stdClass; use Test\Ecotone\Dbal\DbalMessagingTestCase; /** @@ -26,11 +25,22 @@ */ final class DbalDocumentStoreTest extends DbalMessagingTestCase { - private CachedConnectionFactory $cachedConnectionFactory; + public function setUp(): void + { + parent::setUp(); + $this->cleanUpTables(); + } + + public function tearDown(): void + { + parent::tearDown(); + $this->cleanUpTables(); + } public function test_adding_document_to_collection() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertEquals(0, $documentStore->countDocuments('users')); @@ -42,7 +52,8 @@ public function test_adding_document_to_collection() public function test_finding_document_to_collection() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertNull($documentStore->findDocument('users', '123')); @@ -53,7 +64,8 @@ public function test_finding_document_to_collection() public function test_updating_document() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertEquals(0, $documentStore->countDocuments('users')); @@ -65,7 +77,8 @@ public function test_updating_document() public function test_updating_document_with_same_content() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertEquals(0, $documentStore->countDocuments('users')); @@ -77,85 +90,120 @@ public function test_updating_document_with_same_content() public function test_adding_document_as_object_should_return_object() { - $documentStore = new DbalDocumentStore( - CachedConnectionFactory::createFor(new DbalReconnectableConnectionFactory($this->getConnectionFactory())), - true, - InMemoryConversionService::createWithConversion( - new stdClass(), - MediaType::APPLICATION_X_PHP, - stdClass::class, - MediaType::APPLICATION_JSON, - Type::STRING, - '{"name":"johny"}' - )->registerConversion( - '{"name": "johny"}', - MediaType::APPLICATION_JSON, - Type::STRING, - MediaType::APPLICATION_X_PHP, - stdClass::class, - new stdClass() - ) + $converter = new #[\Ecotone\Messaging\Attribute\MediaTypeConverter] class implements \Ecotone\Messaging\Conversion\Converter { + public function convert($source, \Ecotone\Messaging\Handler\Type $sourceType, \Ecotone\Messaging\Conversion\MediaType $sourceMediaType, \Ecotone\Messaging\Handler\Type $targetType, \Ecotone\Messaging\Conversion\MediaType $targetMediaType) + { + if ($sourceMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationXPHP())) { + return '{"name":"johny"}'; + } + + return new \stdClass(); + } + + public function matches(\Ecotone\Messaging\Handler\Type $sourceType, \Ecotone\Messaging\Conversion\MediaType $sourceMediaType, \Ecotone\Messaging\Handler\Type $targetType, \Ecotone\Messaging\Conversion\MediaType $targetMediaType): bool + { + return ($sourceType->toString() === 'stdClass' + && $targetMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationJson())) + || ($sourceMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationJson()) + && $targetType->toString() === 'stdClass'); + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($converter)], + containerOrAvailableServices: [DbalConnectionFactory::class => $this->getConnectionFactory(), $converter], + configuration: ServiceConfiguration::createWithDefaults() + ->withEnvironment('prod') + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withNamespaces([]), + pathToRootCatalog: __DIR__ . '/../../..', + addInMemoryStateStoredRepository: false, ); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertEquals(0, $documentStore->countDocuments('users')); - $documentStore->addDocument('users', '123', new stdClass()); + $documentStore->addDocument('users', '123', new \stdClass()); - $this->assertEquals(new stdClass(), $documentStore->getDocument('users', '123')); + $this->assertEquals(new \stdClass(), $documentStore->getDocument('users', '123')); } public function test_adding_document_as_collection_of_objects_should_return_object() { - $document = [new stdClass(), new stdClass()]; - $documentStore = new DbalDocumentStore( - CachedConnectionFactory::createFor(new DbalReconnectableConnectionFactory($this->getConnectionFactory())), - true, - InMemoryConversionService::createWithConversion( - $document, - MediaType::APPLICATION_X_PHP, - Type::createCollection(stdClass::class), - MediaType::APPLICATION_JSON, - Type::STRING, - '[{"name":"johny"},{"name":"franco"}]' - )->registerConversion( - '[{"name": "johny"}, {"name": "franco"}]', - MediaType::APPLICATION_JSON, - Type::STRING, - MediaType::APPLICATION_X_PHP, - Type::createCollection(stdClass::class), - $document - ) + $converter = new #[\Ecotone\Messaging\Attribute\MediaTypeConverter] class implements \Ecotone\Messaging\Conversion\Converter { + public function convert($source, \Ecotone\Messaging\Handler\Type $sourceType, \Ecotone\Messaging\Conversion\MediaType $sourceMediaType, \Ecotone\Messaging\Handler\Type $targetType, \Ecotone\Messaging\Conversion\MediaType $targetMediaType) + { + if ($sourceMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationXPHP())) { + return '[{"name":"johny"},{"name":"franco"}]'; + } + + return [new \stdClass(), new \stdClass()]; + } + + public function matches(\Ecotone\Messaging\Handler\Type $sourceType, \Ecotone\Messaging\Conversion\MediaType $sourceMediaType, \Ecotone\Messaging\Handler\Type $targetType, \Ecotone\Messaging\Conversion\MediaType $targetMediaType): bool + { + return ($sourceType->toString() === 'array' + && $targetMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationJson())) + || ($sourceMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationJson()) + && $targetType->toString() === 'array'); + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($converter)], + containerOrAvailableServices: [DbalConnectionFactory::class => $this->getConnectionFactory(), $converter], + configuration: ServiceConfiguration::createWithDefaults() + ->withEnvironment('prod') + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withNamespaces([]), + pathToRootCatalog: __DIR__ . '/../../..', + addInMemoryStateStoredRepository: false, ); + $documentStore = $ecotone->getGateway(DocumentStore::class); + + $document = [new \stdClass(), new \stdClass()]; $this->assertEquals(0, $documentStore->countDocuments('users')); $documentStore->addDocument('users', '123', $document); - $this->assertEquals($document, $documentStore->getDocument('users', '123')); + $this->assertEquals([$document], $documentStore->getAllDocuments('users')); } public function test_adding_document_as_array_should_return_array() { - $document = [1, 2, 5]; - $documentStore = new DbalDocumentStore( - CachedConnectionFactory::createFor(new DbalReconnectableConnectionFactory($this->getConnectionFactory())), - true, - InMemoryConversionService::createWithConversion( - $document, - MediaType::APPLICATION_X_PHP, - Type::ARRAY, - MediaType::APPLICATION_JSON, - Type::STRING, - '[1,2,5]' - )->registerConversion( - '[1, 2, 5]', - MediaType::APPLICATION_JSON, - Type::STRING, - MediaType::APPLICATION_X_PHP, - Type::ARRAY, - $document - ) + $converter = new #[\Ecotone\Messaging\Attribute\MediaTypeConverter] class implements \Ecotone\Messaging\Conversion\Converter { + public function convert($source, \Ecotone\Messaging\Handler\Type $sourceType, \Ecotone\Messaging\Conversion\MediaType $sourceMediaType, \Ecotone\Messaging\Handler\Type $targetType, \Ecotone\Messaging\Conversion\MediaType $targetMediaType) + { + if ($sourceMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationXPHP())) { + return '[1,2,5]'; + } + + return [1, 2, 5]; + } + + public function matches(\Ecotone\Messaging\Handler\Type $sourceType, \Ecotone\Messaging\Conversion\MediaType $sourceMediaType, \Ecotone\Messaging\Handler\Type $targetType, \Ecotone\Messaging\Conversion\MediaType $targetMediaType): bool + { + return ($sourceType->isIterable() && !$sourceType->isClassOrInterface() + && $targetMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationJson())) + || ($sourceMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationJson()) + && $targetType->isIterable() && !$targetType->isClassOrInterface()); + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($converter)], + containerOrAvailableServices: [DbalConnectionFactory::class => $this->getConnectionFactory(), $converter], + configuration: ServiceConfiguration::createWithDefaults() + ->withEnvironment('prod') + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withNamespaces([]), + pathToRootCatalog: __DIR__ . '/../../..', + addInMemoryStateStoredRepository: false, ); + $documentStore = $ecotone->getGateway(DocumentStore::class); + + $document = [1, 2, 5]; $this->assertEquals(0, $documentStore->countDocuments('users')); @@ -166,7 +214,8 @@ public function test_adding_document_as_array_should_return_array() public function test_adding_non_json_document_should_fail() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertEquals(0, $documentStore->countDocuments('users')); @@ -177,7 +226,8 @@ public function test_adding_non_json_document_should_fail() public function test_deleting_document() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $documentStore->addDocument('users', '123', '{"name":"Johny"}'); $documentStore->addDocument('companies', '123', '{"name":"Document Stores, INC."}'); @@ -193,7 +243,8 @@ public function test_deleting_document() public function test_deleting_non_existing_document() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $documentStore->deleteDocument('users', '123'); @@ -202,7 +253,8 @@ public function test_deleting_non_existing_document() public function test_throwing_exception_if_looking_for_non_existing_document() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->expectException(DocumentException::class); @@ -211,7 +263,8 @@ public function test_throwing_exception_if_looking_for_non_existing_document() public function test_throwing_exception_if_looking_for_previously_existing_document() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $documentStore->addDocument('users', '123', '{"name":"Johny"}'); $documentStore->deleteDocument('users', '123'); @@ -223,7 +276,8 @@ public function test_throwing_exception_if_looking_for_previously_existing_docum public function test_dropping_collection() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $documentStore->addDocument('users', '123', '{"name":"Johny"}'); $documentStore->addDocument('users', '124', '{"name":"Johny"}'); $documentStore->addDocument('companies', '123', '{"name":"Document Stores, INC."}'); @@ -237,7 +291,8 @@ public function test_dropping_collection() public function test_retrieving_whole_collection() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertEquals([], $documentStore->getAllDocuments('users')); @@ -252,37 +307,49 @@ public function test_retrieving_whole_collection() public function test_retrieving_whole_collection_of_objects() { - $documentStore = new DbalDocumentStore( - CachedConnectionFactory::createFor(new DbalReconnectableConnectionFactory($this->getConnectionFactory())), - true, - InMemoryConversionService::createWithConversion( - new stdClass(), - MediaType::APPLICATION_X_PHP, - stdClass::class, - MediaType::APPLICATION_JSON, - Type::STRING, - '{"name":"johny"}' - )->registerConversion( - '{"name": "johny"}', - MediaType::APPLICATION_JSON, - Type::STRING, - MediaType::APPLICATION_X_PHP, - stdClass::class, - new stdClass() - ) + $converter = new #[\Ecotone\Messaging\Attribute\MediaTypeConverter] class implements \Ecotone\Messaging\Conversion\Converter { + public function convert($source, \Ecotone\Messaging\Handler\Type $sourceType, \Ecotone\Messaging\Conversion\MediaType $sourceMediaType, \Ecotone\Messaging\Handler\Type $targetType, \Ecotone\Messaging\Conversion\MediaType $targetMediaType) + { + if ($sourceMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationXPHP())) { + return '{"name":"johny"}'; + } + + return new \stdClass(); + } + + public function matches(\Ecotone\Messaging\Handler\Type $sourceType, \Ecotone\Messaging\Conversion\MediaType $sourceMediaType, \Ecotone\Messaging\Handler\Type $targetType, \Ecotone\Messaging\Conversion\MediaType $targetMediaType): bool + { + return ($sourceType->toString() === 'stdClass' + && $targetMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationJson())) + || ($sourceMediaType->isCompatibleWith(\Ecotone\Messaging\Conversion\MediaType::createApplicationJson()) + && $targetType->toString() === 'stdClass'); + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + classesToResolve: [get_class($converter)], + containerOrAvailableServices: [DbalConnectionFactory::class => $this->getConnectionFactory(), $converter], + configuration: ServiceConfiguration::createWithDefaults() + ->withEnvironment('prod') + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withNamespaces([]), + pathToRootCatalog: __DIR__ . '/../../..', + addInMemoryStateStoredRepository: false, ); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertEquals(0, $documentStore->countDocuments('users')); - $documentStore->addDocument('users', '123', new stdClass()); - $documentStore->addDocument('users', '124', new stdClass()); + $documentStore->addDocument('users', '123', new \stdClass()); + $documentStore->addDocument('users', '124', new \stdClass()); - $this->assertEquals([new stdClass(), new stdClass()], $documentStore->getAllDocuments('users')); + $this->assertEquals([new \stdClass(), new \stdClass()], $documentStore->getAllDocuments('users')); } public function test_dropping_non_existing_collection() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $documentStore->dropCollection('users'); @@ -291,7 +358,8 @@ public function test_dropping_non_existing_collection() public function test_replacing_document() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertEquals(0, $documentStore->countDocuments('users')); @@ -303,7 +371,8 @@ public function test_replacing_document() public function test_upserting_new_document() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->assertEquals(0, $documentStore->countDocuments('users')); @@ -314,7 +383,8 @@ public function test_upserting_new_document() public function test_excepting_if_trying_to_add_document_twice() { - $documentStore = $this->getDocumentStore(); + $ecotone = $this->bootstrapEcotone(); + $documentStore = $ecotone->getGateway(DocumentStore::class); $this->expectException(DocumentException::class); @@ -322,15 +392,33 @@ public function test_excepting_if_trying_to_add_document_twice() $documentStore->addDocument('users', '123', '{"name":"Johny Mac"}'); } - private function getDocumentStore(): DocumentStore + private function bootstrapEcotone(): FlowTestSupport { - return new DbalDocumentStore( - CachedConnectionFactory::createFor(new DbalReconnectableConnectionFactory($this->getConnectionFactory())), - true, - InMemoryConversionService::createWithoutConversion() + return EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [DbalConnectionFactory::class => $this->getConnectionFactory()], + configuration: ServiceConfiguration::createWithDefaults() + ->withEnvironment('prod') + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withNamespaces([]), + pathToRootCatalog: __DIR__ . '/../../..', + addInMemoryStateStoredRepository: false, ); } + private function cleanUpTables(): void + { + $connection = $this->getConnection(); + $schemaManager = method_exists($connection, 'createSchemaManager') + ? $connection->createSchemaManager() + : $connection->getSchemaManager(); + + foreach ($schemaManager->listTableNames() as $tableName) { + if ($tableName === DbalDocumentStore::ECOTONE_DOCUMENT_STORE) { + $schemaManager->dropTable($tableName); + } + } + } + private function assertJsons(string $expectedJson, string $givenJson): void { $this->assertEquals($expectedJson, json_encode(json_decode($givenJson, true))); diff --git a/packages/Dbal/tests/Integration/Recoverability/DbalDeadLetterTest.php b/packages/Dbal/tests/Integration/Recoverability/DbalDeadLetterTest.php index af3ecd0b3..08440dc80 100644 --- a/packages/Dbal/tests/Integration/Recoverability/DbalDeadLetterTest.php +++ b/packages/Dbal/tests/Integration/Recoverability/DbalDeadLetterTest.php @@ -2,17 +2,19 @@ namespace Test\Ecotone\Dbal\Integration\Recoverability; +use Ecotone\Dbal\Configuration\DbalConfiguration; use Ecotone\Dbal\Recoverability\DbalDeadLetterHandler; -use Ecotone\Messaging\Handler\Logger\StubLoggingGateway; +use Ecotone\Dbal\Recoverability\DeadLetterGateway; +use Ecotone\Lite\EcotoneLite; +use Ecotone\Lite\Test\FlowTestSupport; +use Ecotone\Messaging\Config\ModulePackageList; +use Ecotone\Messaging\Config\ServiceConfiguration; use Ecotone\Messaging\Handler\MessageHandlingException; use Ecotone\Messaging\Handler\Recoverability\ErrorContext; -use Ecotone\Messaging\Handler\Recoverability\RetryRunner; use Ecotone\Messaging\Message; -use Ecotone\Messaging\MessageConverter\DefaultHeaderMapper; -use Ecotone\Messaging\Scheduling\NativeClock; use Ecotone\Messaging\Support\ErrorMessage; use Ecotone\Messaging\Support\MessageBuilder; -use Ecotone\Test\InMemoryConversionService; +use Enqueue\Dbal\DbalConnectionFactory; use Test\Ecotone\Dbal\DbalMessagingTestCase; use Throwable; @@ -25,30 +27,43 @@ */ class DbalDeadLetterTest extends DbalMessagingTestCase { + public function setUp(): void + { + parent::setUp(); + $this->cleanUpTables(); + } + + public function tearDown(): void + { + parent::tearDown(); + $this->cleanUpTables(); + } + public function test_retrieving_error_message_details() { - $dbalDeadLetter = new DbalDeadLetterHandler($this->getConnectionFactory(), DefaultHeaderMapper::createAllHeadersMapping(), InMemoryConversionService::createWithoutConversion(), new RetryRunner(new NativeClock(), new StubLoggingGateway())); + $ecotone = $this->bootstrapEcotone(); + $gateway = $ecotone->getGateway(DeadLetterGateway::class); $errorMessage = MessageBuilder::withPayload('')->build(); - $dbalDeadLetter->store($errorMessage); + $gateway->store($errorMessage); - $this->assertEquals( - $errorMessage, - $dbalDeadLetter->show($errorMessage->getHeaders()->getMessageId()) - ); + $retrievedMessage = $gateway->show($errorMessage->getHeaders()->getMessageId()); + + $this->assertEquals($errorMessage->getPayload(), $retrievedMessage->getPayload()); + $this->assertEquals($errorMessage->getHeaders()->getMessageId(), $retrievedMessage->getHeaders()->getMessageId()); } public function test_storing_wrapped_error_message() { - $dbalDeadLetter = new DbalDeadLetterHandler($this->getConnectionFactory(), DefaultHeaderMapper::createAllHeadersMapping(), InMemoryConversionService::createWithoutConversion(), new RetryRunner(new NativeClock(), new StubLoggingGateway())); - + $ecotone = $this->bootstrapEcotone(); + $gateway = $ecotone->getGateway(DeadLetterGateway::class); $errorMessage = MessageBuilder::withPayload('')->build(); - $dbalDeadLetter->store($this->createFailedMessage($errorMessage)); + $gateway->store($this->createFailedMessage($errorMessage)); $this->assertEquals( $errorMessage->getHeaders()->getMessageId(), - $dbalDeadLetter->show($errorMessage->getHeaders()->getMessageId())->getHeaders()->getMessageId() + $gateway->show($errorMessage->getHeaders()->getMessageId())->getHeaders()->getMessageId() ); } @@ -59,7 +74,8 @@ private function createFailedMessage(Message $message, ?Throwable $exception = n public function test_listing_error_messages() { - $dbalDeadLetter = new DbalDeadLetterHandler($this->getConnectionFactory(), DefaultHeaderMapper::createAllHeadersMapping(), InMemoryConversionService::createWithoutConversion(), new RetryRunner(new NativeClock(), new StubLoggingGateway())); + $ecotone = $this->bootstrapEcotone(); + $gateway = $ecotone->getGateway(DeadLetterGateway::class); $errorMessage = MessageBuilder::withPayload('error1') ->setMultipleHeaders([ @@ -70,28 +86,56 @@ public function test_listing_error_messages() ErrorContext::EXCEPTION_MESSAGE => 'some', ]) ->build(); - $dbalDeadLetter->store($errorMessage); + $gateway->store($errorMessage); $this->assertEquals( [ErrorContext::fromHeaders($errorMessage->getHeaders()->headers())], - $dbalDeadLetter->list(1, 0) + $gateway->list(1, 0) ); } public function test_deleting_error_message() { - $dbalDeadLetter = new DbalDeadLetterHandler($this->getConnectionFactory(), DefaultHeaderMapper::createAllHeadersMapping(), InMemoryConversionService::createWithoutConversion(), new RetryRunner(new NativeClock(), new StubLoggingGateway())); + $ecotone = $this->bootstrapEcotone(); + $gateway = $ecotone->getGateway(DeadLetterGateway::class); $message = MessageBuilder::withPayload('error2')->build(); - $this->assertEquals(0, $dbalDeadLetter->count()); + $this->assertEquals(0, $gateway->count()); + + $gateway->store($message); + $this->assertEquals(1, $gateway->count()); - $dbalDeadLetter->store($message); - $this->assertEquals(1, $dbalDeadLetter->count()); + $gateway->delete($message->getHeaders()->getMessageId()); + + $this->assertEquals([], $gateway->list(1, 0)); + $this->assertEquals(0, $gateway->count()); + } - $dbalDeadLetter->delete($message->getHeaders()->getMessageId()); + private function bootstrapEcotone(): FlowTestSupport + { + $connectionFactory = $this->getConnectionFactory(); + + return EcotoneLite::bootstrapFlowTesting( + containerOrAvailableServices: [ + DbalConnectionFactory::class => $connectionFactory, + ], + configuration: ServiceConfiguration::createWithDefaults() + ->withEnvironment('prod') + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE])) + ->withExtensionObjects([ + DbalConfiguration::createWithDefaults() + ->withAutomaticTableInitialization(true), + ]), + pathToRootCatalog: __DIR__ . '/../../../', + ); + } - $this->assertEquals([], $dbalDeadLetter->list(1, 0)); - $this->assertEquals(0, $dbalDeadLetter->count()); + private function cleanUpTables(): void + { + $connection = $this->getConnection(); + if (self::checkIfTableExists($connection, DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE)) { + $connection->executeStatement('DROP TABLE ' . DbalDeadLetterHandler::DEFAULT_DEAD_LETTER_TABLE); + } } } diff --git a/packages/Ecotone/src/Lite/Test/Configuration/EcotoneTestSupportModule.php b/packages/Ecotone/src/Lite/Test/Configuration/EcotoneTestSupportModule.php index b5731a82a..67377a977 100644 --- a/packages/Ecotone/src/Lite/Test/Configuration/EcotoneTestSupportModule.php +++ b/packages/Ecotone/src/Lite/Test/Configuration/EcotoneTestSupportModule.php @@ -43,6 +43,8 @@ use Ecotone\Modelling\EventBus; use Ecotone\Modelling\QueryBus; use Ecotone\Projecting\InMemory\InMemoryEventStoreStreamSourceBuilder; +use Ecotone\Projecting\InMemory\InMemoryProjectionStateStorage; +use Ecotone\Projecting\InMemory\InMemoryProjectionStateStorageBuilder; use Ecotone\Projecting\InMemory\InMemoryStreamSourceBuilder; #[ModuleAnnotation] @@ -247,7 +249,7 @@ public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, } } - return [new InMemoryEventStoreStreamSourceBuilder()]; + return [new InMemoryEventStoreStreamSourceBuilder(), new InMemoryProjectionStateStorageBuilder()]; } public function getModulePackageName(): string diff --git a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php index 48c9d3c4c..bd02d3287 100644 --- a/packages/Ecotone/src/Messaging/Config/ModuleClassList.php +++ b/packages/Ecotone/src/Messaging/Config/ModuleClassList.php @@ -9,6 +9,7 @@ use Ecotone\Amqp\Transaction\AmqpTransactionModule; use Ecotone\Dbal\Configuration\DbalConnectionModule; use Ecotone\Dbal\Configuration\DbalPublisherModule; +use Ecotone\Dbal\Database\DatabaseSetupModule; use Ecotone\Dbal\DbaBusinessMethod\DbaBusinessMethodModule; use Ecotone\Dbal\DbalTransaction\DbalTransactionModule; use Ecotone\Dbal\Deduplication\DeduplicationModule; @@ -145,6 +146,7 @@ class ModuleClassList DbalPublisherModule::class, DbaBusinessMethodModule::class, MultiTenantConnectionFactoryModule::class, + DatabaseSetupModule::class, ]; public const REDIS_MODULES = [ diff --git a/packages/Ecotone/src/Projecting/Config/ProjectingModule.php b/packages/Ecotone/src/Projecting/Config/ProjectingModule.php index 4d05788bb..3f3069379 100644 --- a/packages/Ecotone/src/Projecting/Config/ProjectingModule.php +++ b/packages/Ecotone/src/Projecting/Config/ProjectingModule.php @@ -95,7 +95,7 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $messagingConfiguration->registerServiceDefinition( $projectingManagerReference = ProjectingManager::class . ':' . $projectionName, new Definition(ProjectingManager::class, [ - $components[$projectionName][ProjectionStateStorage::class] ?? new Definition(InMemoryProjectionStateStorage::class), + $components[$projectionName][ProjectionStateStorage::class] ?? throw ConfigurationException::create("Projection with name {$projectionName} does not have projection state storage configured. Please check your configuration."), new Reference($reference), $components[$projectionName][StreamSource::class] ?? throw ConfigurationException::create("Projection with name {$projectionName} does not have stream source configured. Please check your configuration."), $components[$projectionName][PartitionProvider::class] ?? new Definition(NullPartitionProvider::class), diff --git a/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreAdapterModule.php b/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreAdapterModule.php index 6396e0bd6..0aa1d88b6 100644 --- a/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreAdapterModule.php +++ b/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreAdapterModule.php @@ -40,7 +40,7 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO // Collect EventStoreChannelAdapter extension objects $channelAdapters = []; foreach ($extensionObjects as $extensionObject) { - if ($extensionObject instanceof EventStoreChannelAdapter) { + if ($extensionObject instanceof EventStreamingChannelAdapter) { $channelAdapters[] = $extensionObject; } } @@ -60,7 +60,7 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO public function canHandle($extensionObject): bool { - return $extensionObject instanceof EventStoreChannelAdapter; + return $extensionObject instanceof EventStreamingChannelAdapter; } public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, array $serviceExtensions): array @@ -68,7 +68,7 @@ public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, $extensions = [...$this->extensions]; foreach ($serviceExtensions as $extensionObject) { - if (! ($extensionObject instanceof EventStoreChannelAdapter)) { + if (! ($extensionObject instanceof EventStreamingChannelAdapter)) { continue; } diff --git a/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreStreamingChannelAdapterBuilder.php b/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreStreamingChannelAdapterBuilder.php index 97453d961..243cf3387 100644 --- a/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreStreamingChannelAdapterBuilder.php +++ b/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreStreamingChannelAdapterBuilder.php @@ -21,7 +21,7 @@ class EventStoreStreamingChannelAdapterBuilder implements ProjectionExecutorBuilder { public function __construct( - private EventStoreChannelAdapter $channelAdapter + private EventStreamingChannelAdapter $channelAdapter ) { } diff --git a/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreChannelAdapter.php b/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStreamingChannelAdapter.php similarity index 81% rename from packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreChannelAdapter.php rename to packages/Ecotone/src/Projecting/EventStoreAdapter/EventStreamingChannelAdapter.php index df8ffce85..78f40f5d8 100644 --- a/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStoreChannelAdapter.php +++ b/packages/Ecotone/src/Projecting/EventStoreAdapter/EventStreamingChannelAdapter.php @@ -17,7 +17,7 @@ * #[ServiceContext] * public function eventStoreFeeder(): EventStoreChannelAdapter * { - * return EventStoreChannelAdapter::create( + * return EventStreamingChannelAdapter::create( * streamChannelName: 'event_stream', * endpointId: 'event_store_feeder', * fromStream: Ticket::class @@ -26,15 +26,15 @@ * * licence Enterprise */ -class EventStoreChannelAdapter +readonly class EventStreamingChannelAdapter { private function __construct( - public readonly string $streamChannelName, - public readonly string $endpointId, - public readonly string $fromStream, - public readonly ?string $aggregateType = null, - public readonly int $batchSize = 100, - public readonly array $eventNames = [], + public string $streamChannelName, + public string $endpointId, + public string $fromStream, + public ?string $aggregateType = null, + public int $batchSize = 100, + public array $eventNames = [], ) { } @@ -80,6 +80,6 @@ public function withEventNames(array $eventNames): self */ public function getProjectionName(): string { - return 'event_store_channel_adapter_' . $this->endpointId; + return 'event_streaming_channel_adapter_' . $this->endpointId; } } diff --git a/packages/Ecotone/src/Projecting/InMemory/InMemoryProjectionStateStorageBuilder.php b/packages/Ecotone/src/Projecting/InMemory/InMemoryProjectionStateStorageBuilder.php new file mode 100644 index 000000000..647e4523c --- /dev/null +++ b/packages/Ecotone/src/Projecting/InMemory/InMemoryProjectionStateStorageBuilder.php @@ -0,0 +1,35 @@ +projectionNames === null || in_array($projectionName, $this->projectionNames, true)); + } +} diff --git a/packages/Ecotone/tests/Lite/InMemoryEventStoreRegistrationTest.php b/packages/Ecotone/tests/Lite/InMemoryEventStoreRegistrationTest.php index c04c62ce7..974058340 100644 --- a/packages/Ecotone/tests/Lite/InMemoryEventStoreRegistrationTest.php +++ b/packages/Ecotone/tests/Lite/InMemoryEventStoreRegistrationTest.php @@ -13,6 +13,7 @@ use Ecotone\Modelling\Event; use Ecotone\Projecting\Attribute\Polling; use Ecotone\Projecting\Attribute\ProjectionV2; +use Ecotone\Projecting\InMemory\InMemoryProjectionStateStorageBuilder; use Ecotone\Projecting\InMemory\InMemoryStreamSourceBuilder; use Ecotone\Test\LicenceTesting; use PHPUnit\Framework\TestCase; @@ -108,7 +109,7 @@ public function onEvent(object $event): void [$projection], configuration: ServiceConfiguration::createWithDefaults() ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE])) - ->withExtensionObjects([$customStreamSource]) + ->withExtensionObjects([$customStreamSource, new InMemoryProjectionStateStorageBuilder()]) ->withLicenceKey(LicenceTesting::VALID_LICENCE) ); diff --git a/packages/Ecotone/tests/Projecting/EventStoreChannelAdapterTest.php b/packages/Ecotone/tests/Projecting/EventStoreChannelAdapterTest.php index 4d347913c..aa089662d 100644 --- a/packages/Ecotone/tests/Projecting/EventStoreChannelAdapterTest.php +++ b/packages/Ecotone/tests/Projecting/EventStoreChannelAdapterTest.php @@ -17,7 +17,7 @@ use Ecotone\Modelling\Attribute\EventHandler; use Ecotone\Modelling\Attribute\QueryHandler; use Ecotone\Modelling\Event; -use Ecotone\Projecting\EventStoreAdapter\EventStoreChannelAdapter; +use Ecotone\Projecting\EventStoreAdapter\EventStreamingChannelAdapter; use Ecotone\Test\LicenceTesting; use PHPUnit\Framework\TestCase; @@ -59,7 +59,7 @@ classesToResolve: [$consumer::class], ->withLicenceKey(LicenceTesting::VALID_LICENCE) ->withExtensionObjects([ SimpleMessageChannelBuilder::createStreamingChannel('event_stream'), - EventStoreChannelAdapter::create( + EventStreamingChannelAdapter::create( streamChannelName: 'event_stream', endpointId: 'event_store_feeder', fromStream: 'test_stream' @@ -119,7 +119,7 @@ classesToResolve: [$consumer::class], ->withLicenceKey(LicenceTesting::VALID_LICENCE) ->withExtensionObjects([ SimpleMessageChannelBuilder::createStreamingChannel('event_stream'), - EventStoreChannelAdapter::create( + EventStreamingChannelAdapter::create( streamChannelName: 'event_stream', endpointId: 'event_store_feeder', fromStream: 'test_stream' @@ -209,7 +209,7 @@ classesToResolve: [$ticketCounter::class, $consumer::class], ->withLicenceKey(LicenceTesting::VALID_LICENCE) ->withExtensionObjects([ SimpleMessageChannelBuilder::createStreamingChannel('event_stream'), - EventStoreChannelAdapter::create( + EventStreamingChannelAdapter::create( streamChannelName: 'event_stream', endpointId: 'event_store_feeder', fromStream: 'test_stream' diff --git a/packages/Ecotone/tests/Projecting/EventStreamingProjectionTest.php b/packages/Ecotone/tests/Projecting/EventStreamingProjectionTest.php index 89f008b03..866ae678a 100644 --- a/packages/Ecotone/tests/Projecting/EventStreamingProjectionTest.php +++ b/packages/Ecotone/tests/Projecting/EventStreamingProjectionTest.php @@ -22,7 +22,7 @@ use Ecotone\Modelling\Event; use Ecotone\Projecting\Attribute\ProjectionV2; use Ecotone\Projecting\Attribute\Streaming; -use Ecotone\Projecting\EventStoreAdapter\EventStoreChannelAdapter; +use Ecotone\Projecting\EventStoreAdapter\EventStreamingChannelAdapter; use Ecotone\Test\LicenceTesting; use PHPUnit\Framework\TestCase; @@ -175,7 +175,7 @@ classesToResolve: [$projection::class, ProductRegistered::class, ProductPriceCha ->withNamespaces(['Test\Ecotone\Projecting']) ->withExtensionObjects([ SimpleMessageChannelBuilder::createStreamingChannel('event_stream', conversionMediaType: MediaType::createApplicationXPHP()), - EventStoreChannelAdapter::create( + EventStreamingChannelAdapter::create( streamChannelName: 'event_stream', endpointId: 'event_store_feeder', fromStream: 'product_stream' @@ -246,7 +246,7 @@ classesToResolve: [$productListProjection::class, $productPriceProjection::class ->withNamespaces(['Test\Ecotone\Projecting']) ->withExtensionObjects([ SimpleMessageChannelBuilder::createStreamingChannel('event_stream', conversionMediaType: MediaType::createApplicationXPHP()), - EventStoreChannelAdapter::create( + EventStreamingChannelAdapter::create( streamChannelName: 'event_stream', endpointId: 'event_store_feeder', fromStream: 'product_stream' @@ -323,7 +323,7 @@ classesToResolve: [$eventDrivenProjection::class, $eventStreamingProjection::cla ->withNamespaces(['Test\Ecotone\Projecting']) ->withExtensionObjects([ SimpleMessageChannelBuilder::createStreamingChannel('event_stream', conversionMediaType: MediaType::createApplicationXPHP()), - EventStoreChannelAdapter::create( + EventStreamingChannelAdapter::create( streamChannelName: 'event_stream', endpointId: 'event_store_feeder', fromStream: 'product_stream' diff --git a/packages/Ecotone/tests/Projecting/ProjectingTest.php b/packages/Ecotone/tests/Projecting/ProjectingTest.php index d4908f3d1..11a09cc60 100644 --- a/packages/Ecotone/tests/Projecting/ProjectingTest.php +++ b/packages/Ecotone/tests/Projecting/ProjectingTest.php @@ -25,6 +25,7 @@ use Ecotone\Projecting\Attribute\ProjectionDeployment; use Ecotone\Projecting\Attribute\ProjectionFlush; use Ecotone\Projecting\Attribute\ProjectionV2; +use Ecotone\Projecting\InMemory\InMemoryProjectionStateStorageBuilder; use Ecotone\Projecting\InMemory\InMemoryStreamSourceBuilder; use Ecotone\Test\LicenceTesting; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -85,6 +86,7 @@ public function handle(array $event): void ->withSkippedModulePackageNames(ModulePackageList::allPackages()) ->withLicenceKey(LicenceTesting::VALID_LICENCE) ->addExtensionObject($streamSource = new InMemoryStreamSourceBuilder(partitionField: 'id')) + ->addExtensionObject(new InMemoryProjectionStateStorageBuilder()) ); $streamSource->append( @@ -119,6 +121,7 @@ public function handle(array $event): void ->withLicenceKey(LicenceTesting::VALID_LICENCE) ->addExtensionObject($streamSource = new InMemoryStreamSourceBuilder(partitionField: 'id')) ->addExtensionObject(SimpleMessageChannelBuilder::createQueueChannel('async')) + ->addExtensionObject(new InMemoryProjectionStateStorageBuilder()) ); $streamSource->append( diff --git a/packages/PdoEventSourcing/src/Config/EventSourcingModule.php b/packages/PdoEventSourcing/src/Config/EventSourcingModule.php index 83d74ec02..20d14743c 100644 --- a/packages/PdoEventSourcing/src/Config/EventSourcingModule.php +++ b/packages/PdoEventSourcing/src/Config/EventSourcingModule.php @@ -4,6 +4,8 @@ use Ecotone\AnnotationFinder\AnnotatedDefinition; use Ecotone\AnnotationFinder\AnnotationFinder; +use Ecotone\Dbal\Configuration\DbalConfiguration; +use Ecotone\Dbal\Database\DbalTableManagerReference; use Ecotone\EventSourcing\AggregateStreamMapping; use Ecotone\EventSourcing\AggregateTypeMapping; use Ecotone\EventSourcing\Attribute\AggregateType; @@ -16,6 +18,9 @@ use Ecotone\EventSourcing\Config\InboundChannelAdapter\ProjectionChannelAdapter; use Ecotone\EventSourcing\Config\InboundChannelAdapter\ProjectionEventHandler; use Ecotone\EventSourcing\Config\InboundChannelAdapter\ProjectionExecutorBuilder; +use Ecotone\EventSourcing\Database\EventStreamTableManager; +use Ecotone\EventSourcing\Database\LegacyProjectionsTableManager; +use Ecotone\EventSourcing\Database\ProjectionStateTableManager; use Ecotone\EventSourcing\EventSourcingConfiguration; use Ecotone\EventSourcing\EventSourcingRepositoryBuilder; use Ecotone\EventSourcing\EventStore; @@ -253,13 +258,32 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO { $serviceConfiguration = ExtensionObjectResolver::resolveUnique(ServiceConfiguration::class, $extensionObjects, ServiceConfiguration::createWithDefaults()); $eventSourcingConfiguration = ExtensionObjectResolver::resolveUnique(EventSourcingConfiguration::class, $extensionObjects, EventSourcingConfiguration::createWithDefaults()); + $dbalConfiguration = ExtensionObjectResolver::resolveUnique(DbalConfiguration::class, $extensionObjects, DbalConfiguration::createWithDefaults()); $messagingConfiguration->registerServiceDefinition(EventSourcingConfiguration::class, DefinitionHelper::buildDefinitionFromInstance($eventSourcingConfiguration)); + $messagingConfiguration->registerServiceDefinition( + EventStreamTableManager::class, + new Definition(EventStreamTableManager::class, [ + $eventSourcingConfiguration->getEventStreamTableName(), + true, + $dbalConfiguration->isAutomaticTableInitializationEnabled(), + ]) + ); + $messagingConfiguration->registerServiceDefinition( + LegacyProjectionsTableManager::class, + new Definition(LegacyProjectionsTableManager::class, [ + $eventSourcingConfiguration->getProjectionsTable(), + $this->projectionSetupConfigurations !== [], + $dbalConfiguration->isAutomaticTableInitializationEnabled(), + ]) + ); $messagingConfiguration->registerServiceDefinition(LazyProophEventStore::class, new Definition(LazyProophEventStore::class, [ new Reference(EventSourcingConfiguration::class), new Reference(ProophEventMapper::class), new Reference($eventSourcingConfiguration->getConnectionReferenceName(), ContainerImplementation::NULL_ON_INVALID_REFERENCE), + Reference::to(EventStreamTableManager::class), + Reference::to(LegacyProjectionsTableManager::class), ])); $messagingConfiguration->registerServiceDefinition( @@ -304,6 +328,8 @@ public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, return [ ...$this->buildEventSourcingRepositoryBuilder($serviceExtensions), new EventSourcingModuleRoutingExtension($pollingProjectionNames), + new DbalTableManagerReference(EventStreamTableManager::class), + new DbalTableManagerReference(LegacyProjectionsTableManager::class), ]; } @@ -503,6 +529,8 @@ private function registerEventStreamEmitter(Configuration $configuration, EventS $eventSourcingConfigurationReference, new Reference(ProophEventMapper::class), new Reference($eventSourcingConfiguration->getConnectionReferenceName(), ContainerImplementation::NULL_ON_INVALID_REFERENCE), + Reference::to(EventStreamTableManager::class), + Reference::to(LegacyProjectionsTableManager::class), ])); $eventStoreHandler = EventStoreBuilder::create('appendTo', [HeaderBuilder::create('streamName', 'ecotone.eventSourcing.eventStore.streamName'), PayloadBuilder::create('streamEvents')], $eventSourcingConfiguration, $eventStoreReference) diff --git a/packages/PdoEventSourcing/src/Config/ProophProjectingModule.php b/packages/PdoEventSourcing/src/Config/ProophProjectingModule.php index 707afe292..10986a066 100644 --- a/packages/PdoEventSourcing/src/Config/ProophProjectingModule.php +++ b/packages/PdoEventSourcing/src/Config/ProophProjectingModule.php @@ -8,18 +8,24 @@ namespace Ecotone\EventSourcing\Config; use Ecotone\AnnotationFinder\AnnotationFinder; +use Ecotone\Dbal\Configuration\DbalConfiguration; use Ecotone\EventSourcing\Attribute\AggregateType; use Ecotone\EventSourcing\Attribute\FromAggregateStream; use Ecotone\EventSourcing\Attribute\FromStream; use Ecotone\EventSourcing\Attribute\Stream; +use Ecotone\Dbal\Database\DbalTableManagerReference; +use Ecotone\EventSourcing\Database\ProjectionStateTableManager; +use Ecotone\EventSourcing\EventSourcingConfiguration; use Ecotone\EventSourcing\Projecting\AggregateIdPartitionProviderBuilder; use Ecotone\EventSourcing\Projecting\PartitionState\DbalProjectionStateStorageBuilder; use Ecotone\EventSourcing\Projecting\StreamSource\EventStoreAggregateStreamSourceBuilder; use Ecotone\EventSourcing\Projecting\StreamSource\EventStoreGlobalStreamSourceBuilder; use Ecotone\Messaging\Attribute\ModuleAnnotation; use Ecotone\Messaging\Config\Annotation\AnnotationModule; +use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Configuration; use Ecotone\Messaging\Config\ConfigurationException; +use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ModuleReferenceSearchService; use Ecotone\Messaging\Config\ServiceConfiguration; @@ -30,20 +36,25 @@ use Ecotone\Modelling\Config\Routing\BusRoutingMapBuilder; use Ecotone\Projecting\Attribute\Partitioned; use Ecotone\Projecting\Attribute\ProjectionV2; +use Ecotone\Projecting\Config\EcotoneProjectionExecutorBuilder; use Ecotone\Projecting\Config\ProjectionComponentBuilder; -use Ecotone\Projecting\EventStoreAdapter\EventStoreChannelAdapter; +use Ecotone\Projecting\EventStoreAdapter\EventStreamingChannelAdapter; #[ModuleAnnotation] class ProophProjectingModule implements AnnotationModule { + /** + * @param ProjectionComponentBuilder[] $extensions + * @param string[] $projectionNames + */ public function __construct( - private array $extensions + private array $extensions, + private array $projectionNames, ) { } public static function create(AnnotationFinder $annotationRegistrationService, InterfaceToCallRegistry $interfaceToCallRegistry): static { - $handledProjections = []; $extensions = []; $namedEvents = []; @@ -60,15 +71,19 @@ public static function create(AnnotationFinder $annotationRegistrationService, I ]; foreach ($resolvedConfigs as $config) { - $handledProjections[] = $config['projectionName']; $extensions = [...$extensions, ...self::createStreamSourceExtensions($config)]; } - if ($handledProjections !== []) { - $extensions[] = new DbalProjectionStateStorageBuilder($handledProjections); + $projectionNames = []; + foreach ($annotationRegistrationService->findAnnotatedClasses(ProjectionV2::class) as $projectionClassName) { + $projectionAttribute = $annotationRegistrationService->getAttributeForClass($projectionClassName, ProjectionV2::class); + $projectionNames[] = $projectionAttribute->name; } - return new self($extensions); + return new self( + $extensions, + $projectionNames, + ); } /** @@ -190,13 +205,21 @@ private static function createStreamSourceExtensions(array $config): array public function prepare(Configuration $messagingConfiguration, array $extensionObjects, ModuleReferenceSearchService $moduleReferenceSearchService, InterfaceToCallRegistry $interfaceToCallRegistry): void { - // Polling projection registration is now handled by ProjectingAttributeModule + $dbalConfiguration = ExtensionObjectResolver::resolveUnique(DbalConfiguration::class, $extensionObjects, DbalConfiguration::createWithDefaults()); + + $messagingConfiguration->registerServiceDefinition( + ProjectionStateTableManager::class, + new Definition(ProjectionStateTableManager::class, [ + ProjectionStateTableManager::DEFAULT_TABLE_NAME, + $this->projectionNames !== [], + $dbalConfiguration->isAutomaticTableInitializationEnabled(), + ]) + ); } public function canHandle($extensionObject): bool { - // EventStoreChannelAdapter is now handled by EventStoreAdapterModule in Ecotone package - return false; + return $extensionObject instanceof DbalConfiguration; } public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, array $serviceExtensions): array @@ -204,7 +227,7 @@ public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, $extensions = [...$this->extensions]; foreach ($serviceExtensions as $extensionObject) { - if (! ($extensionObject instanceof EventStoreChannelAdapter)) { + if (! ($extensionObject instanceof EventStreamingChannelAdapter)) { continue; } @@ -215,6 +238,17 @@ public function getModuleExtensions(ServiceConfiguration $serviceConfiguration, ); } + $extensions[] = new DbalTableManagerReference(ProjectionStateTableManager::class); + + $eventSourcingConfiguration = ExtensionObjectResolver::resolveUnique(EventSourcingConfiguration::class, $serviceExtensions, EventSourcingConfiguration::createWithDefaults()); + $eventStreamingChannelAdapters = ExtensionObjectResolver::resolve(EventStreamingChannelAdapter::class, $serviceExtensions); + + if (($this->projectionNames || $eventStreamingChannelAdapters) && !$eventSourcingConfiguration->isInMemory()) { + $projectionNames = array_unique([...$this->projectionNames, ...array_map(fn(EventStreamingChannelAdapter $adapter) => $adapter->getProjectionName(), $eventStreamingChannelAdapters)]); + + $extensions[] = new DbalProjectionStateStorageBuilder($projectionNames); + } + return $extensions; } diff --git a/packages/PdoEventSourcing/src/Database/EventStreamTableManager.php b/packages/PdoEventSourcing/src/Database/EventStreamTableManager.php new file mode 100644 index 000000000..a7f23ecfc --- /dev/null +++ b/packages/PdoEventSourcing/src/Database/EventStreamTableManager.php @@ -0,0 +1,171 @@ +isActive; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getCreateTableSql(Connection $connection): string|array + { + if ($this->isPostgres($connection)) { + return $this->getPostgresCreateSql(); + } + + if ($this->isMariaDb($connection)) { + return $this->getMariaDbCreateSql(); + } + + return $this->getMysqlCreateSql(); + } + + public function getDropTableSql(Connection $connection): string + { + $tableName = $this->tableName; + + if ($this->isPostgres($connection)) { + return "DROP TABLE IF EXISTS {$tableName}"; + } + + return "DROP TABLE IF EXISTS `{$tableName}`"; + } + + public function createTable(Connection $connection): void + { + if ($this->isInitialized($connection)) { + return; + } + + $sql = $this->getCreateTableSql($connection); + if (\is_array($sql)) { + foreach ($sql as $statement) { + $connection->executeStatement($statement); + } + } else { + $connection->executeStatement($sql); + } + } + + public function dropTable(Connection $connection): void + { + $connection->executeStatement($this->getDropTableSql($connection)); + } + + public function isInitialized(Connection $connection): bool + { + return SchemaManagerCompatibility::tableExists($connection, $this->tableName); + } + + public function getDefinition(): Definition + { + return new Definition(self::class, [$this->tableName, $this->isActive, $this->shouldAutoInitialize]); + } + + public function shouldBeInitializedAutomatically(): bool + { + return $this->shouldAutoInitialize; + } + + private function isPostgres(Connection $connection): bool + { + return $connection->getDatabasePlatform() instanceof PostgreSQLPlatform; + } + + private function isMariaDb(Connection $connection): bool + { + return $connection->getDatabasePlatform() instanceof MariaDBPlatform; + } + + private function getPostgresCreateSql(): array + { + $tableName = $this->tableName; + + return [ + <<tableName; + + return <<tableName; + + return <<isActive; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getCreateTableSql(Connection $connection): string|array + { + if ($this->isPostgres($connection)) { + return $this->getPostgresCreateSql(); + } + + if ($this->isMariaDb($connection)) { + return $this->getMariaDbCreateSql(); + } + + return $this->getMysqlCreateSql(); + } + + public function getDropTableSql(Connection $connection): string + { + $tableName = $this->tableName; + + if ($this->isPostgres($connection)) { + return "DROP TABLE IF EXISTS {$tableName}"; + } + + return "DROP TABLE IF EXISTS `{$tableName}`"; + } + + public function createTable(Connection $connection): void + { + if ($this->isInitialized($connection)) { + return; + } + + $sql = $this->getCreateTableSql($connection); + if (\is_array($sql)) { + foreach ($sql as $statement) { + $connection->executeStatement($statement); + } + } else { + $connection->executeStatement($sql); + } + } + + public function dropTable(Connection $connection): void + { + $connection->executeStatement($this->getDropTableSql($connection)); + } + + public function isInitialized(Connection $connection): bool + { + return SchemaManagerCompatibility::tableExists($connection, $this->tableName); + } + + public function getDefinition(): Definition + { + return new Definition(self::class, [$this->tableName, $this->isActive, $this->shouldAutoInitialize]); + } + + public function shouldBeInitializedAutomatically(): bool + { + return $this->shouldAutoInitialize; + } + + private function isPostgres(Connection $connection): bool + { + return $connection->getDatabasePlatform() instanceof PostgreSQLPlatform; + } + + private function isMariaDb(Connection $connection): bool + { + return $connection->getDatabasePlatform() instanceof MariaDBPlatform; + } + + private function getPostgresCreateSql(): string + { + $tableName = $this->tableName; + + return <<tableName; + + return <<tableName; + + return <<isActive; + } + + public function getTableName(): string + { + return $this->tableName; + } + + public function getCreateTableSql(Connection $connection): string|array + { + if ($connection->getDatabasePlatform() instanceof MySQLPlatform) { + return $this->getMysqlCreateSql(); + } + + return $this->getPostgresCreateSql(); + } + + public function getDropTableSql(Connection $connection): string + { + return "DROP TABLE IF EXISTS {$this->tableName}"; + } + + public function createTable(Connection $connection): void + { + if ($this->isInitialized($connection)) { + return; + } + + $sql = $this->getCreateTableSql($connection); + if (\is_array($sql)) { + foreach ($sql as $statement) { + $connection->executeStatement($statement); + } + } else { + $connection->executeStatement($sql); + } + } + + public function dropTable(Connection $connection): void + { + $connection->executeStatement($this->getDropTableSql($connection)); + } + + public function isInitialized(Connection $connection): bool + { + return SchemaManagerCompatibility::tableExists($connection, $this->tableName); + } + + public function getDefinition(): Definition + { + return new Definition(self::class, [$this->tableName, $this->isActive, $this->shouldAutoInitialize]); + } + + public function shouldBeInitializedAutomatically(): bool + { + return $this->shouldAutoInitialize; + } + + private function getPostgresCreateSql(): string + { + return <<tableName} ( + projection_name VARCHAR(255) NOT NULL, + partition_key VARCHAR(255) NOT NULL DEFAULT '', + last_position TEXT NOT NULL, + metadata JSON NOT NULL, + user_state JSON, + PRIMARY KEY (projection_name, partition_key) + ) + SQL; + } + + private function getMysqlCreateSql(): string + { + return <<tableName}` ( + `projection_name` VARCHAR(255) NOT NULL, + `partition_key` VARCHAR(255) NOT NULL DEFAULT '', + `last_position` TEXT NOT NULL, + `metadata` JSON NOT NULL, + `user_state` JSON, + PRIMARY KEY (`projection_name`, `partition_key`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + SQL; + } +} + diff --git a/packages/PdoEventSourcing/src/EventSourcingConfiguration.php b/packages/PdoEventSourcing/src/EventSourcingConfiguration.php index cead3b336..1956a8dc8 100644 --- a/packages/PdoEventSourcing/src/EventSourcingConfiguration.php +++ b/packages/PdoEventSourcing/src/EventSourcingConfiguration.php @@ -15,6 +15,8 @@ /** * licence Apache-2.0 + * + * @TODO Ecotone 2.0 Leave only meaningful configuration for ProjectionV2 */ class EventSourcingConfiguration extends BaseEventSourcingConfiguration { diff --git a/packages/PdoEventSourcing/src/Projecting/PartitionState/DbalProjectionStateStorage.php b/packages/PdoEventSourcing/src/Projecting/PartitionState/DbalProjectionStateStorage.php index b32cdb456..ff8c65c2e 100644 --- a/packages/PdoEventSourcing/src/Projecting/PartitionState/DbalProjectionStateStorage.php +++ b/packages/PdoEventSourcing/src/Projecting/PartitionState/DbalProjectionStateStorage.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\MySQLPlatform; use Ecotone\Dbal\MultiTenant\MultiTenantConnectionFactory; +use Ecotone\EventSourcing\Database\ProjectionStateTableManager; use Ecotone\Projecting\NoOpTransaction; use Ecotone\Projecting\ProjectionInitializationStatus; use Ecotone\Projecting\ProjectionPartitionState; @@ -30,10 +31,15 @@ class DbalProjectionStateStorage implements ProjectionStateStorage public function __construct( private DbalConnectionFactory|ManagerRegistryConnectionFactory|MultiTenantConnectionFactory $connectionFactory, - private string $stateTable = 'ecotone_projection_state', + private ProjectionStateTableManager $tableManager, ) { } + public function getTableName(): string + { + return $this->tableManager->getTableName(); + } + private function getConnection(): Connection { if ($this->connectionFactory instanceof MultiTenantConnectionFactory) { @@ -66,8 +72,9 @@ public function loadPartition(string $projectionName, ?string $partitionKey = nu { $this->createSchema(); + $tableName = $this->getTableName(); $query = <<stateTable} + SELECT last_position, user_state, metadata FROM {$tableName} WHERE projection_name = :projectionName AND partition_key = :partitionKey SQL; @@ -93,16 +100,17 @@ public function initPartition(string $projectionName, ?string $partitionKey = nu $this->createSchema(); $connection = $this->getConnection(); + $tableName = $this->getTableName(); // Try to insert the partition state, ignoring if it already exists $insertQuery = match (true) { $connection->getDatabasePlatform() instanceof MySQLPlatform => <<stateTable} (projection_name, partition_key, last_position, user_state, metadata) + INSERT INTO {$tableName} (projection_name, partition_key, last_position, user_state, metadata) VALUES (:projectionName, :partitionKey, :lastPosition, :userState, :metadata) ON DUPLICATE KEY UPDATE projection_name = projection_name -- no-op to ignore SQL, default => <<stateTable} (projection_name, partition_key, last_position, user_state, metadata) + INSERT INTO {$tableName} (projection_name, partition_key, last_position, user_state, metadata) VALUES (:projectionName, :partitionKey, :lastPosition, :userState, :metadata) ON CONFLICT (projection_name, partition_key) DO NOTHING SQL, @@ -133,15 +141,16 @@ public function savePartition(ProjectionPartitionState $projectionState): void $this->createSchema(); $connection = $this->getConnection(); + $tableName = $this->getTableName(); $saveStateQuery = match (true) { $connection->getDatabasePlatform() instanceof MySQLPlatform => <<stateTable} (projection_name, partition_key, last_position, user_state, metadata) + INSERT INTO {$tableName} (projection_name, partition_key, last_position, user_state, metadata) VALUES (:projectionName, :partitionKey, :lastPosition, :userState, :metadata) ON DUPLICATE KEY UPDATE last_position = :lastPosition, user_state = :userState, metadata = :metadata SQL, default => <<stateTable} (projection_name, partition_key, last_position, user_state, metadata) + INSERT INTO {$tableName} (projection_name, partition_key, last_position, user_state, metadata) VALUES (:projectionName, :partitionKey, :lastPosition, :userState, :metadata) ON CONFLICT (projection_name, partition_key) DO UPDATE SET last_position = :lastPosition, user_state = :userState, metadata = :metadata SQL, @@ -168,8 +177,9 @@ public function delete(string $projectionName): void { $this->createSchema(); + $tableName = $this->getTableName(); $this->getConnection()->executeStatement(<<stateTable} WHERE projection_name = :projectionName + DELETE FROM {$tableName} WHERE projection_name = :projectionName SQL, [ 'projectionName' => $projectionName, ]); @@ -177,22 +187,17 @@ public function delete(string $projectionName): void public function createSchema(): void { - if ($this->isInitialized()) { + if (! $this->tableManager->shouldBeInitializedAutomatically() || $this->isInitialized()) { return; } - $this->getConnection()->executeStatement( - <<stateTable} ( - projection_name VARCHAR(255) NOT NULL, - partition_key VARCHAR(255), - last_position TEXT NOT NULL, - metadata JSON NOT NULL, - user_state JSON, - PRIMARY KEY (projection_name, partition_key) - ) - SQL - ); + $connection = $this->getConnection(); + + // Delegate to table manager - single source of truth for schema + if (! $this->tableManager->isInitialized($connection)) { + $this->tableManager->createTable($connection); + } + $this->markInitialized(); } diff --git a/packages/PdoEventSourcing/src/Projecting/PartitionState/DbalProjectionStateStorageBuilder.php b/packages/PdoEventSourcing/src/Projecting/PartitionState/DbalProjectionStateStorageBuilder.php index ebe2023ee..fe7708f5a 100644 --- a/packages/PdoEventSourcing/src/Projecting/PartitionState/DbalProjectionStateStorageBuilder.php +++ b/packages/PdoEventSourcing/src/Projecting/PartitionState/DbalProjectionStateStorageBuilder.php @@ -7,6 +7,7 @@ namespace Ecotone\EventSourcing\Projecting\PartitionState; +use Ecotone\EventSourcing\Database\ProjectionStateTableManager; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; use Ecotone\Messaging\Config\Container\Reference; @@ -16,8 +17,10 @@ class DbalProjectionStateStorageBuilder implements ProjectionComponentBuilder { - public function __construct(private array $handledProjectionNames) - { + /** @param string[] $handledProjectionNames */ + public function __construct( + private array $handledProjectionNames, + ) { } public function compile(MessagingContainerBuilder $builder): Definition @@ -26,6 +29,7 @@ public function compile(MessagingContainerBuilder $builder): Definition DbalProjectionStateStorage::class, [ new Reference(DbalConnectionFactory::class), + new Reference(ProjectionStateTableManager::class), ], ); } diff --git a/packages/PdoEventSourcing/src/Prooph/LazyProophEventStore.php b/packages/PdoEventSourcing/src/Prooph/LazyProophEventStore.php index ec3c1ee17..f1ff36ce0 100644 --- a/packages/PdoEventSourcing/src/Prooph/LazyProophEventStore.php +++ b/packages/PdoEventSourcing/src/Prooph/LazyProophEventStore.php @@ -4,9 +4,10 @@ use ArrayIterator; use Doctrine\DBAL\Driver\PDOConnection; -use Ecotone\Dbal\Compatibility\SchemaManagerCompatibility; use Ecotone\Dbal\DbalReconnectableConnectionFactory; use Ecotone\Dbal\MultiTenant\MultiTenantConnectionFactory; +use Ecotone\EventSourcing\Database\EventStreamTableManager; +use Ecotone\EventSourcing\Database\LegacyProjectionsTableManager; use Ecotone\EventSourcing\EventSourcingConfiguration; use Ecotone\EventSourcing\InMemory\StreamIteratorWithPosition; use Ecotone\EventSourcing\Prooph\PersistenceStrategy\InterlopMariaDbSimpleStreamStrategy; @@ -83,9 +84,11 @@ class LazyProophEventStore implements EventStore private array $ensuredExistingStreams = []; public function __construct( - private EventSourcingConfiguration $eventSourcingConfiguration, - private ProophEventMapper $messageFactory, - private ConnectionFactory|null $connectionFactory, + private EventSourcingConfiguration $eventSourcingConfiguration, + private ProophEventMapper $messageFactory, + private ConnectionFactory|null $connectionFactory, + private EventStreamTableManager $eventStreamTableManager, + private LegacyProjectionsTableManager $projectionsTableManager, ) { $this->messageConverter = new FromProophMessageToArrayConverter(); $this->canBeInitialized = $eventSourcingConfiguration->isInitializedOnStart(); @@ -177,17 +180,14 @@ public function delete(StreamName $streamName): void public function prepareEventStore(): void { - /** - * @TODO expose CLI for setting up all required tables (including deduplication tables) - */ - $connectionName = $this->getContextName(); if (! $this->canBeInitialized || isset($this->initializated[$connectionName]) || $this->eventSourcingConfiguration->isInMemory()) { return; } - $projectionTableExists = SchemaManagerCompatibility::tableExists($this->getConnection(), $this->eventSourcingConfiguration->getProjectionsTable()); - $eventStreamTableExists = SchemaManagerCompatibility::tableExists($this->getConnection(), $this->eventSourcingConfiguration->getEventStreamTableName()); + $connection = $this->getConnection(); + $projectionTableExists = !$this->projectionsTableManager->shouldBeInitializedAutomatically() || $this->projectionsTableManager->isInitialized($connection); + $eventStreamTableExists = !$this->eventStreamTableManager->shouldBeInitializedAutomatically() || $this->eventStreamTableManager->isInitialized($connection); if ($eventStreamTableExists && $projectionTableExists) { $this->initializated[$connectionName] = true; @@ -195,18 +195,10 @@ public function prepareEventStore(): void } if (! $eventStreamTableExists) { - match ($this->getEventStoreType()) { - self::EVENT_STORE_TYPE_POSTGRES => $this->createPostgresEventStreamTable(), - self::EVENT_STORE_TYPE_MARIADB => $this->createMariadbEventStreamTable(), - self::EVENT_STORE_TYPE_MYSQL => $this->createMysqlEventStreamTable() - }; + $this->eventStreamTableManager->createTable($connection); } if (! $projectionTableExists) { - match ($this->getEventStoreType()) { - self::EVENT_STORE_TYPE_POSTGRES => $this->createPostgresProjectionTable(), - self::EVENT_STORE_TYPE_MARIADB => $this->createMariadbProjectionTable(), - self::EVENT_STORE_TYPE_MYSQL => $this->createMysqlProjectionTable() - }; + $this->projectionsTableManager->createTable($connection); } } @@ -352,111 +344,6 @@ public function getWrappedConnection() } } - private function createMysqlEventStreamTable(): void - { - $this->getConnection()->executeStatement(<<getConnection()->executeStatement(<<getConnection()->executeStatement(<<getConnection()->executeStatement( - <<getConnection()->executeStatement( - <<getConnection()->executeStatement( - <<initializedEventStore[$contextName]['connection_reference'] !== spl_object_id($this->getWrappedConnection()); diff --git a/packages/PdoEventSourcing/tests/Integration/AsynchronousEventDrivenProjectionTest.php b/packages/PdoEventSourcing/tests/Integration/AsynchronousEventDrivenProjectionTest.php index 7425aa6b6..30bed99dc 100644 --- a/packages/PdoEventSourcing/tests/Integration/AsynchronousEventDrivenProjectionTest.php +++ b/packages/PdoEventSourcing/tests/Integration/AsynchronousEventDrivenProjectionTest.php @@ -214,7 +214,7 @@ classesToResolve: $classesToResolve, containerOrAvailableServices: [new InProgressTicketList(self::getConnection()), new TicketEventConverter(), DbalConnectionFactory::class => self::getConnectionFactory()], configuration: ServiceConfiguration::createWithDefaults() ->withEnvironment('prod') - ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::EVENT_SOURCING_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::EVENT_SOURCING_PACKAGE, ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) ->withNamespaces($namespaces) ->withExtensionObjects(array_merge([ EventSourcingConfiguration::createWithDefaults(), diff --git a/packages/PdoEventSourcing/tests/Integration/DeletedEventClassInStreamTest.php b/packages/PdoEventSourcing/tests/Integration/DeletedEventClassInStreamTest.php index 79286c0c4..51336a3ad 100644 --- a/packages/PdoEventSourcing/tests/Integration/DeletedEventClassInStreamTest.php +++ b/packages/PdoEventSourcing/tests/Integration/DeletedEventClassInStreamTest.php @@ -44,7 +44,7 @@ public function test_event_sourcing_with_deleted_event_class_in_stream(): void ], ServiceConfiguration::createWithDefaults() ->withEnvironment('prod') - ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::EVENT_SOURCING_PACKAGE])), + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::EVENT_SOURCING_PACKAGE, ModulePackageList::DBAL_PACKAGE])), runForProductionEventStore: true, licenceKey: LicenceTesting::VALID_LICENCE )->sendCommandWithRoutingKey('create', ['id' => 'aggregate-1']); diff --git a/packages/PdoEventSourcing/tests/Integration/InMemoryEventStoreRegistrationTest.php b/packages/PdoEventSourcing/tests/Integration/InMemoryEventStoreRegistrationTest.php index 0a7a76255..915ba7e1e 100644 --- a/packages/PdoEventSourcing/tests/Integration/InMemoryEventStoreRegistrationTest.php +++ b/packages/PdoEventSourcing/tests/Integration/InMemoryEventStoreRegistrationTest.php @@ -25,11 +25,11 @@ public function test_registering_in_memory_event_store_when_event_sourcing_confi { $eventSourcingConfiguration = \Ecotone\EventSourcing\EventSourcingConfiguration::createInMemory(); - $ecotoneTestSupport = EcotoneLite::bootstrapForTesting( + $ecotoneTestSupport = EcotoneLite::bootstrapFlowTesting( [TestEventConverter::class], [new TestEventConverter()], ServiceConfiguration::createWithDefaults() - ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::EVENT_SOURCING_PACKAGE])) + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::EVENT_SOURCING_PACKAGE, ModulePackageList::TEST_PACKAGE])) ->withEnvironment('test') ->withExtensionObjects([ $eventSourcingConfiguration, @@ -37,7 +37,7 @@ public function test_registering_in_memory_event_store_when_event_sourcing_confi ); /** @var \Ecotone\EventSourcing\EventStore $eventStore */ - $eventStore = $ecotoneTestSupport->getGatewayByName(\Ecotone\EventSourcing\EventStore::class); + $eventStore = $ecotoneTestSupport->getGateway(\Ecotone\EventSourcing\EventStore::class); $streamName = Uuid::uuid4()->toString(); $eventStore->appendTo( diff --git a/packages/PdoEventSourcing/tests/Projecting/EventStoreChannelAdapterTest.php b/packages/PdoEventSourcing/tests/Projecting/EventStoreChannelAdapterTest.php index 46eaab990..510e93dfc 100644 --- a/packages/PdoEventSourcing/tests/Projecting/EventStoreChannelAdapterTest.php +++ b/packages/PdoEventSourcing/tests/Projecting/EventStoreChannelAdapterTest.php @@ -19,7 +19,7 @@ use Ecotone\Messaging\Endpoint\PollingMetadata; use Ecotone\Modelling\Attribute\EventHandler; use Ecotone\Modelling\Attribute\QueryHandler; -use Ecotone\Projecting\EventStoreAdapter\EventStoreChannelAdapter; +use Ecotone\Projecting\EventStoreAdapter\EventStreamingChannelAdapter; use Ecotone\Test\LicenceTesting; use Test\Ecotone\EventSourcing\Fixture\Ticket\Command\CloseTicket; use Test\Ecotone\EventSourcing\Fixture\Ticket\Command\RegisterTicket; @@ -71,7 +71,7 @@ classesToResolve: [Ticket::class, TicketEventConverter::class, $consumer::class] ])) ->withExtensionObjects([ SimpleMessageChannelBuilder::createStreamingChannel('event_stream'), - EventStoreChannelAdapter::create( + EventStreamingChannelAdapter::create( streamChannelName: 'event_stream', endpointId: 'event_store_feeder', fromStream: Ticket::class @@ -135,7 +135,7 @@ classesToResolve: [Ticket::class, TicketEventConverter::class, $consumer::class] configuration: ServiceConfiguration::createWithDefaults() ->withExtensionObjects([ SimpleMessageChannelBuilder::createStreamingChannel('event_stream'), - EventStoreChannelAdapter::create( + EventStreamingChannelAdapter::create( streamChannelName: 'event_stream', endpointId: 'event_store_feeder', fromStream: Ticket::class @@ -226,7 +226,7 @@ classesToResolve: [Ticket::class, TicketEventConverter::class, $ticketCounter::c ->withExtensionObjects([ EventSourcingConfiguration::createWithDefaults(), SimpleMessageChannelBuilder::createStreamingChannel('event_stream'), - EventStoreChannelAdapter::create( + EventStreamingChannelAdapter::create( streamChannelName: 'event_stream', endpointId: 'event_store_feeder', fromStream: Ticket::class diff --git a/packages/PdoEventSourcing/tests/Projecting/ProjectionStateTableInitializationTest.php b/packages/PdoEventSourcing/tests/Projecting/ProjectionStateTableInitializationTest.php new file mode 100644 index 000000000..16d84acd5 --- /dev/null +++ b/packages/PdoEventSourcing/tests/Projecting/ProjectionStateTableInitializationTest.php @@ -0,0 +1,221 @@ +dropProjectionStateTable(); + } + + public function tearDown(): void + { + parent::tearDown(); + $this->dropProjectionStateTable(); + } + + public function test_projection_fails_when_auto_initialization_disabled_and_table_not_created(): void + { + $projection = $this->createPollingProjection(); + + $ecotone = $this->bootstrapEcotone( + [$projection::class], + [$projection], + DbalConfiguration::createWithDefaults()->withAutomaticTableInitialization(false) + ); + + // Verify projection state table does not exist + self::assertFalse($this->projectionStateTableExists()); + + // Triggering projection should fail because projection_state table doesn't exist + $this->expectException(TableNotFoundException::class); + + // Initialize projection and send events + $ecotone->deleteProjection($projection::NAME) + ->initializeProjection($projection::NAME); + } + + public function test_projection_works_after_console_command_creates_table(): void + { + $projection = $this->createPollingProjection(); + + $ecotone = $this->bootstrapEcotone( + [$projection::class], + [$projection], + DbalConfiguration::createWithDefaults()->withAutomaticTableInitialization(false) + ); + + // Verify projection state table does not exist + self::assertFalse($this->projectionStateTableExists()); + + // Run console command to create tables + $result = $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:setup', ['initialize' => true]); + + // Debug: Print features that were registered + $featureNames = array_column($result->getRows(), 0); + + // Verify projection_state table was created + self::assertTrue($this->projectionStateTableExists(), 'Projection state table should exist after initialization. Available features: ' . implode(', ', $featureNames)); + + // Verify the result contains projection_state feature + $featureNames = array_column($result->getRows(), 0); + self::assertContains(ProjectionStateTableManager::FEATURE_NAME, $featureNames); + + // Initialize projection and run projection + $ecotone->deleteProjection($projection::NAME) + ->initializeProjection($projection::NAME); + + $ecotone->sendCommand(new RegisterTicket('123', 'Johnny', 'alert')); + $ecotone->triggerProjection($projection::NAME); + + // Verify projection worked + self::assertEquals([ + ['ticket_id' => '123', 'ticket_type' => 'alert'], + ], $ecotone->sendQueryWithRouting('getInProgressTickets')); + } + + public function test_projection_works_with_auto_initialization_enabled(): void + { + $projection = $this->createPollingProjection(); + + $ecotone = $this->bootstrapEcotone( + [$projection::class], + [$projection], + DbalConfiguration::createWithDefaults()->withAutomaticTableInitialization(true) + ); + + $this->executeConsoleCommand($ecotone, 'ecotone:migration:database:drop', ['force' => true]); + // Verify projection state table does not exist + self::assertFalse($this->projectionStateTableExists()); + + // Initialize projection and run projection + $ecotone->deleteProjection($projection::NAME) + ->initializeProjection($projection::NAME); + + $ecotone->sendCommand(new RegisterTicket('123', 'Johnny', 'alert')); + $ecotone->triggerProjection($projection::NAME); + + // Verify projection worked + self::assertEquals([ + ['ticket_id' => '123', 'ticket_type' => 'alert'], + ], $ecotone->sendQueryWithRouting('getInProgressTickets')); + } + + private function createPollingProjection(): object + { + $connection = $this->getConnection(); + + return new #[ProjectionV2('test_polling_projection'), Polling('test_polling_projection_runner'), FromStream(Ticket::class)] class ($connection) { + public const NAME = 'test_polling_projection'; + public const ENDPOINT_ID = 'test_polling_projection_runner'; + + public function __construct(private Connection $connection) + { + } + + #[QueryHandler('getInProgressTickets')] + public function getTickets(): array + { + return $this->connection->executeQuery('SELECT * FROM in_progress_tickets ORDER BY ticket_id ASC')->fetchAllAssociative(); + } + + #[EventHandler] + public function addTicket(TicketWasRegistered $event): void + { + $this->connection->executeStatement('INSERT INTO in_progress_tickets VALUES (?,?)', [$event->getTicketId(), $event->getTicketType()]); + } + + #[ProjectionInitialization] + public function initialization(): void + { + $this->connection->executeStatement('CREATE TABLE IF NOT EXISTS in_progress_tickets (ticket_id VARCHAR(36) PRIMARY KEY, ticket_type VARCHAR(25))'); + } + + #[ProjectionDelete] + public function delete(): void + { + $this->connection->executeStatement('DROP TABLE IF EXISTS in_progress_tickets'); + } + + #[ProjectionReset] + public function reset(): void + { + $this->connection->executeStatement('DELETE FROM in_progress_tickets'); + } + }; + } + + private function bootstrapEcotone(array $classesToResolve, array $services, DbalConfiguration $dbalConfiguration): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTestingWithEventStore( + classesToResolve: array_merge($classesToResolve, [Ticket::class, TicketEventConverter::class]), + containerOrAvailableServices: array_merge($services, [new TicketEventConverter(), self::getConnectionFactory()]), + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ + ModulePackageList::DBAL_PACKAGE, + ModulePackageList::EVENT_SOURCING_PACKAGE, + ModulePackageList::ASYNCHRONOUS_PACKAGE, + ])) + ->withExtensionObjects([$dbalConfiguration]), + runForProductionEventStore: true, + licenceKey: LicenceTesting::VALID_LICENCE, + ); + } + + private function executeConsoleCommand(FlowTestSupport $ecotone, string $commandName, array $parameters): ConsoleCommandResultSet + { + /** @var ConsoleCommandRunner $runner */ + $runner = $ecotone->getGateway(ConsoleCommandRunner::class); + return $runner->execute($commandName, $parameters); + } + + private function projectionStateTableExists(): bool + { + return self::tableExists($this->getConnection(), ProjectionStateTableManager::DEFAULT_TABLE_NAME); + } + + private function dropProjectionStateTable(): void + { + $connection = $this->getConnection(); + if (self::tableExists($connection, ProjectionStateTableManager::DEFAULT_TABLE_NAME)) { + $connection->executeStatement('DROP TABLE ' . ProjectionStateTableManager::DEFAULT_TABLE_NAME); + } + if (self::tableExists($connection, 'in_progress_tickets')) { + $connection->executeStatement('DROP TABLE in_progress_tickets'); + } + } +} + diff --git a/packages/PdoEventSourcing/tests/Projecting/ProophIntegrationTest.php b/packages/PdoEventSourcing/tests/Projecting/ProophIntegrationTest.php index 11a210ce6..0117db4bf 100644 --- a/packages/PdoEventSourcing/tests/Projecting/ProophIntegrationTest.php +++ b/packages/PdoEventSourcing/tests/Projecting/ProophIntegrationTest.php @@ -112,62 +112,6 @@ public function test_asynchronous_projection(): void self::assertSame('assigned', $ecotone->sendQueryWithRouting('getTicketStatus', $ticketId)); } - public function test_it_can_use_user_projection_state(): void - { - $ecotone = EcotoneLite::bootstrapFlowTestingWithEventStore( - [TicketProjection::class, Ticket::class, TicketAssigned::class, TicketEventConverter::class], - [$projection = new TicketProjection(), $this->getConnectionFactory(), new TicketEventConverter()], - ServiceConfiguration::createWithDefaults() - ->addExtensionObject(new EventStoreAggregateStreamSourceBuilder(TicketProjection::NAME, Ticket::class, Ticket::STREAM_NAME)) - ->addExtensionObject(new DbalProjectionStateStorageBuilder([TicketProjection::NAME])), - runForProductionEventStore: true, - licenceKey: LicenceTesting::VALID_LICENCE, - ); - - $ecotone->deleteEventStream(Ticket::STREAM_NAME); - $projectionRegistry = $ecotone->getGateway(ProjectionRegistry::class); - $projectionRegistry->get(TicketProjection::NAME)->delete(); - - self::assertEquals([], $projection->getProjectedEvents()); - - $ecotone->sendCommand(new CreateTicketCommand('ticket-10')); - $ecotone->sendCommandWithRoutingKey(Ticket::ASSIGN_COMMAND, metadata: ['aggregate.id' => 'ticket-10']); - $ecotone->sendCommandWithRoutingKey(Ticket::ASSIGN_COMMAND, metadata: ['aggregate.id' => 'ticket-10']); - - self::assertEquals( - [ - new TicketCreated('ticket-10'), - new TicketAssigned('ticket-10'), - new TicketAssigned('ticket-10'), - ], - $projection->getProjectedEvents() - ); - - $ecotone->sendCommandWithRoutingKey(Ticket::ASSIGN_COMMAND, metadata: ['aggregate.id' => 'ticket-10']); - - self::assertEquals( - [ - new TicketCreated('ticket-10'), - new TicketAssigned('ticket-10'), - new TicketAssigned('ticket-10'), - ], - $projection->getProjectedEvents(), - 'A maximum of ' . TicketProjection::MAX_ASSIGNMENT_COUNT . ' successive assignment on the same ticket should be recorded' - ); - - $ecotone->sendCommandWithRoutingKey(Ticket::UNASSIGN_COMMAND, metadata: ['aggregate.id' => 'ticket-10']); - - self::assertEquals( - [ - new TicketCreated('ticket-10'), - new TicketAssigned('ticket-10'), - new TicketAssigned('ticket-10'), - new TicketUnassigned('ticket-10'), - ], - $projection->getProjectedEvents(), - ); - } - public function test_auto_initialization_mode_processes_events(): void { $connectionFactory = self::getConnectionFactory(); diff --git a/packages/Symfony/config/reference.php b/packages/Symfony/config/reference.php index 342a587f7..7d2a0f98b 100644 --- a/packages/Symfony/config/reference.php +++ b/packages/Symfony/config/reference.php @@ -300,7 +300,7 @@ * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool, // Default: true + * enabled?: bool, // Default: false * fallbacks?: list, * logging?: bool, // Default: false * formatter?: scalar|null, // Default: "translator.formatter.default" diff --git a/packages/Symfony/tests/phpunit/DbalConnectionRequirement/config/reference.php b/packages/Symfony/tests/phpunit/DbalConnectionRequirement/config/reference.php index 0a6da6768..50240b7bd 100644 --- a/packages/Symfony/tests/phpunit/DbalConnectionRequirement/config/reference.php +++ b/packages/Symfony/tests/phpunit/DbalConnectionRequirement/config/reference.php @@ -300,7 +300,7 @@ * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool, // Default: true + * enabled?: bool, // Default: false * fallbacks?: list, * logging?: bool, // Default: false * formatter?: scalar|null, // Default: "translator.formatter.default" diff --git a/packages/Symfony/tests/phpunit/DbalConnectionRequirementWithConnection/config/reference.php b/packages/Symfony/tests/phpunit/DbalConnectionRequirementWithConnection/config/reference.php index 0a6da6768..50240b7bd 100644 --- a/packages/Symfony/tests/phpunit/DbalConnectionRequirementWithConnection/config/reference.php +++ b/packages/Symfony/tests/phpunit/DbalConnectionRequirementWithConnection/config/reference.php @@ -300,7 +300,7 @@ * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool, // Default: true + * enabled?: bool, // Default: false * fallbacks?: list, * logging?: bool, // Default: false * formatter?: scalar|null, // Default: "translator.formatter.default" diff --git a/packages/Symfony/tests/phpunit/Licence/config/reference.php b/packages/Symfony/tests/phpunit/Licence/config/reference.php index fe5ba5be8..96c6e6bec 100644 --- a/packages/Symfony/tests/phpunit/Licence/config/reference.php +++ b/packages/Symfony/tests/phpunit/Licence/config/reference.php @@ -300,7 +300,7 @@ * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool, // Default: true + * enabled?: bool, // Default: false * fallbacks?: list, * logging?: bool, // Default: false * formatter?: scalar|null, // Default: "translator.formatter.default" diff --git a/packages/Symfony/tests/phpunit/MultiTenant/config/reference.php b/packages/Symfony/tests/phpunit/MultiTenant/config/reference.php index 45cd90dc7..d21354b78 100644 --- a/packages/Symfony/tests/phpunit/MultiTenant/config/reference.php +++ b/packages/Symfony/tests/phpunit/MultiTenant/config/reference.php @@ -300,7 +300,7 @@ * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool, // Default: true + * enabled?: bool, // Default: false * fallbacks?: list, * logging?: bool, // Default: false * formatter?: scalar|null, // Default: "translator.formatter.default" diff --git a/packages/Symfony/tests/phpunit/SingleTenant/config/reference.php b/packages/Symfony/tests/phpunit/SingleTenant/config/reference.php index 45cd90dc7..d21354b78 100644 --- a/packages/Symfony/tests/phpunit/SingleTenant/config/reference.php +++ b/packages/Symfony/tests/phpunit/SingleTenant/config/reference.php @@ -300,7 +300,7 @@ * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool, // Default: true + * enabled?: bool, // Default: false * fallbacks?: list, * logging?: bool, // Default: false * formatter?: scalar|null, // Default: "translator.formatter.default" diff --git a/packages/Symfony/tests/phpunit/config/reference.php b/packages/Symfony/tests/phpunit/config/reference.php index 422d03373..397bbe289 100644 --- a/packages/Symfony/tests/phpunit/config/reference.php +++ b/packages/Symfony/tests/phpunit/config/reference.php @@ -300,7 +300,7 @@ * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool, // Default: true + * enabled?: bool, // Default: false * fallbacks?: list, * logging?: bool, // Default: false * formatter?: scalar|null, // Default: "translator.formatter.default"