Skip to content
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
11 changes: 11 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@

startup-pg-docker:
docker run \
--name pg-test \
-v "$$PWD"/examples/pg/script.sql:/docker-entrypoint-initdb.d/init.sql:ro \
-e POSTGRES_PASSWORD=pass \
-e POSTGRES_USER=user \
-e POSTGRES_DB=testdb \
-p 5432:5432 \
-d \
postgres:15
63 changes: 18 additions & 45 deletions cmd/scima/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ package main

import (
"context"
"database/sql"
"fmt"
"os"
"time"

_ "github.com/lib/pq" // postgres driver
"github.com/scima/scima/internal/config"
"github.com/scima/scima/internal/dialect"
"github.com/scima/scima/internal/migrate"
"github.com/spf13/cobra"
)
Expand All @@ -28,6 +26,7 @@ func addGlobalFlags(cmd *cobra.Command) {
cmd.PersistentFlags().StringVar(&dsn, "dsn", "", "Database DSN / connection string")
cmd.PersistentFlags().StringVar(&migrationsDir, "migrations-dir", "./migrations", "Directory containing migration files")
cmd.PersistentFlags().StringVar(&schema, "schema", "", "Optional database schema for migration tracking table and SQL placeholders ({{schema}}, {{schema?}})")
cmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file")
}

func init() {
Expand All @@ -40,9 +39,9 @@ func init() {
downCmd.Flags().IntVar(&steps, "steps", 1, "Number of migration steps to revert (default 1, 0=all)")
}

var initCmd = &cobra.Command{Use: "init", Short: "Initialize migration tracking table", RunE: func(_ *cobra.Command, _ []string) error {
cfg := gatherConfig()
migr, db, err := buildMigrator(cfg)
var initCmd = &cobra.Command{Use: "init", Short: "Initialize migration tracking table", RunE: func(cmd *cobra.Command, _ []string) error {
cfg := gatherConfig(cmd)
migr, db, err := migrate.BuildMigrator(cfg)
if err != nil {
return err
}
Expand All @@ -58,9 +57,9 @@ var initCmd = &cobra.Command{Use: "init", Short: "Initialize migration tracking
return nil
}}

var statusCmd = &cobra.Command{Use: "status", Short: "Show current and pending migrations", RunE: func(_ *cobra.Command, _ []string) error {
cfg := gatherConfig()
migr, db, err := buildMigrator(cfg)
var statusCmd = &cobra.Command{Use: "status", Short: "Show current and pending migrations", RunE: func(cmd *cobra.Command, _ []string) error {
cfg := gatherConfig(cmd)
migr, db, err := migrate.BuildMigrator(cfg)
if err != nil {
return err
}
Expand All @@ -84,9 +83,9 @@ var statusCmd = &cobra.Command{Use: "status", Short: "Show current and pending m
return nil
}}

var upCmd = &cobra.Command{Use: "up", Short: "Apply pending up migrations", RunE: func(_ *cobra.Command, _ []string) error {
cfg := gatherConfig()
migr, db, err := buildMigrator(cfg)
var upCmd = &cobra.Command{Use: "up", Short: "Apply pending up migrations", RunE: func(cmd *cobra.Command, _ []string) error {
cfg := gatherConfig(cmd)
migr, db, err := migrate.BuildMigrator(cfg)
if err != nil {
return err
}
Expand Down Expand Up @@ -116,9 +115,9 @@ var upCmd = &cobra.Command{Use: "up", Short: "Apply pending up migrations", RunE
}}

var steps int
var downCmd = &cobra.Command{Use: "down", Short: "Revert migrations (default 1 step)", RunE: func(_ *cobra.Command, _ []string) error {
cfg := gatherConfig()
migr, db, err := buildMigrator(cfg)
var downCmd = &cobra.Command{Use: "down", Short: "Revert migrations (default 1 step)", RunE: func(cmd *cobra.Command, _ []string) error {
cfg := gatherConfig(cmd)
migr, db, err := migrate.BuildMigrator(cfg)
if err != nil {
return err
}
Expand Down Expand Up @@ -151,7 +150,7 @@ var downCmd = &cobra.Command{Use: "down", Short: "Revert migrations (default 1 s
return nil
}}

func gatherConfig() config.Config {
func gatherConfig(cmd *cobra.Command) config.Config {
// Try config file first if provided or default locations
var cfg *config.Config
if configPath != "" {
Expand All @@ -169,47 +168,21 @@ func gatherConfig() config.Config {
}
}
// CLI flags override config file
if driver != "" {
if driver != "" && cmd.Flags().Changed("driver") {
cfg.Driver = driver
}
if dsn != "" {
if dsn != "" && cmd.Flags().Changed("dsn") {
cfg.DSN = dsn
}
if migrationsDir != "" {
if migrationsDir != "" && cmd.Flags().Changed("migrations-dir") {
cfg.MigrationsDir = migrationsDir
}
if schema != "" {
if schema != "" && cmd.Flags().Changed("schema") {
cfg.Schema = schema
}
return *cfg
}

func buildMigrator(cfg config.Config) (*migrate.Migrator, *sql.DB, error) {
dial, err := dialect.Get(cfg.Driver)
if err != nil {
return nil, nil, err
}
if cfg.DSN == "" {
return nil, nil, fmt.Errorf("dsn required")
}
db, err := sql.Open(driverNameFor(cfg.Driver), cfg.DSN)
if err != nil {
return nil, nil, err
}
return migrate.NewMigrator(dial, dialect.SQLConn{DB: db}, schema), db, nil
}

func driverNameFor(driver string) string {
switch driver {
case "hana":
return "hdb"
case "postgres", "pg":
return "postgres"
default:
return driver // assume same
}
}

func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
Expand Down
56 changes: 56 additions & 0 deletions cmd/scima/tenants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package main

import (
"context"
"fmt"
"io"
"os"
"time"

"github.com/scima/scima/internal/config"
"github.com/scima/scima/internal/migrate"
"github.com/spf13/cobra"
)

var tenantsCmd = &cobra.Command{
Use: "tenants",
Short: "Multi-tenant schema migrations",
}

var tenantsUpCmd = &cobra.Command{
Use: "up",
Short: "Apply pending up migrations for all tenants",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg := gatherConfig(cmd)
if len(cfg.Tenants) == 0 {
return fmt.Errorf("no tenants configured")
}
pairs, err := migrate.ScanDir(cfg.MigrationsDir)
if err != nil {
return err
}
if err := migrate.Validate(pairs); err != nil {
return err
}
mtMigr := migrate.NewMultiTenantMigrator(cfg.Tenants, func(t config.Tenant) (*migrate.Migrator, io.Closer, error) {
return migrate.BuildTenantMigrator(cfg.Driver, t)
})
start := time.Now()
results := mtMigr.ApplyUpAll(context.Background(), pairs)
for tenant, err := range results {
if err != nil {
fmt.Fprintf(os.Stderr, "tenant %s: error: %v\n", tenant, err)
} else {
fmt.Printf("tenant %s: migrations applied successfully\n", tenant)
}
}
fmt.Printf("completed in %s\n", time.Since(start))
return nil
},
}

// Register the tenants command and its subcommands in main.go's init()
func init() {
rootCmd.AddCommand(tenantsCmd)
tenantsCmd.AddCommand(tenantsUpCmd)
}
1 change: 1 addition & 0 deletions examples/pg/migrations/0010_init.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE {{schema}}users;
5 changes: 5 additions & 0 deletions examples/pg/migrations/0010_init.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE {{schema}}users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
1 change: 1 addition & 0 deletions examples/pg/migrations/0020_add_email.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE {{schema}}users DROP COLUMN email;
1 change: 1 addition & 0 deletions examples/pg/migrations/0020_add_email.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE {{schema}}users ADD COLUMN email VARCHAR(320);
1 change: 1 addition & 0 deletions examples/pg/migrations/0030_add_tenant1_schema.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP SCHEMA tenant1 CASCADE;
1 change: 1 addition & 0 deletions examples/pg/migrations/0030_add_tenant1_schema.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE SCHEMA IF NOT EXISTS tenant1;
1 change: 1 addition & 0 deletions examples/pg/migrations/0040_add_tenant2_schema.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP SCHEMA tenant2 CASCADE;
1 change: 1 addition & 0 deletions examples/pg/migrations/0040_add_tenant2_schema.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE SCHEMA IF NOT EXISTS tenant2;
12 changes: 12 additions & 0 deletions examples/pg/scima.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
driver: postgres
dsn: postgres://user:pass@localhost:5432/testdb?sslmode=disable
schema: public
migrationsdir: ./examples/pg/migrations

tenants:
- name: tenant1
dsn: postgres://user:pass@localhost:5432/testdb?sslmode=disable
schema: tenant1
- name: tenant2
dsn: postgres://user:pass@localhost:5432/testdb?sslmode=disable
schema: tenant2
Empty file added examples/pg/script.sql
Empty file.
16 changes: 12 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ import (

// Config holds runtime configuration for migrations.
type Config struct {
Driver string `mapstructure:"driver"`
DSN string `mapstructure:"dsn"`
MigrationsDir string `mapstructure:"migrationsdir"`
Schema string `mapstructure:"schema"`
Driver string `mapstructure:"driver"`
DSN string `mapstructure:"dsn"`
MigrationsDir string `mapstructure:"migrationsdir"`
Schema string `mapstructure:"schema"`
Tenants []Tenant `mapstructure:"tenants"`
}

// Tenant holds configuration for a single tenant.
type Tenant struct {
Name string `mapstructure:"name"`
DSN string `mapstructure:"dsn"`
Schema string `mapstructure:"schema"`
}

// LoadConfig uses Viper to load config from file, env, and flags.
Expand Down
40 changes: 40 additions & 0 deletions internal/migrate/migrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ package migrate

import (
"context"
"database/sql"
"fmt"
"io"
"os"
"strings"

"github.com/scima/scima/internal/config"
"github.com/scima/scima/internal/dialect"
)

Expand Down Expand Up @@ -110,6 +114,7 @@ func expandPlaceholders(sql string, schema string) (string, error) {
return "", fmt.Errorf("%s used but schema not set", requiredSchemaToken)
}
b.WriteString(schema)
b.WriteByte('.')
i += len(req)
continue
}
Expand All @@ -119,3 +124,38 @@ func expandPlaceholders(sql string, schema string) (string, error) {
}
return b.String(), nil
}

// BuildMigrator constructs a Migrator and underlying sql.DB from config.
func BuildMigrator(cfg config.Config) (*Migrator, *sql.DB, error) {
dial, err := dialect.Get(cfg.Driver)
if err != nil {
return nil, nil, err
}
if cfg.DSN == "" {
return nil, nil, fmt.Errorf("dsn required")
}
db, err := sql.Open(driverNameFor(cfg.Driver), cfg.DSN)
if err != nil {
return nil, nil, err
}
return NewMigrator(dial, dialect.SQLConn{DB: db}, cfg.Schema), db, nil
}

func driverNameFor(driver string) string {
switch driver {
case "hana":
return "hdb"
case "postgres", "pg":
return "postgres"
default:
return driver // assume same
}
}

func closeDb(db io.Closer) {
if db != nil {
if err := db.Close(); err != nil {
fmt.Fprintf(os.Stderr, "error closing db: %v\n", err)
}
}
}
Loading