From 1e7f3518494d4a5885921d574d7dcb683cbb39e7 Mon Sep 17 00:00:00 2001 From: SG Command Date: Sat, 3 Oct 2026 03:10:59 +0200 Subject: [PATCH] feat(cli): add --dry-run to convert, merge and split Read and validate inputs, apply filtering/merging in memory, validate the output target (format, --package, --schema, --extra-fields) and print the plan to stdout without writing any output. Closes #35. Co-Authored-By: Claude Sonnet 5.5 --- cmd/relspec/convert.go | 126 ++++++++++++++++++++++++---- cmd/relspec/dry_run_test.go | 158 ++++++++++++++++++++++++++++++++++++ cmd/relspec/merge.go | 35 ++++++++ cmd/relspec/split.go | 23 ++++++ 4 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 cmd/relspec/dry_run_test.go diff --git a/cmd/relspec/convert.go b/cmd/relspec/convert.go index b09f406..0f1c950 100644 --- a/cmd/relspec/convert.go +++ b/cmd/relspec/convert.go @@ -3,6 +3,7 @@ package main import ( stdjson "encoding/json" "fmt" + "io" "os" "strings" "time" @@ -57,6 +58,7 @@ var ( convertNullableArrays string convertContinueOnError bool convertExtraFields string + convertDryRun bool ) var convertCmd = &cobra.Command{ @@ -185,6 +187,8 @@ func init() { convertCmd.Flags().BoolVar(&convertContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL so psql continues past errors (pgsql output only)") convertCmd.Flags().StringVar(&convertExtraFields, "extra-fields", "", "Path to JSON file containing extra Bun model fields to inject (bun output only); fields support target_table, name, type, bun_tag, json_tag, comment") + convertCmd.Flags().BoolVar(&convertDryRun, "dry-run", false, "Read and validate the input and print the plan without writing any output") + err := convertCmd.MarkFlagRequired("from") if err != nil { fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err) @@ -240,6 +244,22 @@ func runConvert(cmd *cobra.Command, args []string) error { } fmt.Fprintf(os.Stderr, " Found: %d table(s)\n\n", totalTables) + if convertDryRun { + if err := validateWriteTarget(db, convertTargetType, convertPackageName, convertSchemaFilter, convertExtraFields); err != nil { + return fmt.Errorf("dry run validation failed: %w", err) + } + out := outWriter(cmd) + fmt.Fprintf(out, "RelSpec convert plan (dry run - nothing written):\n") + fmt.Fprintf(out, " Input: %s database '%s'\n", convertSourceType, db.Name) + fmt.Fprintf(out, " Output: %s -> %s\n", convertTargetType, convertTargetPath) + if convertSchemaFilter != "" { + fmt.Fprintf(out, " Schema filter: %s\n", convertSchemaFilter) + } + printDryRunPlan(out, db) + fmt.Fprintf(os.Stderr, "=== Dry run complete: no output written ===\n\n") + return nil + } + // Write to target format fmt.Fprintf(os.Stderr, "[2/2] Writing to target format...\n") fmt.Fprintf(os.Stderr, " Format: %s\n", convertTargetType) @@ -408,23 +428,12 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF writerOpts := newWriterOptions(outputPath, packageName, flattenSchema, nullableTypes, nullableArrays, continueOnError) if extraFields != "" { - if !strings.EqualFold(dbType, "bun") { - return fmt.Errorf("--extra-fields is only supported for Bun output") - } - extraFieldsJSON, err := os.ReadFile(extraFields) + extraFieldsJSON, err := loadExtraFields(dbType, extraFields) if err != nil { - return fmt.Errorf("failed to read --extra-fields file %q: %w", extraFields, err) - } - - var parsed []wbun.ExtraFieldConfig - if err := stdjson.Unmarshal(extraFieldsJSON, &parsed); err != nil { - return fmt.Errorf("invalid --extra-fields JSON in %q: %w", extraFields, err) - } - if len(parsed) == 0 { - return fmt.Errorf("--extra-fields must contain at least one field") + return err } writerOpts.Metadata = map[string]interface{}{ - "extra_fields": string(extraFieldsJSON), + "extra_fields": extraFieldsJSON, } } @@ -529,6 +538,95 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF return nil } +// loadExtraFields reads and validates the --extra-fields JSON file, returning +// its raw content. +func loadExtraFields(dbType, path string) (string, error) { + if !strings.EqualFold(dbType, "bun") { + return "", fmt.Errorf("--extra-fields is only supported for Bun output") + } + extraFieldsJSON, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read --extra-fields file %q: %w", path, err) + } + + var parsed []wbun.ExtraFieldConfig + if err := stdjson.Unmarshal(extraFieldsJSON, &parsed); err != nil { + return "", fmt.Errorf("invalid --extra-fields JSON in %q: %w", path, err) + } + if len(parsed) == 0 { + return "", fmt.Errorf("--extra-fields must contain at least one field") + } + return string(extraFieldsJSON), nil +} + +// validateWriteTarget performs the checks writeDatabase makes before writing, +// without constructing a writer or touching the output path. Used by --dry-run. +func validateWriteTarget(db *models.Database, dbType, packageName, schemaFilter, extraFields string) error { + if extraFields != "" { + if _, err := loadExtraFields(dbType, extraFields); err != nil { + return err + } + } + + switch strings.ToLower(dbType) { + case "dbml", "dctx", "drawdb", "json", "yaml", "yml", "drizzle", + "pgsql", "postgres", "postgresql", "sql", + "mssql", "sqlserver", "mssql2016", "mssql2017", "mssql2019", "mssql2022", + "sqlite", "sqlite3", "prisma", "typeorm", "graphql", "gql": + case "gorm", "bun": + if packageName == "" { + return fmt.Errorf("package name is required for %s format (use --package flag)", dbType) + } + default: + return fmt.Errorf("unsupported target format: %s", dbType) + } + + if schemaFilter != "" { + for _, schema := range db.Schemas { + if schema.Name == schemaFilter { + return nil + } + } + return fmt.Errorf("schema '%s' not found in database. Available schemas: %v", + schemaFilter, getSchemaNames(db)) + } + + if strings.EqualFold(dbType, "dctx") { + if len(db.Schemas) == 0 { + return fmt.Errorf("no schemas found in database") + } + if len(db.Schemas) > 1 { + return fmt.Errorf("multiple schemas found, please specify which schema to export using --schema flag. Available schemas: %v", + getSchemaNames(db)) + } + } + return nil +} + +// outWriter returns the command's stdout, falling back to os.Stdout when the +// command is nil (tests call the run functions directly). +func outWriter(cmd *cobra.Command) io.Writer { + if cmd == nil { + return os.Stdout + } + return cmd.OutOrStdout() +} + +// printDryRunPlan prints the schemas and tables that would be written. +func printDryRunPlan(out io.Writer, db *models.Database) { + for _, schema := range db.Schemas { + names := make([]string, 0, len(schema.Tables)) + for _, t := range schema.Tables { + names = append(names, t.Name) + } + fmt.Fprintf(out, " schema %q: %d table(s)", schema.Name, len(schema.Tables)) + if len(names) > 0 { + fmt.Fprintf(out, " [%s]", strings.Join(names, ", ")) + } + fmt.Fprintln(out) + } +} + // getSchemaNames returns a slice of schema names from a database func getSchemaNames(db *models.Database) []string { names := make([]string, len(db.Schemas)) diff --git a/cmd/relspec/dry_run_test.go b/cmd/relspec/dry_run_test.go new file mode 100644 index 0000000..01acd04 --- /dev/null +++ b/cmd/relspec/dry_run_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func newDryRunCmd() (*cobra.Command, *bytes.Buffer) { + var buf bytes.Buffer + cmd := &cobra.Command{} + cmd.SetOut(&buf) + return cmd, &buf +} + +func TestRunConvert_DryRunWritesNothing(t *testing.T) { + defer func(a, b, c, d string, e bool) { + convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun = a, b, c, d, e + }(convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun) + + dir := t.TempDir() + in := filepath.Join(dir, "in.json") + out := filepath.Join(dir, "out.json") + writeTestJSON(t, in, []string{"users", "posts"}) + + convertSourceType, convertSourcePath = "json", in + convertTargetType, convertTargetPath = "json", out + convertDryRun = true + + cmd, buf := newDryRunCmd() + if err := runConvert(cmd, nil); err != nil { + t.Fatalf("dry run: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatal("dry run must not create the output file") + } + for _, want := range []string{"dry run", "users", "posts", out} { + if !strings.Contains(buf.String(), want) { + t.Errorf("plan missing %q:\n%s", want, buf.String()) + } + } + + // Normal behavior is unchanged. + convertDryRun = false + if err := runConvert(cmd, nil); err != nil { + t.Fatalf("real run: %v", err) + } + if _, err := os.Stat(out); err != nil { + t.Fatalf("real run should write output: %v", err) + } +} + +func TestRunConvert_DryRunValidatesTarget(t *testing.T) { + defer func(a, b, c, d string, e bool) { + convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun = a, b, c, d, e + }(convertSourceType, convertSourcePath, convertTargetType, convertTargetPath, convertDryRun) + + dir := t.TempDir() + in := filepath.Join(dir, "in.json") + out := filepath.Join(dir, "models") + writeTestJSON(t, in, []string{"users"}) + + convertSourceType, convertSourcePath = "json", in + convertDryRun = true + + // gorm without --package must fail validation, as a real run would. + convertTargetType, convertTargetPath = "gorm", out + cmd, _ := newDryRunCmd() + err := runConvert(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "package name is required") { + t.Fatalf("expected package validation error, got %v", err) + } + + convertTargetType = "nope" + err = runConvert(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "unsupported target format") { + t.Fatalf("expected unsupported format error, got %v", err) + } + if _, statErr := os.Stat(out); !os.IsNotExist(statErr) { + t.Fatal("dry run must not create the output path") + } +} + +func TestRunSplit_DryRunWritesNothing(t *testing.T) { + defer func(a, b, c, d, e string, f bool) { + splitSourceType, splitSourcePath, splitTargetType, splitTargetPath, splitTables, splitDryRun = a, b, c, d, e, f + }(splitSourceType, splitSourcePath, splitTargetType, splitTargetPath, splitTables, splitDryRun) + + dir := t.TempDir() + in := filepath.Join(dir, "in.json") + out := filepath.Join(dir, "subset.json") + writeTestJSON(t, in, []string{"users", "posts", "comments"}) + + splitSourceType, splitSourcePath = "json", in + splitTargetType, splitTargetPath = "json", out + splitTables = "users,posts" + splitDryRun = true + + cmd, buf := newDryRunCmd() + if err := runSplit(cmd, nil); err != nil { + t.Fatalf("dry run: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatal("dry run must not create the output file") + } + got := buf.String() + if !strings.Contains(got, "2 table(s)") || strings.Contains(got, "comments") { + t.Errorf("plan should show only the 2 selected tables:\n%s", got) + } + + // A selection that matches nothing fails validation in dry-run too. + splitTables = "does_not_exist" + if err := runSplit(cmd, nil); err == nil { + t.Fatal("expected error for empty selection") + } +} + +func TestRunMerge_DryRunWritesNothing(t *testing.T) { + saved := saveMergeState() + defer restoreMergeState(saved) + defer func(v bool) { mergeDryRun = v }(mergeDryRun) + + dir := t.TempDir() + target := filepath.Join(dir, "target.json") + source := filepath.Join(dir, "source.json") + out := filepath.Join(dir, "merged.json") + writeTestJSON(t, target, []string{"users"}) + writeTestJSON(t, source, []string{"posts"}) + + mergeTargetType, mergeTargetPath, mergeTargetConn = "json", target, "" + mergeSourceType, mergeSourcePath, mergeSourceConn = "json", source, "" + mergeFromList = nil + mergeOutputType, mergeOutputPath, mergeOutputConn = "json", out, "" + mergeSkipTables, mergeReportPath = "", "" + mergeDryRun = true + + cmd, buf := newDryRunCmd() + if err := runMerge(cmd, nil); err != nil { + t.Fatalf("dry run: %v", err) + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatal("dry run must not create the output file") + } + for _, want := range []string{"dry run", "users", "posts", out} { + if !strings.Contains(buf.String(), want) { + t.Errorf("plan missing %q:\n%s", want, buf.String()) + } + } + + mergeOutputType = "nope" + if err := runMerge(cmd, nil); err == nil || !strings.Contains(err.Error(), "unsupported format") { + t.Fatalf("expected unsupported output format error, got %v", err) + } +} diff --git a/cmd/relspec/merge.go b/cmd/relspec/merge.go index 7da5e67..8eabb57 100644 --- a/cmd/relspec/merge.go +++ b/cmd/relspec/merge.go @@ -61,6 +61,7 @@ var ( mergeReportPath string // Path to write merge report mergeFullDDL bool // Execute full DDL instead of diffing the live pgsql database mergeFlattenSchema bool + mergeDryRun bool ) var mergeCmd = &cobra.Command{ @@ -130,6 +131,7 @@ func init() { mergeCmd.Flags().BoolVar(&mergeVerbose, "verbose", false, "Show verbose output") mergeCmd.Flags().BoolVar(&mergeFullDDL, "full-ddl", false, "pgsql database output: execute the full DDL instead of only the differences from the live database") mergeCmd.Flags().StringVar(&mergeReportPath, "merge-report", "", "Path to write merge report (JSON format)") + mergeCmd.Flags().BoolVar(&mergeDryRun, "dry-run", false, "Read and merge in memory, then print the merge plan without writing any output") mergeCmd.Flags().BoolVar(&mergeFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table (useful for databases like SQLite that do not support schemas)") } @@ -264,6 +266,29 @@ func runMerge(cmd *cobra.Command, args []string) error { merge.GetColumnTypeConflictSummary(result, 10)) } + if mergeDryRun { + if !isMergeOutputFormat(mergeOutputType) { + return fmt.Errorf("dry run validation failed: Output: unsupported format '%s'", mergeOutputType) + } + out := outWriter(cmd) + fmt.Fprintf(out, "RelSpec merge plan (dry run - nothing written):\n") + fmt.Fprintf(out, " Target: %s database '%s'\n", mergeTargetType, targetDB.Name) + fmt.Fprintf(out, " Source: %s database '%s'\n", mergeSourceType, sourceDB.Name) + switch { + case mergeOutputPath != "": + fmt.Fprintf(out, " Output: %s -> %s\n", mergeOutputType, mergeOutputPath) + case mergeOutputConn != "": + fmt.Fprintf(out, " Output: %s -> %s\n", mergeOutputType, maskPassword(mergeOutputConn)) + default: + fmt.Fprintf(out, " Output: %s\n", mergeOutputType) + } + fmt.Fprintf(out, " Result:\n") + printDryRunPlan(out, targetDB) + fmt.Fprintf(out, "\n%s\n", merge.GetMergeSummary(result)) + fmt.Fprintf(os.Stderr, "\n=== Dry run complete: no output written ===\n") + return nil + } + // Step 4: Write output fmt.Fprintf(os.Stderr, "\n[4/4] Writing output...\n") fmt.Fprintf(os.Stderr, " Format: %s\n", mergeOutputType) @@ -285,6 +310,16 @@ func runMerge(cmd *cobra.Command, args []string) error { return nil } +// isMergeOutputFormat reports whether writeDatabaseForMerge supports dbType. +func isMergeOutputFormat(dbType string) bool { + switch strings.ToLower(dbType) { + case "dbml", "dctx", "drawdb", "graphql", "json", "yaml", "gorm", "bun", + "drizzle", "prisma", "typeorm", "sqlite", "sqlite3", "pgsql": + return true + } + return false +} + func readDatabaseForMerge(dbType, filePath, connString, label string) (*models.Database, error) { var reader readers.Reader diff --git a/cmd/relspec/split.go b/cmd/relspec/split.go index 6d2538f..fd89c30 100644 --- a/cmd/relspec/split.go +++ b/cmd/relspec/split.go @@ -24,6 +24,7 @@ var ( splitExcludeTables string splitNullableTypes string splitNullableArrays string + splitDryRun bool ) var splitCmd = &cobra.Command{ @@ -115,6 +116,8 @@ func init() { splitCmd.Flags().StringVar(&splitNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm): 'baselib' (default, Go pointer types), 'stdlib' (database/sql), or 'sqltypes'") splitCmd.Flags().StringVar(&splitNullableArrays, "array-nullable", "", "Nullable PostgreSQL array representation for the Bun writer in stdlib/baselib --types mode: 'slice' (default, plain slice) or 'pointer_slice' (*[]T, distinguishes NULL from '{}')") + splitCmd.Flags().BoolVar(&splitDryRun, "dry-run", false, "Read, filter and validate the selection and print the plan without writing any output") + err := splitCmd.MarkFlagRequired("from") if err != nil { fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err) @@ -174,6 +177,26 @@ func runSplit(cmd *cobra.Command, args []string) error { } fmt.Fprintf(os.Stderr, " ✓ Filtered to: %d schema(s), %d table(s)\n\n", len(filteredDB.Schemas), filteredTables) + if splitDryRun { + if err := validateWriteTarget(filteredDB, splitTargetType, splitPackageName, "", ""); err != nil { + return fmt.Errorf("dry run validation failed: %w", err) + } + out := outWriter(cmd) + fmt.Fprintf(out, "RelSpec split plan (dry run - nothing written):\n") + fmt.Fprintf(out, " Input: %s database '%s'\n", splitSourceType, db.Name) + fmt.Fprintf(out, " Output: %s -> %s\n", splitTargetType, splitTargetPath) + fmt.Fprintf(out, " Selection: %s\n", splitSelection{ + Schemas: parseCommaSeparated(splitSchemas), + Tables: parseCommaSeparated(splitTables), + ExcludeSchemas: parseCommaSeparated(splitExcludeSchema), + ExcludeTables: parseCommaSeparated(splitExcludeTables), + DatabaseName: splitDatabaseName, + }.summary()) + printDryRunPlan(out, filteredDB) + fmt.Fprintf(os.Stderr, "=== Dry run complete: no output written ===\n\n") + return nil + } + // Write to target format fmt.Fprintf(os.Stderr, "[3/3] Writing to target format...\n") fmt.Fprintf(os.Stderr, " Format: %s\n", splitTargetType) -- 2.54.0