Skip to content

Commit ecb4822

Browse files
committed
feat: added support for local SQL migration
- only .sql files detected (with exception of .down.sql) - WARNING: it will apply full sql files (i.e. does not differentiate between up/down sections) - support for external migration command coming soon
1 parent c98be9a commit ecb4822

4 files changed

Lines changed: 167 additions & 30 deletions

File tree

cmd/snapshot.go

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ var (
2020
snapshotSections bool
2121
snapshotInput string
2222
snapshotClean bool
23-
snapshotBuildFixtures []string
24-
snapshotBuildSchema string
25-
snapshotBuildVerbose bool
23+
snapshotBuildFixtures []string
24+
snapshotBuildSchema string
25+
snapshotBuildMigrations string
26+
snapshotBuildVerbose bool
2627

2728
snapshotCmd = &cobra.Command{
2829
Use: "snapshot",
@@ -147,7 +148,8 @@ func init() {
147148

148149
snapshotBuildCmd.Flags().StringVarP(&snapshotOutput, "output", "o", "", "Output file path")
149150
snapshotBuildCmd.Flags().StringVarP(&snapshotFormat, "format", "f", "", "Dump format: custom, plain, or directory")
150-
snapshotBuildCmd.Flags().StringVar(&snapshotBuildSchema, "schema", "", "Schema SQL file to apply before fixtures")
151+
snapshotBuildCmd.Flags().StringVar(&snapshotBuildSchema, "schema", "", "Schema file to apply before migrations")
152+
snapshotBuildCmd.Flags().StringVar(&snapshotBuildMigrations, "migrations", "", "Directory of SQL migrations to apply")
151153
snapshotBuildCmd.Flags().StringSliceVar(&snapshotBuildFixtures, "fixtures", nil, "Fixture names to apply")
152154
snapshotBuildCmd.Flags().BoolVarP(&snapshotBuildVerbose, "verbose", "v", false, "Print detailed progress")
153155
}
@@ -393,14 +395,27 @@ func runSnapshotBuild() error {
393395
}
394396
}
395397

398+
migrationsDir := snapshotBuildMigrations
399+
if migrationsDir == "" {
400+
migrationsDir = regresql.GetSnapshotMigrations(cfg.Snapshot)
401+
}
402+
if migrationsDir != "" {
403+
if !filepath.IsAbs(migrationsDir) {
404+
migrationsDir = filepath.Join(snapshotCwd, migrationsDir)
405+
}
406+
if stat, err := os.Stat(migrationsDir); err != nil || !stat.IsDir() {
407+
return fmt.Errorf("migrations directory not found: %s", migrationsDir)
408+
}
409+
}
410+
396411
fixtures := snapshotBuildFixtures
397412
if len(fixtures) == 0 {
398413
fixtures = regresql.GetSnapshotFixtures(cfg.Snapshot)
399414
}
400415

401-
// Require at least schema or fixtures
402-
if len(fixtures) == 0 && schemaPath == "" {
403-
return fmt.Errorf("no schema or fixtures specified. Use --schema/--fixtures flags or configure snapshot.schema/snapshot.fixtures in regress.yaml")
416+
// Require at least schema, migrations, or fixtures
417+
if len(fixtures) == 0 && schemaPath == "" && migrationsDir == "" {
418+
return fmt.Errorf("no schema, migrations, or fixtures specified. Use flags or configure in regress.yaml")
404419
}
405420

406421
if len(fixtures) > 0 {
@@ -423,24 +438,28 @@ func runSnapshotBuild() error {
423438
format = regresql.GetSnapshotFormat(cfg.Snapshot)
424439
}
425440

426-
fmt.Printf("Building snapshot from fixtures...\n")
441+
fmt.Printf("Building snapshot...\n")
427442
fmt.Printf(" Database: %s\n", maskConnectionString(cfg.PgUri))
428443
fmt.Printf(" Output: %s\n", outputPath)
429444
fmt.Printf(" Format: %s\n", format)
430445
if schemaPath != "" {
431446
fmt.Printf(" Schema: %s\n", schemaPath)
432447
}
448+
if migrationsDir != "" {
449+
fmt.Printf(" Migrations: %s\n", migrationsDir)
450+
}
433451
if len(fixtures) > 0 {
434452
fmt.Printf(" Fixtures: %v\n", fixtures)
435453
}
436454
fmt.Println()
437455

438456
result, err := regresql.BuildSnapshot(cfg.PgUri, snapshotCwd, regresql.SnapshotBuildOptions{
439-
OutputPath: outputPath,
440-
Format: format,
441-
SchemaPath: schemaPath,
442-
Fixtures: fixtures,
443-
Verbose: snapshotBuildVerbose,
457+
OutputPath: outputPath,
458+
Format: format,
459+
SchemaPath: schemaPath,
460+
MigrationsDir: migrationsDir,
461+
Fixtures: fixtures,
462+
Verbose: snapshotBuildVerbose,
444463
})
445464
if err != nil {
446465
return err
@@ -458,6 +477,9 @@ func runSnapshotBuild() error {
458477
if result.Info.SchemaHash != "" {
459478
fmt.Printf(" Schema: %s\n", result.Info.SchemaHash[:20]+"...")
460479
}
480+
if len(result.Info.MigrationsApplied) > 0 {
481+
fmt.Printf(" Migrations: %d applied\n", len(result.Info.MigrationsApplied))
482+
}
461483
if len(result.FixturesUsed) > 0 {
462484
fmt.Printf(" Fixtures: %d applied\n", len(result.FixturesUsed))
463485
}
@@ -492,6 +514,19 @@ func runSnapshotInfo() error {
492514
fmt.Printf(" Hash: %s\n", info.SchemaHash)
493515
}
494516

517+
if info.MigrationsDir != "" {
518+
fmt.Println()
519+
fmt.Println("Migrations:")
520+
fmt.Printf(" Dir: %s\n", info.MigrationsDir)
521+
fmt.Printf(" Hash: %s\n", info.MigrationsHash)
522+
if len(info.MigrationsApplied) > 0 {
523+
fmt.Println(" Applied:")
524+
for _, m := range info.MigrationsApplied {
525+
fmt.Printf(" - %s\n", m)
526+
}
527+
}
528+
}
529+
495530
if len(info.FixturesUsed) > 0 {
496531
fmt.Println()
497532
fmt.Println("Fixtures used:")

regresql/config.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ 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-
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+
Fixtures []string `yaml:"fixtures,omitempty"` // SQL/YAML fixture files for snapshot build
3536
}
3637
)
3738

regresql/snapshot.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@ 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-
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+
FixturesUsed []string `yaml:"fixtures_used,omitempty"`
3134
}
3235

3336
SnapshotFormat string

regresql/snapshot_build.go

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
package regresql
22

33
import (
4+
"crypto/sha256"
45
"database/sql"
6+
"encoding/hex"
57
"fmt"
68
"os"
79
"os/exec"
810
"path/filepath"
11+
"sort"
912
"strings"
1013
"time"
1114
)
1215

1316
type (
1417
SnapshotBuildOptions struct {
15-
OutputPath string
16-
Format SnapshotFormat
17-
SchemaPath string
18-
Fixtures []string
19-
Verbose bool
18+
OutputPath string
19+
Format SnapshotFormat
20+
SchemaPath string
21+
MigrationsDir string
22+
Fixtures []string
23+
Verbose bool
2024
}
2125

2226
snapshotBuildResult struct {
@@ -87,6 +91,34 @@ func BuildSnapshot(basePgUri string, root string, opts SnapshotBuildOptions) (*s
8791
}
8892
}
8993

94+
// Apply migrations if provided
95+
var migrationsApplied []string
96+
var migrationsHash string
97+
if opts.MigrationsDir != "" {
98+
migrationFiles, err := discoverMigrations(opts.MigrationsDir)
99+
if err != nil {
100+
return nil, fmt.Errorf("failed to discover migrations: %w", err)
101+
}
102+
103+
if len(migrationFiles) > 0 {
104+
if opts.Verbose {
105+
fmt.Printf("Applying %d migration(s)...\n", len(migrationFiles))
106+
}
107+
if err := applyMigrations(db, migrationFiles, opts.Verbose); err != nil {
108+
return nil, err
109+
}
110+
111+
for _, f := range migrationFiles {
112+
migrationsApplied = append(migrationsApplied, filepath.Base(f))
113+
}
114+
115+
migrationsHash, err = computeMigrationsHash(migrationFiles)
116+
if err != nil {
117+
return nil, fmt.Errorf("failed to compute migrations hash: %w", err)
118+
}
119+
}
120+
}
121+
90122
var fixturesUsed []string
91123
if len(opts.Fixtures) > 0 {
92124
if opts.Verbose {
@@ -112,6 +144,9 @@ func BuildSnapshot(basePgUri string, root string, opts SnapshotBuildOptions) (*s
112144

113145
info.SchemaPath = opts.SchemaPath
114146
info.SchemaHash = schemaHash
147+
info.MigrationsDir = opts.MigrationsDir
148+
info.MigrationsHash = migrationsHash
149+
info.MigrationsApplied = migrationsApplied
115150
info.FixturesUsed = fixturesUsed
116151

117152
return &snapshotBuildResult{
@@ -237,6 +272,69 @@ func GetSnapshotSchema(cfg *SnapshotConfig) string {
237272
return cfg.Schema
238273
}
239274

275+
func GetSnapshotMigrations(cfg *SnapshotConfig) string {
276+
if cfg == nil {
277+
return ""
278+
}
279+
return cfg.Migrations
280+
}
281+
282+
// discoverMigrations finds *.sql files in directory (skips *.down.sql), sorted by name
283+
func discoverMigrations(dir string) ([]string, error) {
284+
entries, err := os.ReadDir(dir)
285+
if err != nil {
286+
return nil, err
287+
}
288+
289+
var files []string
290+
for _, e := range entries {
291+
if e.IsDir() {
292+
continue
293+
}
294+
name := e.Name()
295+
lower := strings.ToLower(name)
296+
if !strings.HasSuffix(lower, ".sql") {
297+
continue
298+
}
299+
// Skip .down.sql files (reverse migrations)
300+
if strings.HasSuffix(lower, ".down.sql") {
301+
continue
302+
}
303+
files = append(files, filepath.Join(dir, name))
304+
}
305+
sort.Strings(files) // Lexical sort: 001_init.sql, 002_users.sql, etc.
306+
return files, nil
307+
}
308+
309+
// applyMigrations executes migration files in order
310+
func applyMigrations(db *sql.DB, files []string, verbose bool) error {
311+
for _, f := range files {
312+
if verbose {
313+
fmt.Printf(" Migration: %s\n", filepath.Base(f))
314+
}
315+
if err := execSQLFile(db, f); err != nil {
316+
return fmt.Errorf("migration %q: %w", filepath.Base(f), err)
317+
}
318+
}
319+
return nil
320+
}
321+
322+
// computeMigrationsHash computes combined hash of all migration files
323+
func computeMigrationsHash(files []string) (string, error) {
324+
h := sha256.New()
325+
for _, f := range files {
326+
// Include filename in hash for ordering sensitivity
327+
h.Write([]byte(filepath.Base(f)))
328+
329+
content, err := os.ReadFile(f)
330+
if err != nil {
331+
return "", err
332+
}
333+
h.Write(content)
334+
}
335+
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
336+
}
337+
240338
// FixturesExist validates that all fixture files exist before build.
241339
func FixturesExist(root string, fixtures []string) error {
242340
for _, f := range fixtures {

0 commit comments

Comments
 (0)