fix: address audit execution failures and unsafe type conversions
This commit is contained in:
+121
-164
@@ -2,7 +2,6 @@ package pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -12,6 +11,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
@@ -27,36 +28,12 @@ type Writer struct {
|
||||
executor *TemplateExecutor
|
||||
}
|
||||
|
||||
// ExecutionReport tracks the execution status of SQL statements
|
||||
type ExecutionReport struct {
|
||||
TotalStatements int `json:"total_statements"`
|
||||
ExecutedStatements int `json:"executed_statements"`
|
||||
FailedStatements int `json:"failed_statements"`
|
||||
Schemas []SchemaReport `json:"schemas"`
|
||||
Errors []ExecutionError `json:"errors,omitempty"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
}
|
||||
|
||||
// SchemaReport tracks execution per schema
|
||||
type SchemaReport struct {
|
||||
Name string `json:"name"`
|
||||
Tables []TableReport `json:"tables"`
|
||||
}
|
||||
|
||||
// TableReport tracks execution per table
|
||||
type TableReport struct {
|
||||
Name string `json:"name"`
|
||||
Created bool `json:"created"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionError represents a failed statement
|
||||
type ExecutionError struct {
|
||||
StatementNumber int `json:"statement_number"`
|
||||
Statement string `json:"statement"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
// Shared execution results retain the PostgreSQL package's public type names.
|
||||
type ExecutionReport = writers.ExecutionReport
|
||||
type ExecutionFailure = writers.ExecutionFailure
|
||||
type SchemaReport = writers.SchemaReport
|
||||
type TableReport = writers.TableReport
|
||||
type ExecutionError = writers.ExecutionError
|
||||
|
||||
// NewWriter creates a new PostgreSQL SQL writer
|
||||
func NewWriter(options *writers.WriterOptions) *Writer {
|
||||
@@ -109,9 +86,20 @@ func (w *Writer) WriteDatabase(db *models.Database) error {
|
||||
}
|
||||
fmt.Fprintf(w.writer, "\n")
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
preflights, err := w.generateTypePreflights(schema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range preflights {
|
||||
if _, err := fmt.Fprintln(w.writer, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Process each schema in the database
|
||||
for _, schema := range db.Schemas {
|
||||
if err := w.WriteSchema(schema); err != nil {
|
||||
if err := w.writeSchema(schema); err != nil {
|
||||
return fmt.Errorf("failed to write schema %s: %w", schema.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -129,9 +117,16 @@ func (w *Writer) GenerateDatabaseStatements(db *models.Database) ([]string, erro
|
||||
statements = append(statements, fmt.Sprintf("-- Database: %s", db.Name))
|
||||
statements = append(statements, "-- Generated by RelSpec")
|
||||
|
||||
for _, schema := range db.Schemas {
|
||||
preflights, err := w.generateTypePreflights(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statements = append(statements, preflights...)
|
||||
}
|
||||
// Process each schema in the database
|
||||
for _, schema := range db.Schemas {
|
||||
schemaStatements, err := w.GenerateSchemaStatements(schema)
|
||||
schemaStatements, err := w.generateSchemaStatements(schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate statements for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
@@ -187,6 +182,15 @@ func (w *Writer) primaryKeySetvalStatement(schema *models.Schema, table *models.
|
||||
|
||||
// GenerateSchemaStatements generates SQL statements as a list for a single schema
|
||||
func (w *Writer) GenerateSchemaStatements(schema *models.Schema) ([]string, error) {
|
||||
preflights, err := w.generateTypePreflights(schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statements, err := w.generateSchemaStatements(schema)
|
||||
return append(preflights, statements...), err
|
||||
}
|
||||
|
||||
func (w *Writer) generateSchemaStatements(schema *models.Schema) ([]string, error) {
|
||||
statements := []string{}
|
||||
|
||||
if err := w.checkDirectives(schema); err != nil {
|
||||
@@ -510,15 +514,11 @@ func (w *Writer) GenerateAlterColumnTypeStatements(schema *models.Schema) ([]str
|
||||
// rather than risk an ALTER COLUMN TYPE Postgres won't accept cleanly.
|
||||
continue
|
||||
}
|
||||
targetType := effectiveAlterColumnSQLType(col)
|
||||
stmt, err := w.executor.ExecuteAlterColumnTypeWithCheck(AlterColumnTypeWithCheckData{
|
||||
SchemaName: schema.Name,
|
||||
TableName: table.Name,
|
||||
ColumnName: col.Name,
|
||||
NewType: targetType,
|
||||
EquivalentTypes: equivalentTypeListSQL(targetType),
|
||||
UsingExpr: buildAlterColumnUsingExpression(col.Name, targetType),
|
||||
})
|
||||
data, err := conversionData(schema.Name, table.Name, col)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt, err := w.executor.ExecuteAlterColumnTypeWithCheck(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate alter column type for %s.%s.%s: %w", schema.Name, table.Name, col.Name, err)
|
||||
}
|
||||
@@ -774,6 +774,22 @@ func (w *Writer) WriteSchema(schema *models.Schema) error {
|
||||
if w.writer == nil {
|
||||
w.writer = os.Stdout
|
||||
}
|
||||
preflights, err := w.generateTypePreflights(schema)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range preflights {
|
||||
if _, err := fmt.Fprintln(w.writer, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.writeSchema(schema)
|
||||
}
|
||||
|
||||
func (w *Writer) writeSchema(schema *models.Schema) error {
|
||||
if w.writer == nil {
|
||||
w.writer = os.Stdout
|
||||
}
|
||||
|
||||
if err := w.checkDirectives(schema); err != nil {
|
||||
return err
|
||||
@@ -2054,7 +2070,8 @@ func extractSequenceName(defaultExpr string) string {
|
||||
func (w *Writer) executeDatabaseSQL(db *models.Database, connString string) error {
|
||||
// Initialize execution report
|
||||
w.executionReport = &ExecutionReport{
|
||||
StartTime: getCurrentTimestamp(),
|
||||
Dialect: "PostgreSQL",
|
||||
StartTime: time.Now().Format(time.RFC3339Nano),
|
||||
Schemas: make([]SchemaReport, 0),
|
||||
Errors: make([]ExecutionError, 0),
|
||||
}
|
||||
@@ -2067,7 +2084,8 @@ func (w *Writer) executeDatabaseSQL(db *models.Database, connString string) erro
|
||||
} else {
|
||||
statements = diffStatements
|
||||
if len(statements) == 0 {
|
||||
w.executionReport.EndTime = getCurrentTimestamp()
|
||||
w.executionReport.EndTime = time.Now().Format(time.RFC3339Nano)
|
||||
w.executionReport.Outcome = "no_changes"
|
||||
fmt.Fprintln(os.Stderr, "✓ Database is already up to date; nothing to execute")
|
||||
return w.finishReport()
|
||||
}
|
||||
@@ -2179,12 +2197,17 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
|
||||
return nil, fmt.Errorf("failed to generate migration scripts: %w", err)
|
||||
}
|
||||
|
||||
var preflights []string
|
||||
lastSchema := ""
|
||||
for _, script := range scripts {
|
||||
body := strings.TrimSpace(script.Body)
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
if script.ObjectType == "preflight column type" {
|
||||
preflights = append(preflights, body)
|
||||
continue
|
||||
}
|
||||
if script.Schema != "" && script.Schema != lastSchema {
|
||||
statements = append(statements, fmt.Sprintf("-- Schema: %s", script.Schema))
|
||||
lastSchema = script.Schema
|
||||
@@ -2192,150 +2215,84 @@ func (w *Writer) diffStatements(model, current *models.Database) ([]string, erro
|
||||
statements = append(statements, body)
|
||||
}
|
||||
statements = append(statements, setvalStatements...)
|
||||
statements = append(preflights, statements...)
|
||||
|
||||
return statements, nil
|
||||
}
|
||||
|
||||
// executeStatements runs statements one by one against the database and writes the report.
|
||||
func (w *Writer) executeStatements(statements []string, connString string) error {
|
||||
w.executionReport.TotalStatements = len(statements)
|
||||
|
||||
// Connect to database
|
||||
fmt.Fprintln(os.Stderr, " → Connecting to PostgreSQL output database...")
|
||||
ctx := context.Background()
|
||||
conn, err := pgsql.Connect(ctx, connString, "writer-pgsql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
err = writers.FailExecution(w.options, "PostgreSQL", writers.StatementOperations(statements), fmt.Errorf("failed to connect to database: %w", err))
|
||||
w.executionReport = w.options.Metadata["execution_report"].(*ExecutionReport)
|
||||
return err
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
fmt.Fprintln(os.Stderr, " → Connected. Executing statements...")
|
||||
|
||||
// Track schemas and tables
|
||||
schemaMap := make(map[string]*SchemaReport)
|
||||
w.executionReport.Dialect = "PostgreSQL"
|
||||
var tx pgx.Tx
|
||||
exec := func(ctx context.Context, i int, stmt string) error {
|
||||
fmt.Fprintf(os.Stderr, "Executing statement %d/%d [%s] %s...\n", i+1, len(statements), detectStatementType(stmt), extractStatementContext(stmt))
|
||||
if tx != nil {
|
||||
_, err := tx.Exec(ctx, stmt)
|
||||
return err
|
||||
}
|
||||
_, err := conn.Exec(ctx, stmt)
|
||||
return err
|
||||
}
|
||||
backend := writers.ExecutionBackend{
|
||||
Exec: exec,
|
||||
Begin: func(ctx context.Context) error { var err error; tx, err = conn.Begin(ctx); return err },
|
||||
Commit: func(ctx context.Context) error { return tx.Commit(ctx) },
|
||||
Rollback: func(ctx context.Context) error { return tx.Rollback(ctx) },
|
||||
}
|
||||
schemaNames := make([]string, len(statements))
|
||||
currentSchema := ""
|
||||
|
||||
// Execute each statement
|
||||
for i, stmt := range statements {
|
||||
stmtTrimmed := strings.TrimSpace(stmt)
|
||||
|
||||
// Skip comments
|
||||
if strings.HasPrefix(stmtTrimmed, "--") {
|
||||
// Check if this is a schema comment to track schema changes
|
||||
if strings.Contains(stmtTrimmed, "Schema:") {
|
||||
parts := strings.Split(stmtTrimmed, "Schema:")
|
||||
if len(parts) > 1 {
|
||||
currentSchema = strings.TrimSpace(parts[1])
|
||||
if _, exists := schemaMap[currentSchema]; !exists {
|
||||
schemaReport := SchemaReport{
|
||||
Name: currentSchema,
|
||||
Tables: make([]TableReport, 0),
|
||||
}
|
||||
schemaMap[currentSchema] = &schemaReport
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty statements
|
||||
if stmtTrimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
stmtType := detectStatementType(stmtTrimmed)
|
||||
stmtCtx := extractStatementContext(stmtTrimmed)
|
||||
if stmtCtx != "" {
|
||||
fmt.Fprintf(os.Stderr, "Executing statement %d/%d [%s] %s...\n", i+1, len(statements), stmtType, stmtCtx)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Executing statement %d/%d [%s]...\n", i+1, len(statements), stmtType)
|
||||
}
|
||||
|
||||
_, execErr := conn.Exec(ctx, stmt)
|
||||
if execErr != nil {
|
||||
w.executionReport.FailedStatements++
|
||||
execError := ExecutionError{
|
||||
StatementNumber: i + 1,
|
||||
Statement: truncateStatement(stmt),
|
||||
Error: execErr.Error(),
|
||||
}
|
||||
w.executionReport.Errors = append(w.executionReport.Errors, execError)
|
||||
|
||||
// Track table creation failure
|
||||
if strings.Contains(strings.ToUpper(stmtTrimmed), "CREATE TABLE") && currentSchema != "" {
|
||||
tableName := extractTableNameFromCreate(stmtTrimmed)
|
||||
if tableName != "" && schemaMap[currentSchema] != nil {
|
||||
schemaMap[currentSchema].Tables = append(schemaMap[currentSchema].Tables, TableReport{
|
||||
Name: tableName,
|
||||
Created: false,
|
||||
Error: execErr.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Continue with next statement instead of failing completely
|
||||
fmt.Fprintf(os.Stderr, "⚠ Warning: Statement %d failed: %v\n", i+1, execErr)
|
||||
continue
|
||||
}
|
||||
|
||||
w.executionReport.ExecutedStatements++
|
||||
|
||||
// Track successful table creation
|
||||
if strings.Contains(strings.ToUpper(stmtTrimmed), "CREATE TABLE") && currentSchema != "" {
|
||||
tableName := extractTableNameFromCreate(stmtTrimmed)
|
||||
if tableName != "" && schemaMap[currentSchema] != nil {
|
||||
schemaMap[currentSchema].Tables = append(schemaMap[currentSchema].Tables, TableReport{
|
||||
Name: tableName,
|
||||
Created: true,
|
||||
})
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(stmt), "-- Schema:") {
|
||||
currentSchema = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(stmt), "-- Schema:"))
|
||||
}
|
||||
schemaNames[i] = currentSchema
|
||||
}
|
||||
|
||||
// Convert schema map to slice
|
||||
for _, schemaReport := range schemaMap {
|
||||
w.executionReport.Schemas = append(w.executionReport.Schemas, *schemaReport)
|
||||
schemaIndexes := make(map[string]int)
|
||||
// Populate per-schema details before finalizing the shared report.
|
||||
backend.Exec = func(ctx context.Context, i int, stmt string) error {
|
||||
err := exec(ctx, i, stmt)
|
||||
if strings.Contains(strings.ToUpper(stmt), "CREATE TABLE") {
|
||||
schemaName := schemaNames[i]
|
||||
if schemaName != "" {
|
||||
idx, exists := schemaIndexes[schemaName]
|
||||
if !exists {
|
||||
idx = -1
|
||||
}
|
||||
if idx < 0 {
|
||||
w.executionReport.Schemas = append(w.executionReport.Schemas, SchemaReport{Name: schemaName, Tables: []TableReport{}})
|
||||
idx = len(w.executionReport.Schemas) - 1
|
||||
schemaIndexes[schemaName] = idx
|
||||
}
|
||||
table := TableReport{Name: extractTableNameFromCreate(stmt), Created: err == nil}
|
||||
if err != nil {
|
||||
table.Error = err.Error()
|
||||
}
|
||||
w.executionReport.Schemas[idx].Tables = append(w.executionReport.Schemas[idx].Tables, table)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
w.executionReport.EndTime = getCurrentTimestamp()
|
||||
return w.finishReport()
|
||||
return writers.RunExecution(ctx, w.options, w.executionReport, writers.StatementOperations(statements), backend)
|
||||
}
|
||||
|
||||
// finishReport writes the optional report file and prints the execution summary.
|
||||
func (w *Writer) finishReport() error {
|
||||
// Write report if path is specified
|
||||
if reportPath, ok := w.options.Metadata["report_path"].(string); ok && reportPath != "" {
|
||||
if err := w.writeReport(reportPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "⚠ Warning: Failed to write report: %v\n", err)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "✓ Report written to: %s\n", reportPath)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if w.executionReport.FailedStatements > 0 {
|
||||
fmt.Fprintf(os.Stderr, "⚠ Completed with %d errors out of %d statements\n",
|
||||
w.executionReport.FailedStatements, w.executionReport.TotalStatements)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "✓ Successfully executed %d statements\n", w.executionReport.ExecutedStatements)
|
||||
err = &ExecutionFailure{Report: w.executionReport}
|
||||
}
|
||||
|
||||
return nil
|
||||
return writers.FinishExecution(w.options, w.executionReport, err)
|
||||
}
|
||||
|
||||
// writeReport writes the execution report to a JSON file
|
||||
func (w *Writer) writeReport(reportPath string) error {
|
||||
file, err := os.Create(reportPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create report file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(w.executionReport); err != nil {
|
||||
return fmt.Errorf("failed to encode report: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return writers.WriteExecutionReport(reportPath, w.executionReport)
|
||||
}
|
||||
|
||||
// extractTableNameFromCreate extracts table name from CREATE TABLE statement
|
||||
|
||||
Reference in New Issue
Block a user