215 lines
7.8 KiB
Go
215 lines
7.8 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
batchSourceType string
|
|
batchInputs []string
|
|
batchTargetType string
|
|
batchTargetDir string
|
|
batchPackageName string
|
|
batchSchemaFilter string
|
|
batchFlattenSchema bool
|
|
batchNullableTypes string
|
|
batchNullableArrays string
|
|
batchContinueOnError bool
|
|
batchKeepGoing bool
|
|
batchDryRun bool
|
|
)
|
|
|
|
var batchCmd = &cobra.Command{
|
|
Use: "batch",
|
|
Short: "Convert many input files to a target format in one run",
|
|
Long: `Convert each input file independently to the target format.
|
|
|
|
Unlike 'convert --from-list', which merges all inputs into one output, batch
|
|
mode writes one output per input into --to-dir. The output is named after the
|
|
input file (without its extension). Directory-style targets (gorm, bun,
|
|
drizzle) get a sub-directory per input.
|
|
|
|
Inputs are given with --input, which accepts file paths and glob patterns and
|
|
may be repeated or comma-separated. Inputs are processed in sorted order and
|
|
duplicates are removed. The command exits non-zero if any input fails.
|
|
|
|
For named, multi-step workflows use 'relspec job run' instead.
|
|
|
|
Examples:
|
|
# Convert every DBML file in a directory to JSON
|
|
relspec batch --from dbml --input "schemas/*.dbml" --to json --to-dir out/
|
|
|
|
# Convert specific files to GORM models, one package directory per input
|
|
relspec batch --from json --input a.json,b.json \
|
|
--to gorm --to-dir models/ --package models
|
|
|
|
# Validate everything first, writing nothing
|
|
relspec batch --from yaml --input "specs/*.yaml" --to pgsql --to-dir sql/ --dry-run
|
|
|
|
# Report all failures instead of stopping at the first
|
|
relspec batch --from json --input "*.json" --to yaml --to-dir out/ --keep-going`,
|
|
RunE: runBatch,
|
|
}
|
|
|
|
func init() {
|
|
batchCmd.Flags().StringVar(&batchSourceType, "from", "", "Source format for every input (dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, sqlite)")
|
|
batchCmd.Flags().StringSliceVar(&batchInputs, "input", nil, "Input file path or glob pattern (repeatable, comma-separated)")
|
|
batchCmd.Flags().StringVar(&batchTargetType, "to", "", "Target format")
|
|
batchCmd.Flags().StringVar(&batchTargetDir, "to-dir", "", "Output directory; one output per input is written here")
|
|
batchCmd.Flags().StringVar(&batchPackageName, "package", "", "Package name (for code generation formats like gorm/bun)")
|
|
batchCmd.Flags().StringVar(&batchSchemaFilter, "schema", "", "Filter to a specific schema by name")
|
|
batchCmd.Flags().BoolVar(&batchFlattenSchema, "flatten-schema", false, "Flatten schema.table names to schema_table")
|
|
batchCmd.Flags().StringVar(&batchNullableTypes, "types", "", "Nullable type package for code-gen writers (bun/gorm)")
|
|
batchCmd.Flags().StringVar(&batchNullableArrays, "array-nullable", "", "Nullable array representation for the Bun writer")
|
|
batchCmd.Flags().BoolVar(&batchContinueOnError, "continue-on-error", false, "Prepend \\set ON_ERROR_STOP off to generated SQL (pgsql output only)")
|
|
batchCmd.Flags().BoolVar(&batchKeepGoing, "keep-going", false, "Process remaining inputs after a failure; still exits non-zero")
|
|
batchCmd.Flags().BoolVar(&batchDryRun, "dry-run", false, "Read and validate every input and print the plan without writing any output")
|
|
|
|
for _, f := range []string{"from", "input", "to", "to-dir"} {
|
|
if err := batchCmd.MarkFlagRequired(f); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error marking %s flag as required: %v\n", f, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// batchDirTargets are writers that emit a directory of files rather than one file.
|
|
var batchDirTargets = map[string]bool{"gorm": true, "bun": true, "drizzle": true}
|
|
|
|
// batchExtensions maps single-file target formats to their output extension.
|
|
var batchExtensions = map[string]string{
|
|
"dbml": ".dbml", "dctx": ".dctx", "drawdb": ".ddb", "json": ".json",
|
|
"yaml": ".yaml", "yml": ".yaml", "pgsql": ".sql", "postgres": ".sql",
|
|
"postgresql": ".sql", "sql": ".sql", "mssql": ".sql", "sqlserver": ".sql",
|
|
"mssql2016": ".sql", "mssql2017": ".sql", "mssql2019": ".sql", "mssql2022": ".sql",
|
|
"sqlite": ".sql", "sqlite3": ".sql", "prisma": ".prisma", "typeorm": ".ts",
|
|
"graphql": ".graphql", "gql": ".graphql",
|
|
}
|
|
|
|
// expandBatchInputs resolves paths and glob patterns into a sorted,
|
|
// de-duplicated file list. A pattern that matches nothing is an error.
|
|
func expandBatchInputs(patterns []string) ([]string, error) {
|
|
seen := map[string]bool{}
|
|
var files []string
|
|
for _, p := range patterns {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
matches, err := filepath.Glob(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid pattern %q: %w", p, err)
|
|
}
|
|
if len(matches) == 0 {
|
|
return nil, fmt.Errorf("no files match %q", p)
|
|
}
|
|
for _, m := range matches {
|
|
if !seen[m] {
|
|
seen[m] = true
|
|
files = append(files, m)
|
|
}
|
|
}
|
|
}
|
|
if len(files) == 0 {
|
|
return nil, fmt.Errorf("no input files given")
|
|
}
|
|
sort.Strings(files)
|
|
return files, nil
|
|
}
|
|
|
|
// batchOutputPaths returns the output path for each input. It errors when two
|
|
// inputs would collide on the same output name.
|
|
func batchOutputPaths(files []string, targetType, dir string) ([]string, error) {
|
|
key := strings.ToLower(targetType)
|
|
ext := ""
|
|
if !batchDirTargets[key] {
|
|
var ok bool
|
|
if ext, ok = batchExtensions[key]; !ok {
|
|
return nil, fmt.Errorf("unsupported target format: %s", targetType)
|
|
}
|
|
}
|
|
outs := make([]string, len(files))
|
|
owner := map[string]string{}
|
|
for i, f := range files {
|
|
stem := strings.TrimSuffix(filepath.Base(f), filepath.Ext(f))
|
|
out := filepath.Join(dir, stem+ext)
|
|
if prev, dup := owner[out]; dup {
|
|
return nil, fmt.Errorf("inputs %s and %s would both write %s", prev, f, out)
|
|
}
|
|
owner[out] = f
|
|
outs[i] = out
|
|
}
|
|
return outs, nil
|
|
}
|
|
|
|
func runBatch(cmd *cobra.Command, args []string) error {
|
|
files, err := expandBatchInputs(batchInputs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
outs, err := batchOutputPaths(files, batchTargetType, batchTargetDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "\n=== RelSpec Batch Converter ===\n")
|
|
fmt.Fprintf(os.Stderr, "Started at: %s\n", getCurrentTimestamp())
|
|
fmt.Fprintf(os.Stderr, "Inputs: %d file(s), %s -> %s\n\n", len(files), batchSourceType, batchTargetType)
|
|
|
|
out := outWriter(cmd)
|
|
if batchDryRun {
|
|
fmt.Fprintf(out, "RelSpec batch plan (dry run - nothing written):\n")
|
|
}
|
|
|
|
var failed []string
|
|
for i, f := range files {
|
|
fmt.Fprintf(os.Stderr, "[%d/%d] %s -> %s\n", i+1, len(files), f, outs[i])
|
|
if err := processBatchItem(cmd, f, outs[i]); err != nil {
|
|
fmt.Fprintf(os.Stderr, " ✗ %v\n", err)
|
|
failed = append(failed, fmt.Sprintf("%s: %v", f, err))
|
|
if !batchKeepGoing {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, " ✓ done\n")
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "\n=== Batch Complete: %d ok, %d failed ===\n", len(files)-len(failed), len(failed))
|
|
if len(failed) > 0 {
|
|
return fmt.Errorf("batch finished with %d failure(s):\n %s", len(failed), strings.Join(failed, "\n "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func processBatchItem(cmd *cobra.Command, in, outPath string) error {
|
|
db, err := readDatabaseForConvert(batchSourceType, in, "")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read source: %w", err)
|
|
}
|
|
finalizeCommentedRefs(db, stderrWarn)
|
|
|
|
if batchDryRun {
|
|
if err := validateWriteTarget(db, batchTargetType, batchPackageName, batchSchemaFilter, ""); err != nil {
|
|
return fmt.Errorf("dry run validation failed: %w", err)
|
|
}
|
|
w := outWriter(cmd)
|
|
fmt.Fprintf(w, " %s -> %s (database '%s')\n", in, outPath, db.Name)
|
|
printDryRunPlan(w, db)
|
|
return nil
|
|
}
|
|
|
|
if err := os.MkdirAll(batchTargetDir, 0o755); err != nil {
|
|
return fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
if err := writeDatabase(db, batchTargetType, outPath, batchPackageName, batchSchemaFilter, batchFlattenSchema, batchNullableTypes, batchNullableArrays, batchContinueOnError, ""); err != nil {
|
|
return fmt.Errorf("failed to write target: %w", err)
|
|
}
|
|
return nil
|
|
}
|