Files
relspecgo/cmd/relspec/scripts.go
Hein cafe6a461f
All checks were successful
CI / Test (1.24) (push) Successful in -26m18s
CI / Test (1.25) (push) Successful in -26m14s
CI / Build (push) Successful in -26m38s
CI / Lint (push) Successful in -26m30s
Release / Build and Release (push) Successful in -26m27s
Integration Tests / Integration Tests (push) Successful in -26m10s
feat(scripts): 🎉 Add --ignore-errors flag for script execution
- Allow continued execution of scripts even if errors occur.
- Update execution summary to include counts of successful and failed scripts.
- Enhance error handling and reporting for better visibility.
2026-01-31 20:21:22 +02:00

296 lines
9.4 KiB
Go

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
scriptsIgnoreErrors bool
)
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 and subdirectories.
Scripts are executed in order: Priority (ascending), Sequence (ascending), Name (alphabetical).`,
}
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 recursively from the specified directory and displayed in the order
they would be executed: Priority (ascending), then Sequence (ascending), then Name (alphabetical).
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), Sequence (ascending), Name (alphabetical).
By default, execution stops immediately on the first error. Use --ignore-errors to continue execution.
The directory is scanned recursively for all subdirectories and 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 from a directory (including subdirectories)
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"
# Continue executing even if errors occur
relspec scripts execute --dir ./migrations \
--conn "postgres://localhost/mydb" \
--ignore-errors`,
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)")
scriptsExecuteCmd.Flags().BoolVar(&scriptsIgnoreErrors, "ignore-errors", false, "Continue executing scripts even if errors occur")
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, Sequence, then Name
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
}
if sortedScripts[i].sequence != sortedScripts[j].sequence {
return sortedScripts[i].sequence < sortedScripts[j].sequence
}
return sortedScripts[i].name < sortedScripts[j].name
})
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 → Name)...\n\n")
writer := sqlexec.NewWriter(&writers.WriterOptions{
Metadata: map[string]any{
"connection_string": scriptsConn,
"ignore_errors": scriptsIgnoreErrors,
},
})
if err := writer.WriteSchema(schema); err != nil {
fmt.Fprintf(os.Stderr, "\n")
return fmt.Errorf("script execution failed: %w", err)
}
// Get execution results from writer metadata
totalCount := len(schema.Scripts)
successCount := totalCount
failedCount := 0
opts := writer.Options()
if total, exists := opts.Metadata["execution_total"].(int); exists {
totalCount = total
}
if success, exists := opts.Metadata["execution_success"].(int); exists {
successCount = success
}
if failed, exists := opts.Metadata["execution_failed"].(int); exists {
failedCount = failed
}
fmt.Fprintf(os.Stderr, "\n=== Execution Complete ===\n")
fmt.Fprintf(os.Stderr, "Completed at: %s\n", getCurrentTimestamp())
fmt.Fprintf(os.Stderr, "Total scripts: %d\n", totalCount)
fmt.Fprintf(os.Stderr, "Successful: %d\n", successCount)
if failedCount > 0 {
fmt.Fprintf(os.Stderr, "Failed: %d\n", failedCount)
}
fmt.Fprintf(os.Stderr, "\n")
return nil
}