Skip to content

Commit 009b9d1

Browse files
committed
feat: migration_command in snapshot configuration (for 3rd party
migration tools) add env var PGURI or DATABASE_RUL
1 parent 3b052ca commit 009b9d1

5 files changed

Lines changed: 161 additions & 43 deletions

File tree

cmd/snapshot.go

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@ import (
1111
)
1212

1313
var (
14-
snapshotCwd string
15-
snapshotOutput string
16-
snapshotOutputDir string
17-
snapshotFormat string
18-
snapshotSchemaOnly bool
19-
snapshotSection string
20-
snapshotSections bool
21-
snapshotInput string
22-
snapshotClean bool
14+
snapshotCwd string
15+
snapshotOutput string
16+
snapshotOutputDir string
17+
snapshotFormat string
18+
snapshotSchemaOnly bool
19+
snapshotSection string
20+
snapshotSections bool
21+
snapshotInput string
22+
snapshotClean bool
2323
snapshotBuildFixtures []string
2424
snapshotBuildSchema string
2525
snapshotBuildMigrations string
@@ -408,13 +408,20 @@ func runSnapshotBuild() error {
408408
}
409409
}
410410

411+
migrationCommand := regresql.GetSnapshotMigrationCommand(cfg.Snapshot)
412+
413+
// migrations dir and migration_command are mutually exclusive
414+
if migrationsDir != "" && migrationCommand != "" {
415+
return fmt.Errorf("cannot use both 'migrations' directory and 'migration_command' - choose one")
416+
}
417+
411418
fixtures := snapshotBuildFixtures
412419
if len(fixtures) == 0 {
413420
fixtures = regresql.GetSnapshotFixtures(cfg.Snapshot)
414421
}
415422

416-
// Require at least schema, migrations, or fixtures
417-
if len(fixtures) == 0 && schemaPath == "" && migrationsDir == "" {
423+
// require at least schema, migrations, migration_command, or fixtures
424+
if len(fixtures) == 0 && schemaPath == "" && migrationsDir == "" && migrationCommand == "" {
418425
return fmt.Errorf("no schema, migrations, or fixtures specified. Use flags or configure in regress.yaml")
419426
}
420427

@@ -448,18 +455,22 @@ func runSnapshotBuild() error {
448455
if migrationsDir != "" {
449456
fmt.Printf(" Migrations: %s\n", migrationsDir)
450457
}
458+
if migrationCommand != "" {
459+
fmt.Printf(" Migration cmd: %s\n", migrationCommand)
460+
}
451461
if len(fixtures) > 0 {
452462
fmt.Printf(" Fixtures: %v\n", fixtures)
453463
}
454464
fmt.Println()
455465

456466
result, err := regresql.BuildSnapshot(cfg.PgUri, snapshotCwd, regresql.SnapshotBuildOptions{
457-
OutputPath: outputPath,
458-
Format: format,
459-
SchemaPath: schemaPath,
460-
MigrationsDir: migrationsDir,
461-
Fixtures: fixtures,
462-
Verbose: snapshotBuildVerbose,
467+
OutputPath: outputPath,
468+
Format: format,
469+
SchemaPath: schemaPath,
470+
MigrationsDir: migrationsDir,
471+
MigrationCommand: migrationCommand,
472+
Fixtures: fixtures,
473+
Verbose: snapshotBuildVerbose,
463474
})
464475
if err != nil {
465476
return err
@@ -480,6 +491,9 @@ func runSnapshotBuild() error {
480491
if len(result.Info.MigrationsApplied) > 0 {
481492
fmt.Printf(" Migrations: %d applied\n", len(result.Info.MigrationsApplied))
482493
}
494+
if result.Info.MigrationCommandHash != "" {
495+
fmt.Printf(" Migration cmd: executed\n")
496+
}
483497
if len(result.FixturesUsed) > 0 {
484498
fmt.Printf(" Fixtures: %d applied\n", len(result.FixturesUsed))
485499
}
@@ -527,6 +541,13 @@ func runSnapshotInfo() error {
527541
}
528542
}
529543

544+
if info.MigrationCommand != "" {
545+
fmt.Println()
546+
fmt.Println("Migration command:")
547+
fmt.Printf(" Command: %s\n", info.MigrationCommand)
548+
fmt.Printf(" Hash: %s\n", info.MigrationCommandHash)
549+
}
550+
530551
if len(info.FixturesUsed) > 0 {
531552
fmt.Println()
532553
fmt.Println("Fixtures used:")

regresql/config.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@ type (
2828
}
2929

3030
SnapshotConfig struct {
31-
Path string `yaml:"path,omitempty"` // snapshot dump file path (default: snapshots/default.dump)
32-
Format string `yaml:"format,omitempty"` // pg_dump format: custom, plain, or directory
33-
Schema string `yaml:"schema,omitempty"` // external schema file (SQL, dump, or directory)
34-
Migrations string `yaml:"migrations,omitempty"` // directory of SQL migrations to apply
35-
Fixtures []string `yaml:"fixtures,omitempty"` // SQL/YAML fixture files for snapshot build
31+
Path string `yaml:"path,omitempty"` // snapshot dump file path (default: snapshots/default.dump)
32+
Format string `yaml:"format,omitempty"` // pg_dump format: custom, plain, or directory
33+
Schema string `yaml:"schema,omitempty"` // external schema file (SQL, dump, or directory)
34+
Migrations string `yaml:"migrations,omitempty"` // directory of SQL migrations to apply
35+
MigrationCommand string `yaml:"migration_command,omitempty"` // external command to run migrations (e.g., goose, migrate)
36+
Fixtures []string `yaml:"fixtures,omitempty"` // SQL/YAML fixture files for snapshot build
3637
}
3738
)
3839

regresql/regresql.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ func PlanQueries(root string, runFilter string) {
5757
suite := Walk(root, ignorePatterns)
5858
suite.SetRunFilter(runFilter)
5959
config, err = suite.readConfig()
60-
6160
if err != nil {
6261
fmt.Print(err.Error())
6362
os.Exit(3)
@@ -103,7 +102,6 @@ func Update(root string, runFilter string, commit bool) {
103102
suite := Walk(root, ignorePatterns)
104103
suite.SetRunFilter(runFilter)
105104
config, err = suite.readConfig()
106-
107105
if err != nil {
108106
fmt.Print(err.Error())
109107
os.Exit(3)
@@ -161,12 +159,18 @@ func Test(root, runFilter, formatName, outputPath string, commit bool) {
161159
os.Exit(1)
162160
}
163161

164-
// Validate migrations haven't changed since last snapshot build
162+
// migrations haven't changed since last snapshot build?:
165163
if err := ValidateMigrationsHash(root); err != nil {
166164
fmt.Printf("Error: %s\n", err)
167165
os.Exit(1)
168166
}
169167

168+
// Validate migration command hasn't changed since last snapshot build
169+
if err := ValidateMigrationCommandHash(root); err != nil {
170+
fmt.Printf("Error: %s\n", err)
171+
os.Exit(1)
172+
}
173+
170174
if err := TestConnectionString(config.PgUri); err != nil {
171175
fmt.Print(err.Error())
172176
os.Exit(2)

regresql/snapshot.go

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,19 @@ type (
2020
}
2121

2222
SnapshotInfo struct {
23-
Path string `yaml:"path"`
24-
Hash string `yaml:"hash"`
25-
Created time.Time `yaml:"created"`
26-
SizeBytes int64 `yaml:"size_bytes"`
27-
Format string `yaml:"format"`
28-
SchemaPath string `yaml:"schema_path,omitempty"`
29-
SchemaHash string `yaml:"schema_hash,omitempty"`
30-
MigrationsDir string `yaml:"migrations_dir,omitempty"`
31-
MigrationsHash string `yaml:"migrations_hash,omitempty"`
32-
MigrationsApplied []string `yaml:"migrations_applied,omitempty"`
33-
FixturesUsed []string `yaml:"fixtures_used,omitempty"`
23+
Path string `yaml:"path"`
24+
Hash string `yaml:"hash"`
25+
Created time.Time `yaml:"created"`
26+
SizeBytes int64 `yaml:"size_bytes"`
27+
Format string `yaml:"format"`
28+
SchemaPath string `yaml:"schema_path,omitempty"`
29+
SchemaHash string `yaml:"schema_hash,omitempty"`
30+
MigrationsDir string `yaml:"migrations_dir,omitempty"`
31+
MigrationsHash string `yaml:"migrations_hash,omitempty"`
32+
MigrationsApplied []string `yaml:"migrations_applied,omitempty"`
33+
MigrationCommand string `yaml:"migration_command,omitempty"`
34+
MigrationCommandHash string `yaml:"migration_command_hash,omitempty"`
35+
FixturesUsed []string `yaml:"fixtures_used,omitempty"`
3436
}
3537

3638
SnapshotFormat string
@@ -645,3 +647,47 @@ func migrationChangeError(info *SnapshotInfo, currentHash string, current, store
645647
Run 'regresql snapshot build --migrations=%s' to rebuild the snapshot`,
646648
info.MigrationsDir, expectedHash, currentHash, changes.String(), info.MigrationsDir)
647649
}
650+
651+
func ValidateMigrationCommandHash(root string) error {
652+
snapshotsDir := GetSnapshotsDir(root)
653+
654+
metadata, err := ReadSnapshotMetadata(snapshotsDir)
655+
if err != nil {
656+
return nil // No metadata
657+
}
658+
659+
info := metadata.Current
660+
if info == nil || info.MigrationCommand == "" {
661+
return nil
662+
}
663+
664+
// Read current config to get current migration_command
665+
cfg, err := ReadConfig(root)
666+
if err != nil {
667+
return nil // Can't read config - skip validation
668+
}
669+
670+
currentCommand := GetSnapshotMigrationCommand(cfg.Snapshot)
671+
if currentCommand == "" {
672+
// Command was used before but now removed from config
673+
return fmt.Errorf(`migration_command was removed from config since last snapshot build
674+
675+
Previous command: %s
676+
677+
Run 'regresql snapshot build' to rebuild the snapshot without migration_command`,
678+
info.MigrationCommand)
679+
}
680+
681+
currentHash := computeCommandHash(currentCommand)
682+
if currentHash != info.MigrationCommandHash {
683+
return fmt.Errorf(`migration_command has changed since last snapshot build
684+
685+
Previous: %s
686+
Current: %s
687+
688+
Run 'regresql snapshot build' to rebuild the snapshot`,
689+
info.MigrationCommand, currentCommand)
690+
}
691+
692+
return nil
693+
}

regresql/snapshot_build.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ import (
1515

1616
type (
1717
SnapshotBuildOptions struct {
18-
OutputPath string
19-
Format SnapshotFormat
20-
SchemaPath string
21-
MigrationsDir string
22-
Fixtures []string
23-
Verbose bool
18+
OutputPath string
19+
Format SnapshotFormat
20+
SchemaPath string
21+
MigrationsDir string
22+
MigrationCommand string
23+
Fixtures []string
24+
Verbose bool
2425
}
2526

2627
snapshotBuildResult struct {
@@ -91,9 +92,11 @@ func BuildSnapshot(basePgUri string, root string, opts SnapshotBuildOptions) (*s
9192
}
9293
}
9394

94-
// Apply migrations if provided
95+
// Apply migrations - either from directory or via external command (mutually exclusive)
9596
var migrationsApplied []string
9697
var migrationsHash string
98+
var migrationCommandHash string
99+
97100
if opts.MigrationsDir != "" {
98101
migrationFiles, err := discoverMigrations(opts.MigrationsDir)
99102
if err != nil {
@@ -117,6 +120,11 @@ func BuildSnapshot(basePgUri string, root string, opts SnapshotBuildOptions) (*s
117120
return nil, fmt.Errorf("failed to compute migrations hash: %w", err)
118121
}
119122
}
123+
} else if opts.MigrationCommand != "" {
124+
if err := runMigrationCommand(opts.MigrationCommand, tempDB.PgUri, opts.Verbose); err != nil {
125+
return nil, err
126+
}
127+
migrationCommandHash = computeCommandHash(opts.MigrationCommand)
120128
}
121129

122130
var fixturesUsed []string
@@ -147,6 +155,8 @@ func BuildSnapshot(basePgUri string, root string, opts SnapshotBuildOptions) (*s
147155
info.MigrationsDir = opts.MigrationsDir
148156
info.MigrationsHash = migrationsHash
149157
info.MigrationsApplied = migrationsApplied
158+
info.MigrationCommand = opts.MigrationCommand
159+
info.MigrationCommandHash = migrationCommandHash
150160
info.FixturesUsed = fixturesUsed
151161

152162
return &snapshotBuildResult{
@@ -279,6 +289,42 @@ func GetSnapshotMigrations(cfg *SnapshotConfig) string {
279289
return cfg.Migrations
280290
}
281291

292+
func GetSnapshotMigrationCommand(cfg *SnapshotConfig) string {
293+
if cfg == nil {
294+
return ""
295+
}
296+
return cfg.MigrationCommand
297+
}
298+
299+
// runMigrationCommand executes an external migration tool with PGURI env var set
300+
func runMigrationCommand(command, pguri string, verbose bool) error {
301+
if verbose {
302+
fmt.Printf("Running migration command: %s\n", command)
303+
}
304+
305+
cmd := exec.Command("sh", "-c", command)
306+
cmd.Env = append(os.Environ(), "PGURI="+pguri, "DATABASE_URL="+pguri)
307+
308+
output, err := cmd.CombinedOutput()
309+
if verbose && len(output) > 0 {
310+
fmt.Printf("%s", output)
311+
}
312+
313+
if err != nil {
314+
// Always show output on error, even if not verbose
315+
if !verbose && len(output) > 0 {
316+
return fmt.Errorf("migration command failed: %w\n%s", err, output)
317+
}
318+
return fmt.Errorf("migration command failed: %w", err)
319+
}
320+
return nil
321+
}
322+
323+
func computeCommandHash(command string) string {
324+
h := sha256.Sum256([]byte(command))
325+
return "sha256:" + hex.EncodeToString(h[:])
326+
}
327+
282328
// discoverMigrations finds *.sql files in directory (skips *.down.sql), sorted by name
283329
func discoverMigrations(dir string) ([]string, error) {
284330
entries, err := os.ReadDir(dir)

0 commit comments

Comments
 (0)