package core import ( "context" "database/sql" "fmt" "io/fs" "log/slog" "sort" "strconv" "strings" ) // Migrate applies every embedded migration whose number is greater than the // database's current PRAGMA user_version, in ascending order, each inside its // own transaction. // // A migration is recorded by its numeric prefix. This is why applied files are // never edited: the runner has no checksum, only a version number, so changing // an applied file would silently diverge a running database from the schema. func Migrate(ctx context.Context, db *sql.DB, fsys fs.FS, log *slog.Logger) error { names, err := fs.Glob(fsys, "*.sql") if err != nil { return fmt.Errorf("list migrations: %w", err) } sort.Strings(names) current, err := userVersion(ctx, db) if err != nil { return err } log.Debug("schema version", "current", current, "available", len(names)) for _, name := range names { n, err := migrationNumber(name) if err != nil { return err } if n <= current { continue } if err := applyMigration(ctx, db, fsys, name, n); err != nil { return fmt.Errorf("migration %s: %w", name, err) } log.Info("applied migration", "file", name, "version", n) } return nil } func applyMigration(ctx context.Context, db *sql.DB, fsys fs.FS, name string, n int) error { body, err := fs.ReadFile(fsys, name) if err != nil { return err } tx, err := db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() //nolint:errcheck // no-op once Commit succeeds for _, stmt := range splitStatements(string(body)) { if _, err := tx.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("exec: %w\nstatement:\n%s", err, stmt) } } // PRAGMA does not accept a bound parameter. n is parsed from the filename // as an integer above, so it is not attacker-controlled. if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", n)); err != nil { return fmt.Errorf("set user_version: %w", err) } return tx.Commit() } func userVersion(ctx context.Context, db *sql.DB) (int, error) { var v int if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&v); err != nil { return 0, fmt.Errorf("read user_version: %w", err) } return v, nil } // migrationNumber parses the numeric prefix of "0001_core.sql" -> 1. func migrationNumber(name string) (int, error) { base := name if i := strings.IndexByte(base, '_'); i >= 0 { base = base[:i] } else if i := strings.IndexByte(base, '.'); i >= 0 { base = base[:i] } n, err := strconv.Atoi(base) if err != nil { return 0, fmt.Errorf("migration %q: filename must start with a number, e.g. 0001_core.sql", name) } return n, nil } // splitStatements splits a SQL file on semicolons that terminate a statement. // // This is not a general SQL parser. It is correct for the schema files this // project writes: no stored procedures, no BEGIN...END blocks, no semicolons // inside string literals. Comments are stripped first so that a ';' inside a // comment cannot split a statement. func splitStatements(sqlText string) []string { var out []string for _, stmt := range strings.Split(stripSQLComments(sqlText), ";") { if s := strings.TrimSpace(stmt); s != "" { out = append(out, s) } } return out } // stripSQLComments removes -- line comments and /* */ block comments. func stripSQLComments(s string) string { var b strings.Builder b.Grow(len(s)) for i := 0; i < len(s); { switch { case strings.HasPrefix(s[i:], "--"): for i < len(s) && s[i] != '\n' { i++ } case strings.HasPrefix(s[i:], "/*"): i += 2 for i < len(s) && !strings.HasPrefix(s[i:], "*/") { i++ } i += 2 default: b.WriteByte(s[i]) i++ } } return b.String() }