Skip to content

Commit 45fa822

Browse files
authored
Merge pull request #2414 from flow-php/migrations-drop-if-exists
feature: added --drop-if-exists option to postgresql migrator
2 parents f33e10c + 5677c58 commit 45fa822

21 files changed

Lines changed: 390 additions & 24 deletions

File tree

documentation/components/bridges/symfony-postgresql-bundle.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,15 @@ flow_postgresql:
143143
rollback_file_name: "rollback.php" # Name of the rollback file in each version directory
144144
all_or_nothing: false # Wrap all migrations in a single transaction
145145
generate_rollback: true # Generate rollback files automatically
146+
drop_if_exists: false # Emit IF EXISTS on diff-generated DROP statements
146147
```
147148

149+
#### `drop_if_exists`
150+
151+
Set `drop_if_exists: true` to render every diff-generated `DROP` with `IF EXISTS` — useful for convergence migrations
152+
that run against both fresh and legacy databases. Default `false`, so a missing object fails loudly (drift detection).
153+
Override for a single run with the [`--drop-if-exists`](#generating-migrations-from-schema-diff) flag.
154+
148155
### Catalog Providers
149156

150157
Catalog providers define the target database schema. When you run `flow:migrations:diff`, the bundle compares
@@ -316,6 +323,7 @@ These commands are available when `migrations.enabled: true` for at least one co
316323
| `--up` / `--down` | Migration direction (execute) |
317324
| `--allow-empty-diff` | Don't fail when no changes detected (diff) |
318325
| `--from-empty-schema` | Generate as if the database were empty (diff) |
326+
| `--drop-if-exists` | Emit `IF EXISTS` on generated DROP statements (diff) |
319327

320328
## Migration Workflow
321329

@@ -349,6 +357,13 @@ with the catalog, and `rollback.php` with the reverse operations (if `generate_r
349357

350358
Use `--from-empty-schema` to generate a migration as if the database were empty (useful for initial setup).
351359

360+
Use `--drop-if-exists` to emit `IF EXISTS` on every generated `DROP` for this run (a per-run override of the
361+
[`drop_if_exists`](#drop_if_exists) config key):
362+
363+
```bash
364+
php bin/console flow:migrations:diff --drop-if-exists
365+
```
366+
352367
### Generating Blank Migrations
353368

354369
Create an empty migration for manual SQL (data migrations, custom operations):

documentation/components/libs/postgresql.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ execute it with the Client, and map results to objects.
3333
| Add pagination to existing queries | [Query Modification](#query-modification) | `ext-pg_query` |
3434
| Analyze query performance | [Query Plan Analysis](/documentation/components/libs/postgresql/client-explain.md) | `ext-pgsql`, `ext-pg_query` |
3535
| Traverse or modify AST directly | [Advanced Features](#advanced-features) | `ext-pg_query` |
36+
| Generate SQL from schema diffs | [Schema Diff](#schema-diff) | `ext-pg_query` |
3637

3738
## Requirements
3839

@@ -687,6 +688,42 @@ foreach ($query->raw()->getStmts() as $stmt) {
687688

688689
---
689690

691+
## Schema Diff
692+
693+
Compare two catalogs and generate the SQL that turns the source into the target.
694+
695+
```php
696+
<?php
697+
698+
use Flow\PostgreSql\Schema\Catalog;
699+
700+
use function Flow\PostgreSql\DSL\{catalog_comparator, schema, schema_column_integer, schema_table};
701+
702+
$current = new Catalog([schema('public', tables: [schema_table('users', [schema_column_integer('id', false)])])]);
703+
$target = new Catalog([schema('public')]); // users removed
704+
705+
foreach (catalog_comparator()->compare($current, $target)->generate() as $sql) {
706+
echo $sql->toSql() . "\n"; // DROP TABLE public.users CASCADE
707+
}
708+
```
709+
710+
### `dropIfExists`
711+
712+
By default, generated `DROP` statements have no `IF EXISTS`, so a missing object fails loudly (drift detection).
713+
Set `dropIfExists: true` to render every diff-generated drop with `IF EXISTS` — useful for convergence migrations
714+
that must run against both fresh and legacy databases:
715+
716+
```php
717+
// On the comparator (applies to every compare):
718+
catalog_comparator(dropIfExists: true)->compare($current, $target);
719+
720+
// Or per call (overrides the comparator's default):
721+
catalog_comparator()->compare($current, $target, dropIfExists: true);
722+
// -> DROP TABLE IF EXISTS public.users CASCADE
723+
```
724+
725+
---
726+
690727
## Reference
691728

692729
### Exception Handling

src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/Command/DiffCommand.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ protected function configure(): void
4141
->addOption('connection', 'c', InputOption::VALUE_OPTIONAL, 'The connection to use', null)
4242
->addArgument('name', InputArgument::OPTIONAL, 'The name of the migration (e.g. \'add_categories\')')
4343
->addOption('allow-empty-diff', null, InputOption::VALUE_NONE, 'Do not throw when no changes are detected')
44-
->addOption('from-empty-schema', null, InputOption::VALUE_NONE, 'Generate as if the database were empty');
44+
->addOption('from-empty-schema', null, InputOption::VALUE_NONE, 'Generate as if the database were empty')
45+
->addOption(
46+
'drop-if-exists',
47+
null,
48+
InputOption::VALUE_NONE,
49+
'Emit IF EXISTS on generated DROP statements (overrides the migrations.drop_if_exists config for this run)',
50+
);
4551
}
4652

4753
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -58,9 +64,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5864
$name = $input->getArgument('name');
5965
$allowEmpty = $input->getOption('allow-empty-diff') === true;
6066
$fromEmpty = $input->getOption('from-empty-schema') === true;
67+
$dropIfExists = $input->getOption('drop-if-exists') === true ? true : null;
6168

6269
try {
63-
$version = $diffGenerator->generate($name, $allowEmpty, $fromEmpty);
70+
$version = $diffGenerator->generate($name, $allowEmpty, $fromEmpty, $dropIfExists);
6471
} catch (MigrationException $e) {
6572
$io->warning($e->getMessage());
6673

src/bridge/symfony/postgresql-bundle/src/Flow/Bridge/Symfony/PostgreSqlBundle/FlowPostgreSqlBundle.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,10 @@ public function configure(DefinitionConfigurator $definition): void
357357
->defaultTrue()
358358
->info('Generate rollback files when creating migrations (default: true)')
359359
->end()
360+
->booleanNode('drop_if_exists')
361+
->defaultFalse()
362+
->info('Emit IF EXISTS on diff-generated DROP statements (default: false)')
363+
->end()
360364
->arrayNode('exclude')
361365
->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.')
362366
->arrayPrototype()
@@ -424,7 +428,7 @@ public function configure(DefinitionConfigurator $definition): void
424428
}
425429

426430
/**
427-
* @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, 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
431+
* @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
428432
*/
429433
#[Override]
430434
public function loadExtension(array $config, ContainerConfigurator $configurator, ContainerBuilder $container): void
@@ -666,7 +670,7 @@ private function registerMessenger(
666670
}
667671

668672
/**
669-
* @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, 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
673+
* @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
670674
*/
671675
private function registerMigrations(string $name, array $mc, ContainerBuilder $container, bool $isFirst): void
672676
{
@@ -684,6 +688,7 @@ private function registerMigrations(string $name, array $mc, ContainerBuilder $c
684688
$mc['all_or_nothing'],
685689
$mc['generate_rollback'],
686690
$this->buildExclusionPolicy($mc['exclude'] ?? []),
691+
$mc['drop_if_exists'],
687692
]);
688693
$configDef->setPublic(true);
689694
$container->setDefinition("flow.postgresql.{$name}.migrations.configuration", $configDef);

src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/Command/DiffCommandTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,19 @@ public function test_generates_schema_migration(): void
3434
static::assertStringContainsString('DROP TABLE', $rollbackContent);
3535
static::assertStringContainsString('implements Rollback', $rollbackContent);
3636
}
37+
38+
public function test_generates_schema_migration_with_drop_if_exists_flag(): void
39+
{
40+
$command = $this->context->command('flow.postgresql.command.diff');
41+
$tester = new CommandTester($command);
42+
$tester->execute(['name' => 'create_test_users_if_exists', '--drop-if-exists' => true]);
43+
44+
static::assertSame(Command::SUCCESS, $tester->getStatusCode());
45+
46+
$dirs = $this->context->migrationDirs('*_create_test_users_if_exists');
47+
static::assertCount(1, $dirs);
48+
49+
$rollbackContent = $this->context->fileContent((string) $dirs[0] . '/rollback.php');
50+
static::assertStringContainsString('DROP TABLE IF EXISTS', $rollbackContent);
51+
}
3752
}

src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Integration/FlowPostgreSqlExtensionTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,7 @@ public function test_migration_configuration_values_propagated(): void
11511151
'namespace' => 'Custom\\Migrations',
11521152
'table_name' => 'custom_migrations_table',
11531153
'table_schema' => 'custom_schema',
1154+
'drop_if_exists' => true,
11541155
],
11551156
'catalog_providers' => [
11561157
['catalog_provider_id' => 'test.catalog_provider'],
@@ -1166,6 +1167,7 @@ public function test_migration_configuration_values_propagated(): void
11661167
static::assertSame('Custom\\Migrations', $configuration->migrationsNamespace);
11671168
static::assertSame('custom_migrations_table', $configuration->tableName);
11681169
static::assertSame('custom_schema', $configuration->tableSchema);
1170+
static::assertTrue($configuration->dropIfExists);
11691171
}
11701172

11711173
public function test_migration_generate_and_up_to_date_commands_registered(): void

src/bridge/symfony/postgresql-bundle/tests/Flow/Bridge/Symfony/PostgreSqlBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,22 @@ public function test_migrations_default_values(): void
402402
static::assertSame('public', $config['migrations']['table_schema']);
403403
static::assertFalse($config['migrations']['all_or_nothing']);
404404
static::assertTrue($config['migrations']['generate_rollback']);
405+
static::assertFalse($config['migrations']['drop_if_exists']);
406+
}
407+
408+
public function test_migrations_drop_if_exists_can_be_enabled(): void
409+
{
410+
$config = $this->context->processConfig([
411+
'connections' => [
412+
'default' => ['dsn' => 'postgresql://user:pass@localhost:5432/db'],
413+
],
414+
'migrations' => [
415+
'enabled' => true,
416+
'drop_if_exists' => true,
417+
],
418+
]);
419+
420+
static::assertTrue($config['migrations']['drop_if_exists']);
405421
}
406422

407423
public function test_migrations_exclude_defaults_to_empty(): void

src/lib/postgresql/src/Flow/PostgreSql/DSL/schema.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1958,8 +1958,14 @@ function catalog_comparator(
19581958
?RenameStrategy $renameStrategy = null,
19591959
?ViewDependencyResolver $viewDependencyResolver = null,
19601960
?ExecutionOrderStrategy $tableOrderStrategy = null,
1961+
bool $dropIfExists = false,
19611962
): CatalogComparator {
1962-
return CatalogComparator::create($renameStrategy, $viewDependencyResolver, $tableOrderStrategy);
1963+
return CatalogComparator::create(
1964+
$renameStrategy,
1965+
$viewDependencyResolver,
1966+
$tableOrderStrategy,
1967+
dropIfExists: $dropIfExists,
1968+
);
19631969
}
19641970

19651971
#[DocumentationDSL(module: Module::PG_QUERY, type: DSLType::HELPER)]

src/lib/postgresql/src/Flow/PostgreSql/Migrations/Configuration.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ public function __construct(
2222
public bool $allOrNothing = false,
2323
public bool $generateRollback = true,
2424
public ?ExclusionPolicy $exclusionPolicy = null,
25+
public bool $dropIfExists = false,
2526
) {}
2627
}

src/lib/postgresql/src/Flow/PostgreSql/Migrations/Generator/DiffMigrationGenerator.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@ public function __construct(
2323
private bool $generateRollback = true,
2424
) {}
2525

26-
public function generate(?string $name = null, bool $allowEmpty = false, bool $fromEmptySchema = false): Version
27-
{
26+
public function generate(
27+
?string $name = null,
28+
bool $allowEmpty = false,
29+
bool $fromEmptySchema = false,
30+
?bool $dropIfExists = null,
31+
): Version {
2832
$source = $fromEmptySchema ? new Catalog([]) : $this->sourceCatalog->get();
2933
$target = $this->targetCatalog->get();
3034

31-
$diff = $this->comparator->compare($source, $target);
35+
$diff = $this->comparator->compare($source, $target, $dropIfExists);
3236

3337
if ($diff->isEmpty() && !$allowEmpty) {
3438
throw MigrationException::noChangesDetected();
@@ -41,7 +45,7 @@ public function generate(?string $name = null, bool $allowEmpty = false, bool $f
4145
if ($this->generateRollback) {
4246
$downSql = array_map(
4347
static fn(Sql $query) => $query->toSql(),
44-
$this->comparator->compare($target, $this->sourceCatalog->get())->generate(),
48+
$this->comparator->compare($target, $this->sourceCatalog->get(), $dropIfExists)->generate(),
4549
);
4650
}
4751

0 commit comments

Comments
 (0)