package migrate import ( "context" "database/sql" "os" "path/filepath" "testing" _ "modernc.org/sqlite" ) // sqliteDialect implements Dialect for SQLite. type sqliteDialect struct{} func (sqliteDialect) Placeholder(n int) string { return "?" } func (sqliteDialect) TableExistsQuery() string { return "SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?)" } func openTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("open sqlite: %v", err) } t.Cleanup(func() { db.Close() }) return db } // writeMigrations creates .up.sql files in dir and returns the directory path. func writeMigrations(t *testing.T, files map[string]string) string { t.Helper() dir := t.TempDir() for name, content := range files { if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatalf("write migration %s: %v", name, err) } } return dir } func TestDiscoverMigrations(t *testing.T) { dir := writeMigrations(t, map[string]string{ "000002_create_posts.up.sql": "CREATE TABLE posts (id INTEGER);", "000001_create_users.up.sql": "CREATE TABLE users (id INTEGER);", "000001_create_users.down.sql": "DROP TABLE users;", "README.md": "not a migration", }) got, err := discoverMigrations(dir) if err != nil { t.Fatalf("discoverMigrations: %v", err) } want := []string{ "000001_create_users.up.sql", "000002_create_posts.up.sql", } if len(got) != len(want) { t.Fatalf("got %d migrations, want %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { t.Errorf("migration[%d] = %q, want %q", i, got[i], want[i]) } } } func TestDiscoverMigrations_empty(t *testing.T) { dir := t.TempDir() got, err := discoverMigrations(dir) if err != nil { t.Fatalf("discoverMigrations: %v", err) } if got != nil { t.Fatalf("got %v, want nil", got) } } func TestRun(t *testing.T) { db := openTestDB(t) ctx := context.Background() dialect := sqliteDialect{} dir := writeMigrations(t, map[string]string{ "000001_create_users.up.sql": "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);", "000002_create_posts.up.sql": "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER);", }) err := Run(ctx, db, dialect, &Options{MigrationsDir: dir}) if err != nil { t.Fatalf("Run: %v", err) } // Verify tables were created. for _, table := range []string{"users", "posts", "schema_migrations"} { if !tableExists(ctx, db, dialect, table) { t.Errorf("table %q should exist", table) } } // Verify migration records. applied, err := getAppliedMigrations(ctx, db) if err != nil { t.Fatalf("getAppliedMigrations: %v", err) } if !applied["000001_create_users.up.sql"] || !applied["000002_create_posts.up.sql"] { t.Errorf("applied = %v, want both migrations", applied) } } func TestRun_idempotent(t *testing.T) { db := openTestDB(t) ctx := context.Background() dialect := sqliteDialect{} dir := writeMigrations(t, map[string]string{ "000001_create_users.up.sql": "CREATE TABLE users (id INTEGER PRIMARY KEY);", }) opts := &Options{MigrationsDir: dir} if err := Run(ctx, db, dialect, opts); err != nil { t.Fatalf("first Run: %v", err) } if err := Run(ctx, db, dialect, opts); err != nil { t.Fatalf("second Run: %v", err) } // Still only one record. applied, err := getAppliedMigrations(ctx, db) if err != nil { t.Fatalf("getAppliedMigrations: %v", err) } if len(applied) != 1 { t.Errorf("got %d applied migrations, want 1", len(applied)) } } func TestRun_nilOpts(t *testing.T) { db := openTestDB(t) ctx := context.Background() dialect := sqliteDialect{} // nil opts should not panic; it will use cwd/migrations which won't exist, // so we expect an error but not a panic. err := Run(ctx, db, dialect, nil) if err == nil { t.Fatal("expected error with nil opts (no migrations dir), got nil") } } func TestRun_bootstrap(t *testing.T) { db := openTestDB(t) ctx := context.Background() dialect := sqliteDialect{} // Create the sentinel table to simulate an existing schema. if _, err := db.ExecContext(ctx, "CREATE TABLE my_app (id INTEGER)"); err != nil { t.Fatalf("create sentinel: %v", err) } dir := writeMigrations(t, map[string]string{ "000001_create_users.up.sql": "CREATE TABLE users (id INTEGER PRIMARY KEY);", "000002_create_posts.up.sql": "CREATE TABLE posts (id INTEGER PRIMARY KEY);", }) err := Run(ctx, db, dialect, &Options{ MigrationsDir: dir, BootstrapTable: "my_app", }) if err != nil { t.Fatalf("Run: %v", err) } // Migrations should be recorded but NOT executed (tables should not exist). applied, err := getAppliedMigrations(ctx, db) if err != nil { t.Fatalf("getAppliedMigrations: %v", err) } if len(applied) != 2 { t.Fatalf("got %d applied, want 2 (bootstrapped)", len(applied)) } // The actual tables should NOT have been created. if tableExists(ctx, db, dialect, "users") { t.Error("users table should not exist after bootstrap") } if tableExists(ctx, db, dialect, "posts") { t.Error("posts table should not exist after bootstrap") } } func TestRun_noMigrationsDir(t *testing.T) { db := openTestDB(t) ctx := context.Background() dialect := sqliteDialect{} err := Run(ctx, db, dialect, &Options{MigrationsDir: "/nonexistent/path"}) if err == nil { t.Fatal("expected error for nonexistent dir, got nil") } }