fix: address audit execution failures and unsafe type conversions
This commit is contained in:
@@ -186,11 +186,30 @@ Future support planned for:
|
||||
- SQLite
|
||||
- Generic SQL via database/sql
|
||||
|
||||
## Transaction Behavior
|
||||
## Execution and transaction behavior
|
||||
|
||||
**Current**: Each script executes in its own implicit transaction (PostgreSQL default behavior)
|
||||
This is a raw PostgreSQL script runner, not a migration manager. It has no applied
|
||||
history, checksums, migration lock, or duplicate-run protection. Re-running repeats
|
||||
the supplied scripts. Scripts may contain transaction control, so RelSpec does not
|
||||
wrap them in a transaction and rejects `WriterOptions.AtomicExecution`.
|
||||
|
||||
**Future Enhancement**: Option to wrap all scripts in a single transaction for atomic execution with rollback on error.
|
||||
By default execution stops on the first SQL or embed-preparation failure. Set
|
||||
`WriterOptions.ContinueOnError`, legacy `Metadata["ignore_errors"]`, or CLI
|
||||
`--ignore-errors` / `--continue-on-error` / `--coe` to attempt later scripts. Any
|
||||
failure still returns a nonzero result and prevents dependent jobs from running.
|
||||
|
||||
The shared `writers.ExecutionReport` is available at `Metadata["execution_report"]`;
|
||||
`Metadata["report_path"]` requests a JSON report and report-write failures are errors.
|
||||
Counters and operations cover every schema in the invocation, including scripts
|
||||
skipped after stopping or because they are empty. Legacy `execution_total`,
|
||||
`execution_success`, `execution_failed`, `execution_skipped`, and
|
||||
`execution_rolled_back` metadata counters are also populated on failure.
|
||||
|
||||
Reports use `outcome: script_managed` and successful script status `executed`, rather
|
||||
than claiming committed database changes. A script may commit, roll back, or leave a
|
||||
transaction open, and a failed script may already have committed work. The report
|
||||
records which scripts succeeded/failed/skipped; it cannot infer statement-level
|
||||
commit or rollback inside arbitrary scripts. The connection closes after execution.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
@@ -226,7 +245,7 @@ docker run -d --name postgres-test \
|
||||
postgres:16
|
||||
|
||||
# Run your integration tests
|
||||
go test -tags=integration ./pkg/writers/sqlexec/
|
||||
RELSPEC_TEST_PG_CONN='postgres://postgres:test@127.0.0.1:5432/testdb?sslmode=disable' go test -count=1 ./pkg/writers/sqlexec/
|
||||
|
||||
# Cleanup
|
||||
docker stop postgres-test
|
||||
@@ -237,7 +256,7 @@ docker rm postgres-test
|
||||
|
||||
- `WriteTable()` is not supported (returns error)
|
||||
- Requires PostgreSQL connection (no offline mode)
|
||||
- No built-in transaction wrapping (yet)
|
||||
- No executor-level transaction wrapping for raw scripts
|
||||
- No rollback script support (yet, though `models.Script.Rollback` field exists)
|
||||
|
||||
## Related
|
||||
|
||||
+44
-105
@@ -42,22 +42,25 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
return fmt.Errorf("connection_string is required in writer metadata")
|
||||
}
|
||||
|
||||
// Preserve schema order and script ordering within each schema, but aggregate once.
|
||||
var scripts []*models.Script
|
||||
for _, schema := range db.Schemas {
|
||||
for _, script := range sortedScripts(schema.Scripts) {
|
||||
copy := *script
|
||||
copy.Name = schema.Name + "." + script.Name
|
||||
scripts = append(scripts, ©)
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
ctx := context.Background()
|
||||
conn, err := pgsql.Connect(ctx, connString, "writer-sqlexec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
return writers.FailExecution(w.options, "PostgreSQL scripts", scriptOperations(scripts), fmt.Errorf("failed to connect to database: %w", err))
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
// Execute scripts from all schemas
|
||||
for _, schema := range db.Schemas {
|
||||
if err := w.executeScripts(ctx, conn, schema.Scripts); err != nil {
|
||||
return fmt.Errorf("failed to execute scripts from schema %s: %w", schema.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return w.runScripts(ctx, conn, scripts)
|
||||
}
|
||||
|
||||
// WriteSchema executes all scripts from a single schema
|
||||
@@ -76,7 +79,7 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
ctx := context.Background()
|
||||
conn, err := pgsql.Connect(ctx, connString, "writer-sqlexec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
return writers.FailExecution(w.options, "PostgreSQL scripts", scriptOperations(sortedScripts(schema.Scripts)), fmt.Errorf("failed to connect to database: %w", err))
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
@@ -93,113 +96,49 @@ func (w *Writer) WriteTable(table *models.Table) error {
|
||||
return fmt.Errorf("WriteTable is not supported for SQL script execution")
|
||||
}
|
||||
|
||||
// executeScripts executes scripts in Priority, Sequence, then Name order
|
||||
// executeScripts executes scripts in Priority, Sequence, then Name order.
|
||||
func (w *Writer) executeScripts(ctx context.Context, conn *pgx.Conn, scripts []*models.Script) error {
|
||||
if len(scripts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return w.runScripts(ctx, conn, sortedScripts(scripts))
|
||||
}
|
||||
|
||||
// Check if we should ignore errors
|
||||
ignoreErrors := false
|
||||
if val, ok := w.options.Metadata["ignore_errors"].(bool); ok {
|
||||
ignoreErrors = val
|
||||
}
|
||||
|
||||
// Track failed scripts and execution counts
|
||||
var failedScripts []struct {
|
||||
name string
|
||||
priority int
|
||||
sequence uint
|
||||
err error
|
||||
}
|
||||
successCount := 0
|
||||
totalCount := 0
|
||||
|
||||
// Sort scripts by Priority (ascending), Sequence (ascending), then Name (ascending)
|
||||
sortedScripts := make([]*models.Script, len(scripts))
|
||||
copy(sortedScripts, scripts)
|
||||
sort.Slice(sortedScripts, func(i, j int) bool {
|
||||
if sortedScripts[i].Priority != sortedScripts[j].Priority {
|
||||
return sortedScripts[i].Priority < sortedScripts[j].Priority
|
||||
func sortedScripts(scripts []*models.Script) []*models.Script {
|
||||
ordered := append([]*models.Script(nil), scripts...)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
if ordered[i].Priority != ordered[j].Priority {
|
||||
return ordered[i].Priority < ordered[j].Priority
|
||||
}
|
||||
if sortedScripts[i].Sequence != sortedScripts[j].Sequence {
|
||||
return sortedScripts[i].Sequence < sortedScripts[j].Sequence
|
||||
if ordered[i].Sequence != ordered[j].Sequence {
|
||||
return ordered[i].Sequence < ordered[j].Sequence
|
||||
}
|
||||
return sortedScripts[i].Name < sortedScripts[j].Name
|
||||
return ordered[i].Name < ordered[j].Name
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
|
||||
// Execute each script in order
|
||||
for _, script := range sortedScripts {
|
||||
if script.SQL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
totalCount++
|
||||
fmt.Printf("Executing script: %s (Priority=%d, Sequence=%d)\n",
|
||||
script.Name, script.Priority, script.Sequence)
|
||||
|
||||
// Execute the SQL script
|
||||
func (w *Writer) runScripts(ctx context.Context, conn *pgx.Conn, scripts []*models.Script) error {
|
||||
operations := scriptOperations(scripts)
|
||||
backend := writers.ExecutionBackend{ScriptManaged: true, Exec: func(ctx context.Context, i int, _ string) error {
|
||||
script := scripts[i]
|
||||
fmt.Printf("Executing script: %s (Priority=%d, Sequence=%d)\n", script.Name, script.Priority, script.Sequence)
|
||||
sql, err := processEmbedDirectives(script)
|
||||
if err != nil {
|
||||
if ignoreErrors {
|
||||
fmt.Printf("⚠ Error preparing %s: %v (continuing due to --ignore-errors)\n", script.Name, err)
|
||||
failedScripts = append(failedScripts, struct {
|
||||
name string
|
||||
priority int
|
||||
sequence uint
|
||||
err error
|
||||
}{
|
||||
name: script.Name,
|
||||
priority: script.Priority,
|
||||
sequence: script.Sequence,
|
||||
err: err,
|
||||
})
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("script %s (Priority=%d, Sequence=%d): %w",
|
||||
script.Name, script.Priority, script.Sequence, err)
|
||||
return fmt.Errorf("preparing script %s: %w", script.Name, err)
|
||||
}
|
||||
|
||||
_, err = conn.Exec(ctx, sql)
|
||||
if err != nil {
|
||||
if ignoreErrors {
|
||||
fmt.Printf("⚠ Error executing %s: %v (continuing due to --ignore-errors)\n", script.Name, err)
|
||||
failedScripts = append(failedScripts, struct {
|
||||
name string
|
||||
priority int
|
||||
sequence uint
|
||||
err error
|
||||
}{
|
||||
name: script.Name,
|
||||
priority: script.Priority,
|
||||
sequence: script.Sequence,
|
||||
err: err,
|
||||
})
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("script %s (Priority=%d, Sequence=%d): %w",
|
||||
script.Name, script.Priority, script.Sequence, err)
|
||||
if _, err := conn.Exec(ctx, sql); err != nil {
|
||||
return fmt.Errorf("script %s: %w", script.Name, err)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
// Supplied scripts can manage transactions themselves. Do not promise whole-script rollback.
|
||||
return writers.RunExecution(ctx, w.options, &writers.ExecutionReport{Dialect: "PostgreSQL scripts"}, operations, backend)
|
||||
}
|
||||
|
||||
successCount++
|
||||
fmt.Printf("✓ Successfully executed: %s\n", script.Name)
|
||||
func scriptOperations(scripts []*models.Script) []writers.ExecutionOperation {
|
||||
operations := make([]writers.ExecutionOperation, len(scripts))
|
||||
for i, script := range scripts {
|
||||
operations[i] = writers.ExecutionOperation{Name: script.Name, Statement: script.SQL}
|
||||
}
|
||||
|
||||
// Store execution results in metadata for caller
|
||||
w.options.Metadata["execution_total"] = totalCount
|
||||
w.options.Metadata["execution_success"] = successCount
|
||||
w.options.Metadata["execution_failed"] = len(failedScripts)
|
||||
|
||||
// Print summary of failed scripts if any
|
||||
if len(failedScripts) > 0 {
|
||||
fmt.Printf("\n⚠ Failed Scripts Summary (%d failed):\n", len(failedScripts))
|
||||
for i, failed := range failedScripts {
|
||||
fmt.Printf(" %d. %s (Priority=%d, Sequence=%d)\n Error: %v\n",
|
||||
i+1, failed.name, failed.priority, failed.sequence, failed.err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return operations
|
||||
}
|
||||
|
||||
func processEmbedDirectives(script *models.Script) (string, error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqlexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -121,7 +122,7 @@ func TestLive_ExecuteScriptsOrder(t *testing.T) {
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("execution order = %v, want %v", got, want)
|
||||
}
|
||||
if opts.Metadata["execution_total"] != 6 || opts.Metadata["execution_success"] != 6 || opts.Metadata["execution_failed"] != 0 {
|
||||
if opts.Metadata["execution_total"] != 7 || opts.Metadata["execution_success"] != 6 || opts.Metadata["execution_failed"] != 0 {
|
||||
t.Errorf("counts: %v", opts.Metadata)
|
||||
}
|
||||
}
|
||||
@@ -160,8 +161,8 @@ func TestLive_IgnoreErrorsContinues(t *testing.T) {
|
||||
{Name: "02_ok", Priority: 2, SQL: fmt.Sprintf("CREATE TABLE %s.after(id int);", schema)},
|
||||
}
|
||||
opts := liveOptions(connString, map[string]interface{}{"ignore_errors": true})
|
||||
if err := NewWriter(opts).WriteSchema(&models.Schema{Name: schema, Scripts: scripts}); err != nil {
|
||||
t.Fatalf("ignore_errors must not fail: %v", err)
|
||||
if err := NewWriter(opts).WriteSchema(&models.Schema{Name: schema, Scripts: scripts}); err == nil {
|
||||
t.Fatal("continuation must still return failure")
|
||||
}
|
||||
if opts.Metadata["execution_total"] != 2 || opts.Metadata["execution_success"] != 1 || opts.Metadata["execution_failed"] != 1 {
|
||||
t.Errorf("counts: %v", opts.Metadata)
|
||||
@@ -186,8 +187,8 @@ func TestLive_EmbedDirectiveErrorHandling(t *testing.T) {
|
||||
}
|
||||
|
||||
opts := liveOptions(connString, map[string]interface{}{"ignore_errors": true})
|
||||
if err := NewWriter(opts).WriteSchema(&models.Schema{Name: schema, Scripts: []*models.Script{bad}}); err != nil {
|
||||
t.Errorf("ignore_errors: %v", err)
|
||||
if err := NewWriter(opts).WriteSchema(&models.Schema{Name: schema, Scripts: []*models.Script{bad}}); err == nil {
|
||||
t.Error("embed preparation failures must propagate even when continuing")
|
||||
}
|
||||
if opts.Metadata["execution_failed"] != 1 {
|
||||
t.Errorf("counts: %v", opts.Metadata)
|
||||
@@ -217,7 +218,29 @@ func TestLive_WriteDatabaseMultiSchema(t *testing.T) {
|
||||
// A failure in one schema aborts and names that schema.
|
||||
db.Schemas[1].Scripts[0].SQL = "SELECT * FROM definitely_missing_table;"
|
||||
err := NewWriter(liveOptions(connString, nil)).WriteDatabase(db)
|
||||
if err == nil || !strings.Contains(err.Error(), "schema "+s2) {
|
||||
if err == nil || !strings.Contains(err.Error(), s2+".b") {
|
||||
t.Errorf("expected error naming schema %s, got %v", s2, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLive_MultiSchemaFailuresAggregate(t *testing.T) {
|
||||
connection := liveConn(t)
|
||||
opts := liveOptions(connection, nil)
|
||||
opts.ContinueOnError = true
|
||||
db := &models.Database{Schemas: []*models.Schema{
|
||||
{Name: "one", Scripts: []*models.Script{{Name: "bad", SQL: "SELECT 1 / 0"}, {Name: "empty"}}},
|
||||
{Name: "two", Scripts: []*models.Script{{Name: "ok", SQL: "SELECT 1"}}},
|
||||
}}
|
||||
err := NewWriter(opts).WriteDatabase(db)
|
||||
var failure *writers.ExecutionFailure
|
||||
if !errors.As(err, &failure) {
|
||||
t.Fatalf("expected structured failure: %v", err)
|
||||
}
|
||||
report := failure.Report
|
||||
if report.TotalStatements != 3 || report.FailedStatements != 1 || report.ExecutedStatements != 1 || report.SkippedStatements != 1 || report.Outcome != "script_managed" {
|
||||
t.Fatalf("incorrect aggregate: %+v", report)
|
||||
}
|
||||
if report.Operations[0].Name != "one.bad" || report.Operations[2].Name != "two.ok" || report.Operations[2].Status != "executed" {
|
||||
t.Fatalf("script details: %+v", report.Operations)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user