Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions documentation/components/bridges/symfony-postgresql-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,90 @@ Set `drop_if_exists: true` to render every diff-generated `DROP` with `IF EXISTS
that run against both fresh and legacy databases. Default `false`, so a missing object fails loudly (drift detection).
Override for a single run with the [`--drop-if-exists`](#generating-migrations-from-schema-diff) flag.

#### Accessing the service container in a migration

When migrations are enabled, the bundle injects the application's service container into the migration context under
`FlowPostgreSqlBundle::SERVICE_CONTAINER`. This gives migrations access to resolved parameters — including values
that Symfony resolves at container build time, such as decrypted secrets and `%env(...)%` processors — and to any
service, matching the behaviour of Doctrine's container-aware migrations.

Read it through `MigrationContext::attribute()`, which throws when the attribute is absent (use `hasAttribute()` to
check first):

```php
<?php

declare(strict_types=1);

use Flow\Bridge\Symfony\PostgreSqlBundle\FlowPostgreSqlBundle;
use Flow\PostgreSql\Migrations\Migration;
use Flow\PostgreSql\Migrations\MigrationContext;
use Symfony\Component\DependencyInjection\ContainerInterface;

return new class implements Migration {
public function migrate(MigrationContext $context): void
{
/** @var ContainerInterface $container */
$container = $context->attribute(FlowPostgreSqlBundle::SERVICE_CONTAINER);

$dsn = $container->getParameter('app.analytics_database_url_readonly');

// ... use the resolved value, e.g. to provision a role ...
}

public function transactional(): bool
{
return true;
}
};
```

#### Passing custom attributes via configuration

Injecting the whole container forces any service a migration needs to be public. To avoid that, declare extra
attributes under `migrations.context` — they are merged into the migration context next to the container. Each value
can be a literal, an `@service_id` reference (use `@@` to keep a literal leading `@`), a `%parameter%` placeholder, or
a `%env(VAR)%` expression. Referenced services may be private.

```yaml
flow_postgresql:
migrations:
enabled: true
context:
report_generator: '@app.report_generator' # service (may be private)
readonly_url: '%env(DATABASE_READONLY_URL)%' # resolved env
batch_size: 500 # literal
```

Each key is read in a migration via `MigrationContext::attribute()`:

```php
<?php

declare(strict_types=1);

use App\Migration\ReportGenerator;
use Flow\PostgreSql\Migrations\Migration;
use Flow\PostgreSql\Migrations\MigrationContext;

return new class implements Migration {
public function migrate(MigrationContext $context): void
{
/** @var ReportGenerator $reports */
$reports = $context->attribute('report_generator');

// ... use the injected service / value ...
}

public function transactional(): bool
{
return true;
}
};
```

The `service_container` key is always present and cannot be overridden by a `context` entry.

### Catalog Providers

Catalog providers define the target database schema. When you run `flow:migrations:diff`, the bundle compares
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,15 @@
use function Flow\Types\DSL\type_string;
use function implode;
use function in_array;
use function is_string;
use function sprintf;
use function str_starts_with;
use function substr;

final class FlowPostgreSqlBundle extends AbstractBundle
{
public const string SERVICE_CONTAINER = 'service_container';

protected string $extensionAlias = 'flow_postgresql';

#[Override]
Expand Down Expand Up @@ -361,6 +366,14 @@ public function configure(DefinitionConfigurator $definition): void
->defaultFalse()
->info('Emit IF EXISTS on diff-generated DROP statements (default: false)')
->end()
->arrayNode('context')
->info(
'Extra attributes merged into the migration context alongside the service container (available under FlowPostgreSqlBundle::SERVICE_CONTAINER). Read them in a migration via MigrationContext::attribute(). Values can be literals, @service_id references (@@ escapes a literal @), %parameter% placeholders, or %env(VAR)% expressions. Useful for handing private services or resolved config to a migration without making them public.',
)
->useAttributeAsKey('name')
->variablePrototype()
->end()
->end()
->arrayNode('exclude')
->info('Schema objects excluded from migration diffing (e.g. tables created dynamically at runtime). Each entry defines exactly one matcher: schema, table, exact, starts_with, ends_with, pattern or policy_id.')
->arrayPrototype()
Expand Down Expand Up @@ -428,7 +441,7 @@ public function configure(DefinitionConfigurator $definition): void
}

/**
* @param array{connections: array<string, array{dsn: string, dbname: ?string, host: ?string, port: ?int, user: ?string, password: ?string, dbname_suffix: string, test_transaction_rollback: bool, context?: array<string, mixed>, 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<string, array{connection: ?string, table_name: string, schema: string, id_col: string, data_col: string, lifetime_col: string, time_col: string, namespace: string, default_lifetime: int, marshaller_service_id: ?string, share_connection: bool}>}, 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, drop_if_exists: bool, exclude?: list<array{schema: ?string, table: ?string, exact: ?string, starts_with: ?string, ends_with: ?string, pattern: ?string, policy_id: ?string, type: ?string, for_schema: ?string}>}, catalog_providers: list<array{catalog_provider_id: ?string, catalog: ?array<string, mixed>}>} $config
* @param array{connections: array<string, array{dsn: string, dbname: ?string, host: ?string, port: ?int, user: ?string, password: ?string, dbname_suffix: string, test_transaction_rollback: bool, context?: array<string, mixed>, 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<string, array{connection: ?string, table_name: string, schema: string, id_col: string, data_col: string, lifetime_col: string, time_col: string, namespace: string, default_lifetime: int, marshaller_service_id: ?string, share_connection: bool}>}, 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, drop_if_exists: bool, context?: array<string, mixed>, exclude?: list<array{schema: ?string, table: ?string, exact: ?string, starts_with: ?string, ends_with: ?string, pattern: ?string, policy_id: ?string, type: ?string, for_schema: ?string}>}, catalog_providers: list<array{catalog_provider_id: ?string, catalog: ?array<string, mixed>}>} $config
*/
#[Override]
public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void
Expand Down Expand Up @@ -670,7 +683,7 @@ private function registerMessenger(
}

/**
* @param 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, drop_if_exists: bool, exclude?: list<array{schema: ?string, table: ?string, exact: ?string, starts_with: ?string, ends_with: ?string, pattern: ?string, policy_id: ?string, type: ?string, for_schema: ?string}>} $mc
* @param 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, drop_if_exists: bool, context?: array<string, mixed>, exclude?: list<array{schema: ?string, table: ?string, exact: ?string, starts_with: ?string, ends_with: ?string, pattern: ?string, policy_id: ?string, type: ?string, for_schema: ?string}>} $mc
*/
private function registerMigrations(string $name, array $mc, ContainerBuilder $container, bool $isFirst): void
{
Expand All @@ -689,6 +702,7 @@ private function registerMigrations(string $name, array $mc, ContainerBuilder $c
$mc['generate_rollback'],
$this->buildExclusionPolicy($mc['exclude'] ?? []),
$mc['drop_if_exists'],
$this->buildMigrationContextAttributes($mc['context'] ?? []),
]);
$configDef->setPublic(true);
$container->setDefinition("flow.postgresql.{$name}.migrations.configuration", $configDef);
Expand Down Expand Up @@ -778,6 +792,37 @@ private function registerMigrations(string $name, array $mc, ContainerBuilder $c
}
}

/**
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*/
private function buildMigrationContextAttributes(array $context): array
{
$attributes = [];

foreach ($context as $name => $value) {
$attributes[$name] = $this->resolveMigrationContextValue($value);
}

$attributes[self::SERVICE_CONTAINER] = new Reference('service_container');

return $attributes;
}

private function resolveMigrationContextValue(mixed $value): mixed
{
if (is_string($value) && str_starts_with($value, '@@')) {
return substr($value, 1);
}

if (is_string($value) && str_starts_with($value, '@')) {
return new Reference(substr($value, 1));
}

return $value;
}

/**
* @param list<array{schema: ?string, table: ?string, exact: ?string, starts_with: ?string, ends_with: ?string, pattern: ?string, policy_id: ?string, type: ?string, for_schema: ?string}> $exclude
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Fixtures;

final readonly class MigrationSeedProvider
{
public function __construct(
private string $value = 'seeded-by-service',
) {}

public function value(): string
{
return $this->value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Fixtures\MigrationSeedProvider;
use Flow\PostgreSql\Migrations\Migration;
use Flow\PostgreSql\Migrations\MigrationContext;

use function Flow\Types\DSL\type_instance_of;
use function Flow\Types\DSL\type_string;

return new class implements Migration {
public function migrate(MigrationContext $context): void
{
$table = type_string()->assert($context->attribute('table_name'));
$seed = type_instance_of(MigrationSeedProvider::class)->assert($context->attribute('seed_provider'))->value();

$context->client->execute(sprintf('CREATE TABLE %s (value TEXT NOT NULL)', $table));
$context->client->execute(sprintf('INSERT INTO %s (value) VALUES ($1)', $table), [$seed]);
}

public function transactional(): bool
{
return true;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

use Flow\Bridge\Symfony\PostgreSqlBundle\FlowPostgreSqlBundle;
use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Fixtures\MigrationSeedProvider;
use Flow\PostgreSql\Migrations\Migration;
use Flow\PostgreSql\Migrations\MigrationContext;
use Symfony\Component\DependencyInjection\ContainerInterface;

use function Flow\Types\DSL\type_instance_of;
use function Flow\Types\DSL\type_string;

return new class implements Migration {
public function migrate(MigrationContext $context): void
{
$container = type_instance_of(ContainerInterface::class)->assert($context->attribute(FlowPostgreSqlBundle::SERVICE_CONTAINER));

$table = type_string()->assert($container->getParameter('flow_test.container_table'));
$seed = type_instance_of(MigrationSeedProvider::class)
->assert($container->get('flow_test.public_seed_provider'))
->value();

$context->client->execute(sprintf('CREATE TABLE %s (value TEXT NOT NULL)', $table));
$context->client->execute(sprintf('INSERT INTO %s (value) VALUES ($1)', $table), [$seed]);
}

public function transactional(): bool
{
return true;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Flow\Bridge\Symfony\PostgreSqlBundle\FlowPostgreSqlBundle;
use Flow\Bridge\Symfony\PostgreSqlBundle\Messenger\FlowPostgreSqlTransportFactory;
use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Fixtures\AttributeTestCatalogProvider;
use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Fixtures\MigrationSeedProvider;
use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Fixtures\TestKernel;
use Flow\Bridge\Symfony\PostgreSqlBundle\Tests\Fixtures\VoidTelemetryFactory;
use Flow\Bridge\Symfony\PostgreSQLCache\CacheCatalogProvider;
Expand Down Expand Up @@ -45,6 +46,7 @@
use LogicException;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;

Expand Down Expand Up @@ -453,6 +455,82 @@ public function test_class_aliases_for_migration_services(): void
static::assertTrue($this->getContainer()->has(DiffMigrationGenerator::class));
}

public function test_migrations_configuration_injects_service_container_attribute(): void
{
$this->bootKernel([
'config' => static function (TestKernel $kernel): void {
$kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void {
$container->register('flow.postgresql.default.client', SpyClient::class)->setPublic(true);
});
$kernel->addTestExtensionConfig('flow_postgresql', [
'connections' => [
'default' => [
'dsn' => 'postgresql://postgres:postgres@localhost:5432/postgres',
],
],
'migrations' => [
'enabled' => true,
'directory' => '/tmp/test_migrations',
'namespace' => 'App\\Migrations',
],
'catalog_providers' => [
['catalog' => ['schemas' => []]],
],
]);
},
]);

$configuration = $this->getContainer()->get('flow.postgresql.default.migrations.configuration');
static::assertInstanceOf(MigrationsConfiguration::class, $configuration);

static::assertArrayHasKey(FlowPostgreSqlBundle::SERVICE_CONTAINER, $configuration->attributes);
static::assertInstanceOf(
ContainerInterface::class,
$configuration->attributes[FlowPostgreSqlBundle::SERVICE_CONTAINER],
);
}

public function test_migrations_configuration_merges_configured_context_attributes(): void
{
$this->bootKernel([
'config' => static function (TestKernel $kernel): void {
$kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void {
$container->register('flow.postgresql.default.client', SpyClient::class)->setPublic(true);
$container->register('app.report_generator', MigrationSeedProvider::class);
});
$kernel->addTestExtensionConfig('flow_postgresql', [
'connections' => [
'default' => [
'dsn' => 'postgresql://postgres:postgres@localhost:5432/postgres',
],
],
'migrations' => [
'enabled' => true,
'directory' => '/tmp/test_migrations',
'namespace' => 'App\\Migrations',
'context' => [
'report_generator' => '@app.report_generator',
'batch_size' => 500,
],
],
'catalog_providers' => [
['catalog' => ['schemas' => []]],
],
]);
},
]);

$configuration = $this->getContainer()->get('flow.postgresql.default.migrations.configuration');
static::assertInstanceOf(MigrationsConfiguration::class, $configuration);

static::assertInstanceOf(MigrationSeedProvider::class, $configuration->attributes['report_generator']);
static::assertSame(500, $configuration->attributes['batch_size']);
static::assertInstanceOf(
ContainerInterface::class,
$configuration->attributes[FlowPostgreSqlBundle::SERVICE_CONTAINER],
);
}

public function test_migrations_exclude_config_builds_exclusion_policy(): void
{
$this->bootKernel([
Expand Down
Loading
Loading