From 43babb470e1e9606ce2416b5a746278d48174f9f Mon Sep 17 00:00:00 2001 From: Christopher Wunder Date: Tue, 30 Dec 2025 13:47:11 +0100 Subject: [PATCH 1/5] Improve placeholder logic Make consistent the usage of placeholders and dots in migration SQL files. --- internal/migrate/migrator.go | 1 + internal/migrate/migrator_test.go | 5 +++++ internal/migrate/placeholder_test.go | 6 +++--- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/migrate/migrator.go b/internal/migrate/migrator.go index 19df7f3..f81d2bf 100644 --- a/internal/migrate/migrator.go +++ b/internal/migrate/migrator.go @@ -110,6 +110,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 } diff --git a/internal/migrate/migrator_test.go b/internal/migrate/migrator_test.go index 33cc40f..1c94e79 100644 --- a/internal/migrate/migrator_test.go +++ b/internal/migrate/migrator_test.go @@ -77,15 +77,20 @@ func (d mockDialect) DeleteVersion(_ context.Context, _ dialect.Conn, _ string, } func TestMigratorApplyUpDown(t *testing.T) { + // Setup versions := map[int64]bool{10: true} migr := NewMigrator(mockDialect{versions: versions}, &mockConn{Versions: versions}, "") ups := []MigrationFile{{Version: 20, Name: "add_col", Direction: "up", SQL: "ALTER"}} + + // Test ApplyUp if err := migr.ApplyUp(context.Background(), ups); err != nil { t.Fatalf("apply up: %v", err) } if !versions[20] { t.Fatalf("version 20 not inserted") } + + // Test ApplyDown downs := []MigrationFile{{Version: 20, Name: "add_col", Direction: "down", SQL: "ALTER"}} if err := migr.ApplyDown(context.Background(), downs); err != nil { t.Fatalf("apply down: %v", err) diff --git a/internal/migrate/placeholder_test.go b/internal/migrate/placeholder_test.go index 93402b0..a0b8d13 100644 --- a/internal/migrate/placeholder_test.go +++ b/internal/migrate/placeholder_test.go @@ -5,7 +5,7 @@ import ( ) func TestExpandPlaceholdersRequired(t *testing.T) { - in := "CREATE TABLE {{schema}}.users(id INT);" + in := "CREATE TABLE {{schema}}users(id INT);" out, err := expandPlaceholders(in, "tenant1") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -38,14 +38,14 @@ func TestExpandPlaceholdersOptionalNoSchema(t *testing.T) { } func TestExpandPlaceholdersRequiredMissingSchema(t *testing.T) { - in := "CREATE TABLE {{schema}}.users(id INT);" + in := "CREATE TABLE {{schema}}users(id INT);" if _, err := expandPlaceholders(in, ""); err == nil { t.Fatalf("expected error when schema empty with required placeholder") } } func TestExpandPlaceholdersMultiple(t *testing.T) { - in := "INSERT INTO {{schema}}.a SELECT * FROM {{schema}}.b;" + in := "INSERT INTO {{schema}}a SELECT * FROM {{schema}}b;" out, err := expandPlaceholders(in, "tenant1") if err != nil { t.Fatalf("unexpected: %v", err) From 7101e02346d148852ed7a284a46e7f52a04146db Mon Sep 17 00:00:00 2001 From: Christopher Wunder Date: Mon, 24 Nov 2025 01:19:59 +0100 Subject: [PATCH 2/5] Factor out BuildMigrator --- cmd/scima/main.go | 36 ++++-------------------------------- internal/migrate/migrator.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/cmd/scima/main.go b/cmd/scima/main.go index 2fd6dce..bd4340f 100644 --- a/cmd/scima/main.go +++ b/cmd/scima/main.go @@ -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" ) @@ -42,7 +40,7 @@ func init() { var initCmd = &cobra.Command{Use: "init", Short: "Initialize migration tracking table", RunE: func(_ *cobra.Command, _ []string) error { cfg := gatherConfig() - migr, db, err := buildMigrator(cfg) + migr, db, err := migrate.BuildMigrator(cfg) if err != nil { return err } @@ -60,7 +58,7 @@ var initCmd = &cobra.Command{Use: "init", Short: "Initialize migration tracking 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) + migr, db, err := migrate.BuildMigrator(cfg) if err != nil { return err } @@ -86,7 +84,7 @@ var statusCmd = &cobra.Command{Use: "status", Short: "Show current and pending m var upCmd = &cobra.Command{Use: "up", Short: "Apply pending up migrations", RunE: func(_ *cobra.Command, _ []string) error { cfg := gatherConfig() - migr, db, err := buildMigrator(cfg) + migr, db, err := migrate.BuildMigrator(cfg) if err != nil { return err } @@ -118,7 +116,7 @@ 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) + migr, db, err := migrate.BuildMigrator(cfg) if err != nil { return err } @@ -184,32 +182,6 @@ func gatherConfig() config.Config { 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) diff --git a/internal/migrate/migrator.go b/internal/migrate/migrator.go index f81d2bf..fa507a1 100644 --- a/internal/migrate/migrator.go +++ b/internal/migrate/migrator.go @@ -3,9 +3,11 @@ package migrate import ( "context" + "database/sql" "fmt" "strings" + "github.com/scima/scima/internal/config" "github.com/scima/scima/internal/dialect" ) @@ -120,3 +122,30 @@ 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 + } +} From 1b86b4d2126bbb9e8e9cbec57b4a0515b87621f9 Mon Sep 17 00:00:00 2001 From: Christopher Wunder Date: Mon, 24 Nov 2025 01:40:03 +0100 Subject: [PATCH 3/5] Implement tenant migrator --- cmd/scima/tenants.go | 56 ++++++++ internal/config/config.go | 16 ++- internal/migrate/migrator.go | 10 ++ internal/migrate/migrator_tenants.go | 103 +++++++++++++ internal/migrate/migrator_tenants_test.go | 82 +++++++++++ .../postgres/migrations/0010_init.down.sql | 2 +- .../postgres/migrations/0010_init.up.sql | 2 +- .../migrations/0020_add_email.down.sql | 2 +- .../postgres/migrations/0020_add_email.up.sql | 2 +- .../postgres_multitenant_integration_test.go | 135 ++++++++++++++++++ 10 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 cmd/scima/tenants.go create mode 100644 internal/migrate/migrator_tenants.go create mode 100644 internal/migrate/migrator_tenants_test.go create mode 100644 tests/integration/postgres/postgres_multitenant_integration_test.go diff --git a/cmd/scima/tenants.go b/cmd/scima/tenants.go new file mode 100644 index 0000000..fb68375 --- /dev/null +++ b/cmd/scima/tenants.go @@ -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(_ *cobra.Command, _ []string) error { + cfg := gatherConfig() + 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) +} diff --git a/internal/config/config.go b/internal/config/config.go index 6bc35c5..69ba4d6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/migrate/migrator.go b/internal/migrate/migrator.go index fa507a1..df6d5dc 100644 --- a/internal/migrate/migrator.go +++ b/internal/migrate/migrator.go @@ -5,6 +5,8 @@ import ( "context" "database/sql" "fmt" + "io" + "os" "strings" "github.com/scima/scima/internal/config" @@ -149,3 +151,11 @@ func driverNameFor(driver string) string { 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) + } + } +} diff --git a/internal/migrate/migrator_tenants.go b/internal/migrate/migrator_tenants.go new file mode 100644 index 0000000..c4f49fa --- /dev/null +++ b/internal/migrate/migrator_tenants.go @@ -0,0 +1,103 @@ +package migrate + +import ( + "context" + "database/sql" + "fmt" + "io" + + "github.com/scima/scima/internal/config" + "github.com/scima/scima/internal/dialect" +) + +// MultiTenantMigrator manages migrations across multiple tenants. +type MultiTenantMigrator struct { + tenants []config.Tenant + buildMigrator func(cfg config.Tenant) (*Migrator, io.Closer, error) +} + +// BuildDefaultMigrator constructs a Migrator and underlying io.Closer from config. +func BuildDefaultMigrator(driver string, cfg config.Tenant) (*Migrator, io.Closer, error) { + dial, err := dialect.Get(driver) + if err != nil { + return nil, nil, err + } + if cfg.DSN == "" { + return nil, nil, fmt.Errorf("dsn required") + } + db, err := sql.Open(driverNameFor(driver), cfg.DSN) + if err != nil { + return nil, nil, err + } + return NewMigrator(dial, dialect.SQLConn{DB: db}, cfg.Schema), db, nil +} + +// NewMultiTenantMigrator creates a MultiTenantMigrator. +func NewMultiTenantMigrator( + tenants []config.Tenant, + buildMigrator func(cfg config.Tenant) (*Migrator, io.Closer, error), +) *MultiTenantMigrator { + return &MultiTenantMigrator{ + tenants, + buildMigrator, + } +} + +// ApplyUpAll applies all pending up migrations for all tenants. +func (m *MultiTenantMigrator) ApplyUpAll(ctx context.Context, pairs []MigrationPair) map[string]error { + results := make(map[string]error) + for _, t := range m.tenants { + migr, db, err := m.buildMigrator(t) + if err != nil { + results[t.Name] = err + continue + } + applied, err := migr.Status(ctx) + if err != nil { + results[t.Name] = err + closeDb(db) + continue + } + ups := FilterPending(pairs, applied) // You may need to implement this helper + err = migr.ApplyUp(ctx, ups) + results[t.Name] = err + closeDb(db) + } + return results +} + +// StatusAll retrieves the migration status for all tenants. +func (m *MultiTenantMigrator) StatusAll(ctx context.Context) map[string]map[int64]bool { + results := make(map[string]map[int64]bool) + for _, t := range m.tenants { + migr, db, err := m.buildMigrator(t) + if err != nil { + results[t.Name] = nil + continue + } + status, err := migr.Status(ctx) + if err != nil { + results[t.Name] = nil + } else { + results[t.Name] = status + } + closeDb(db) + } + return results +} + +// BuildTenantMigrator constructs a Migrator and underlying *sql.DB for a tenant. +func BuildTenantMigrator(driver string, cfg config.Tenant) (*Migrator, *sql.DB, error) { + dial, err := dialect.Get(driver) + if err != nil { + return nil, nil, err + } + if cfg.DSN == "" { + return nil, nil, fmt.Errorf("dsn required") + } + db, err := sql.Open(driverNameFor(driver), cfg.DSN) + if err != nil { + return nil, nil, err + } + return NewMigrator(dial, dialect.SQLConn{DB: db}, cfg.Schema), db, nil +} diff --git a/internal/migrate/migrator_tenants_test.go b/internal/migrate/migrator_tenants_test.go new file mode 100644 index 0000000..2977295 --- /dev/null +++ b/internal/migrate/migrator_tenants_test.go @@ -0,0 +1,82 @@ +package migrate + +import ( + "context" + "io" + "testing" + + "github.com/scima/scima/internal/config" +) + +type MockCloser struct{} + +func (m MockCloser) Close() error { + return nil +} + +func TestApplyMultiTenants(t *testing.T) { + tenants := []config.Tenant{ + {Name: "tenant1", DSN: "dsn1", Schema: "schema1"}, + {Name: "tenant2", DSN: "dsn2", Schema: "schema2"}, + } + versions := map[int64]bool{10: true} + pairs := []MigrationPair{ + { + Up: &MigrationFile{Version: 10, Name: "init", Direction: "up", SQL: "CREATE TABLE"}, + Down: &MigrationFile{Version: 10, Name: "init", Direction: "down", SQL: "DROP TABLE"}, + }, + { + Up: &MigrationFile{Version: 20, Name: "add_col", Direction: "up", SQL: "ALTER TABLE"}, + Down: &MigrationFile{Version: 20, Name: "add_col", Direction: "down", SQL: "ALTER TABLE DROP COLUMN"}, + }, + } + migr := NewMigrator(mockDialect{versions: versions}, &mockConn{Versions: versions}, "") + mtMigr := NewMultiTenantMigrator(tenants, func(_ config.Tenant) (*Migrator, io.Closer, error) { + return migr, MockCloser{}, nil + }) + results := mtMigr.ApplyUpAll(context.Background(), pairs) + for _, res := range results { + if res != nil { + t.Errorf("expected nil error, got %v", res) + } + } +} + +func TestApplyOnlyPendingMigrations(t *testing.T) { + tenants := []config.Tenant{ + {Name: "tenant1", DSN: "dsn1", Schema: "schema1"}, + } + versions1 := map[int64]bool{10: true, 20: true} + versions2 := map[int64]bool{10: true} + pairs := []MigrationPair{ + { + Up: &MigrationFile{Version: 10, Name: "init", Direction: "up", SQL: "CREATE TABLE"}, + Down: &MigrationFile{Version: 10, Name: "init", Direction: "down", SQL: "DROP TABLE"}, + }, + { + Up: &MigrationFile{Version: 20, Name: "add_col", Direction: "up", SQL: "ALTER TABLE"}, + Down: &MigrationFile{Version: 20, Name: "add_col", Direction: "down", SQL: "ALTER TABLE DROP COLUMN"}, + }, + { + Up: &MigrationFile{Version: 30, Name: "new_feature", Direction: "up", SQL: "UPDATE TABLE"}, + Down: &MigrationFile{Version: 30, Name: "new_feature", Direction: "down", SQL: "REVERT UPDATE"}, + }, + } + migr1 := NewMigrator(mockDialect{versions: versions1}, &mockConn{Versions: versions1}, "") + migr2 := NewMigrator(mockDialect{versions: versions2}, &mockConn{Versions: versions2}, "") + mtMigr := NewMultiTenantMigrator(tenants, func(config config.Tenant) (*Migrator, io.Closer, error) { + switch config.Name { + case "tenant1": + return migr1, MockCloser{}, nil + case "tenant2": + return migr2, MockCloser{}, nil + } + return nil, nil, nil + }) + results := mtMigr.ApplyUpAll(context.Background(), pairs) + for _, res := range results { + if res != nil { + t.Errorf("expected nil error, got %v", res) + } + } +} diff --git a/tests/integration/postgres/migrations/0010_init.down.sql b/tests/integration/postgres/migrations/0010_init.down.sql index 441087a..fcf7965 100644 --- a/tests/integration/postgres/migrations/0010_init.down.sql +++ b/tests/integration/postgres/migrations/0010_init.down.sql @@ -1 +1 @@ -DROP TABLE users; \ No newline at end of file +DROP TABLE {{schema}}users; \ No newline at end of file diff --git a/tests/integration/postgres/migrations/0010_init.up.sql b/tests/integration/postgres/migrations/0010_init.up.sql index af6f81c..0f7d855 100644 --- a/tests/integration/postgres/migrations/0010_init.up.sql +++ b/tests/integration/postgres/migrations/0010_init.up.sql @@ -1,4 +1,4 @@ -CREATE TABLE users ( +CREATE TABLE {{schema}}users ( id BIGSERIAL PRIMARY KEY, username VARCHAR(255) NOT NULL, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP diff --git a/tests/integration/postgres/migrations/0020_add_email.down.sql b/tests/integration/postgres/migrations/0020_add_email.down.sql index d4a02ce..038c728 100644 --- a/tests/integration/postgres/migrations/0020_add_email.down.sql +++ b/tests/integration/postgres/migrations/0020_add_email.down.sql @@ -1 +1 @@ -ALTER TABLE users DROP COLUMN email; \ No newline at end of file +ALTER TABLE {{schema}}users DROP COLUMN email; \ No newline at end of file diff --git a/tests/integration/postgres/migrations/0020_add_email.up.sql b/tests/integration/postgres/migrations/0020_add_email.up.sql index e3e4eac..1cb03fb 100644 --- a/tests/integration/postgres/migrations/0020_add_email.up.sql +++ b/tests/integration/postgres/migrations/0020_add_email.up.sql @@ -1 +1 @@ -ALTER TABLE users ADD COLUMN email VARCHAR(320); \ No newline at end of file +ALTER TABLE {{schema}}users ADD COLUMN email VARCHAR(320); \ No newline at end of file diff --git a/tests/integration/postgres/postgres_multitenant_integration_test.go b/tests/integration/postgres/postgres_multitenant_integration_test.go new file mode 100644 index 0000000..0b79a8a --- /dev/null +++ b/tests/integration/postgres/postgres_multitenant_integration_test.go @@ -0,0 +1,135 @@ +//go:build integration +// +build integration + +package integration + +import ( + "context" + "database/sql" + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/docker/go-connections/nat" + _ "github.com/lib/pq" + "github.com/scima/scima/internal/config" + "github.com/scima/scima/internal/dialect" + "github.com/scima/scima/internal/migrate" + tc "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +func TestPostgresMultiTenantMigrationsIntegration(t *testing.T) { + ctx := context.Background() + + rootDir := getenvDefault("SCIMA_TEST_ROOT_DIR", "/Users/I758791/github.com/scima") + migDir := filepath.Join(rootDir, "tests", "integration", "postgres", "migrations") + if _, err := os.Stat(migDir); os.IsNotExist(err) { + t.Fatalf("migrations folder not found: %s", migDir) + } + image := getenvDefault("SCIMA_TEST_PG_IMAGE", "postgres:15-alpine") + port := getenvDefault("SCIMA_TEST_PG_PORT", "5432") + user := getenvDefault("SCIMA_TEST_PG_USER", "test") + password := getenvDefault("SCIMA_TEST_PG_PASSWORD", "test") + dbname := getenvDefault("SCIMA_TEST_PG_DB", "testdb") + registry := os.Getenv("SCIMA_TEST_PG_REGISTRY") + if registry != "" { + image = registry + "/" + image + } + + pgPort := nat.Port(port + "/tcp") + + req := tc.ContainerRequest{ + Image: image, + ExposedPorts: []string{string(pgPort)}, + Env: map[string]string{ + "POSTGRES_USER": user, + "POSTGRES_PASSWORD": password, + "POSTGRES_DB": dbname, + }, + WaitingFor: wait.ForListeningPort(pgPort).WithStartupTimeout(60 * time.Second), + } + container, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{ContainerRequest: req, Started: true}) + if err != nil { + t.Fatalf("container start: %v", err) + } + defer container.Terminate(ctx) + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("host: %v", err) + } + mappedPort, err := container.MappedPort(ctx, pgPort) + if err != nil { + t.Fatalf("mapped port: %v", err) + } + dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, password, host, mappedPort.Port(), dbname) + db, err := sql.Open("postgres", dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + if err := db.PingContext(ctx); err != nil { + t.Fatalf("ping: %v", err) + } + + // Create schemas for tenants + tenantSchemas := []string{"tenant1", "tenant2"} + for _, schema := range tenantSchemas { + _, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS %s`, schema)) + if err != nil { + t.Fatalf("create schema %s: %v", schema, err) + } + } + + // Prepare tenants config + var tenants []config.Tenant + for _, schema := range tenantSchemas { + tenants = append(tenants, config.Tenant{ + Name: schema, + DSN: dsn, + Schema: schema, + }) + } + + // Parse and validate migrations + pairs, err := migrate.ScanDir(migDir) + if err != nil { + t.Fatalf("scan migrations: %v", err) + } + if err := migrate.Validate(pairs); err != nil { + t.Fatalf("validate: %v", err) + } + + // Multi-tenant migrator + mtMigr := migrate.NewMultiTenantMigrator(tenants, func(t config.Tenant) (*migrate.Migrator, io.Closer, error) { + d, err := dialect.Get("postgres") + if err != nil { + return nil, nil, err + } + db, err := sql.Open("postgres", t.DSN) + if err != nil { + return nil, nil, err + } + return migrate.NewMigrator(d, dialect.SQLConn{DB: db}, t.Schema), db, nil + }) + + // Apply migrations for all tenants + results := mtMigr.ApplyUpAll(ctx, pairs) + for tenant, err := range results { + if err != nil { + t.Errorf("tenant %s: migration error: %v", tenant, err) + } + } + + // Verify all migrations applied for each tenant + statusAll := mtMigr.StatusAll(ctx) + for _, status := range statusAll { + if len(status) != len(pairs) { + t.Errorf("expected %d applied migrations, got %d", len(pairs), len(status)) + } + } +} From e56edfecd35f4b62be22c40a26331e0746fc59a2 Mon Sep 17 00:00:00 2001 From: Christopher Wunder Date: Tue, 30 Dec 2025 15:36:37 +0100 Subject: [PATCH 4/5] Add support for config path CLI flag --- cmd/scima/main.go | 27 ++++++++++++++------------- cmd/scima/tenants.go | 4 ++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/cmd/scima/main.go b/cmd/scima/main.go index bd4340f..2c935c5 100644 --- a/cmd/scima/main.go +++ b/cmd/scima/main.go @@ -26,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() { @@ -38,8 +39,8 @@ 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() +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 @@ -56,8 +57,8 @@ 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() +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 @@ -82,8 +83,8 @@ 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() +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 @@ -114,8 +115,8 @@ 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() +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 @@ -149,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 != "" { @@ -167,16 +168,16 @@ 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 diff --git a/cmd/scima/tenants.go b/cmd/scima/tenants.go index fb68375..6d33240 100644 --- a/cmd/scima/tenants.go +++ b/cmd/scima/tenants.go @@ -20,8 +20,8 @@ var tenantsCmd = &cobra.Command{ var tenantsUpCmd = &cobra.Command{ Use: "up", Short: "Apply pending up migrations for all tenants", - RunE: func(_ *cobra.Command, _ []string) error { - cfg := gatherConfig() + RunE: func(cmd *cobra.Command, _ []string) error { + cfg := gatherConfig(cmd) if len(cfg.Tenants) == 0 { return fmt.Errorf("no tenants configured") } From 029d584539e425b19ae01048c7c8336ce45e3dec Mon Sep 17 00:00:00 2001 From: Christopher Wunder Date: Tue, 30 Dec 2025 15:36:43 +0100 Subject: [PATCH 5/5] Add example for testing CLI --- Makefile | 11 +++++++++++ examples/pg/migrations/0010_init.down.sql | 1 + examples/pg/migrations/0010_init.up.sql | 5 +++++ examples/pg/migrations/0020_add_email.down.sql | 1 + examples/pg/migrations/0020_add_email.up.sql | 1 + .../pg/migrations/0030_add_tenant1_schema.down.sql | 1 + .../pg/migrations/0030_add_tenant1_schema.up.sql | 1 + .../pg/migrations/0040_add_tenant2_schema.down.sql | 1 + .../pg/migrations/0040_add_tenant2_schema.up.sql | 1 + examples/pg/scima.yaml | 12 ++++++++++++ examples/pg/script.sql | 0 11 files changed, 35 insertions(+) create mode 100644 Makefile create mode 100644 examples/pg/migrations/0010_init.down.sql create mode 100644 examples/pg/migrations/0010_init.up.sql create mode 100644 examples/pg/migrations/0020_add_email.down.sql create mode 100644 examples/pg/migrations/0020_add_email.up.sql create mode 100644 examples/pg/migrations/0030_add_tenant1_schema.down.sql create mode 100644 examples/pg/migrations/0030_add_tenant1_schema.up.sql create mode 100644 examples/pg/migrations/0040_add_tenant2_schema.down.sql create mode 100644 examples/pg/migrations/0040_add_tenant2_schema.up.sql create mode 100644 examples/pg/scima.yaml create mode 100644 examples/pg/script.sql diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..96ee4ff --- /dev/null +++ b/Makefile @@ -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 diff --git a/examples/pg/migrations/0010_init.down.sql b/examples/pg/migrations/0010_init.down.sql new file mode 100644 index 0000000..fcf7965 --- /dev/null +++ b/examples/pg/migrations/0010_init.down.sql @@ -0,0 +1 @@ +DROP TABLE {{schema}}users; \ No newline at end of file diff --git a/examples/pg/migrations/0010_init.up.sql b/examples/pg/migrations/0010_init.up.sql new file mode 100644 index 0000000..0f7d855 --- /dev/null +++ b/examples/pg/migrations/0010_init.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE {{schema}}users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/examples/pg/migrations/0020_add_email.down.sql b/examples/pg/migrations/0020_add_email.down.sql new file mode 100644 index 0000000..038c728 --- /dev/null +++ b/examples/pg/migrations/0020_add_email.down.sql @@ -0,0 +1 @@ +ALTER TABLE {{schema}}users DROP COLUMN email; \ No newline at end of file diff --git a/examples/pg/migrations/0020_add_email.up.sql b/examples/pg/migrations/0020_add_email.up.sql new file mode 100644 index 0000000..1cb03fb --- /dev/null +++ b/examples/pg/migrations/0020_add_email.up.sql @@ -0,0 +1 @@ +ALTER TABLE {{schema}}users ADD COLUMN email VARCHAR(320); \ No newline at end of file diff --git a/examples/pg/migrations/0030_add_tenant1_schema.down.sql b/examples/pg/migrations/0030_add_tenant1_schema.down.sql new file mode 100644 index 0000000..e973a14 --- /dev/null +++ b/examples/pg/migrations/0030_add_tenant1_schema.down.sql @@ -0,0 +1 @@ +DROP SCHEMA tenant1 CASCADE; \ No newline at end of file diff --git a/examples/pg/migrations/0030_add_tenant1_schema.up.sql b/examples/pg/migrations/0030_add_tenant1_schema.up.sql new file mode 100644 index 0000000..90b1ae9 --- /dev/null +++ b/examples/pg/migrations/0030_add_tenant1_schema.up.sql @@ -0,0 +1 @@ +CREATE SCHEMA IF NOT EXISTS tenant1; \ No newline at end of file diff --git a/examples/pg/migrations/0040_add_tenant2_schema.down.sql b/examples/pg/migrations/0040_add_tenant2_schema.down.sql new file mode 100644 index 0000000..af1d6df --- /dev/null +++ b/examples/pg/migrations/0040_add_tenant2_schema.down.sql @@ -0,0 +1 @@ +DROP SCHEMA tenant2 CASCADE; \ No newline at end of file diff --git a/examples/pg/migrations/0040_add_tenant2_schema.up.sql b/examples/pg/migrations/0040_add_tenant2_schema.up.sql new file mode 100644 index 0000000..d32613b --- /dev/null +++ b/examples/pg/migrations/0040_add_tenant2_schema.up.sql @@ -0,0 +1 @@ +CREATE SCHEMA IF NOT EXISTS tenant2; \ No newline at end of file diff --git a/examples/pg/scima.yaml b/examples/pg/scima.yaml new file mode 100644 index 0000000..bf4d921 --- /dev/null +++ b/examples/pg/scima.yaml @@ -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 diff --git a/examples/pg/script.sql b/examples/pg/script.sql new file mode 100644 index 0000000..e69de29