Skip to content

Commit

Permalink
feat(migrations): Adding migrations to be up and downable (#24)
Browse files Browse the repository at this point in the history
* adding create for the migrations

* Adding migration files

* Updating the examples
  • Loading branch information
Jacobbrewer1 authored Oct 19, 2024
1 parent af511ee commit 90d0c93
Show file tree
Hide file tree
Showing 57 changed files with 12,451 additions and 0 deletions.
111 changes: 111 additions & 0 deletions cmd/schema/cmd_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package main

import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"

"github.com/Jacobbrewer1/goschema/pkg/migrations"
"github.com/google/subcommands"
)

type createCmd struct {
// name is the name of the migration to create.
name string

// OutputLocation is the location to write the generated files to.
outputLocation string
}

func (c *createCmd) Name() string {
return "create"
}

func (c *createCmd) Synopsis() string {
return "Create a new migration"
}

func (c *createCmd) Usage() string {
return `create:
Create a new migration.
`
}

func (c *createCmd) SetFlags(f *flag.FlagSet) {
f.StringVar(&c.name, "name", "", "The name of the migration to create.")
f.StringVar(&c.outputLocation, "out", ".", "The location to write the generated files to.")
}

func (c *createCmd) Execute(_ context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if c.name == "" {
slog.Error("Name is required")
return subcommands.ExitUsageError
}

if c.outputLocation == "" {
slog.Error("Output location is required")
return subcommands.ExitUsageError
}

// File name is timestamp_name.up.sql and timestamp_name.down.sql
// The timestamp is the current time in the format YYYYMMDDHHMMSS
// The name is the name of the migration with spaces as underscores

now := time.Now()
name := fmt.Sprintf("%s_%s", now.Format(migrations.FilePrefix), strings.TrimSpace(c.name))
name = strings.ReplaceAll(name, " ", "_")

upName := fmt.Sprintf("%s.up.sql", name)
downName := fmt.Sprintf("%s.down.sql", name)

upPath := fmt.Sprintf("%s/%s", c.outputLocation, upName)
downPath := fmt.Sprintf("%s/%s", c.outputLocation, downName)

upAbs, err := filepath.Abs(upPath)
if err != nil {
slog.Error("Error getting absolute path", slog.String("path", upPath), slog.String("error", err.Error()))
return subcommands.ExitFailure
}

downAbs, err := filepath.Abs(downPath)
if err != nil {
slog.Error("Error getting absolute path", slog.String("path", downPath), slog.String("error", err.Error()))
return subcommands.ExitFailure
}

if err := createFile(upAbs); err != nil {
slog.Error("Error creating file", slog.String("path", upAbs), slog.String("error", err.Error()))
return subcommands.ExitFailure
}

slog.Info("Up migration created", slog.String("path", upAbs))

if err := createFile(downAbs); err != nil {
slog.Error("Error creating file", slog.String("path", downAbs), slog.String("error", err.Error()))
return subcommands.ExitFailure
}

slog.Info("Down migration created", slog.String("path", downAbs))

return subcommands.ExitSuccess
}

func createFile(name string) error {
// Create the path if it does not exist.
dir := filepath.Dir(name)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return fmt.Errorf("error creating path: %w", err)
}

f, err := os.Create(name)
if err != nil {
return fmt.Errorf("error creating file: %w", err)
}

return f.Close()
}
88 changes: 88 additions & 0 deletions cmd/schema/cmd_migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package main

import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"

"github.com/Jacobbrewer1/goschema/pkg/migrations"
"github.com/google/subcommands"
)

type migrateCmd struct {
// up is the flag to migrate up.
up bool

// down is the flag to migrate down.
down bool

// migrationLocation is where the migrations are located.
migrationLocation string
}

func (m *migrateCmd) Name() string {
return "migrate"
}

func (m *migrateCmd) Synopsis() string {
return "Migrate the database"
}

func (m *migrateCmd) Usage() string {
return `migrate:
Migrate the database.
`
}

func (m *migrateCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&m.up, "up", false, "Migrate up.")
f.BoolVar(&m.down, "down", false, "Migrate down.")
f.StringVar(&m.migrationLocation, "loc", "./migrations", "The location of the migrations.")
}

func (m *migrateCmd) Execute(ctx context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if m.up && m.down {
slog.Error("Cannot migrate up and down at the same time")
return subcommands.ExitUsageError
} else if !m.up && !m.down {
slog.Error("Must specify up or down")
return subcommands.ExitUsageError
}

if e := os.Getenv(migrations.DbEnvVar); e == "" {
slog.Error(fmt.Sprintf("Environment variable %s is not set", migrations.DbEnvVar))
return subcommands.ExitFailure
}

absPath, err := filepath.Abs(m.migrationLocation)
if err != nil {
slog.Error("Error getting absolute path", slog.String("error", err.Error()))
return subcommands.ExitFailure
}

db, err := migrations.ConnectDB()
if err != nil {
slog.Error("Error connecting to the database", slog.String("error", err.Error()))
return subcommands.ExitFailure
}

switch {
case m.up:
if err := migrations.NewVersioning(db, absPath).MigrateUp(); err != nil {
slog.Error("Error migrating up", slog.String("error", err.Error()))
return subcommands.ExitFailure
}
case m.down:
if err := migrations.NewVersioning(db, absPath).MigrateDown(); err != nil {
slog.Error("Error migrating down", slog.String("error", err.Error()))
return subcommands.ExitFailure
}
}

slog.Info("Migration complete")

return subcommands.ExitSuccess
}
2 changes: 2 additions & 0 deletions cmd/schema/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ func main() {

subcommands.Register(new(versionCmd), "")
subcommands.Register(new(generateCmd), "")
subcommands.Register(new(createCmd), "")
subcommands.Register(new(migrateCmd), "")

flag.Parse()

Expand Down
82 changes: 82 additions & 0 deletions example/database/migrate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/bin/bash

function fail() {
gum style --foreground 196 "$1"
exit 1
}

if ! command -v gum &> /dev/null; then
echo "gum is required to generate models.'"
echo "Trying to install gum..."
go install github.com/charmbracelet/gum@latest || fail "Failed to install gum"
fi

if ! command -v goschema &> /dev/null; then
gum style --foreground 196 "goschema is required to generate models. Please install it"
exit 1
fi

if ! command -v goimports &> /dev/null; then
gum style --foreground 196 "goimports is required to generate models. Please install it by running 'go get golang.org/x/tools/cmd/goimports'"
exit 1
fi

up=false
down=false
forced=false

# Get the flags passed to the script and set the variables accordingly
while getopts "udcf" flag; do
case $flag in
u)
up=true
;;
d)
down=true
;;
f)
forced=true
;;
*)
gum style --foreground 196 "Invalid flag $flag"
exit 1
;;
esac
done

option=""

if [ "$up" = false ] && [ "$down" = false ]; then
# Assume being ran by the user
gum style --foreground 222 "Please choose an option"
option=$(gum choose "create" "up" "down")
fi

if [ "$up" = true ] && [ "$down" = true ]; then
gum style --foreground 196 "Cannot run both up and down migrations"
exit 1
fi

case $option in
up)
# Is the DATABASE_URL set?
if [ -z "$DATABASE_URL" ]; then
gum style --foreground 196 "DATABASE_URL is not set"
exit 1
fi

gum spin --spinner dot --title "Running up migrations" -- goschema migrate --up --loc=./migrations
;;
down)
# Is the DATABASE_URL set?
if [ -z "$DATABASE_URL" ]; then
gum style --foreground 196 "DATABASE_URL is not set"
exit 1
fi

gum spin --spinner dot --title "Running down migrations" -- goschema migrate --down --loc=./migrations
;;
create)
name=$(gum input --placeholder "Please describe the migration")
gum spin --spinner dot --title "Creating migrations" -- goschema create --out=./migrations --name="$name"
esac
1 change: 1 addition & 0 deletions example/database/migrations/20241019200631_test.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE test;
4 changes: 4 additions & 0 deletions example/database/migrations/20241019200631_test.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE TABLE test (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL
);
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.22

require (
github.com/Masterminds/sprig v2.22.0+incompatible
github.com/go-sql-driver/mysql v1.8.1
github.com/google/subcommands v1.2.0
github.com/huandu/xstrings v1.5.0
github.com/jmoiron/sqlx v1.4.0
Expand All @@ -13,6 +14,7 @@ require (
)

require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v0.0.0-20170626110600-a368813c5e64 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver v1.4.2 // indirect
Expand Down
35 changes: 35 additions & 0 deletions pkg/migrations/connect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package migrations

import (
"fmt"
"os"
"strings"

_ "github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
)

const (
DbEnvVar = "DATABASE_URL"
)

func ConnectDB() (*sqlx.DB, error) {
// Get the connection string.
connStr := getConnectionStr()
// Open the database connection.
db, err := sqlx.Open("mysql", connStr)
if err != nil {
return nil, fmt.Errorf("error opening database: %w", err)
}
return db, nil
}

func getConnectionStr() string {
// Get the connection string from the environment.
connStr := os.Getenv(DbEnvVar)
// Append "?timeout=90s&multiStatements=true&parseTime=true" to the connection string. But remove any current query string.
if strings.Contains(connStr, "?") {
connStr = strings.Split(connStr, "?")[0]
}
return fmt.Sprintf("%s?timeout=90s&multiStatements=true&parseTime=true", connStr)
}
Loading

0 comments on commit 90d0c93

Please sign in to comment.