Skip to content

Commit

Permalink
Add support for seamless DB upgrades / migrations (--upgrade).
Browse files Browse the repository at this point in the history
  • Loading branch information
knadh committed Dec 1, 2023
1 parent b4eb5ad commit dfa63f8
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 4 deletions.
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
dictpress
18 changes: 17 additions & 1 deletion cmd/dictpress/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"fmt"
"os"
"strings"

"github.com/jmoiron/sqlx"
)

func installSchema(app *App, prompt bool) {
func installSchema(ver string, app *App, prompt bool) {
if prompt {
fmt.Println("")
fmt.Println("** first time installation **")
Expand Down Expand Up @@ -39,5 +41,19 @@ func installSchema(app *App, prompt bool) {
return
}

// Insert the current migration version.
if err := recordMigrationVersion(ver, app.db); err != nil {
app.lo.Fatal(err)
}

app.lo.Println("successfully installed schema")
}

// recordMigrationVersion inserts the given version (of DB migration) into the
// `migrations` array in the settings table.
func recordMigrationVersion(ver string, db *sqlx.DB) error {
_, err := db.Exec(fmt.Sprintf(`INSERT INTO settings (key, value)
VALUES('migrations', '["%s"]'::JSONB)
ON CONFLICT (key) DO UPDATE SET value = settings.value || EXCLUDED.value`, ver))
return err
}
13 changes: 11 additions & 2 deletions cmd/dictpress/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ func init() {
"path to one or more config files (will be merged in order)")
f.String("site", "", "path to a site theme. If left empty, only HTTP APIs will be available.")
f.Bool("install", false, "run first time DB installation")
f.Bool("upgrade", false, "upgrade database to the current version")
f.Bool("yes", false, "assume 'yes' to prompts during --install/upgrade")
f.String("import", "", "import a CSV file into the database. eg: --import=data.csv")
f.Bool("yes", false, "assume 'yes' to prompts, eg: during --install")
f.Bool("version", false, "current version of the build")

if err := f.Parse(os.Args[1:]); err != nil {
Expand Down Expand Up @@ -143,10 +144,18 @@ func main() {

// Install schema.
if ko.Bool("install") {
installSchema(app, !ko.Bool("yes"))
installSchema(migList[len(migList)-1].version, app, !ko.Bool("yes"))
return
}

if ko.Bool("upgrade") {
upgrade(db, app.fs, !ko.Bool("yes"))
os.Exit(0)
}

// Before the queries are prepared, see if there are pending upgrades.
checkUpgrade(db)

// Load SQL queries.
qB, err := app.fs.Read("/queries.sql")
if err != nil {
Expand Down
147 changes: 147 additions & 0 deletions cmd/dictpress/upgrade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package main

import (
"fmt"
"strings"

"github.com/jmoiron/sqlx"
"github.com/knadh/dictpress/internal/migrations"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
"golang.org/x/mod/semver"
)

// migFunc represents a migration function for a particular version.
// fn (generally) executes database migrations and additionally
// takes the filesystem and config objects in case there are additional bits
// of logic to be performed before executing upgrades. fn is idempotent.
type migFunc struct {
version string
fn func(*sqlx.DB, stuffbin.FileSystem, *koanf.Koanf) error
}

// migList is the list of available migList ordered by the semver.
// Each migration is a Go file in internal/migrations named after the semver.
// The functions are named as: v0.7.0 => migrations.V0_7_0() and are idempotent.
var migList = []migFunc{
{"v2.0.0", migrations.V2_0_0},
}

// upgrade upgrades the database to the current version by running SQL migration files
// for all version from the last known version to the current one.
func upgrade(db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
if prompt {
var ok string
fmt.Printf("** IMPORTANT: Take a backup of the database before upgrading.\n")
fmt.Print("continue (y/n)? ")
if _, err := fmt.Scanf("%s", &ok); err != nil {
lo.Fatalf("error reading value from terminal: %v", err)
}
if strings.ToLower(ok) != "y" {
fmt.Println("upgrade cancelled")
return
}
}

_, toRun, err := getPendingMigrations(db)
if err != nil {
lo.Fatalf("error checking migrations: %v", err)
}

// No migrations to run.
if len(toRun) == 0 {
lo.Printf("no upgrades to run. Database is up to date.")
return
}

// Execute migrations in succession.
for _, m := range toRun {
lo.Printf("running migration %s", m.version)
if err := m.fn(db, fs, ko); err != nil {
lo.Fatalf("error running migration %s: %v", m.version, err)
}

// Record the migration version in the settings table. There was no
// settings table until v0.7.0, so ignore the no-table errors.
if err := recordMigrationVersion(m.version, db); err != nil {
if isTableNotExistErr(err) {
continue
}
lo.Fatalf("error recording migration version %s: %v", m.version, err)
}
}

lo.Printf("upgrade complete")
}

// checkUpgrade checks if the current database schema matches the expected
// binary version.
func checkUpgrade(db *sqlx.DB) {
lastVer, toRun, err := getPendingMigrations(db)
if err != nil {
lo.Fatalf("error checking migrations: %v", err)
}

// No migrations to run.
if len(toRun) == 0 {
return
}

var vers []string
for _, m := range toRun {
vers = append(vers, m.version)
}

lo.Fatalf(`there are %d pending database upgrade(s): %v. The last upgrade was %s. Backup the database and run --upgrade`,
len(toRun), vers, lastVer)
}

// getPendingMigrations gets the pending migrations by comparing the last
// recorded migration in the DB against all migrations listed in `migrations`.
func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) {
lastVer, err := getLastMigrationVersion(db)
if err != nil {
return "", nil, err
}

// Iterate through the migration versions and get everything above the last
// upgraded semver.
var toRun []migFunc
for i, m := range migList {
if semver.Compare(m.version, lastVer) > 0 {
toRun = migList[i:]
break
}
}

return lastVer, toRun, nil
}

// getLastMigrationVersion returns the last migration semver recorded in the DB.
// If there isn't any, `v0.0.0` is returned.
func getLastMigrationVersion(db *sqlx.DB) (string, error) {
var v string
if err := db.Get(&v, `
SELECT COALESCE(
(SELECT value->>-1 FROM settings WHERE key='migrations'),
'v0.0.0')`); err != nil {
if isTableNotExistErr(err) {
return "v0.0.0", nil
}
return v, err
}
return v, nil
}

// isTableNotExistErr checks if the given error represents a Postgres/pq
// "table does not exist" error.
func isTableNotExistErr(err error) bool {
if p, ok := err.(*pq.Error); ok {
// `settings` table does not exist. It was introduced in v0.7.0.
if p.Code == "42P01" {
return true
}
}
return false
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/lib/pq v1.10.9
github.com/spf13/pflag v1.0.5
gitlab.com/joice/mlphone-go v0.0.0-20201001084309-2bb02984eed8
golang.org/x/mod v0.8.0
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
Expand Down
27 changes: 27 additions & 0 deletions internal/migrations/v2_0_0.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package migrations

import (
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/stuffbin"
)

// V2_0_0 performs the DB migrations.
func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT NOT NULL UNIQUE,
value JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key);
`); err != nil {
return err
}

if _, err := db.Exec(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}'`); err != nil {
return err
}

return nil
}
9 changes: 9 additions & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,12 @@ CREATE TABLE comments (

created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- settings
DROP TABLE IF EXISTS settings CASCADE;
CREATE TABLE settings (
key TEXT NOT NULL UNIQUE,
value JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
DROP INDEX IF EXISTS idx_settings_key; CREATE INDEX idx_settings_key ON settings(key);

0 comments on commit dfa63f8

Please sign in to comment.