diff --git a/app.go b/app.go index 50aadef..9fc04a9 100644 --- a/app.go +++ b/app.go @@ -36,6 +36,7 @@ import ( "github.com/writeas/web-core/log" "github.com/writeas/writefreely/author" "github.com/writeas/writefreely/config" + "github.com/writeas/writefreely/migrations" "github.com/writeas/writefreely/page" ) @@ -193,6 +194,7 @@ func Serve() { doConfig := flag.Bool("config", false, "Run the configuration process") genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys") createSchema := flag.Bool("init-db", false, "Initialize app database") + migrate := flag.Bool("migrate", false, "Migrate the database") createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password") createUser := flag.String("create-user", "", "Create a regular user with the given username:password") resetPassUser := flag.String("reset-pass", "", "Reset the given user's password") @@ -312,6 +314,18 @@ func Serve() { } log.Info("Success.") os.Exit(0) + } else if *migrate { + loadConfig(app) + connectToDatabase(app) + defer shutdown(app) + + err := migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName)) + if err != nil { + log.Error("migrate: %s", err) + os.Exit(1) + } + + os.Exit(0) } log.Info("Initializing...") diff --git a/migrations/drivers.go b/migrations/drivers.go new file mode 100644 index 0000000..455cadc --- /dev/null +++ b/migrations/drivers.go @@ -0,0 +1,66 @@ +/* + * Copyright © 2019 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +import ( + "fmt" +) + +// TODO: use now() from writefreely pkg +func (db *datastore) now() string { + if db.driverName == driverSQLite { + return "strftime('%Y-%m-%d %H:%M:%S','now')" + } + return "NOW()" +} + +func (db *datastore) typeInt() string { + if db.driverName == driverSQLite { + return "INTEGER" + } + return "INT" +} + +func (db *datastore) typeSmallInt() string { + if db.driverName == driverSQLite { + return "INTEGER" + } + return "SMALLINT" +} + +func (db *datastore) typeText() string { + return "TEXT" +} + +func (db *datastore) typeChar(l int) string { + if db.driverName == driverSQLite { + return "TEXT" + } + return fmt.Sprintf("CHAR(%d)", l) +} + +func (db *datastore) typeBool() string { + if db.driverName == driverSQLite { + return "INTEGER" + } + return "TINYINT(1)" +} + +func (db *datastore) typeDateTime() string { + return "DATETIME" +} + +func (db *datastore) engine() string { + if db.driverName == driverSQLite { + return "" + } + return " ENGINE = InnoDB" +} diff --git a/migrations/migrations.go b/migrations/migrations.go new file mode 100644 index 0000000..0005c4a --- /dev/null +++ b/migrations/migrations.go @@ -0,0 +1,116 @@ +/* + * Copyright © 2019 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +// Package migrations contains database migrations for WriteFreely +package migrations + +import ( + "database/sql" + "github.com/writeas/web-core/log" +) + +// TODO: refactor to use the datastore struct from writefreely pkg +type datastore struct { + *sql.DB + driverName string +} + +func NewDatastore(db *sql.DB, dn string) *datastore { + return &datastore{db, dn} +} + +// TODO: use these consts from writefreely pkg +const ( + driverMySQL = "mysql" + driverSQLite = "sqlite3" +) + +type Migration interface { + Description() string + Migrate(db *datastore) error +} + +type migration struct { + description string + migrate func(db *datastore) error +} + +func New(d string, fn func(db *datastore) error) Migration { + return &migration{d, fn} +} + +func (m *migration) Description() string { + return m.description +} + +func (m *migration) Migrate(db *datastore) error { + return m.migrate(db) +} + +var migrations = []Migration{} + +func Migrate(db *datastore) error { + var version int + var err error + if db.tableExists("appmigrations") { + err = db.QueryRow("SELECT MAX(version) FROM appmigrations").Scan(&version) + } else { + log.Info("Initializing appmigrations table...") + version = 0 + _, err = db.Exec(`CREATE TABLE appmigrations ( + version ` + db.typeInt() + ` NOT NULL, + migrated ` + db.typeDateTime() + ` NOT NULL, + result ` + db.typeText() + ` NOT NULL + ) ` + db.engine() + `;`) + if err != nil { + return err + } + } + + if len(migrations[version:]) > 0 { + for i, m := range migrations[version:] { + curVer := version + i + 1 + log.Info("Migrating to V%d: %s", curVer, m.Description()) + err = m.Migrate(db) + if err != nil { + return err + } + + // Update migrations table + _, err = db.Exec("INSERT INTO appmigrations (version, migrated, result) VALUES (?, "+db.now()+", ?)", curVer, "") + if err != nil { + return err + } + } + } else { + log.Info("Database up-to-date. No migrations to run.") + } + + return nil +} + +func (db *datastore) tableExists(t string) bool { + var dummy string + var err error + if db.driverName == driverSQLite { + err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", t).Scan(&dummy) + } else { + err = db.QueryRow("SHOW TABLES LIKE ?", t).Scan(&dummy) + } + switch { + case err == sql.ErrNoRows: + return false + case err != nil: + log.Error("Couldn't SHOW TABLES: %v", err) + return false + } + + return true +}