2021-03-09 08:05:07 +00:00
|
|
|
package migration
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2021-03-10 05:43:34 +00:00
|
|
|
_ "embed"
|
|
|
|
"fmt"
|
|
|
|
"os"
|
2021-04-12 09:56:44 +00:00
|
|
|
"os/exec"
|
2021-03-10 05:43:34 +00:00
|
|
|
"path/filepath"
|
|
|
|
"sort"
|
|
|
|
"strings"
|
2021-03-09 08:05:07 +00:00
|
|
|
"time"
|
|
|
|
|
2021-04-12 09:56:44 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/config"
|
2021-03-11 03:39:24 +00:00
|
|
|
"git.handmade.network/hmn/hmn/src/db"
|
|
|
|
"git.handmade.network/hmn/hmn/src/migration/migrations"
|
|
|
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
|
|
|
"git.handmade.network/hmn/hmn/src/website"
|
2021-04-25 06:27:04 +00:00
|
|
|
"github.com/jackc/pgconn"
|
2021-03-09 08:05:07 +00:00
|
|
|
"github.com/jackc/pgx/v4"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
)
|
|
|
|
|
2021-03-10 05:43:34 +00:00
|
|
|
var listMigrations bool
|
2021-03-09 08:05:07 +00:00
|
|
|
|
|
|
|
func init() {
|
|
|
|
migrateCommand := &cobra.Command{
|
2021-03-10 05:43:34 +00:00
|
|
|
Use: "migrate [target migration id]",
|
2021-03-09 08:05:07 +00:00
|
|
|
Short: "Run database migrations",
|
|
|
|
Run: func(cmd *cobra.Command, args []string) {
|
2021-03-10 05:43:34 +00:00
|
|
|
if listMigrations {
|
|
|
|
ListMigrations()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
targetVersion := time.Time{}
|
|
|
|
if len(args) > 0 {
|
|
|
|
var err error
|
|
|
|
targetVersion, err = time.Parse(time.RFC3339, args[0])
|
|
|
|
if err != nil {
|
|
|
|
fmt.Printf("ERROR: bad version string: %v", err)
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
Migrate(types.MigrationVersion(targetVersion))
|
|
|
|
},
|
|
|
|
}
|
|
|
|
migrateCommand.Flags().BoolVar(&listMigrations, "list", false, "List available migrations")
|
|
|
|
|
|
|
|
makeMigrationCommand := &cobra.Command{
|
|
|
|
Use: "makemigration <name> <description>...",
|
|
|
|
Short: "Create a new database migration file",
|
|
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
|
|
if len(args) < 2 {
|
2021-03-28 15:32:30 +00:00
|
|
|
fmt.Printf("You must provide a name and a description.\n\n")
|
|
|
|
cmd.Usage()
|
2021-03-10 05:43:34 +00:00
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
|
|
|
|
name := args[0]
|
|
|
|
description := strings.Join(args[1:], " ")
|
|
|
|
|
|
|
|
MakeMigration(name, description)
|
2021-03-09 08:05:07 +00:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2021-04-12 09:56:44 +00:00
|
|
|
seedFromFileCommand := &cobra.Command{
|
2021-09-06 00:43:49 +00:00
|
|
|
Use: "seedfile <filename>",
|
|
|
|
Short: "Resets the db and runs the seed file.",
|
2021-04-12 09:56:44 +00:00
|
|
|
Run: func(cmd *cobra.Command, args []string) {
|
2021-09-06 00:43:49 +00:00
|
|
|
if len(args) < 1 {
|
|
|
|
fmt.Printf("You must provide a seed file.\n\n")
|
2021-04-12 09:56:44 +00:00
|
|
|
cmd.Usage()
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
|
2021-09-06 00:43:49 +00:00
|
|
|
SeedFromFile(args[0])
|
2021-04-12 09:56:44 +00:00
|
|
|
},
|
|
|
|
}
|
|
|
|
|
2021-03-09 08:05:07 +00:00
|
|
|
website.WebsiteCommand.AddCommand(migrateCommand)
|
2021-03-10 05:43:34 +00:00
|
|
|
website.WebsiteCommand.AddCommand(makeMigrationCommand)
|
2021-04-12 09:56:44 +00:00
|
|
|
website.WebsiteCommand.AddCommand(seedFromFileCommand)
|
2021-03-09 08:05:07 +00:00
|
|
|
}
|
|
|
|
|
2021-03-10 05:43:34 +00:00
|
|
|
func getSortedMigrationVersions() []types.MigrationVersion {
|
|
|
|
var allVersions []types.MigrationVersion
|
|
|
|
for migrationTime, _ := range migrations.All {
|
|
|
|
allVersions = append(allVersions, migrationTime)
|
|
|
|
}
|
|
|
|
sort.Slice(allVersions, func(i, j int) bool {
|
|
|
|
return allVersions[i].Before(allVersions[j])
|
|
|
|
})
|
|
|
|
|
|
|
|
return allVersions
|
|
|
|
}
|
|
|
|
|
2021-04-23 04:07:44 +00:00
|
|
|
func getCurrentVersion(ctx context.Context, conn *pgx.Conn) (types.MigrationVersion, error) {
|
2021-03-10 05:43:34 +00:00
|
|
|
var currentVersion time.Time
|
2021-04-23 04:07:44 +00:00
|
|
|
row := conn.QueryRow(ctx, "SELECT version FROM hmn_migration")
|
2021-03-10 05:43:34 +00:00
|
|
|
err := row.Scan(¤tVersion)
|
|
|
|
if err != nil {
|
|
|
|
return types.MigrationVersion{}, err
|
|
|
|
}
|
|
|
|
currentVersion = currentVersion.UTC()
|
|
|
|
|
|
|
|
return types.MigrationVersion(currentVersion), nil
|
|
|
|
}
|
|
|
|
|
2021-07-31 02:36:37 +00:00
|
|
|
func tryGetCurrentVersion(ctx context.Context) types.MigrationVersion {
|
|
|
|
defer func() {
|
|
|
|
recover()
|
|
|
|
}()
|
2021-04-23 04:07:44 +00:00
|
|
|
|
2021-03-09 08:05:07 +00:00
|
|
|
conn := db.NewConn()
|
2021-04-23 04:07:44 +00:00
|
|
|
defer conn.Close(ctx)
|
2021-03-09 08:05:07 +00:00
|
|
|
|
2021-04-23 04:07:44 +00:00
|
|
|
currentVersion, _ := getCurrentVersion(ctx, conn)
|
2021-07-31 02:36:37 +00:00
|
|
|
|
|
|
|
return currentVersion
|
|
|
|
}
|
|
|
|
|
|
|
|
func ListMigrations() {
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
currentVersion := tryGetCurrentVersion(ctx)
|
2021-03-10 05:43:34 +00:00
|
|
|
for _, version := range getSortedMigrationVersions() {
|
|
|
|
migration := migrations.All[version]
|
|
|
|
indicator := " "
|
|
|
|
if version.Equal(currentVersion) {
|
|
|
|
indicator = "✔ "
|
|
|
|
}
|
|
|
|
fmt.Printf("%s%v (%s: %s)\n", indicator, version, migration.Name(), migration.Description())
|
|
|
|
}
|
|
|
|
}
|
2021-03-09 08:05:07 +00:00
|
|
|
|
2021-03-10 05:43:34 +00:00
|
|
|
func Migrate(targetVersion types.MigrationVersion) {
|
2021-04-23 04:07:44 +00:00
|
|
|
ctx := context.Background() // In the future, this could actually do something cool.
|
|
|
|
|
2021-03-10 05:43:34 +00:00
|
|
|
conn := db.NewConn()
|
2021-04-23 04:07:44 +00:00
|
|
|
defer conn.Close(ctx)
|
2021-03-10 05:43:34 +00:00
|
|
|
|
|
|
|
// create migration table
|
2021-04-23 04:07:44 +00:00
|
|
|
_, err := conn.Exec(ctx, `
|
2021-03-10 05:43:34 +00:00
|
|
|
CREATE TABLE IF NOT EXISTS hmn_migration (
|
|
|
|
version TIMESTAMP WITH TIME ZONE
|
|
|
|
)
|
|
|
|
`)
|
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to create migration table: %w", err))
|
|
|
|
}
|
|
|
|
|
|
|
|
// ensure there is a row
|
2021-04-23 04:07:44 +00:00
|
|
|
row := conn.QueryRow(ctx, "SELECT COUNT(*) FROM hmn_migration")
|
2021-03-10 05:43:34 +00:00
|
|
|
var numRows int
|
|
|
|
err = row.Scan(&numRows)
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
if numRows < 1 {
|
2021-04-23 04:07:44 +00:00
|
|
|
_, err := conn.Exec(ctx, "INSERT INTO hmn_migration (version) VALUES ($1)", time.Time{})
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to insert initial migration row: %w", err))
|
|
|
|
}
|
|
|
|
}
|
2021-03-09 08:05:07 +00:00
|
|
|
|
|
|
|
// run migrations
|
2021-04-23 04:07:44 +00:00
|
|
|
currentVersion, err := getCurrentVersion(ctx, conn)
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to get current version: %w", err))
|
|
|
|
}
|
|
|
|
if currentVersion.IsZero() {
|
|
|
|
fmt.Println("This is the first time you have run database migrations.")
|
|
|
|
} else {
|
|
|
|
fmt.Printf("Current version: %s\n", currentVersion.String())
|
|
|
|
}
|
|
|
|
|
|
|
|
allVersions := getSortedMigrationVersions()
|
|
|
|
if targetVersion.IsZero() {
|
|
|
|
targetVersion = allVersions[len(allVersions)-1]
|
|
|
|
}
|
|
|
|
|
|
|
|
currentIndex := -1
|
|
|
|
targetIndex := -1
|
|
|
|
for i, version := range allVersions {
|
|
|
|
if currentVersion.Equal(version) {
|
|
|
|
currentIndex = i
|
|
|
|
}
|
|
|
|
if targetVersion.Equal(version) {
|
|
|
|
targetIndex = i
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if targetIndex < 0 {
|
|
|
|
fmt.Printf("ERROR: Could not find migration with version %v\n", targetVersion)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if currentIndex < targetIndex {
|
|
|
|
// roll forward
|
|
|
|
for i := currentIndex + 1; i <= targetIndex; i++ {
|
|
|
|
version := allVersions[i]
|
|
|
|
migration := migrations.All[version]
|
2021-04-11 21:46:06 +00:00
|
|
|
fmt.Printf("Applying migration %v (%v)\n", version, migration.Name())
|
2021-03-10 05:43:34 +00:00
|
|
|
|
2021-04-23 04:07:44 +00:00
|
|
|
tx, err := conn.Begin(ctx)
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to start transaction: %w", err))
|
|
|
|
}
|
2021-04-23 04:07:44 +00:00
|
|
|
defer tx.Rollback(ctx)
|
2021-03-10 05:43:34 +00:00
|
|
|
|
2021-04-23 04:07:44 +00:00
|
|
|
err = migration.Up(ctx, tx)
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
fmt.Printf("MIGRATION FAILED for migration %v.\n", version)
|
|
|
|
fmt.Printf("Error: %v\n", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-04-23 04:07:44 +00:00
|
|
|
_, err = tx.Exec(ctx, "UPDATE hmn_migration SET version = $1", version)
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to update version in migrations table: %w", err))
|
|
|
|
}
|
|
|
|
|
2021-04-23 04:07:44 +00:00
|
|
|
err = tx.Commit(ctx)
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to commit transaction: %w", err))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else if currentIndex > targetIndex {
|
|
|
|
// roll back
|
|
|
|
for i := currentIndex; i > targetIndex; i-- {
|
|
|
|
version := allVersions[i]
|
|
|
|
previousVersion := types.MigrationVersion{}
|
|
|
|
if i > 0 {
|
|
|
|
previousVersion = allVersions[i-1]
|
|
|
|
}
|
|
|
|
|
2021-04-23 04:07:44 +00:00
|
|
|
tx, err := conn.Begin(ctx)
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to start transaction: %w", err))
|
|
|
|
}
|
2021-04-23 04:07:44 +00:00
|
|
|
defer tx.Rollback(ctx)
|
2021-03-10 05:43:34 +00:00
|
|
|
|
|
|
|
fmt.Printf("Rolling back migration %v\n", version)
|
|
|
|
migration := migrations.All[version]
|
2021-04-23 04:07:44 +00:00
|
|
|
err = migration.Down(ctx, tx)
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
fmt.Printf("MIGRATION FAILED for migration %v.\n", version)
|
|
|
|
fmt.Printf("Error: %v\n", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2021-04-23 04:07:44 +00:00
|
|
|
_, err = tx.Exec(ctx, "UPDATE hmn_migration SET version = $1", previousVersion)
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to update version in migrations table: %w", err))
|
|
|
|
}
|
|
|
|
|
2021-04-23 04:07:44 +00:00
|
|
|
err = tx.Commit(ctx)
|
2021-03-10 05:43:34 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to commit transaction: %w", err))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
fmt.Println("Already migrated; nothing to do.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
//go:embed migrationTemplate.txt
|
|
|
|
var migrationTemplate string
|
|
|
|
|
|
|
|
func MakeMigration(name, description string) {
|
|
|
|
result := migrationTemplate
|
|
|
|
result = strings.ReplaceAll(result, "%NAME%", name)
|
|
|
|
result = strings.ReplaceAll(result, "%DESCRIPTION%", fmt.Sprintf("%#v", description))
|
|
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
nowConstructor := fmt.Sprintf("time.Date(%d, %d, %d, %d, %d, %d, 0, time.UTC)", now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second())
|
|
|
|
result = strings.ReplaceAll(result, "%DATE%", nowConstructor)
|
|
|
|
|
|
|
|
safeVersion := strings.ReplaceAll(types.MigrationVersion(now).String(), ":", "")
|
|
|
|
filename := fmt.Sprintf("%v_%v.go", safeVersion, name)
|
2021-03-27 21:10:11 +00:00
|
|
|
path := filepath.Join("src", "migration", "migrations", filename)
|
2021-03-10 05:43:34 +00:00
|
|
|
|
|
|
|
err := os.WriteFile(path, []byte(result), 0644)
|
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to write migration file: %w", err))
|
|
|
|
}
|
|
|
|
|
|
|
|
fmt.Println("Successfully created migration file:")
|
|
|
|
fmt.Println(path)
|
2021-03-09 08:05:07 +00:00
|
|
|
}
|
2021-04-12 09:56:44 +00:00
|
|
|
|
|
|
|
// Applies a cloned db to the local db.
|
2021-04-12 14:12:57 +00:00
|
|
|
// Applies the seed after the migration specified in `afterMigration`.
|
2021-04-25 06:27:04 +00:00
|
|
|
// NOTE(asaf): The db role specified in the config must have the CREATEDB attribute! `ALTER ROLE hmn WITH CREATEDB;`
|
2021-09-06 00:43:49 +00:00
|
|
|
func SeedFromFile(seedFile string) {
|
2021-04-12 09:56:44 +00:00
|
|
|
file, err := os.Open(seedFile)
|
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("couldn't open seed file %s: %w", seedFile, err))
|
|
|
|
}
|
|
|
|
file.Close()
|
|
|
|
|
2021-04-25 06:27:04 +00:00
|
|
|
fmt.Println("Resetting database...")
|
|
|
|
{
|
|
|
|
ctx := context.Background()
|
|
|
|
// NOTE(asaf): We connect to db "template1", because we have to connect to something other than our own db in order to drop it.
|
|
|
|
template1DSN := fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s",
|
|
|
|
config.Config.Postgres.User,
|
|
|
|
config.Config.Postgres.Password,
|
|
|
|
config.Config.Postgres.Hostname,
|
|
|
|
config.Config.Postgres.Port,
|
|
|
|
"template1", // NOTE(asaf): template1 must always exist in postgres, as it's the db that gets cloned when you create new DBs
|
|
|
|
)
|
|
|
|
// NOTE(asaf): We have to use the low-level API of pgconn, because the pgx Exec always wraps the query in a transaction.
|
|
|
|
lowLevelConn, err := pgconn.Connect(ctx, template1DSN)
|
2021-04-27 01:49:46 +00:00
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to connect to db: %w", err))
|
|
|
|
}
|
2021-04-25 06:27:04 +00:00
|
|
|
defer lowLevelConn.Close(ctx)
|
|
|
|
|
2021-04-27 01:49:46 +00:00
|
|
|
result := lowLevelConn.ExecParams(ctx, fmt.Sprintf("DROP DATABASE %s", config.Config.Postgres.DbName), nil, nil, nil, nil)
|
2021-04-25 06:27:04 +00:00
|
|
|
_, err = result.Close()
|
|
|
|
pgErr, isPgError := err.(*pgconn.PgError)
|
|
|
|
if err != nil {
|
2021-04-27 01:49:46 +00:00
|
|
|
if !(isPgError && pgErr.SQLState() == "3D000") { // NOTE(asaf): 3D000 means "Database does not exist"
|
2021-04-25 06:27:04 +00:00
|
|
|
panic(fmt.Errorf("failed to drop db: %w", err))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-04-27 01:49:46 +00:00
|
|
|
result = lowLevelConn.ExecParams(ctx, fmt.Sprintf("CREATE DATABASE %s", config.Config.Postgres.DbName), nil, nil, nil, nil)
|
2021-04-25 06:27:04 +00:00
|
|
|
_, err = result.Close()
|
|
|
|
if err != nil {
|
|
|
|
panic(fmt.Errorf("failed to create db: %w", err))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-04-12 09:56:44 +00:00
|
|
|
fmt.Println("Executing seed...")
|
2021-08-08 20:05:52 +00:00
|
|
|
cmd := exec.Command("pg_restore",
|
2021-04-12 09:56:44 +00:00
|
|
|
"--single-transaction",
|
2021-09-06 00:43:49 +00:00
|
|
|
"--dbname", config.Config.Postgres.DSN(),
|
2021-04-12 09:56:44 +00:00
|
|
|
seedFile,
|
|
|
|
)
|
|
|
|
fmt.Println("Running command:", cmd)
|
2021-09-04 18:59:03 +00:00
|
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
|
|
fmt.Print(string(output))
|
2021-09-04 21:09:01 +00:00
|
|
|
panic(fmt.Errorf("failed to execute seed: %w", err))
|
2021-04-12 09:56:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
fmt.Println("Done! You may want to migrate forward from here.")
|
|
|
|
ListMigrations()
|
|
|
|
}
|
|
|
|
|
|
|
|
// NOTE(asaf): This will be useful for open-sourcing the website, but is not yet necessary.
|
|
|
|
// Creates only what's necessary for a fresh deployment with no data
|
2021-04-16 23:04:01 +00:00
|
|
|
// TODO(opensource)
|
2021-04-12 09:56:44 +00:00
|
|
|
func BareMinimumSeed() {
|
|
|
|
}
|
|
|
|
|
|
|
|
// NOTE(asaf): This will be useful for open-sourcing the website, but is not yet necessary.
|
|
|
|
// Creates enough data for development
|
2021-04-16 23:04:01 +00:00
|
|
|
// TODO(opensource)
|
2021-04-12 09:56:44 +00:00
|
|
|
func SampleSeed() {
|
|
|
|
// admin := CreateAdminUser("admin", "12345678")
|
|
|
|
// user := CreateUser("regular_user", "12345678")
|
|
|
|
// hmnProject := CreateProject("hmn", "Handmade Network")
|
|
|
|
// Create category
|
|
|
|
// Create thread
|
|
|
|
// Create accepted user project
|
|
|
|
// Create pending user project
|
|
|
|
// Create showcase items
|
|
|
|
// Create codelanguages
|
|
|
|
// Create library and library resources
|
|
|
|
}
|