initial commit
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// Migration is one versioned schema change, embedded in the binary.
|
||||
type Migration struct {
|
||||
Version int
|
||||
Name string
|
||||
SQL string
|
||||
}
|
||||
|
||||
// MigrationStatus reports whether a migration has been applied.
|
||||
type MigrationStatus struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Applied bool `json:"applied"`
|
||||
AppliedAt int64 `json:"applied_at,omitempty"`
|
||||
Checksum string `json:"checksum"`
|
||||
Drifted bool `json:"drifted"`
|
||||
}
|
||||
|
||||
// loadMigrations reads and orders the embedded migration files. File names must
|
||||
// look like "0001_description.sql".
|
||||
func loadMigrations() ([]Migration, error) {
|
||||
entries, err := fs.ReadDir(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded migrations: %w", err)
|
||||
}
|
||||
var out []Migration
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
base := strings.TrimSuffix(e.Name(), ".sql")
|
||||
parts := strings.SplitN(base, "_", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("migration %q: expected NNNN_name.sql", e.Name())
|
||||
}
|
||||
v, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migration %q: bad version prefix: %w", e.Name(), err)
|
||||
}
|
||||
body, err := migrationFS.ReadFile("migrations/" + e.Name())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration %q: %w", e.Name(), err)
|
||||
}
|
||||
out = append(out, Migration{Version: v, Name: parts[1], SQL: string(body)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
|
||||
|
||||
for i := 1; i < len(out); i++ {
|
||||
if out[i].Version == out[i-1].Version {
|
||||
return nil, fmt.Errorf("duplicate migration version %d", out[i].Version)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func checksum(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ensureMigrationTable creates the migration bookkeeping table.
|
||||
func (db *DB) ensureMigrationTable(ctx context.Context) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type appliedMigration struct {
|
||||
name string
|
||||
checksum string
|
||||
appliedAt int64
|
||||
}
|
||||
|
||||
func (db *DB) appliedMigrations(ctx context.Context) (map[int]appliedMigration, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT version, name, checksum, applied_at FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := map[int]appliedMigration{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
var a appliedMigration
|
||||
if err := rows.Scan(&v, &a.name, &a.checksum, &a.appliedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[v] = a
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Migrate applies every pending migration in version order. It returns the
|
||||
// number of migrations that were applied.
|
||||
func (db *DB) Migrate(ctx context.Context) (int, error) {
|
||||
if err := db.ensureMigrationTable(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
migrations, err := loadMigrations()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applied, err := db.appliedMigrations(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, m := range migrations {
|
||||
sum := checksum(m.SQL)
|
||||
if prev, ok := applied[m.Version]; ok {
|
||||
if prev.checksum != sum {
|
||||
return count, fmt.Errorf(
|
||||
"migration %04d_%s was modified after being applied (expected checksum %s, found %s); "+
|
||||
"roll the change into a new migration instead of editing history",
|
||||
m.Version, m.Name, prev.checksum, sum)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Each migration is one transaction: a failure leaves no partial schema.
|
||||
err := db.InTx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
|
||||
return fmt.Errorf("apply migration %04d_%s: %w", m.Version, m.Name, err)
|
||||
}
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations (version, name, checksum) VALUES (?, ?, ?)`,
|
||||
m.Version, m.Name, sum)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// SchemaVersion returns the highest applied migration version, or 0.
|
||||
func (db *DB) SchemaVersion(ctx context.Context) (int, error) {
|
||||
if err := db.ensureMigrationTable(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var v int
|
||||
err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read schema version: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// MigrationStatuses lists every known migration and whether it is applied.
|
||||
func (db *DB) MigrationStatuses(ctx context.Context) ([]MigrationStatus, error) {
|
||||
if err := db.ensureMigrationTable(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
migrations, err := loadMigrations()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applied, err := db.appliedMigrations(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]MigrationStatus, 0, len(migrations))
|
||||
for _, m := range migrations {
|
||||
sum := checksum(m.SQL)
|
||||
st := MigrationStatus{Version: m.Version, Name: m.Name, Checksum: sum[:12]}
|
||||
if a, ok := applied[m.Version]; ok {
|
||||
st.Applied = true
|
||||
st.AppliedAt = a.appliedAt
|
||||
st.Drifted = a.checksum != sum
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user