diff --git a/documentation/components/bridges/symfony-postgresql-bundle.md b/documentation/components/bridges/symfony-postgresql-bundle.md index d53c6f3cce..cc832d3bbd 100644 --- a/documentation/components/bridges/symfony-postgresql-bundle.md +++ b/documentation/components/bridges/symfony-postgresql-bundle.md @@ -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 +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 +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 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 af7895ac4d..09b6598aa2 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 @@ -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] @@ -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() @@ -428,7 +441,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, drop_if_exists: 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, drop_if_exists: bool, context?: array, exclude?: list}, catalog_providers: list}>} $config */ #[Override] public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void @@ -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} $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, exclude?: list} $mc */ private function registerMigrations(string $name, array $mc, ContainerBuilder $container, bool $isFirst): void { @@ -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); @@ -778,6 +792,37 @@ private function registerMigrations(string $name, array $mc, ContainerBuilder $c } } + /** + * @param array $context + * + * @return array + */ + 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 $exclude */ diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/MigrationSeedProvider.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/MigrationSeedProvider.php new file mode 100644 index 0000000000..80ee7b567a --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/MigrationSeedProvider.php @@ -0,0 +1,17 @@ +value; + } +} diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/migrations/configured_context/20260601000000_configured_context/migration.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/migrations/configured_context/20260601000000_configured_context/migration.php new file mode 100644 index 0000000000..f4afb41bd7 --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/migrations/configured_context/20260601000000_configured_context/migration.php @@ -0,0 +1,26 @@ +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; + } +}; diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/migrations/container_access/20260601000000_container_access/migration.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/migrations/container_access/20260601000000_container_access/migration.php new file mode 100644 index 0000000000..63e15741a1 --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Fixtures/migrations/container_access/20260601000000_container_access/migration.php @@ -0,0 +1,32 @@ +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; + } +}; 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 bd0ee30ff5..d81de1d21f 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 @@ -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; @@ -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; @@ -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([ diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/MigrationContextExecutionTest.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/MigrationContextExecutionTest.php new file mode 100644 index 0000000000..0e1dd3b38c --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/MigrationContextExecutionTest.php @@ -0,0 +1,45 @@ +context = new MigrationExecutionContext(); + } + + protected function tearDown(): void + { + $this->context->shutdown(); + } + + public function test_migration_reads_configured_private_service_and_literal_from_context(): void + { + $this->context->bootConfiguredContext(); + + $this->context->migrator()->migrate(); + + static::assertTrue($this->context->tableExists(MigrationExecutionContext::CONFIGURED_TABLE)); + static::assertSame( + 'seeded-by-service', + $this->context->fetchValue(MigrationExecutionContext::CONFIGURED_TABLE), + ); + } + + public function test_migration_reads_parameter_and_service_from_service_container(): void + { + $this->context->bootContainerAccess(); + + $this->context->migrator()->migrate(); + + static::assertTrue($this->context->tableExists(MigrationExecutionContext::CONTAINER_TABLE)); + static::assertSame('seeded-by-service', $this->context->fetchValue(MigrationExecutionContext::CONTAINER_TABLE)); + } +} diff --git a/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/MigrationExecutionContext.php b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/MigrationExecutionContext.php new file mode 100644 index 0000000000..dce75778fa --- /dev/null +++ b/src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/MigrationExecutionContext.php @@ -0,0 +1,144 @@ +symfony = new SymfonyContext(); + $this->dsn = getenv('PGSQL_DATABASE_URL') ?: 'postgresql://postgres:postgres@localhost:5432/postgres'; + $this->dropTestTables(); + } + + public function bootConfiguredContext(): void + { + $this->symfony->bootKernel([ + 'config' => function (TestKernel $kernel): void { + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container->register('flow_test.private_seed_provider', MigrationSeedProvider::class); + $container->register('test.catalog_provider', SimpleTestCatalogProvider::class)->setPublic(true); + }); + $kernel->addTestExtensionConfig('flow_postgresql', [ + 'connections' => [ + 'default' => ['dsn' => $this->dsn], + ], + 'migrations' => [ + 'enabled' => true, + 'directory' => dirname(__DIR__) . '/Fixtures/migrations/configured_context', + 'namespace' => 'FlowTest\\Migrations', + 'table_name' => 'flow_migrations_test', + 'context' => [ + 'table_name' => self::CONFIGURED_TABLE, + 'seed_provider' => '@flow_test.private_seed_provider', + ], + ], + 'catalog_providers' => [ + ['catalog_provider_id' => 'test.catalog_provider'], + ], + ]); + }, + ]); + } + + public function bootContainerAccess(): void + { + $this->symfony->bootKernel([ + 'config' => function (TestKernel $kernel): void { + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container): void { + $container->setParameter('flow_test.container_table', self::CONTAINER_TABLE); + $container + ->register('flow_test.public_seed_provider', MigrationSeedProvider::class) + ->setPublic(true); + $container->register('test.catalog_provider', SimpleTestCatalogProvider::class)->setPublic(true); + }); + $kernel->addTestExtensionConfig('flow_postgresql', [ + 'connections' => [ + 'default' => ['dsn' => $this->dsn], + ], + 'migrations' => [ + 'enabled' => true, + 'directory' => dirname(__DIR__) . '/Fixtures/migrations/container_access', + 'namespace' => 'FlowTest\\Migrations', + 'table_name' => 'flow_migrations_test', + ], + 'catalog_providers' => [ + ['catalog_provider_id' => 'test.catalog_provider'], + ], + ]); + }, + ]); + } + + public function fetchValue(string $table): string + { + $client = $this->connect(); + $value = $client->fetchScalarString(sprintf('SELECT value FROM %s LIMIT 1', $table)); + $client->close(); + + return $value; + } + + public function migrator(): Migrator + { + return $this->symfony->getService('flow.postgresql.default.migrations.migrator', Migrator::class); + } + + public function shutdown(): void + { + $this->symfony->shutdown(); + $this->dropTestTables(); + } + + public function tableExists(string $table): bool + { + $client = $this->connect(); + $count = $client->fetchScalarInt('SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2', [ + 'public', + $table, + ]); + $client->close(); + + return $count > 0; + } + + private function connect(): Client + { + return PgSqlClient::connect((new DsnParser())->parse($this->dsn)); + } + + private function dropTestTables(): void + { + $client = $this->connect(); + $client->execute(sprintf( + 'DROP TABLE IF EXISTS %s, %s, flow_migrations_test CASCADE', + self::CONTAINER_TABLE, + self::CONFIGURED_TABLE, + )); + $client->close(); + } +} 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 ae6e031430..b1318fe93f 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 @@ -314,6 +314,32 @@ public function test_context_accepts_arbitrary_variables(): void ); } + public function test_migrations_context_accepts_arbitrary_variables(): void + { + $config = $this->context->processConfig([ + 'connections' => [ + 'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'], + ], + 'migrations' => [ + 'enabled' => true, + 'context' => [ + 'report_generator' => '@app.report_generator', + 'readonly_url' => '%env(DATABASE_READONLY_URL)%', + 'batch_size' => 500, + ], + ], + ]); + + static::assertSame( + [ + 'report_generator' => '@app.report_generator', + 'readonly_url' => '%env(DATABASE_READONLY_URL)%', + 'batch_size' => 500, + ], + $config['migrations']['context'], + ); + } + public function test_context_defaults_to_empty(): void { $config = $this->context->processConfig([ diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Configuration.php b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Configuration.php index 4a6b52b2ea..2bde78f978 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Configuration.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Configuration.php @@ -10,6 +10,9 @@ final readonly class Configuration { + /** + * @param array $attributes + */ public function __construct( public Client $client, public CatalogProvider $targetCatalogProvider, @@ -23,5 +26,6 @@ public function __construct( public bool $generateRollback = true, public ?ExclusionPolicy $exclusionPolicy = null, public bool $dropIfExists = false, + public array $attributes = [], ) {} } diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Exception/MigrationException.php b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Exception/MigrationException.php index 218337ea2f..5a387857cc 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Exception/MigrationException.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Exception/MigrationException.php @@ -12,6 +12,11 @@ class MigrationException extends RuntimeException { + public static function attributeNotFound(string $name): self + { + return new self(sprintf('Migration context attribute "%s" not found.', $name)); + } + public static function configurationFileNotFound(string $fileName): self { return new self(sprintf( diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/MigrationContext.php b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/MigrationContext.php index d8e595b19b..13ccbbf504 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/MigrationContext.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/MigrationContext.php @@ -5,10 +5,31 @@ namespace Flow\PostgreSql\Migrations; use Flow\PostgreSql\Client\Client; +use Flow\PostgreSql\Migrations\Exception\MigrationException; + +use function array_key_exists; final readonly class MigrationContext { + /** + * @param array $attributes + */ public function __construct( public Client $client, + public array $attributes = [], ) {} + + public function attribute(string $name): mixed + { + if (!array_key_exists($name, $this->attributes)) { + throw MigrationException::attributeNotFound($name); + } + + return $this->attributes[$name]; + } + + public function hasAttribute(string $name): bool + { + return array_key_exists($name, $this->attributes); + } } diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Migrator.php b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Migrator.php index 3039c4f486..0f2d238673 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Migrator.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Migrations/Migrator.php @@ -29,7 +29,7 @@ public function executeVersion(Version $version, Direction $direction, bool $dry $available = $this->repository->get($version); $plan = new MigrationPlan($available->version, $available->migration, $available->rollback, $direction); - $context = new MigrationContext($this->client); + $context = new MigrationContext($this->client, $this->configuration->attributes); if ($dryRun) { $this->client->beginTransaction(); @@ -177,7 +177,7 @@ private function executeAllOrNothing(array $plans): array $results = []; $this->client->transaction(function () use ($plans, &$results): void { - $context = new MigrationContext($this->client); + $context = new MigrationContext($this->client, $this->configuration->attributes); foreach ($plans as $plan) { $result = $this->executor->execute($plan, $context); @@ -204,7 +204,7 @@ private function executeAllOrNothing(array $plans): array */ private function executePlans(array $plans): array { - $context = new MigrationContext($this->client); + $context = new MigrationContext($this->client, $this->configuration->attributes); $results = []; foreach ($plans as $plan) { @@ -234,7 +234,7 @@ private function executeWithDryRun(array $plans): array $this->client->beginTransaction(); try { - $context = new MigrationContext($this->client); + $context = new MigrationContext($this->client, $this->configuration->attributes); $results = []; foreach ($plans as $plan) { diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Double/SpyMigrationExecutor.php b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Double/SpyMigrationExecutor.php index c5d59edf1b..bf500534c0 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Double/SpyMigrationExecutor.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Double/SpyMigrationExecutor.php @@ -16,9 +16,15 @@ final class SpyMigrationExecutor implements MigrationExecutor */ public array $executedPlans = []; + /** + * @var list + */ + public array $executedContexts = []; + public function execute(MigrationPlan $plan, MigrationContext $context): ExecutionResult { $this->executedPlans[] = $plan; + $this->executedContexts[] = $context; return new ExecutionResult($plan->version, $plan->direction, 0, false, null); } diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/ConfigurationTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/ConfigurationTest.php index 2cfa37140b..7af80633da 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/ConfigurationTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/ConfigurationTest.php @@ -9,9 +9,25 @@ use Flow\PostgreSql\Migrations\Tests\Double\SpyClient; use Flow\PostgreSql\Schema\Catalog; use PHPUnit\Framework\TestCase; +use stdClass; final class ConfigurationTest extends TestCase { + public function test_custom_attributes(): void + { + $attribute = new stdClass(); + + $config = new Configuration( + client: new SpyClient(), + targetCatalogProvider: new FakeCatalogProvider(new Catalog([])), + migrationsDirectory: '/app/migrations', + migrationsNamespace: 'App\\Migrations', + attributes: ['container' => $attribute], + ); + + static::assertSame(['container' => $attribute], $config->attributes); + } + public function test_custom_values(): void { $client = new SpyClient(); @@ -51,5 +67,6 @@ public function test_default_values(): void static::assertSame('public', $config->tableSchema); static::assertFalse($config->allOrNothing); static::assertTrue($config->generateRollback); + static::assertSame([], $config->attributes); } } diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/Exception/MigrationExceptionTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/Exception/MigrationExceptionTest.php index 413899859d..9765153ab0 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/Exception/MigrationExceptionTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/Exception/MigrationExceptionTest.php @@ -11,6 +11,13 @@ final class MigrationExceptionTest extends TestCase { + public function test_attribute_not_found(): void + { + $exception = MigrationException::attributeNotFound('container'); + + static::assertSame('Migration context attribute "container" not found.', $exception->getMessage()); + } + public function test_configuration_file_not_found(): void { $exception = MigrationException::configurationFileNotFound('migrations.php'); diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigrationContextTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigrationContextTest.php new file mode 100644 index 0000000000..39504c11a2 --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigrationContextTest.php @@ -0,0 +1,61 @@ + $value]); + + static::assertSame($value, $context->attribute('service')); + } + + public function test_attribute_throws_when_missing(): void + { + $context = new MigrationContext(new SpyClient()); + + $this->expectException(MigrationException::class); + $this->expectExceptionMessage('Migration context attribute "missing" not found.'); + + $context->attribute('missing'); + } + + public function test_attributes_default_to_empty_array(): void + { + $context = new MigrationContext(new SpyClient()); + + static::assertSame([], $context->attributes); + } + + public function test_has_attribute_returns_false_for_missing_key(): void + { + $context = new MigrationContext(new SpyClient()); + + static::assertFalse($context->hasAttribute('missing')); + } + + public function test_has_attribute_returns_true_for_existing_key(): void + { + $context = new MigrationContext(new SpyClient(), ['service' => new stdClass()]); + + static::assertTrue($context->hasAttribute('service')); + } + + public function test_has_attribute_returns_true_for_null_value(): void + { + $context = new MigrationContext(new SpyClient(), ['nullable' => null]); + + static::assertTrue($context->hasAttribute('nullable')); + static::assertNull($context->attribute('nullable')); + } +} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigratorTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigratorTest.php index 7bb6dcd084..c4ea472b26 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigratorTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Migrations/Tests/Unit/MigratorTest.php @@ -19,6 +19,7 @@ use Flow\PostgreSql\Migrations\Version; use Flow\PostgreSql\Schema\Catalog; use PHPUnit\Framework\TestCase; +use stdClass; use function array_filter; use function iterator_to_array; @@ -126,6 +127,34 @@ public function test_migrate_executes_pending_migrations(): void } } + public function test_migrate_propagates_configuration_attributes_to_context(): void + { + $attribute = new stdClass(); + $executor = new SpyMigrationExecutor(); + $client = new SpyClient(); + + $migrator = new Migrator( + new FakeMigrationRepository( + new AvailableMigration(Version::fromString('20260401120000'), 'first', new SpyMigration(), null), + ), + new FakeMigrationStore(), + $executor, + $client, + new Configuration( + $client, + new FakeCatalogProvider(new Catalog([])), + '/tmp', + 'App\\Migrations', + attributes: ['container' => $attribute], + ), + ); + + $migrator->migrate(); + + static::assertCount(1, $executor->executedContexts); + static::assertSame($attribute, $executor->executedContexts[0]->attribute('container')); + } + public function test_migrate_initializes_store(): void { $store = new FakeMigrationStore();