Skip to content

Implement migration system #15

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,46 @@ dbx is a database schema migration library for Go that lets you manage database

## Features

- **Rails-like migrations**: Define and organize schema changes with timestamp-based migrations
- **Database inspection**: Introspect existing database schemas
- **Schema comparison**: Compare schemas and generate migration statements
- **Built on `database/sql`**: Works with standard Go database drivers
- **Automatic SQL generation**: Automatically generate SQL statements for schema changes
- **CLI tool**: Command-line interface for creating and running migrations

## Usage Examples

### Rails-like Migrations

Create and run migrations using the CLI:

```bash
# Generate a new migration
dbx generate create_users

# Run migrations
dbx --database "postgres://postgres:postgres@localhost:5432/dbx_test?sslmode=disable" migrate
```

Or programmatically in your code:

```go
import (
"database/sql"
_ "github.com/lib/pq"
"github.com/swiftcarrot/dbx/migration"
)

// Set the migrations directory
migration.SetMigrationsDir("./migrations")

// Run migrations
db, _ := sql.Open("postgres", "postgres://postgres:postgres@localhost:5432/dbname")
migration.RunMigrations(db, "")
```

See the [migration documentation](./migration/README.md) for more details.

### Database Inspection

Introspect an existing database schema:
Expand Down
57 changes: 57 additions & 0 deletions cmd/dbx/example.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"fmt"
"os"
"path/filepath"

"github.com/swiftcarrot/dbx/migration"

Check failure on line 8 in cmd/dbx/example.go

View workflow job for this annotation

GitHub Actions / Lint

could not import github.com/swiftcarrot/dbx/migration (-: # github.com/swiftcarrot/dbx/migration
"github.com/swiftcarrot/dbx/schema"
)

// This example demonstrates how to use the migration system
func Example() {
// Set the migrations directory
migrationsDir := filepath.Join(os.TempDir(), "dbx_migrations")
migration.SetMigrationsDir(migrationsDir)

// Create a new migration
migrationPath, err := migration.CreateMigration("create_users")
if err != nil {
fmt.Printf("Error creating migration: %s\n", err)
return
}
fmt.Printf("Created migration: %s\n", migrationPath)

// Register a migration programmatically
migration.Register("20250520000000", "create_posts", upCreatePosts, downCreatePosts)
}

// Migration functions for create_posts
func upCreatePosts() *schema.Schema {
s := schema.NewSchema()

s.CreateTable("posts", func(t *schema.Table) {
t.Column("id", &schema.IntegerType{})
t.Column("title", &schema.VarcharType{Length: 255})
t.Column("body", &schema.TextType{})
t.Column("user_id", &schema.IntegerType{})
t.Column("created_at", &schema.TimestampType{})
t.Column("updated_at", &schema.TimestampType{})

t.SetPrimaryKey("pk_posts", []string{"id"})
t.ForeignKey("fk_posts_user", []string{"user_id"}, "users", []string{"id"})

t.Index("idx_posts_created_at", []string{"created_at"})
})

return s
}

func downCreatePosts() *schema.Schema {
s := schema.NewSchema()

s.DropTable("posts")

Check failure on line 54 in cmd/dbx/example.go

View workflow job for this annotation

GitHub Actions / Lint

s.DropTable undefined (type *schema.Schema has no field or method DropTable) (typecheck)

return s
}
226 changes: 226 additions & 0 deletions cmd/dbx/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package main

import (
"database/sql"
"flag"
"fmt"
"os"
"strconv"
"strings"

_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/swiftcarrot/dbx/migration"
)

const defaultMigrationsDir = "./migrations"

func main() {
// Define flags
migrationsDir := flag.String("migrations-dir", defaultMigrationsDir, "Directory containing migrations")
dbUrl := flag.String("database", "", "Database connection URL")

// Parse command line arguments
flag.Parse()

// Set up the migrations directory
migration.SetMigrationsDir(*migrationsDir)

// Get the subcommand
args := flag.Args()
if len(args) == 0 {
printUsage()
os.Exit(1)
}

command := args[0]
switch command {
case "generate", "g":
if len(args) < 2 {
fmt.Println("Error: Missing migration name")
fmt.Println("Usage: dbx generate <name>")
os.Exit(1)
}
generateMigration(args[1])
case "migrate", "m":
if *dbUrl == "" {
fmt.Println("Error: Database URL is required")
fmt.Println("Usage: dbx migrate --database <url>")
os.Exit(1)
}
var version string
if len(args) > 1 {
version = args[1]
}
runMigrations(*dbUrl, version)
case "rollback", "r":
if *dbUrl == "" {
fmt.Println("Error: Database URL is required")
fmt.Println("Usage: dbx rollback --database <url> [steps]")
os.Exit(1)
}
steps := 1
if len(args) > 1 {
s, err := strconv.Atoi(args[1])
if err == nil {
steps = s
}
}
rollbackMigrations(*dbUrl, steps)
case "status", "s":
if *dbUrl == "" {
fmt.Println("Error: Database URL is required")
fmt.Println("Usage: dbx status --database <url>")
os.Exit(1)
}
showStatus(*dbUrl)
case "help", "h":
printUsage()
default:
fmt.Printf("Unknown command: %s\n", command)
printUsage()
os.Exit(1)
}
}

func printUsage() {
fmt.Println("DBX Migration Tool")
fmt.Println("Usage:")
fmt.Println(" dbx [options] <command> [arguments]")
fmt.Println("")
fmt.Println("Options:")
fmt.Println(" --migrations-dir <dir> Directory containing migrations (default: ./migrations)")
fmt.Println(" --database <url> Database connection URL")
fmt.Println("")
fmt.Println("Commands:")
fmt.Println(" generate, g <name> Generate a new migration")
fmt.Println(" migrate, m [version] Run migrations (up to optional version)")
fmt.Println(" rollback, r [steps] Rollback migrations (default: 1 step)")
fmt.Println(" status, s Show migration status")
fmt.Println(" help, h Show this help")
fmt.Println("")
fmt.Println("Examples:")
fmt.Println(" dbx generate create_users")
fmt.Println(" dbx --database \"postgres://user:pass@localhost/dbname\" migrate")
fmt.Println(" dbx --database \"mysql://user:pass@localhost/dbname\" rollback 2")
}

func generateMigration(name string) {
filePath, err := migration.CreateMigration(name)
if err != nil {
fmt.Printf("Error generating migration: %s\n", err)
os.Exit(1)
}
fmt.Printf("Created migration: %s\n", filePath)
}

func connectToDatabase(dbUrl string) (*sql.DB, error) {
// Parse the URL to determine the driver
var driver string
if strings.HasPrefix(dbUrl, "postgres://") {
driver = "postgres"
} else if strings.HasPrefix(dbUrl, "mysql://") {
driver = "mysql"
// Convert mysql URL to DSN format if needed
dbUrl = strings.TrimPrefix(dbUrl, "mysql://")
dbUrl = strings.Replace(dbUrl, "/", "?parseTime=true&loc=Local&charset=utf8mb4&collation=utf8mb4_unicode_ci&database=", 1)
} else if strings.HasPrefix(dbUrl, "sqlite://") {
driver = "sqlite3"
dbUrl = strings.TrimPrefix(dbUrl, "sqlite://")
} else {
return nil, fmt.Errorf("unsupported database URL: must start with postgres://, mysql:// or sqlite://")
}

// Connect to the database
db, err := sql.Open(driver, dbUrl)
if err != nil {
return nil, err
}

// Test the connection
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}

return db, nil
}

func runMigrations(dbUrl, version string) {
db, err := connectToDatabase(dbUrl)
if err != nil {
fmt.Printf("Error connecting to database: %s\n", err)
os.Exit(1)
}
defer db.Close()

err = migration.RunMigrations(db, version)
if err != nil {
fmt.Printf("Error running migrations: %s\n", err)
os.Exit(1)
}

fmt.Println("Migrations applied successfully")
}

func rollbackMigrations(dbUrl string, steps int) {
db, err := connectToDatabase(dbUrl)
if err != nil {
fmt.Printf("Error connecting to database: %s\n", err)
os.Exit(1)
}
defer db.Close()

err = migration.RollbackMigration(db, steps)
if err != nil {
fmt.Printf("Error rolling back migrations: %s\n", err)
os.Exit(1)
}

fmt.Println("Migrations rolled back successfully")
}

func showStatus(dbUrl string) {
db, err := connectToDatabase(dbUrl)
if err != nil {
fmt.Printf("Error connecting to database: %s\n", err)
os.Exit(1)
}
defer db.Close()

status, err := migration.GetMigrationStatus(db)
if err != nil {
fmt.Printf("Error getting migration status: %s\n", err)
os.Exit(1)
}

if len(status) == 0 {
fmt.Println("No migrations found")
return
}

// Print status table
fmt.Println("Migration Status:")
fmt.Println("--------------------------------------------------------------------------------------------------------")
fmt.Printf("%-14s | %-50s | %-10s | %s\n", "Version", "Name", "Status", "Applied At")
fmt.Println("--------------------------------------------------------------------------------------------------------")

for _, s := range status {
fmt.Printf("%-14s | %-50s | %-10s | %s\n", s.Version, s.Name, s.Status, s.AppliedAt)
}
fmt.Println("--------------------------------------------------------------------------------------------------------")

// Print current version
currentVersion, err := migration.GetCurrentVersion(db)
if err != nil {
fmt.Printf("Error getting current version: %s\n", err)
os.Exit(1)
}

if currentVersion == "" {
fmt.Println("Current version: none")
} else {
fmt.Printf("Current version: %s\n", currentVersion)
}
}
Loading
Loading