package migration import ( "context" "errors" "os" "path/filepath" "regexp" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" ) const createMigrationsTableSQL = `CREATE TABLE IF NOT EXISTS schema_migrations ( version VARCHAR(100) PRIMARY KEY, dirty BOOLEAN NOT NULL DEFAULT TRUE, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )` func writeMigration(t *testing.T, name, sqlText string) string { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, name), []byte(sqlText), 0o600); err != nil { t.Fatalf("write migration: %v", err) } return dir } func expectCleanState(mock sqlmock.Sqlmock, applied ...string) { mock.ExpectExec(regexp.QuoteMeta(createMigrationsTableSQL)).WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM schema_migrations WHERE dirty = TRUE ORDER BY applied_at DESC LIMIT 1`)). WillReturnRows(sqlmock.NewRows([]string{"version"})) rows := sqlmock.NewRows([]string{"version"}) for _, version := range applied { rows.AddRow(version) } mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM schema_migrations WHERE dirty = FALSE`)).WillReturnRows(rows) } func TestRunAppliesEachMigrationOnce(t *testing.T) { dir := writeMigration(t, "001_create_widgets.sql", `CREATE TABLE widgets (id BIGINT PRIMARY KEY);`) db, mock, err := sqlmock.New() if err != nil { t.Fatalf("open sqlmock: %v", err) } t.Cleanup(func() { db.Close() }) expectCleanState(mock) mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO schema_migrations (version, dirty) VALUES (?, TRUE)`)). WithArgs("001_create_widgets.sql").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(regexp.QuoteMeta(`CREATE TABLE widgets (id BIGINT PRIMARY KEY)`)). WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec(regexp.QuoteMeta(`UPDATE schema_migrations SET dirty = FALSE, applied_at = CURRENT_TIMESTAMP WHERE version = ?`)). WithArgs("001_create_widgets.sql").WillReturnResult(sqlmock.NewResult(0, 1)) if err := Run(context.Background(), db, dir); err != nil { t.Fatalf("first run: %v", err) } expectCleanState(mock, "001_create_widgets.sql") if err := Run(context.Background(), db, dir); err != nil { t.Fatalf("second run: %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestRunLeavesFailedMigrationDirty(t *testing.T) { dir := writeMigration(t, "002_broken.sql", `CREATE TABLE broken (id BIGINT); ALTER TABLE missing ADD value INT;`) db, mock, err := sqlmock.New() if err != nil { t.Fatalf("open sqlmock: %v", err) } t.Cleanup(func() { db.Close() }) expectCleanState(mock) mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO schema_migrations (version, dirty) VALUES (?, TRUE)`)). WithArgs("002_broken.sql").WillReturnResult(sqlmock.NewResult(1, 1)) mock.ExpectExec(regexp.QuoteMeta(`CREATE TABLE broken (id BIGINT)`)).WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectExec(regexp.QuoteMeta(`ALTER TABLE missing ADD value INT`)).WillReturnError(errors.New("missing table")) err = Run(context.Background(), db, dir) if err == nil { t.Fatal("expected migration failure") } for _, fragment := range []string{"002_broken.sql", "statement 2", "dirty", "manual repair"} { if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(fragment)) { t.Fatalf("error %q does not contain %q", err, fragment) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestRunStopsWhenDirtyMigrationExists(t *testing.T) { dir := writeMigration(t, "003_should_not_run.sql", `CREATE TABLE should_not_run (id BIGINT);`) db, mock, err := sqlmock.New() if err != nil { t.Fatalf("open sqlmock: %v", err) } t.Cleanup(func() { db.Close() }) mock.ExpectExec(regexp.QuoteMeta(createMigrationsTableSQL)).WillReturnResult(sqlmock.NewResult(0, 0)) mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM schema_migrations WHERE dirty = TRUE ORDER BY applied_at DESC LIMIT 1`)). WillReturnRows(sqlmock.NewRows([]string{"version"}).AddRow("002_broken.sql")) err = Run(context.Background(), db, dir) if err == nil || !strings.Contains(err.Error(), "002_broken.sql") { t.Fatalf("expected dirty migration error, got %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestSplitStatementsPreservesSemicolonsInsideStrings(t *testing.T) { statements, err := splitStatements("INSERT INTO notes(value) VALUES ('a;b'); -- comment\nUPDATE notes SET value = \"c;d\";") if err != nil { t.Fatalf("split statements: %v", err) } want := []string{ "INSERT INTO notes(value) VALUES ('a;b')", "UPDATE notes SET value = \"c;d\"", } if len(statements) != len(want) { t.Fatalf("got %d statements, want %d: %#v", len(statements), len(want), statements) } for i := range want { if statements[i] != want[i] { t.Fatalf("statement %d = %q, want %q", i+1, statements[i], want[i]) } } }