From 9fb5b4eda6f7512269a9fc2b6dd2bf2bd3925752 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 9 Apr 2026 02:14:19 -0600 Subject: [PATCH] feat(litemigrate): add SQLite backend for sqlmigrate --- database/sqlmigrate/litemigrate/go.mod | 5 ++ .../sqlmigrate/litemigrate/litemigrate.go | 87 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 database/sqlmigrate/litemigrate/go.mod create mode 100644 database/sqlmigrate/litemigrate/litemigrate.go diff --git a/database/sqlmigrate/litemigrate/go.mod b/database/sqlmigrate/litemigrate/go.mod new file mode 100644 index 0000000..f3051e5 --- /dev/null +++ b/database/sqlmigrate/litemigrate/go.mod @@ -0,0 +1,5 @@ +module github.com/therootcompany/golib/database/sqlmigrate/litemigrate + +go 1.26.1 + +require github.com/therootcompany/golib/database/sqlmigrate v1.0.1 diff --git a/database/sqlmigrate/litemigrate/litemigrate.go b/database/sqlmigrate/litemigrate/litemigrate.go new file mode 100644 index 0000000..e5c5ea2 --- /dev/null +++ b/database/sqlmigrate/litemigrate/litemigrate.go @@ -0,0 +1,87 @@ +// Package litemigrate implements sqlmigrate.Migrator for SQLite +// using database/sql. The caller imports the driver: +// +// import _ "modernc.org/sqlite" +// +// db, err := sql.Open("sqlite", "app.db?_pragma=foreign_keys(1)") +// +// SQLite disables foreign key enforcement by default. The _pragma DSN +// parameter enables it on every connection the pool opens. +package litemigrate + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/therootcompany/golib/database/sqlmigrate" +) + +// Migrator implements sqlmigrate.Migrator using a *sql.DB with SQLite. +type Migrator struct { + DB *sql.DB +} + +// New creates a Migrator from the given database handle. +func New(db *sql.DB) *Migrator { + return &Migrator{DB: db} +} + +var _ sqlmigrate.Migrator = (*Migrator)(nil) + +// ExecUp runs the up migration SQL inside a transaction. +func (m *Migrator) ExecUp(ctx context.Context, mig sqlmigrate.Migration) error { + return m.execInTx(ctx, mig.Up) +} + +// ExecDown runs the down migration SQL inside a transaction. +func (m *Migrator) ExecDown(ctx context.Context, mig sqlmigrate.Migration) error { + return m.execInTx(ctx, mig.Down) +} + +func (m *Migrator) execInTx(ctx context.Context, sqlStr string) error { + tx, err := m.DB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("%w: begin: %w", sqlmigrate.ErrExecFailed, err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, sqlStr); err != nil { + return fmt.Errorf("%w: exec: %w", sqlmigrate.ErrExecFailed, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("%w: commit: %w", sqlmigrate.ErrExecFailed, err) + } + + return nil +} + +// Applied returns all applied migrations from the _migrations table. +// Returns an empty slice if the table does not exist. +func (m *Migrator) Applied(ctx context.Context) ([]sqlmigrate.AppliedMigration, error) { + rows, err := m.DB.QueryContext(ctx, "SELECT id, name FROM _migrations ORDER BY name") + if err != nil { + // SQLite reports "no such table: _migrations" — stable across versions + if strings.Contains(err.Error(), "no such table") { + return nil, nil + } + return nil, fmt.Errorf("%w: %w", sqlmigrate.ErrQueryApplied, err) + } + defer rows.Close() + + var applied []sqlmigrate.AppliedMigration + for rows.Next() { + var a sqlmigrate.AppliedMigration + if err := rows.Scan(&a.ID, &a.Name); err != nil { + return nil, fmt.Errorf("%w: scanning row: %w", sqlmigrate.ErrQueryApplied, err) + } + applied = append(applied, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("%w: reading rows: %w", sqlmigrate.ErrQueryApplied, err) + } + + return applied, nil +}