diff --git a/documentation/components/bridges/symfony-postgresql-bundle.md b/documentation/components/bridges/symfony-postgresql-bundle.md index d4a887864..f6eabd64b 100644 --- a/documentation/components/bridges/symfony-postgresql-bundle.md +++ b/documentation/components/bridges/symfony-postgresql-bundle.md @@ -49,6 +49,56 @@ flow_postgresql: dsn: '%env(DATABASE_URL)%' ``` +### Connection Overrides + +Each connection may override individual parts of the parsed DSN. Every key maps 1:1 onto an immutable +`ConnectionParameters` method in the `flow-php/postgresql` library; the bundle adds no logic of its own. +Overrides are applied at service-factory time, so values may be `%env(...)%` placeholders — they are +resolved at runtime, not when the configuration is parsed. + +```yaml +flow_postgresql: + connections: + default: + dsn: '%env(DATABASE_URL)%' + + dbname: null # Replaces the database name parsed from the DSN + host: null # Replaces the host parsed from the DSN + port: null # Replaces the port parsed from the DSN + user: null # Replaces the user parsed from the DSN + password: null # Replaces the password parsed from the DSN + dbname_suffix: '' # Appends the given suffix to the configured database name +``` + +| Key | Type | Default | Effect | +|-----------------|--------|---------|-----------------------------------------------------------| +| `dbname` | string | `null` | Replaces the database name parsed from the DSN. | +| `host` | string | `null` | Replaces the host parsed from the DSN. | +| `port` | int | `null` | Replaces the port parsed from the DSN. | +| `user` | string | `null` | Replaces the user parsed from the DSN. | +| `password` | string | `null` | Replaces the password parsed from the DSN. | +| `dbname_suffix` | string | `''` | Appends the given suffix to the configured database name. | + +`dbname`/`host`/`port`/`user`/`password` are applied first, then `dbname_suffix` **last** — so +`dbname: 'foo'` + `dbname_suffix: '_test'` yields `foo_test`. A connection with only `dsn` behaves +exactly as before. + +#### `dbname_suffix` for parallel tests + +```yaml +# config/packages/flow_postgresql.yaml +flow_postgresql: + connections: + default: + dsn: '%env(DATABASE_ANALYTICAL_URL)%' + +when@test: + flow_postgresql: + connections: + default: + dbname_suffix: '_test%env(default::TEST_TOKEN)%' +``` + ### Telemetry Enable telemetry per connection to get distributed tracing, query logging, and metrics. diff --git a/documentation/components/libs/postgresql/client-connection.md b/documentation/components/libs/postgresql/client-connection.md index a543c5504..95f2ce717 100644 --- a/documentation/components/libs/postgresql/client-connection.md +++ b/documentation/components/libs/postgresql/client-connection.md @@ -98,6 +98,44 @@ $client = pgsql_client( ); ``` +## Overriding Parsed Parameters + +`ConnectionParameters` is immutable. Each `with*()` method returns a new instance, so a parsed DSN +can be adjusted without re-parsing. + +```php +parse('postgresql://user:pass@localhost:5432/mydb') + ->withHost('db.internal') + ->withPort(5544) + ->withUser('svc') + ->withPassword('secret') + ->withDatabase('analytics'); +``` + +### Database Suffix + +`withDatabaseSuffix()` appends a suffix to the configured database name. The base DSN stays constant +and only the suffix changes per environment, which makes per-worker parallel-test databases work +(`mydb` → `mydb_test7`) without a second DSN. The suffix is appended verbatim — nothing is inserted +automatically, so include a separator yourself if you want one (e.g. `_test7`). An empty suffix is a +no-op: + +```php +parse('postgresql://user:pass@localhost:5432/mydb') + ->withDatabaseSuffix('_test7'); // database() === 'mydb_test7' +``` + +When combined with `withDatabase()`, apply the override first — the suffix is appended to the +overridden name (`withDatabase('other')->withDatabaseSuffix('_test')` ⇒ `other_test`). + ## Connection Lifecycle ### Checking Connection Status diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Connection/ConnectionParametersFactory.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Connection/ConnectionParametersFactory.php new file mode 100644 index 000000000..9701883aa --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Connection/ConnectionParametersFactory.php @@ -0,0 +1,41 @@ +parse($dsn); + + if (($overrides['dbname'] ?? null) !== null) { + $params = $params->withDatabase($overrides['dbname']); + } + + if (($overrides['host'] ?? null) !== null) { + $params = $params->withHost($overrides['host']); + } + + if (($overrides['port'] ?? null) !== null) { + $params = $params->withPort($overrides['port']); + } + + if (($overrides['user'] ?? null) !== null) { + $params = $params->withUser($overrides['user']); + } + + if (($overrides['password'] ?? null) !== null) { + $params = $params->withPassword($overrides['password']); + } + + return $params->withDatabaseSuffix($overrides['dbname_suffix'] ?? ''); + } +} diff --git a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php index bbc3edbb4..0915f55cf 100644 --- a/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php +++ b/src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php @@ -8,6 +8,7 @@ use Flow\Bridge\Symfony\PostgreSqlBundle\Attribute\AsCatalogProvider; use Flow\Bridge\Symfony\PostgreSqlBundle\CatalogProvider\ArrayCatalogProvider; use Flow\Bridge\Symfony\PostgreSqlBundle\Command\SessionPurgeCommand; +use Flow\Bridge\Symfony\PostgreSqlBundle\Connection\ConnectionParametersFactory; use Flow\Bridge\Symfony\PostgreSqlBundle\DependencyInjection\Compiler\CatalogProviderPass; use Flow\Bridge\Symfony\PostgreSqlBundle\DependencyInjection\Compiler\CommandLocatorPass; use Flow\Bridge\Symfony\PostgreSqlBundle\Generator\TwigMigrationGenerator; @@ -107,6 +108,30 @@ public function configure(DefinitionConfigurator $definition): void ->cannotBeEmpty() ->info('PostgreSQL connection DSN (e.g. postgresql://user:pass@localhost:5432/dbname)') ->end() + ->scalarNode('dbname') + ->defaultNull() + ->info('Overrides the database name parsed from the DSN.') + ->end() + ->scalarNode('host') + ->defaultNull() + ->info('Overrides the host parsed from the DSN.') + ->end() + ->integerNode('port') + ->defaultNull() + ->info('Overrides the port parsed from the DSN.') + ->end() + ->scalarNode('user') + ->defaultNull() + ->info('Overrides the user parsed from the DSN.') + ->end() + ->scalarNode('password') + ->defaultNull() + ->info('Overrides the password parsed from the DSN.') + ->end() + ->scalarNode('dbname_suffix') + ->defaultValue('') + ->info('Adds the given suffix to the configured database name.') + ->end() ->booleanNode('test_transaction_rollback') ->defaultFalse() ->info( @@ -399,7 +424,7 @@ public function configure(DefinitionConfigurator $definition): void } /** - * @param array{connections: array, telemetry?: array{service_id: string, clock_service_id: ?string, trace_queries: bool, trace_transactions: bool, collect_metrics: bool, log_queries: bool, max_query_length: int, include_parameters: bool, max_parameters: int, max_parameter_length: int}}>, messenger: array{enabled: bool, table_name: string, schema: string}, cache: array{pools?: array}, session: array{enabled: bool, connection: ?string, table_name: string, schema: string, id_col: string, data_col: string, lifetime_col: string, time_col: string, lock_mode: string, ttl: ?int, share_connection: bool}, migrations: array{enabled: bool, directory: string, namespace: string, table_name: string, table_schema: string, migration_file_name: string, rollback_file_name: string, all_or_nothing: bool, generate_rollback: bool, exclude?: list}, catalog_providers: list}>} $config + * @param array{connections: array, telemetry?: array{service_id: string, clock_service_id: ?string, trace_queries: bool, trace_transactions: bool, collect_metrics: bool, log_queries: bool, max_query_length: int, include_parameters: bool, max_parameters: int, max_parameter_length: int}}>, messenger: array{enabled: bool, table_name: string, schema: string}, cache: array{pools?: array}, session: array{enabled: bool, connection: ?string, table_name: string, schema: string, id_col: string, data_col: string, lifetime_col: string, time_col: string, lock_mode: string, ttl: ?int, share_connection: bool}, migrations: array{enabled: bool, directory: string, namespace: string, table_name: string, table_schema: string, migration_file_name: string, rollback_file_name: string, all_or_nothing: bool, generate_rollback: bool, exclude?: list}, catalog_providers: list}>} $config */ #[Override] public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void @@ -537,7 +562,7 @@ private function registerCatalogProviders(array $catalogProviders, ContainerBuil } /** - * @param array{dsn: string, test_transaction_rollback: bool, context?: array, telemetry?: array{service_id: string, clock_service_id: ?string, trace_queries: bool, trace_transactions: bool, collect_metrics: bool, log_queries: bool, max_query_length: int, include_parameters: bool, max_parameters: int, max_parameter_length: int}} $connectionConfig + * @param array{dsn: string, dbname: ?string, host: ?string, port: ?int, user: ?string, password: ?string, dbname_suffix: string, test_transaction_rollback: bool, context?: array, telemetry?: array{service_id: string, clock_service_id: ?string, trace_queries: bool, trace_transactions: bool, collect_metrics: bool, log_queries: bool, max_query_length: int, include_parameters: bool, max_parameters: int, max_parameter_length: int}} $connectionConfig */ private function registerConnection( string $name, @@ -549,8 +574,19 @@ private function registerConnection( $container->setDefinition("flow.postgresql.{$name}.dsn_parser", $parserDef); $paramsDef = new Definition(ConnectionParameters::class); - $paramsDef->setFactory([new Reference("flow.postgresql.{$name}.dsn_parser"), 'parse']); - $paramsDef->setArguments([$connectionConfig['dsn']]); + $paramsDef->setFactory([ConnectionParametersFactory::class, 'create']); + $paramsDef->setArguments([ + new Reference("flow.postgresql.{$name}.dsn_parser"), + $connectionConfig['dsn'], + [ + 'dbname' => $connectionConfig['dbname'] ?? null, + 'host' => $connectionConfig['host'] ?? null, + 'port' => $connectionConfig['port'] ?? null, + 'user' => $connectionConfig['user'] ?? null, + 'password' => $connectionConfig['password'] ?? null, + 'dbname_suffix' => $connectionConfig['dbname_suffix'] ?? '', + ], + ]); $container->setDefinition("flow.postgresql.{$name}.connection_parameters", $paramsDef); $paramsDef->setPublic(true); diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/FlowPostgreSqlExtensionTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/FlowPostgreSqlExtensionTest.php index c15ada7db..8f4018660 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/FlowPostgreSqlExtensionTest.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/FlowPostgreSqlExtensionTest.php @@ -651,6 +651,117 @@ public function test_client_without_telemetry_is_not_decorated(): void static::assertFalse($this->getContainer()->has('flow.postgresql.default.telemetry.config')); } + public function test_connection_only_dsn_behaves_as_before(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_postgresql', [ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://postgres:postgres@localhost:5432/app', + ], + ], + ]); + }, + ]); + + $params = $this->getContainer()->get('flow.postgresql.default.connection_parameters'); + static::assertSame('app', $params->database()); + static::assertSame('localhost', $params->host()); + } + + public function test_connection_parameters_apply_dbname_override_before_suffix(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_postgresql', [ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://postgres:postgres@localhost:5432/app', + 'dbname' => 'other', + 'dbname_suffix' => '_test', + ], + ], + ]); + }, + ]); + + static::assertSame( + 'other_test', + $this->getContainer()->get('flow.postgresql.default.connection_parameters')->database(), + ); + } + + public function test_connection_parameters_apply_dsn_part_overrides(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_postgresql', [ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://user:pass@localhost:5432/app', + 'host' => 'db.internal', + 'port' => 5544, + 'user' => 'svc', + 'password' => 'pw', + ], + ], + ]); + }, + ]); + + $params = $this->getContainer()->get('flow.postgresql.default.connection_parameters'); + static::assertSame('db.internal', $params->host()); + static::assertSame(5544, $params->port()); + static::assertSame('svc', $params->user()); + static::assertSame('pw', $params->password()); + } + + public function test_connection_parameters_apply_dbname_suffix(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestExtensionConfig('flow_postgresql', [ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://postgres:postgres@localhost:5432/app', + 'dbname_suffix' => '_test', + ], + ], + ]); + }, + ]); + + static::assertSame( + 'app_test', + $this->getContainer()->get('flow.postgresql.default.connection_parameters')->database(), + ); + } + + public function test_connection_parameters_resolve_dbname_suffix_from_env(): void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel): void { + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container->setParameter('env(FLOW_TEST_DB_SUFFIX)', '_test7'); + }); + $kernel->addTestExtensionConfig('flow_postgresql', [ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://postgres:postgres@localhost:5432/app', + 'dbname_suffix' => '%env(FLOW_TEST_DB_SUFFIX)%', + ], + ], + ]); + }, + ]); + + static::assertSame( + 'app_test7', + $this->getContainer()->get('flow.postgresql.default.connection_parameters')->database(), + ); + } + public function test_connection_with_migrations_registers_migration_services(): void { $this->bootKernel([ diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php index 7f3ed5117..842303280 100644 --- a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php @@ -208,6 +208,74 @@ public function test_catalog_providers_multiple_entries(): void static::assertSame('app.second_provider', $config['catalog_providers'][1]['catalog_provider_id']); } + public function test_connection_accepts_dbname_suffix(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://user:pass@localhost:5432/db', + 'dbname_suffix' => '_test', + ], + ], + ]); + + static::assertSame('_test', $config['connections']['default']['dbname_suffix']); + } + + public function test_connection_accepts_dsn_part_overrides(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://user:pass@localhost:5432/db', + 'dbname' => 'other', + 'host' => 'db.internal', + 'port' => 5544, + 'user' => 'svc', + 'password' => 'pw', + ], + ], + ]); + + $connection = $config['connections']['default']; + static::assertSame('other', $connection['dbname']); + static::assertSame('db.internal', $connection['host']); + static::assertSame(5544, $connection['port']); + static::assertSame('svc', $connection['user']); + static::assertSame('pw', $connection['password']); + } + + public function test_connection_overrides_default_to_no_override(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + ]); + + $connection = $config['connections']['default']; + static::assertNull($connection['dbname']); + static::assertNull($connection['host']); + static::assertNull($connection['port']); + static::assertNull($connection['user']); + static::assertNull($connection['password']); + static::assertSame('', $connection['dbname_suffix']); + } + + public function test_connection_port_rejects_non_int(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->context->processConfig([ + 'connections' => [ + 'default' => [ + 'dsn' => 'postgresql://user:pass@localhost:5432/db', + 'port' => 'not-an-int', + ], + ], + ]); + } + public function test_connections_requires_at_least_one_element(): void { $this->expectException(InvalidConfigurationException::class); diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Client/ConnectionParameters.php b/src/lib/postgresql/src/Flow/PostgreSql/Client/ConnectionParameters.php index bc00772dd..1456afb51 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Client/ConnectionParameters.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Client/ConnectionParameters.php @@ -180,6 +180,15 @@ public function withDatabase(string $database): self ); } + public function withDatabaseSuffix(string $suffix): self + { + if ($suffix === '') { + return $this; + } + + return $this->withDatabase($this->database . $suffix); + } + public function withHost(string $host): self { return new self( diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/ConnectionParametersTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/ConnectionParametersTest.php index 6d3fec645..53780d9b9 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/ConnectionParametersTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Client/ConnectionParametersTest.php @@ -5,6 +5,7 @@ namespace Flow\PostgreSql\Tests\Unit\Client; use Flow\PostgreSql\Client\ConnectionParameters; +use Flow\PostgreSql\Client\DsnParser; use InvalidArgumentException; use PHPUnit\Framework\TestCase; @@ -188,6 +189,45 @@ public function test_with_database_allows_appending_suffix(): void static::assertSame('myapp_test', $testParams->database()); } + public function test_with_database_suffix_appends_to_database(): void + { + $params = ConnectionParameters::fromParams(database: 'app'); + + $modified = $params->withDatabaseSuffix('_test'); + + static::assertSame('app', $params->database()); + static::assertSame('app_test', $modified->database()); + static::assertNotSame($params, $modified); + } + + public function test_with_database_suffix_composes_after_with_database(): void + { + $params = ConnectionParameters::fromParams(database: 'app'); + + $modified = $params->withDatabase('other')->withDatabaseSuffix('_test'); + + static::assertSame('other_test', $modified->database()); + } + + public function test_with_database_suffix_empty_is_no_op(): void + { + $params = ConnectionParameters::fromParams(database: 'app'); + + $modified = $params->withDatabaseSuffix(''); + + static::assertSame($params, $modified); + static::assertSame('app', $modified->database()); + } + + public function test_with_database_suffix_round_trips_with_dsn_parser(): void + { + $params = (new DsnParser()) + ->parse('postgresql://user:pass@localhost:5432/mydb') + ->withDatabaseSuffix('_x'); + + static::assertSame('mydb_x', $params->database()); + } + public function test_with_host(): void { $params = ConnectionParameters::fromParams(database: 'testdb', host: 'localhost');