package main import ( "errors" "flag" "fmt" "log" "os" "path/filepath" "strconv" "strings" "time" "kjol/config" "kjol/dbutil" _ "time/tzdata" _ "github.com/lib/pq" ) func invalidInput() { fmt.Println("Usage: [up, down, drop, goto {V}, new {migration name}]") os.Exit(1) } const ( defaultTimeFormat = "20060102150405" defaultTimezone = "UTC" ) var ( errInvalidSequenceWidth = errors.New("Digits must be positive") errIncompatibleSeqAndFormat = errors.New("The seq and format options are mutually exclusive") errInvalidTimeFormat = errors.New("Time format may not be empty") ) func createFile(filename string) error { // create exclusive (fails if file already exists) // os.Create() specifies 0666 as the FileMode, so we're doing the same f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) if err != nil { return err } return f.Close() } func nextSeqVersion(matches []string, seqDigits int) (string, error) { if seqDigits <= 0 { return "", errInvalidSequenceWidth } nextSeq := uint64(1) if len(matches) > 0 { filename := matches[len(matches)-1] matchSeqStr := filepath.Base(filename) idx := strings.Index(matchSeqStr, "_") if idx < 1 { // Using 1 instead of 0 since there should be at least 1 digit return "", fmt.Errorf("Malformed migration filename: %s", filename) } var err error matchSeqStr = matchSeqStr[0:idx] nextSeq, err = strconv.ParseUint(matchSeqStr, 10, 64) if err != nil { return "", err } nextSeq++ } version := fmt.Sprintf("%0[2]*[1]d", nextSeq, seqDigits) if len(version) > seqDigits { return "", fmt.Errorf("Next sequence number %s too large. At most %d digits are allowed", version, seqDigits) } return version, nil } func timeVersion(startTime time.Time, format string) (version string, err error) { switch format { case "": err = errInvalidTimeFormat case "unix": version = strconv.FormatInt(startTime.Unix(), 10) case "unixNano": version = strconv.FormatInt(startTime.UnixNano(), 10) default: version = startTime.Format(format) } return } func newCmd(dir string, startTime time.Time, format string, name string, ext string, seq bool, seqDigits int, print bool) error { if seq && format != defaultTimeFormat { return errIncompatibleSeqAndFormat } var version string var err error dir = filepath.Clean(dir) ext = "." + strings.TrimPrefix(ext, ".") if seq { matches, err := filepath.Glob(filepath.Join(dir, "*"+ext)) if err != nil { return err } version, err = nextSeqVersion(matches, seqDigits) if err != nil { return err } } else { version, err = timeVersion(startTime, format) if err != nil { return err } } versionGlob := filepath.Join(dir, version+"_*"+ext) matches, err := filepath.Glob(versionGlob) if err != nil { return err } if len(matches) > 0 { return fmt.Errorf("duplicate migration version: %s", version) } if err = os.MkdirAll(dir, os.ModePerm); err != nil { return err } for _, direction := range []string{"up", "down"} { basename := fmt.Sprintf("%s_%s.%s%s", version, name, direction, ext) filename := filepath.Join(dir, basename) if err = createFile(filename); err != nil { return err } if print { absPath, _ := filepath.Abs(filename) log.Println(absPath) } } return nil } func main() { envfile := flag.String("env-file", "", "Load environment variables from this file instead of the implicit ./.env. Real environment variables always take precedence.") dir := flag.String("dir", "./migrations", "Directory containing migration files.") flag.Parse() var dbc struct { Username string `env:"DATABASE_USERNAME"` Password string `env:"DATABASE_PASSWORD"` Host string `env:"DATABASE_HOST"` Port int `env:"DATABASE_PORT"` Name string `env:"DATABASE_NAME"` Schema string `env:"DATABASE_SCHEMA"` SSLMode string `env:"DATABASE_SSL_MODE"` } if err := config.Load(*envfile, &dbc); err != nil { fmt.Println(err.Error()) os.Exit(1) } connectionString := dbutil.BuildConnectionString(dbutil.ConnConfig{ Username: dbc.Username, Password: dbc.Password, Host: dbc.Host, Port: dbc.Port, Name: dbc.Name, Schema: dbc.Schema, SSLMode: dbc.SSLMode, }) // parse CLI args and do actions args := flag.Args() if len(args) < 1 { invalidInput() } // handle "new" command early — it doesn't need a database connection if args[0] == "new" { if len(args) < 2 { fmt.Println("Please provide a name for the new migration.") os.Exit(1) } newCmd(*dir, time.Now(), defaultTimeFormat, args[1], "sql", true, 7, true) return } eng, err := newEngine(*dir, connectionString, dbc.Schema) if err != nil { fmt.Println(err.Error()) os.Exit(1) } defer eng.close() migrateNum := 0 if len(args) >= 2 { var parseErr error migrateNum, parseErr = strconv.Atoi(args[1]) if parseErr != nil { fmt.Println("Please provide a valid migration number.") os.Exit(1) } } switch args[0] { case "up": if err := eng.up(); err != nil { if errors.Is(err, errNoChange) { fmt.Println("No change") os.Exit(0) } fmt.Println(err.Error()) os.Exit(1) } fmt.Println("Database successfully migrated to latest version") case "down": if err := eng.down(); err != nil { if errors.Is(err, errNoChange) { fmt.Println("No change") os.Exit(0) } fmt.Println(err.Error()) os.Exit(1) } fmt.Println("Database successfully migrated to previous version") case "goto": if err := eng.goTo(uint(migrateNum)); err != nil { if errors.Is(err, errNoChange) { fmt.Println("No change") os.Exit(0) } fmt.Println(err.Error()) os.Exit(1) } fmt.Printf("Database successfully migrated to version `%d`\n", migrateNum) case "drop": if err := eng.dropAll(); err != nil { fmt.Println(err.Error()) os.Exit(1) } fmt.Println("Database tables, views, and types successfully dropped") default: invalidInput() } }