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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
6e2e7eb19e
commit
1e7f351849
+112
-14
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user