~egtann/migrate

8ec5d6ae50984af4c23142d03b9676e17df0fe09 — Evan Tann 11 months ago 63ab11e
add content to migrations, tests
A .gitignore => .gitignore +1 -0
@@ 0,0 1,1 @@
test.env

M go.mod => go.mod +6 -7
@@ 1,14 1,13 @@
module github.com/egtann/migrate

require (
	github.com/go-sql-driver/mysql v1.4.1
	github.com/go-sql-driver/mysql v1.5.0
	github.com/jmoiron/sqlx v1.2.0
	github.com/lib/pq v1.2.0
	github.com/mattn/go-sqlite3 v1.11.0
	github.com/pkg/errors v0.8.1
	golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
	golang.org/x/sys v0.0.0-20191010194322-b09406accb47
	google.golang.org/appengine v1.6.5 // indirect
	github.com/lib/pq v1.3.0
	github.com/mattn/go-sqlite3 v2.0.3+incompatible
	github.com/pkg/errors v0.9.1
	golang.org/x/crypto v0.0.0-20200208060501-ecb85df21340
	golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5
)

go 1.13

M go.sum => go.sum +12 -24
@@ 1,37 1,25 @@
github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586 h1:7KByu05hhLed2MO29w7p1XfZvZ13m8mub3shuVftRs0=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200208060501-ecb85df21340 h1:KOcEaR10tFr7gdJV2GCKw8Os5yED1u1aOqHjOAb6d2Y=
golang.org/x/crypto v0.0.0-20200208060501-ecb85df21340/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47 h1:/XfQ9z7ib8eEJX2hdgFTZJ/ntt0swNk5oYBziWeTCvY=
golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5 h1:LfCXLvNmTYH9kEmVgqbnsWfruoXZIrh4YBgqVHtDvw0=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=

M migrate.go => migrate.go +61 -13
@@ 16,6 16,9 @@ import (
	"github.com/pkg/errors"
)

// version of the migrate tool's database schema.
const version = 1

type Migrate struct {
	Migrations []Migration
	Files      []os.FileInfo


@@ 29,11 32,16 @@ type Migrate struct {
type Migration struct {
	Filename string
	Checksum string
	Content  string
}

var regexNum = regexp.MustCompile(`^\d+`)

func New(db Store, log Logger, dir, skip string) (*Migrate, error) {
func New(
	db Store,
	log Logger,
	dir, skip string,
) (*Migrate, error) {
	m := &Migrate{db: db, log: log, dir: dir}

	// Get files in migration dir and sort them


@@ 54,6 62,25 @@ func New(db Store, log Logger, dir, skip string) (*Migrate, error) {
	if err = db.CreateMetaCheckpointsIfNotExists(); err != nil {
		return nil, errors.Wrap(err, "create meta checkpoints table")
	}
	curVersion, err := db.CreateMetaVersionIfNotExists()
	if err != nil {
		return nil, errors.Wrap(err, "create meta version table")
	}

	// Migrate the database schema to match the tool's expectations
	// automatically
	if curVersion > version {
		return nil, errors.New("must upgrade migrate: go get -u github.com/egtann/migrate")
	}
	if curVersion < 1 {
		tmpMigrations, err := migrationsFromFiles(m)
		if err != nil {
			return nil, errors.Wrap(err, "migrations from files")
		}
		if err = db.UpgradeToV1(tmpMigrations); err != nil {
			return nil, errors.Wrap(err, "upgrade to v1")
		}
	}

	// If skip, then we record the migrations but do not perform them. This
	// enables you to start using this package on an existing database


@@ 70,6 97,7 @@ func New(db Store, log Logger, dir, skip string) (*Migrate, error) {
	if err != nil {
		return nil, errors.Wrap(err, "get migrations")
	}

	if err = m.validHistory(); err != nil {
		return nil, err
	}


@@ 118,7 146,7 @@ func (m *Migrate) checkHash(mg Migration) error {
		return err
	}
	defer fi.Close()
	check, err := computeChecksum(fi)
	_, check, err := computeChecksum(fi)
	if err != nil {
		return err
	}


@@ 171,7 199,7 @@ func (m *Migrate) migrateFile(filename string) error {
		// Confirm the file up to our checkpoint has not changed
		if i < len(checkpoints) {
			r := strings.NewReader(cmd)
			checksum, err := computeChecksum(r)
			_, checksum, err := computeChecksum(r)
			if err != nil {
				return errors.Wrap(err, "compute checkpoint checksum")
			}


@@ 191,11 219,11 @@ func (m *Migrate) migrateFile(filename string) error {
		}

		// Save a checkpoint
		checksum, err := computeChecksum(strings.NewReader(cmd))
		_, checksum, err := computeChecksum(strings.NewReader(cmd))
		if err != nil {
			return errors.Wrap(err, "compute checksum")
		}
		err = m.db.InsertMetaCheckpoint(filename, checksum, i)
		err = m.db.InsertMetaCheckpoint(filename, cmd, checksum, i)
		if err != nil {
			return errors.Wrap(err, "insert checkpoint")
		}


@@ 207,11 235,11 @@ func (m *Migrate) migrateFile(filename string) error {
		return errors.Wrap(err, "delete checkpoints")
	}

	checksum, err := computeChecksum(bytes.NewReader(byt))
	_, checksum, err := computeChecksum(bytes.NewReader(byt))
	if err != nil {
		return errors.Wrap(err, "compute file checksum")
	}
	if err = m.db.InsertMigration(filename, checksum); err != nil {
	if err = m.db.InsertMigration(filename, string(byt), checksum); err != nil {
		return errors.Wrap(err, "insert migration")
	}
	return nil


@@ 238,12 266,12 @@ func (m *Migrate) skip(toFile string) (int, error) {
		if err != nil {
			return -1, err
		}
		checksum, err := computeChecksum(fi)
		content, checksum, err := computeChecksum(fi)
		if err != nil {
			fi.Close()
			return -1, err
		}
		if err = m.db.UpsertMigration(name, checksum); err != nil {
		if err = m.db.UpsertMigration(name, content, checksum); err != nil {
			fi.Close()
			return -1, err
		}


@@ 254,12 282,16 @@ func (m *Migrate) skip(toFile string) (int, error) {
	return index, nil
}

func computeChecksum(r io.Reader) (string, error) {
func computeChecksum(r io.Reader) (content string, checksum string, err error) {
	h := md5.New()
	if _, err := io.Copy(h, r); err != nil {
		return "", err
	byt, err := ioutil.ReadAll(r)
	if err != nil {
		return "", "", errors.Wrap(err, "read all")
	}
	if _, err := io.Copy(h, bytes.NewReader(byt)); err != nil {
		return "", "", err
	}
	return fmt.Sprintf("%x", h.Sum(nil)), nil
	return string(byt), fmt.Sprintf("%x", h.Sum(nil)), nil
}

// readdir collects file infos from the migration directory.


@@ 314,3 346,19 @@ func sortfiles(files []os.FileInfo) error {
	})
	return nameErr
}

func migrationsFromFiles(m *Migrate) ([]Migration, error) {
	ms := make([]Migration, len(m.Files))
	for i, fileInfo := range m.Files {
		filename := filepath.Join(m.dir, fileInfo.Name())
		byt, err := ioutil.ReadFile(filename)
		if err != nil {
			return nil, errors.Wrap(err, "read file")
		}
		ms[i] = Migration{
			Filename: fileInfo.Name(),
			Content:  string(byt),
		}
	}
	return ms, nil
}

M mysql/mysql.go => mysql/mysql.go +113 -12
@@ 3,6 3,7 @@ package mysql
import (
	"crypto/tls"
	"crypto/x509"
	"database/sql"
	"fmt"
	"io/ioutil"



@@ 40,10 41,31 @@ func New(
	return db, nil
}

func (db *DB) CreateMetaVersionIfNotExists() (int, error) {
	q := `CREATE TABLE IF NOT EXISTS metaversion (
		version INTEGER NOT NULL
	)`
	if _, err := db.Exec(q); err != nil {
		return 0, errors.Wrap(err, "create metaversion table")
	}

	var version int
	q = `SELECT version FROM metaversion`
	err := db.Get(&version, q)
	switch {
	case err == sql.ErrNoRows:
		return 0, nil
	case err != nil:
		return 0, errors.Wrap(err, "get version")
	}
	return version, nil
}

func (db *DB) CreateMetaIfNotExists() error {
	q := `CREATE TABLE IF NOT EXISTS meta (
		filename VARCHAR(255) UNIQUE NOT NULL,
		md5 VARCHAR(255) NOT NULL,
		content TEXT NOT NULL,
		createdat DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
	)`
	if _, err := db.Exec(q); err != nil {


@@ 57,6 79,7 @@ func (db *DB) CreateMetaCheckpointsIfNotExists() error {
		filename VARCHAR(255) NOT NULL,
		idx INTEGER NOT NULL,
		md5 VARCHAR(255) NOT NULL,
		content TEXT NOT NULL,
		createdat DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
		PRIMARY KEY (filename, idx)
	)`


@@ 68,7 91,10 @@ func (db *DB) CreateMetaCheckpointsIfNotExists() error {

func (db *DB) GetMigrations() ([]migrate.Migration, error) {
	migrations := []migrate.Migration{}
	q := `SELECT filename, md5 AS checksum FROM meta ORDER BY filename * 1`
	q := `
	SELECT filename, content, md5 AS checksum
	FROM meta
	ORDER BY filename * 1`
	err := db.Select(&migrations, q)
	return migrations, err



@@ 81,25 107,28 @@ func (db *DB) GetMetaCheckpoints(filename string) ([]string, error) {
	return checkpoints, err
}

func (db *DB) UpsertMigration(filename, checksum string) error {
func (db *DB) UpsertMigration(filename, content, checksum string) error {
	q := `
		INSERT INTO meta (filename, md5) VALUES (?, ?)
		ON DUPLICATE KEY UPDATE md5=?`
	_, err := db.Exec(q, filename, checksum, checksum)
		INSERT INTO meta (filename, content, md5) VALUES (?, ?, ?)
		ON DUPLICATE KEY UPDATE md5=?, content=?`
	_, err := db.Exec(q, filename, content, checksum, checksum, content)
	return err
}

func (db *DB) InsertMetaCheckpoint(filename, checksum string, idx int) error {
func (db *DB) InsertMetaCheckpoint(
	filename, content, checksum string,
	idx int,
) error {
	q := `
		INSERT INTO metacheckpoints (filename, idx, md5)
		VALUES (?, ?, ?)`
	_, err := db.Exec(q, filename, idx, checksum)
		INSERT INTO metacheckpoints (filename, content, idx, md5)
		VALUES (?, ?, ?, ?)`
	_, err := db.Exec(q, filename, content, idx, checksum)
	return err
}

func (db *DB) InsertMigration(filename, checksum string) error {
	q := `INSERT INTO meta (filename, md5) VALUES (?, ?)`
	_, err := db.Exec(q, filename, checksum)
func (db *DB) InsertMigration(filename, content, checksum string) error {
	q := `INSERT INTO meta (filename, content, md5) VALUES (?, ?, ?)`
	_, err := db.Exec(q, filename, content, checksum)
	return err
}



@@ 109,6 138,78 @@ func (db *DB) DeleteMetaCheckpoints() error {
	return err
}

// UpgradeToV1 migrates existing meta tables to the v1 format. Complete any
// migrations before running this function; this will not succeed if have any
// existing metacheckpoints.
func (db *DB) UpgradeToV1(migrations []migrate.Migration) (err error) {
	// Begin Tx
	tx, err := db.Beginx()
	if err != nil {
		return errors.Wrap(err, "begin tx")
	}
	defer func() {
		if err != nil {
			_ = tx.Rollback()
			return
		}
		err = tx.Commit()
	}()

	// Remove the uniqueness constraint from md5
	q := `ALTER TABLE meta DROP CONSTRAINT md5`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "remove md5 unique")
		return
	}

	// Add a content column to record the exact migration that ran
	// alongside the md5, insert the appropriate data, then set not null
	q = `ALTER TABLE meta ADD COLUMN content TEXT`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "add content column")
		return
	}
	for _, m := range migrations {
		q = `UPDATE meta SET content=? WHERE filename=?`
		if _, err = tx.Exec(q, m.Content, m.Filename); err != nil {
			err = errors.Wrap(err, "update meta content")
			return
		}
	}
	q = `ALTER TABLE meta MODIFY COLUMN content TEXT NOT NULL`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "update meta content not null")
		return
	}

	// Add the content column to metacheckpoints
	q = `
	ALTER TABLE metacheckpoints
	ADD COLUMN IF NOT EXISTS content TEXT NOT NULL`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "add metacheckpoints content")
		return
	}

	q = `
	CREATE TABLE IF NOT EXISTS metaversion (version INTEGER NOT NULL)`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "create metaversion table")
		return
	}
	q = `DELETE FROM metaversion`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "delete metaversion")
		return
	}
	q = `INSERT INTO metaversion (version) VALUES (1)`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "insert metaversion")
		return
	}
	return nil
}

func (db *DB) Open() error {
	if db.tlsConfig != nil {
		err := mysql.RegisterTLSConfig(db.tlsConfig.ServerName,

A mysql/mysql_test.go => mysql/mysql_test.go +265 -0
@@ 0,0 1,265 @@
package mysql

import (
	"bufio"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/egtann/migrate"
	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
	"github.com/pkg/errors"
)

const checkpointFile = "2.sql"

func TestMain(m *testing.M) {
	path := filepath.Join("..", "test.env")
	err := parseEnv(path)
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to parse %s: %s\n", path, err)
		os.Exit(1)
	}
	os.Exit(m.Run())
}

func TestCreateMetaIfNotExists(t *testing.T) {
	db := newDB(t)
	defer teardown(t, db)

	err := db.CreateMetaIfNotExists()
	check(t, err)

	var tmp []int
	err = db.DB.Select(&tmp, `SELECT 1 FROM meta`)
	check(t, err)
}

func TestCreateMetaCheckpointsIfNotExists(t *testing.T) {
	db := newDB(t)
	defer teardown(t, db)

	err := db.CreateMetaCheckpointsIfNotExists()
	check(t, err)

	var tmp []int
	err = db.DB.Select(&tmp, `SELECT 1 FROM metacheckpoints`)
	check(t, err)
}

func TestGetMigrations(t *testing.T) {
	db := setupDBV1(t)
	defer teardown(t, db)

	ms, err := db.GetMigrations()
	check(t, err)
	if len(ms) != 1 {
		t.Fatal("expected 1 migration")
	}
}

func TestGetMetaCheckpoints(t *testing.T) {
	db := setupDBV1(t)
	defer teardown(t, db)

	mcs, err := db.GetMetaCheckpoints(checkpointFile)
	check(t, err)
	if len(mcs) != 1 {
		t.Fatal("expected 1 checkpoint")
	}
}

func TestUpsertMigration(t *testing.T) {
	db := setupDBV1(t)
	defer teardown(t, db)

	// Test update
	err := db.UpsertMigration("1.sql", "SELECT 1;", "md5")
	check(t, err)

	// Test insert
	err = db.UpsertMigration("3.sql", "SELECT 3;", "md5")
	check(t, err)

	ms, err := db.GetMigrations()
	check(t, err)
	if len(ms) != 2 {
		t.Fatalf("expected 2 migrations, got %d", len(ms))
	}
}

func TestInsertMetaCheckpoint(t *testing.T) {
	db := setupDBV1(t)
	defer teardown(t, db)

	err := db.InsertMetaCheckpoint(checkpointFile, "SELECT 3;", "md5", 1)
	check(t, err)

	mcs, err := db.GetMetaCheckpoints(checkpointFile)
	check(t, err)
	if len(mcs) != 2 {
		t.Fatal("expected 2 checkpoints")
	}
}

func TestInsertMigration(t *testing.T) {
	db := setupDBV1(t)
	defer teardown(t, db)

	err := db.InsertMigration("3.sql", "SELECT 3;", "md5")
	check(t, err)

	ms, err := db.GetMigrations()
	check(t, err)
	if len(ms) != 2 {
		t.Fatal("expected 2 migrations")
	}
}

func TestDeleteMetaCheckpoints(t *testing.T) {
	db := setupDBV1(t)
	defer teardown(t, db)

	err := db.DeleteMetaCheckpoints()
	check(t, err)

	mcs, err := db.GetMetaCheckpoints(checkpointFile)
	check(t, err)
	if len(mcs) != 0 {
		t.Fatal("expected 0 checkpoints")
	}
}

func check(t *testing.T, err error) {
	t.Helper()
	if err != nil {
		t.Fatal(err)
	}
}

func must(err error) {
}

func newDB(t *testing.T) *DB {
	db := createDBAndOpen(t)
	return &DB{DB: db}
}

func parseEnv(filename string) error {
	fi, err := os.Open(filename)
	if err != nil {
		return err
	}
	defer fi.Close()

	scn := bufio.NewScanner(fi)
	for i := 1; scn.Scan(); i++ {
		line := scn.Text()
		if line == "" {
			continue
		}
		parts := strings.SplitN(line, "=", 2)
		if len(parts) != 2 {
			return fmt.Errorf("bad line %d: %s", i, line)
		}
		if err = os.Setenv(parts[0], parts[1]); err != nil {
			return errors.Wrap(err, "set env")
		}
	}
	if err = scn.Err(); err != nil {
		return errors.Wrap(err, "scan")
	}
	return nil
}

func createDBAndOpen(t *testing.T) *sqlx.DB {
	user := os.Getenv("MYSQL_USER")
	if user == "" {
		panic("missing MYSQL_USER")
	}
	pass := os.Getenv("MYSQL_PASSWORD")
	if pass == "" {
		panic("missing MYSQL_PASSWORD")
	}
	host := os.Getenv("MYSQL_HOST")
	if host == "" {
		panic("missing MYSQL_HOST")
	}

	dsn := fmt.Sprintf("%s:%s@tcp(%s)/?timeout=1s", user, pass, host)
	db, err := sqlx.Open("mysql", dsn)
	check(t, err)

	q := `DROP DATABASE IF EXISTS migrate_test`
	_, err = db.Exec(q)
	check(t, err)

	q = `CREATE DATABASE migrate_test`
	_, err = db.Exec(q)
	check(t, err)

	err = db.Close()
	check(t, err)

	dsn = fmt.Sprintf("%s:%s@tcp(%s)/migrate_test?timeout=1s", user, pass,
		host)
	db, err = sqlx.Open("mysql", dsn)
	check(t, err)

	return db
}

func teardown(t *testing.T, db *DB) {
	q := `DROP DATABASE migrate_test`
	_, err := db.Exec(q)
	check(t, err)
}

func setupDBV1(t *testing.T) *DB {
	db := setupDBV0(t)
	err := db.UpgradeToV1([]migrate.Migration{{
		Filename: "1.sql",
		Checksum: "md5",
		Content:  "SELECT 1;",
	}})
	check(t, err)

	q := `
		INSERT INTO metacheckpoints (idx, filename, content, md5)
		VALUES (?, ?, ?, ?)`
	_, err = db.DB.Exec(q, 0, checkpointFile, "SELECT 2;", "md5")
	check(t, err)

	return db
}

func setupDBV0(t *testing.T) *DB {
	db := newDB(t)

	q := `CREATE TABLE IF NOT EXISTS meta (
		filename VARCHAR(255) UNIQUE NOT NULL,
		md5 VARCHAR(255) UNIQUE NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
	)`
	_, err := db.DB.Exec(q)
	check(t, err)

	q = `CREATE TABLE IF NOT EXISTS metacheckpoints (
		filename VARCHAR(255) NOT NULL,
		idx INTEGER NOT NULL,
		md5 VARCHAR(255) NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
		PRIMARY KEY (filename, idx)
	)`
	_, err = db.DB.Exec(q)
	check(t, err)

	q = `INSERT INTO meta (filename, md5) VALUES (?, ?)`
	_, err = db.DB.Exec(q, "1.sql", "md5")
	check(t, err)

	return db
}

M postgres/postgres.go => postgres/postgres.go +112 -12
@@ 1,6 1,7 @@
package postgres

import (
	"database/sql"
	"fmt"

	"github.com/egtann/migrate"


@@ 39,6 40,7 @@ func (db *DB) CreateMetaIfNotExists() error {
	q := `CREATE TABLE IF NOT EXISTS meta (
		filename TEXT UNIQUE NOT NULL,
		md5 TEXT NOT NULL,
		content TEXT NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT (now() AT TIME ZONE 'utc')
	)`
	if _, err := db.Exec(q); err != nil {


@@ 63,7 65,10 @@ func (db *DB) CreateMetaCheckpointsIfNotExists() error {

func (db *DB) GetMigrations() ([]migrate.Migration, error) {
	migrations := []migrate.Migration{}
	q := `SELECT filename, md5 AS checksum FROM meta`
	q := `
	SELECT filename, content, md5 AS checksum
	FROM meta
	ORDER BY substring(filename, '^\d+')::int`
	err := db.Select(&migrations, q)
	return migrations, err



@@ 76,25 81,28 @@ func (db *DB) GetMetaCheckpoints(filename string) ([]string, error) {
	return checkpoints, err
}

func (db *DB) UpsertMigration(filename, checksum string) error {
func (db *DB) UpsertMigration(filename, content, checksum string) error {
	q := `
		INSERT INTO meta (filename, md5) VALUES ($1, $2)
		ON CONFLICT UPDATE md5=$3`
	_, err := db.Exec(q, filename, checksum, checksum)
		INSERT INTO meta (filename, content, md5) VALUES ($1, $2, $3)
		ON CONFLICT (filename) DO UPDATE SET md5=$4, content=$5`
	_, err := db.Exec(q, filename, content, checksum, checksum, content)
	return err
}

func (db *DB) InsertMetaCheckpoint(filename, checksum string, idx int) error {
func (db *DB) InsertMetaCheckpoint(
	filename, content, checksum string,
	idx int,
) error {
	q := `
		INSERT INTO metacheckpoints (filename, idx, md5)
		VALUES ($1, $2, $3)`
	_, err := db.Exec(q, filename, idx, checksum)
		INSERT INTO metacheckpoints (filename, content, idx, md5)
		VALUES ($1, $2, $3, $4)`
	_, err := db.Exec(q, filename, content, idx, checksum)
	return err
}

func (db *DB) InsertMigration(filename, checksum string) error {
	q := `INSERT INTO meta (filename, md5) VALUES ($1, $2)`
	_, err := db.Exec(q, filename, checksum)
func (db *DB) InsertMigration(filename, content, checksum string) error {
	q := `INSERT INTO meta (filename, content, md5) VALUES ($1, $2, $3)`
	_, err := db.Exec(q, filename, content, checksum)
	return err
}



@@ 104,6 112,26 @@ func (db *DB) DeleteMetaCheckpoints() error {
	return err
}

func (db *DB) CreateMetaVersionIfNotExists() (int, error) {
	q := `CREATE TABLE IF NOT EXISTS metaversion (
		version INTEGER NOT NULL
	)`
	if _, err := db.Exec(q); err != nil {
		return 0, errors.Wrap(err, "create metaversion table")
	}

	var version int
	q = `SELECT version FROM metaversion`
	err := db.Get(&version, q)
	switch {
	case err == sql.ErrNoRows:
		return 0, nil
	case err != nil:
		return 0, errors.Wrap(err, "get version")
	}
	return version, nil
}

func (db *DB) Open() error {
	var err error
	db.DB, err = sqlx.Open("postgres", db.connURL)


@@ 112,3 140,75 @@ func (db *DB) Open() error {
	}
	return nil
}

// UpgradeToV1 migrates existing meta tables to the v1 format. Complete any
// migrations before running this function; this will not succeed if have any
// existing metacheckpoints.
func (db *DB) UpgradeToV1(migrations []migrate.Migration) (err error) {
	// Begin Tx
	tx, err := db.Beginx()
	if err != nil {
		return errors.Wrap(err, "begin tx")
	}
	defer func() {
		if err != nil {
			_ = tx.Rollback()
			return
		}
		err = tx.Commit()
	}()

	// Remove the uniqueness constraint from md5
	q := `ALTER TABLE meta DROP CONSTRAINT meta_md5_key`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "remove md5 unique")
		return
	}

	// Add a content column to record the exact migration that ran
	// alongside the md5, insert the appropriate data, then set not null
	q = `ALTER TABLE meta ADD COLUMN content TEXT`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "add content column")
		return
	}
	for _, m := range migrations {
		q = `UPDATE meta SET content=$1 WHERE filename=$2`
		if _, err = tx.Exec(q, m.Content, m.Filename); err != nil {
			err = errors.Wrap(err, "update meta content")
			return
		}
	}
	q = `ALTER TABLE meta ALTER COLUMN content SET NOT NULL`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "update meta content not null")
		return
	}

	// Add the content column to metacheckpoints
	q = `
	ALTER TABLE metacheckpoints
	ADD COLUMN IF NOT EXISTS content TEXT NOT NULL`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "add metacheckpoints content")
		return
	}

	q = `
	CREATE TABLE IF NOT EXISTS metaversion (version INTEGER NOT NULL)`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "create metaversion table")
		return
	}
	q = `DELETE FROM metaversion`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "delete metaversion")
		return
	}
	q = `INSERT INTO metaversion (version) VALUES (1)`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "insert metaversion")
		return
	}
	return nil
}

A postgres/postgres_test.go => postgres/postgres_test.go +272 -0
@@ 0,0 1,272 @@
package postgres

import (
	"bufio"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/egtann/migrate"
	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq"
	"github.com/pkg/errors"
)

const checkpointFile = "2.sql"

func TestMain(m *testing.M) {
	path := filepath.Join("..", "test.env")
	err := parseEnv(path)
	if err != nil {
		fmt.Fprintf(os.Stderr, "failed to parse %s: %s\n", path, err)
		os.Exit(1)
	}
	os.Exit(m.Run())
}

func TestCreateMetaIfNotExists(t *testing.T) {
	db := newDB(t)

	err := db.CreateMetaIfNotExists()
	check(t, err)

	var tmp []int
	err = db.DB.Select(&tmp, `SELECT 1 FROM meta`)
	check(t, err)
}

func TestCreateMetaCheckpointsIfNotExists(t *testing.T) {
	db := newDB(t)

	err := db.CreateMetaCheckpointsIfNotExists()
	check(t, err)

	var tmp []int
	err = db.DB.Select(&tmp, `SELECT 1 FROM metacheckpoints`)
	check(t, err)
}

func TestGetMigrations(t *testing.T) {
	db := setupDBV1(t)

	ms, err := db.GetMigrations()
	check(t, err)
	if len(ms) != 1 {
		t.Fatalf("expected 1 migration, got %d", len(ms))
	}
}

func TestGetMetaCheckpoints(t *testing.T) {
	db := setupDBV1(t)

	mcs, err := db.GetMetaCheckpoints(checkpointFile)
	check(t, err)
	if len(mcs) != 1 {
		t.Fatal("expected 1 checkpoint")
	}
}

func TestUpsertMigration(t *testing.T) {
	db := setupDBV1(t)

	// Test update
	err := db.UpsertMigration("1.sql", "SELECT 1;", "md5")
	check(t, err)

	// Test insert
	err = db.UpsertMigration("3.sql", "SELECT 3;", "md5")
	check(t, err)

	ms, err := db.GetMigrations()
	check(t, err)
	if len(ms) != 2 {
		t.Fatalf("expected 2 migrations, got %d", len(ms))
	}
}

func TestInsertMetaCheckpoint(t *testing.T) {
	db := setupDBV1(t)

	err := db.InsertMetaCheckpoint(checkpointFile, "SELECT 3;", "md5", 1)
	check(t, err)

	mcs, err := db.GetMetaCheckpoints(checkpointFile)
	check(t, err)
	if len(mcs) != 2 {
		t.Fatal("expected 2 checkpoints")
	}
}

func TestInsertMigration(t *testing.T) {
	db := setupDBV1(t)

	err := db.InsertMigration("3.sql", "SELECT 3;", "md5")
	check(t, err)

	ms, err := db.GetMigrations()
	check(t, err)
	if len(ms) != 2 {
		t.Fatal("expected 2 migrations")
	}
}

func TestDeleteMetaCheckpoints(t *testing.T) {
	db := setupDBV1(t)

	err := db.DeleteMetaCheckpoints()
	check(t, err)

	mcs, err := db.GetMetaCheckpoints(checkpointFile)
	check(t, err)
	if len(mcs) != 0 {
		t.Fatal("expected 0 checkpoints")
	}
}

func check(t *testing.T, err error) {
	t.Helper()
	if err != nil {
		t.Fatal(err)
	}
}

func newDB(t *testing.T) *DB {
	db := createDBAndOpen(t)
	return &DB{DB: db}
}

func parseEnv(filename string) error {
	fi, err := os.Open(filename)
	if err != nil {
		return err
	}
	defer fi.Close()

	scn := bufio.NewScanner(fi)
	for i := 1; scn.Scan(); i++ {
		line := scn.Text()
		if line == "" {
			continue
		}
		parts := strings.SplitN(line, "=", 2)
		if len(parts) != 2 {
			return fmt.Errorf("bad line %d: %s", i, line)
		}
		if err = os.Setenv(parts[0], parts[1]); err != nil {
			return errors.Wrap(err, "set env")
		}
	}
	if err = scn.Err(); err != nil {
		return errors.Wrap(err, "scan")
	}
	return nil
}

func createDBAndOpen(t *testing.T) *sqlx.DB {
	db, err := sqlx.Open("postgres", dsnForDB(""))
	check(t, err)

	q := `DROP DATABASE IF EXISTS migrate_test`
	_, err = db.Exec(q)
	check(t, err)

	q = `CREATE DATABASE migrate_test`
	_, err = db.Exec(q)
	check(t, err)

	err = db.Close()
	check(t, err)

	db, err = sqlx.Open("postgres", dsnForDB("migrate_test"))
	check(t, err)

	t.Cleanup(teardown(db))
	return db
}

func dsnForDB(dbName string) string {
	user := os.Getenv("POSTGRES_USER")
	if user == "" {
		panic("missing POSTGRES_USER")
	}
	pass := os.Getenv("POSTGRES_PASSWORD")
	if pass == "" {
		panic("missing POSTGRES_PASSWORD")
	}
	host := os.Getenv("POSTGRES_HOST")
	if host == "" {
		panic("missing POSTGRES_HOST")
	}
	params := "sslmode=disable&connect_timeout=1"
	return fmt.Sprintf("postgres://%s:%s@%s/%s?%s", user, pass, host,
		dbName, params)
}

func teardown(db *sqlx.DB) func() {
	return func() {
		if err := db.Close(); err != nil {
			return
		}

		var err error
		db, err = sqlx.Open("postgres", dsnForDB(""))
		if err != nil {
			return
		}
		defer db.Close()

		q := `DROP DATABASE migrate_test`
		_, err = db.Exec(q)
		if err != nil {
			return
		}
	}
}

func setupDBV1(t *testing.T) *DB {
	db := setupDBV0(t)
	err := db.UpgradeToV1([]migrate.Migration{{
		Filename: "1.sql",
		Checksum: "md5",
		Content:  "SELECT 1;",
	}})
	check(t, err)

	q := `
		INSERT INTO metacheckpoints (idx, filename, content, md5)
		VALUES ($1, $2, $3, $4)`
	_, err = db.DB.Exec(q, 0, checkpointFile, "SELECT 2;", "md5")
	check(t, err)

	return db
}

func setupDBV0(t *testing.T) *DB {
	db := newDB(t)

	q := `CREATE TABLE IF NOT EXISTS meta (
		filename VARCHAR(255) UNIQUE NOT NULL,
		md5 VARCHAR(255) UNIQUE NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
	)`
	_, err := db.DB.Exec(q)
	check(t, err)

	q = `CREATE TABLE IF NOT EXISTS metacheckpoints (
		filename VARCHAR(255) NOT NULL,
		idx INTEGER NOT NULL,
		md5 VARCHAR(255) NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
		PRIMARY KEY (filename, idx)
	)`
	_, err = db.DB.Exec(q)
	check(t, err)

	q = `INSERT INTO meta (filename, md5) VALUES ($1, $2)`
	_, err = db.DB.Exec(q, "1.sql", "md5")
	check(t, err)

	return db
}

M sqlite/sqlite.go => sqlite/sqlite.go +173 -12
@@ 1,6 1,8 @@
package sqlite

import (
	"database/sql"

	"github.com/egtann/migrate"
	"github.com/jmoiron/sqlx"
	"github.com/pkg/errors"


@@ 23,6 25,7 @@ func (db *DB) CreateMetaIfNotExists() error {
	q := `CREATE TABLE IF NOT EXISTS meta (
		filename TEXT UNIQUE NOT NULL,
		md5 TEXT NOT NULL,
		content TEXT NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
	)`
	if _, err := db.Exec(q); err != nil {


@@ 34,6 37,7 @@ func (db *DB) CreateMetaIfNotExists() error {
func (db *DB) CreateMetaCheckpointsIfNotExists() error {
	q := `CREATE TABLE IF NOT EXISTS metacheckpoints (
		filename TEXT NOT NULL,
		content TEXT NOT NULL,
		idx INTEGER NOT NULL,
		md5 TEXT NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,


@@ 47,7 51,7 @@ func (db *DB) CreateMetaCheckpointsIfNotExists() error {

func (db *DB) GetMigrations() ([]migrate.Migration, error) {
	migrations := []migrate.Migration{}
	q := `SELECT filename, md5 AS checksum FROM meta`
	q := `SELECT filename, content, md5 AS checksum FROM meta`
	err := db.Select(&migrations, q)
	return migrations, err



@@ 60,25 64,28 @@ func (db *DB) GetMetaCheckpoints(filename string) ([]string, error) {
	return checkpoints, err
}

func (db *DB) UpsertMigration(filename, checksum string) error {
func (db *DB) UpsertMigration(filename, content, checksum string) error {
	q := `
		INSERT INTO meta (filename, md5) VALUES ($1, $2)
		ON CONFLICT UPDATE md5=$3`
	_, err := db.Exec(q, filename, checksum, checksum)
		INSERT INTO meta (filename, content, md5) VALUES ($1, $2, $3)
		ON CONFLICT(filename) DO UPDATE SET md5=$4, content=$5`
	_, err := db.Exec(q, filename, content, checksum, checksum, content)
	return err
}

func (db *DB) InsertMetaCheckpoint(filename, checksum string, idx int) error {
func (db *DB) InsertMetaCheckpoint(
	filename, content, checksum string,
	idx int,
) error {
	q := `
		INSERT INTO metacheckpoints (filename, idx, md5)
		VALUES ($1, $2, $3)`
	_, err := db.Exec(q, filename, idx, checksum)
		INSERT INTO metacheckpoints (filename, content, idx, md5)
		VALUES ($1, $2, $3, $4)`
	_, err := db.Exec(q, filename, content, idx, checksum)
	return err
}

func (db *DB) InsertMigration(filename, checksum string) error {
	q := `INSERT INTO meta (filename, md5) VALUES ($1, $2)`
	_, err := db.Exec(q, filename, checksum)
func (db *DB) InsertMigration(filename, content, checksum string) error {
	q := `INSERT INTO meta (filename, content, md5) VALUES ($1, $2, $3)`
	_, err := db.Exec(q, filename, content, checksum)
	return err
}



@@ 88,6 95,26 @@ func (db *DB) DeleteMetaCheckpoints() error {
	return err
}

func (db *DB) CreateMetaVersionIfNotExists() (int, error) {
	q := `CREATE TABLE IF NOT EXISTS metaversion (
		version INTEGER NOT NULL
	)`
	if _, err := db.Exec(q); err != nil {
		return 0, errors.Wrap(err, "create metaversion table")
	}

	var version int
	q = `SELECT version FROM metaversion`
	err := db.Get(&version, q)
	switch {
	case err == sql.ErrNoRows:
		return 0, nil
	case err != nil:
		return 0, errors.Wrap(err, "get version")
	}
	return version, nil
}

func (db *DB) Open() error {
	var err error
	db.DB, err = sqlx.Open("sqlite3", db.filepath)


@@ 96,3 123,137 @@ func (db *DB) Open() error {
	}
	return nil
}

// UpgradeToV1 migrates existing meta tables to the v1 format. Complete any
// migrations before running this function; this will not succeed if have any
// existing metacheckpoints.
func (db *DB) UpgradeToV1(migrations []migrate.Migration) (err error) {
	// Begin Tx
	tx, err := db.Beginx()
	if err != nil {
		return errors.Wrap(err, "begin tx")
	}
	defer func() {
		if err != nil {
			_ = tx.Rollback()
			return
		}
		err = tx.Commit()
	}()

	// Remove the uniqueness constraint from md5. sqlite doesn't support
	// MODIFY COLUMN so we recreate the table.
	q := `CREATE TABLE metatmp (
		filename TEXT UNIQUE NOT NULL,
		md5 TEXT NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
	)`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "create metatmp")
		return
	}
	q = `INSERT INTO metatmp SELECT filename, md5, createdat FROM meta`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "insert metatmp")
	}
	q = `DROP TABLE meta`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "drop meta")
	}
	q = `ALTER TABLE metatmp RENAME TO meta`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "rename metatmp 1")
		return
	}

	// Add a content column to record the exact migration that ran
	// alongside the md5, insert the appropriate data, then set not null
	q = `ALTER TABLE meta ADD COLUMN content TEXT`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "add content column")
		return
	}
	for _, m := range migrations {
		q = `UPDATE meta SET content=$1 WHERE filename=$2`
		if _, err = tx.Exec(q, m.Content, m.Filename); err != nil {
			err = errors.Wrap(err, "update meta content")
			return
		}
	}

	// Once again, sqlite3 doesn't support modify column, so we have to
	// recreate our tables
	q = `CREATE TABLE metatmp (
		filename TEXT UNIQUE NOT NULL,
		content TEXT NOT NULL,
		md5 TEXT NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
	)`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "create metatmp")
		return
	}
	q = `
		INSERT INTO metatmp
		SELECT filename, content, md5, createdat FROM meta`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "")
	}
	q = `DROP TABLE meta`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "drop meta")
		return
	}
	q = `ALTER TABLE metatmp RENAME TO meta`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "rename metatmp 2")
		return
	}

	// Add the content column to metacheckpoints. Same song and dance as above
	q = `CREATE TABLE metacheckpointstmp (
		filename TEXT NOT NULL,
		content TEXT NOT NULL,
		idx INTEGER NOT NULL,
		md5 TEXT NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
		PRIMARY KEY (filename, idx)
	)`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "create metacheckpointstmp")
		return
	}
	q = `
		INSERT INTO metacheckpointstmp
		SELECT filename, md5, createdat FROM meta`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "insert metacheckpointstmp")
	}
	q = `DROP TABLE metacheckpoints`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "drop metacheckpoints")
		return
	}
	q = `ALTER TABLE metacheckpointstmp RENAME TO metacheckpoints`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "rename metacheckpointstmp")
		return
	}

	q = `CREATE TABLE IF NOT EXISTS metaversion (version INTEGER NOT NULL)`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "create metaversion table")
		return
	}
	q = `DELETE FROM metaversion`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "delete metaversion")
		return
	}
	q = `INSERT INTO metaversion (version) VALUES (1)`
	if _, err = tx.Exec(q); err != nil {
		err = errors.Wrap(err, "update metaversion")
		return
	}
	return nil
}

A sqlite/sqlite_test.go => sqlite/sqlite_test.go +178 -0
@@ 0,0 1,178 @@
package sqlite

import (
	"testing"

	"github.com/egtann/migrate"
	"github.com/jmoiron/sqlx"
	_ "github.com/mattn/go-sqlite3"
)

const checkpointFile = "2.sql"

func TestCreateMetaIfNotExists(t *testing.T) {
	t.Parallel()
	db := newDB()

	err := db.CreateMetaIfNotExists()
	check(t, err)

	var tmp []int
	err = db.DB.Select(&tmp, `SELECT 1 FROM meta`)
	check(t, err)
}

func TestCreateMetaCheckpointsIfNotExists(t *testing.T) {
	t.Parallel()
	db := newDB()
	err := db.CreateMetaCheckpointsIfNotExists()
	check(t, err)

	var tmp []int
	err = db.DB.Select(&tmp, `SELECT 1 FROM metacheckpoints`)
	check(t, err)
}

func TestGetMigrations(t *testing.T) {
	t.Parallel()
	db := setupDBV1(t)
	ms, err := db.GetMigrations()
	check(t, err)
	if len(ms) != 1 {
		t.Fatal("expected 1 migration")
	}
}

func TestGetMetaCheckpoints(t *testing.T) {
	t.Parallel()
	db := setupDBV1(t)
	mcs, err := db.GetMetaCheckpoints(checkpointFile)
	check(t, err)
	if len(mcs) != 1 {
		t.Fatal("expected 1 checkpoint")
	}
}

func TestUpsertMigration(t *testing.T) {
	t.Parallel()
	db := setupDBV1(t)

	// Test update
	err := db.UpsertMigration("1.sql", "SELECT 1;", "md5")
	check(t, err)

	// Test insert
	err = db.UpsertMigration("3.sql", "SELECT 3;", "md5")
	check(t, err)

	ms, err := db.GetMigrations()
	check(t, err)
	if len(ms) != 2 {
		t.Fatal("expected 2 migrations")
	}
}

func TestInsertMetaCheckpoint(t *testing.T) {
	t.Parallel()
	db := setupDBV1(t)

	err := db.InsertMetaCheckpoint(checkpointFile, "SELECT 3;", "md5", 1)
	check(t, err)

	mcs, err := db.GetMetaCheckpoints(checkpointFile)
	check(t, err)
	if len(mcs) != 2 {
		t.Fatal("expected 2 checkpoints")
	}
}

func TestInsertMigration(t *testing.T) {
	t.Parallel()
	db := setupDBV1(t)

	err := db.InsertMigration("3.sql", "SELECT 3;", "md5")
	check(t, err)

	ms, err := db.GetMigrations()
	check(t, err)
	if len(ms) != 2 {
		t.Fatal("expected 2 migrations")
	}
}

func TestDeleteMetaCheckpoints(t *testing.T) {
	t.Parallel()
	db := setupDBV1(t)

	err := db.DeleteMetaCheckpoints()
	check(t, err)

	mcs, err := db.GetMetaCheckpoints(checkpointFile)
	check(t, err)
	if len(mcs) != 0 {
		t.Fatal("expected 0 checkpoints")
	}
}

func check(t *testing.T, err error) {
	t.Helper()
	if err != nil {
		t.Fatal(err)
	}
}

func newDB() *DB {
	// Every database connection sees a different database, which is
	// perfect, as that lets us run tests in parallel.
	db, err := sqlx.Open("sqlite3", ":memory:")
	if err != nil {
		panic(err)
	}
	return &DB{DB: db}
}

func setupDBV1(t *testing.T) *DB {
	db := setupDBV0(t)
	err := db.UpgradeToV1([]migrate.Migration{{
		Filename: "1.sql",
		Checksum: "md5",
		Content:  "SELECT 1;",
	}})
	check(t, err)

	q := `
		INSERT INTO metacheckpoints (idx, filename, content, md5)
		VALUES ($1, $2, $3, $4)`
	_, err = db.DB.Exec(q, 0, checkpointFile, "SELECT 2;", "md5")
	check(t, err)

	return db
}

func setupDBV0(t *testing.T) *DB {
	db := newDB()

	q := `CREATE TABLE IF NOT EXISTS meta (
		filename TEXT UNIQUE NOT NULL,
		md5 TEXT UNIQUE NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
	)`
	_, err := db.DB.Exec(q)
	check(t, err)

	q = `CREATE TABLE IF NOT EXISTS metacheckpoints (
		filename TEXT NOT NULL,
		idx INTEGER NOT NULL,
		md5 TEXT NOT NULL,
		createdat TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
		PRIMARY KEY (filename, idx)
	)`
	_, err = db.DB.Exec(q)
	check(t, err)

	q = `INSERT INTO meta (filename, md5) VALUES ($1, $2)`
	_, err = db.DB.Exec(q, "1.sql", "md5")
	check(t, err)

	return db
}

M store.go => store.go +7 -3
@@ 8,14 8,18 @@ type Store interface {
	Open() error
	Exec(string, ...interface{}) (sql.Result, error)

	// CreateMetaversionIfNotExists and report the current version.
	CreateMetaVersionIfNotExists() (int, error)
	CreateMetaIfNotExists() error
	CreateMetaCheckpointsIfNotExists() error

	GetMigrations() ([]Migration, error)
	InsertMigration(filename, checksum string) error
	UpsertMigration(filename, checksum string) error
	InsertMigration(filename, content, checksum string) error
	UpsertMigration(filename, content, checksum string) error

	GetMetaCheckpoints(string) ([]string, error)
	InsertMetaCheckpoint(filename, checksum string, idx int) error
	InsertMetaCheckpoint(filename, content, checksum string, idx int) error
	DeleteMetaCheckpoints() error

	UpgradeToV1([]Migration) error
}

A test.env.example => test.env.example +7 -0
@@ 0,0 1,7 @@
MYSQL_USER=root
MYSQL_PASSWORD=password
MYSQL_HOST=127.0.0.1:3306

POSTGRES_USER=postgres
POSTGRES_PASSWORD=password
POSTGRES_HOST=127.0.0.1:5432