package main import ( "fmt" "os" "sort" "github.com/spf13/cobra" "git.warky.dev/wdevs/relspecgo/pkg/readers" "git.warky.dev/wdevs/relspecgo/pkg/readers/sqldir" "git.warky.dev/wdevs/relspecgo/pkg/writers" "git.warky.dev/wdevs/relspecgo/pkg/writers/sqlexec" ) var ( scriptsDir string scriptsConn string scriptsSchemaName string scriptsDBName string ) var scriptsCmd = &cobra.Command{ Use: "scripts", Short: "Manage and execute SQL migration scripts", Long: `Manage and execute SQL migration scripts from a directory. Scripts must follow the naming pattern (both separators supported): {priority}_{sequence}_{name}.sql or .pgsql {priority}-{sequence}-{name}.sql or .pgsql Example filenames (underscore format): 1_001_create_users.sql # Priority 1, Sequence 1 1_002_create_posts.sql # Priority 1, Sequence 2 2_001_add_indexes.pgsql # Priority 2, Sequence 1 Example filenames (hyphen format): 1-001-create-users.sql # Priority 1, Sequence 1 1-002-create-posts.sql # Priority 1, Sequence 2 10-10-create-newid.pgsql # Priority 10, Sequence 10 Both formats can be mixed in the same directory. Scripts are executed in order: Priority (ascending), then Sequence (ascending).`, } var scriptsListCmd = &cobra.Command{ Use: "list", Short: "List SQL scripts from a directory", Long: `List SQL scripts from a directory and show their execution order. The scripts are read from the specified directory and displayed in the order they would be executed (Priority ascending, then Sequence ascending). Example: relspec scripts list --dir ./migrations`, RunE: runScriptsList, } var scriptsExecuteCmd = &cobra.Command{ Use: "execute", Short: "Execute SQL scripts against a database", Long: `Execute SQL scripts from a directory against a PostgreSQL database. Scripts are executed in order: Priority (ascending), then Sequence (ascending). Execution stops immediately on the first error. The directory is scanned recursively for files matching the patterns: {priority}_{sequence}_{name}.sql or .pgsql (underscore format) {priority}-{sequence}-{name}.sql or .pgsql (hyphen format) PostgreSQL Connection String Examples: postgres://username:password@localhost:5432/database_name postgres://username:password@localhost/database_name postgresql://user:pass@host:5432/dbname?sslmode=disable postgresql://user:pass@host/dbname?sslmode=require Examples: # Execute migration scripts relspec scripts execute --dir ./migrations \ --conn "postgres://user:pass@localhost:5432/mydb" # Execute with custom schema name relspec scripts execute --dir ./migrations \ --conn "postgres://localhost/mydb" \ --schema public # Execute with SSL disabled relspec scripts execute --dir ./sql \ --conn "postgres://user:pass@localhost/db?sslmode=disable"`, RunE: runScriptsExecute, } func init() { // List command flags scriptsListCmd.Flags().StringVar(&scriptsDir, "dir", "", "Directory containing SQL scripts (required)") scriptsListCmd.Flags().StringVar(&scriptsSchemaName, "schema", "public", "Schema name (optional, default: public)") scriptsListCmd.Flags().StringVar(&scriptsDBName, "database", "database", "Database name (optional, default: database)") err := scriptsListCmd.MarkFlagRequired("dir") if err != nil { fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err) } // Execute command flags scriptsExecuteCmd.Flags().StringVar(&scriptsDir, "dir", "", "Directory containing SQL scripts (required)") scriptsExecuteCmd.Flags().StringVar(&scriptsConn, "conn", "", "PostgreSQL connection string (required)") scriptsExecuteCmd.Flags().StringVar(&scriptsSchemaName, "schema", "public", "Schema name (optional, default: public)") scriptsExecuteCmd.Flags().StringVar(&scriptsDBName, "database", "database", "Database name (optional, default: database)") err = scriptsExecuteCmd.MarkFlagRequired("dir") if err != nil { fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err) } err = scriptsExecuteCmd.MarkFlagRequired("conn") if err != nil { fmt.Fprintf(os.Stderr, "Error marking conn flag as required: %v\n", err) } // Add subcommands to scripts command scriptsCmd.AddCommand(scriptsListCmd) scriptsCmd.AddCommand(scriptsExecuteCmd) } func runScriptsList(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "\n=== SQL Scripts List ===\n") fmt.Fprintf(os.Stderr, "Directory: %s\n\n", scriptsDir) // Read scripts from directory reader := sqldir.NewReader(&readers.ReaderOptions{ FilePath: scriptsDir, Metadata: map[string]any{ "schema_name": scriptsSchemaName, "database_name": scriptsDBName, }, }) db, err := reader.ReadDatabase() if err != nil { return fmt.Errorf("failed to read scripts: %w", err) } if len(db.Schemas) == 0 { fmt.Fprintf(os.Stderr, "No schemas found\n") return nil } schema := db.Schemas[0] if len(schema.Scripts) == 0 { fmt.Fprintf(os.Stderr, "No SQL scripts found matching pattern {priority}_{sequence}_{name}.sql\n") return nil } // Sort scripts by Priority then Sequence sortedScripts := make([]*struct { name string priority int sequence uint sqlLines int }, len(schema.Scripts)) for i, script := range schema.Scripts { // Count non-empty lines in SQL sqlLines := 0 for _, line := range []byte(script.SQL) { if line == '\n' { sqlLines++ } } if len(script.SQL) > 0 { sqlLines++ // Count last line if no trailing newline } sortedScripts[i] = &struct { name string priority int sequence uint sqlLines int }{ name: script.Name, priority: script.Priority, sequence: script.Sequence, sqlLines: sqlLines, } } sort.Slice(sortedScripts, func(i, j int) bool { if sortedScripts[i].priority != sortedScripts[j].priority { return sortedScripts[i].priority < sortedScripts[j].priority } return sortedScripts[i].sequence < sortedScripts[j].sequence }) fmt.Fprintf(os.Stderr, "Found %d script(s) in execution order:\n\n", len(sortedScripts)) fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-30s %s\n", "No.", "Priority", "Sequence", "Name", "Lines") fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-30s %s\n", "----", "--------", "--------", "------------------------------", "-----") for i, script := range sortedScripts { fmt.Fprintf(os.Stderr, "%-4d %-10d %-8d %-30s %d\n", i+1, script.priority, script.sequence, script.name, script.sqlLines, ) } fmt.Fprintf(os.Stderr, "\n") return nil } func runScriptsExecute(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "\n=== SQL Scripts Execution ===\n") fmt.Fprintf(os.Stderr, "Started at: %s\n", getCurrentTimestamp()) fmt.Fprintf(os.Stderr, "Directory: %s\n", scriptsDir) fmt.Fprintf(os.Stderr, "Database: %s\n\n", maskPassword(scriptsConn)) // Step 1: Read scripts from directory fmt.Fprintf(os.Stderr, "[1/2] Reading SQL scripts...\n") reader := sqldir.NewReader(&readers.ReaderOptions{ FilePath: scriptsDir, Metadata: map[string]any{ "schema_name": scriptsSchemaName, "database_name": scriptsDBName, }, }) db, err := reader.ReadDatabase() if err != nil { return fmt.Errorf("failed to read scripts: %w", err) } if len(db.Schemas) == 0 { return fmt.Errorf("no schemas found") } schema := db.Schemas[0] if len(schema.Scripts) == 0 { fmt.Fprintf(os.Stderr, " No scripts found. Nothing to execute.\n\n") return nil } fmt.Fprintf(os.Stderr, " ✓ Found %d script(s)\n\n", len(schema.Scripts)) // Step 2: Execute scripts fmt.Fprintf(os.Stderr, "[2/2] Executing scripts in order (Priority → Sequence)...\n\n") writer := sqlexec.NewWriter(&writers.WriterOptions{ Metadata: map[string]any{ "connection_string": scriptsConn, }, }) if err := writer.WriteSchema(schema); err != nil { fmt.Fprintf(os.Stderr, "\n") return fmt.Errorf("execution failed: %w", err) } fmt.Fprintf(os.Stderr, "\n=== Execution Complete ===\n") fmt.Fprintf(os.Stderr, "Completed at: %s\n", getCurrentTimestamp()) fmt.Fprintf(os.Stderr, "Successfully executed %d script(s)\n\n", len(schema.Scripts)) return nil }