Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d961536186 | ||
|
|
b38f53c603 | ||
|
|
ccba53c494 | ||
|
|
53327b9a5a | ||
|
|
734b14d48d | ||
|
|
938f0ed51f |
@@ -44,6 +44,17 @@ relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path
|
||||
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
|
||||
```
|
||||
|
||||
### `batch` — Convert many inputs in one run
|
||||
|
||||
Converts each input independently (one output per input, unlike `--from-list`
|
||||
which merges). `--input` takes paths or globs; outputs go to `--to-dir`.
|
||||
Use `--keep-going` to continue past failures (exit is still non-zero) and
|
||||
`--dry-run` to validate without writing. For named workflows see `relspec job run`.
|
||||
|
||||
```bash
|
||||
relspec batch --from dbml --input "schemas/*.dbml" --to json --to-dir out/
|
||||
```
|
||||
|
||||
PostgreSQL connections opened by relspec set `application_name` by default to
|
||||
`relspecgo/<version>` (with component suffixes internally, e.g. readers/writers).
|
||||
If you need a custom value, provide `application_name` explicitly in the connection
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func saveBatchState(t *testing.T) {
|
||||
t.Helper()
|
||||
a, b, c, d, e, f, g := batchSourceType, batchInputs, batchTargetType, batchTargetDir, batchPackageName, batchKeepGoing, batchDryRun
|
||||
t.Cleanup(func() {
|
||||
batchSourceType, batchInputs, batchTargetType, batchTargetDir, batchPackageName, batchKeepGoing, batchDryRun = a, b, c, d, e, f, g
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunBatch_ConvertsEachInput(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
writeTestJSON(t, filepath.Join(dir, "b.json"), []string{"posts"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchKeepGoing, batchDryRun = "", false, false
|
||||
batchInputs = []string{filepath.Join(dir, "*.json")}
|
||||
|
||||
cmd, _ := newDryRunCmd()
|
||||
if err := runBatch(cmd, nil); err != nil {
|
||||
t.Fatalf("batch: %v", err)
|
||||
}
|
||||
for _, name := range []string{"a.yaml", "b.yaml"} {
|
||||
if _, err := os.Stat(filepath.Join(outDir, name)); err != nil {
|
||||
t.Errorf("expected %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatch_DryRunWritesNothing(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchKeepGoing, batchDryRun = "", false, true
|
||||
batchInputs = []string{filepath.Join(dir, "a.json")}
|
||||
|
||||
cmd, buf := newDryRunCmd()
|
||||
if err := runBatch(cmd, nil); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outDir); !os.IsNotExist(err) {
|
||||
t.Fatal("dry run must not create the output directory")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "users") || !strings.Contains(buf.String(), "a.yaml") {
|
||||
t.Errorf("plan incomplete:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBatch_FailureHandling(t *testing.T) {
|
||||
saveBatchState(t)
|
||||
dir := t.TempDir()
|
||||
writeTestJSON(t, filepath.Join(dir, "a.json"), []string{"users"})
|
||||
if err := os.WriteFile(filepath.Join(dir, "b.json"), []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestJSON(t, filepath.Join(dir, "c.json"), []string{"posts"})
|
||||
outDir := filepath.Join(dir, "out")
|
||||
|
||||
batchSourceType, batchTargetType, batchTargetDir = "json", "yaml", outDir
|
||||
batchPackageName, batchDryRun = "", false
|
||||
batchInputs = []string{filepath.Join(dir, "*.json")}
|
||||
cmd, _ := newDryRunCmd()
|
||||
|
||||
// Default: stop at first failure.
|
||||
batchKeepGoing = false
|
||||
err := runBatch(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "b.json") {
|
||||
t.Fatalf("expected failure naming b.json, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outDir, "c.yaml")); !os.IsNotExist(statErr) {
|
||||
t.Error("c.json should not be processed without --keep-going")
|
||||
}
|
||||
|
||||
// --keep-going: remaining inputs are processed, exit still fails.
|
||||
batchKeepGoing = true
|
||||
if err := runBatch(cmd, nil); err == nil {
|
||||
t.Fatal("expected non-zero result with --keep-going")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outDir, "c.yaml")); statErr != nil {
|
||||
t.Errorf("c.yaml should be written with --keep-going: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandBatchInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, n := range []string{"b.json", "a.json"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := expandBatchInputs([]string{filepath.Join(dir, "*.json"), filepath.Join(dir, "a.json")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || filepath.Base(got[0]) != "a.json" || filepath.Base(got[1]) != "b.json" {
|
||||
t.Errorf("want sorted deduped [a b], got %v", got)
|
||||
}
|
||||
if _, err := expandBatchInputs([]string{filepath.Join(dir, "*.nope")}); err == nil {
|
||||
t.Error("unmatched pattern should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchOutputPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
target string
|
||||
want []string
|
||||
wantErr string
|
||||
}{
|
||||
{"file target", []string{"x/a.dbml"}, "json", []string{"out/a.json"}, ""},
|
||||
{"dir target", []string{"x/a.json"}, "gorm", []string{"out/a"}, ""},
|
||||
{"collision", []string{"x/a.json", "y/a.json"}, "yaml", nil, "both write"},
|
||||
{"unsupported", []string{"a.json"}, "nope", nil, "unsupported target"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := batchOutputPaths(tt.files, tt.target, "out")
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("want error %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || len(got) != len(tt.want) || got[0] != filepath.FromSlash(tt.want[0]) {
|
||||
t.Fatalf("got %v, %v; want %v", got, err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ JSON, YAML, SQL, etc.).`,
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(convertCmd)
|
||||
rootCmd.AddCommand(batchCmd)
|
||||
rootCmd.AddCommand(diffCmd)
|
||||
rootCmd.AddCommand(inspectCmd)
|
||||
rootCmd.AddCommand(scriptsCmd)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -207,6 +207,10 @@ func (se *SchemaEditor) showDomainEditor(index int, domain *models.Domain) {
|
||||
se.showDomainList()
|
||||
})
|
||||
|
||||
form.AddButton("Tables", func() {
|
||||
se.showDomainTables(index)
|
||||
})
|
||||
|
||||
form.AddButton("Delete", func() {
|
||||
se.showDeleteDomainConfirm(index)
|
||||
})
|
||||
|
||||
@@ -39,6 +39,18 @@ func (se *SchemaEditor) createMainMenu() tview.Primitive {
|
||||
AddItem("Manage Domains", "View, create, edit, and delete domains", 'd', func() {
|
||||
se.showDomainList()
|
||||
}).
|
||||
AddItem("Manage Indexes", "View, create, edit, and delete table indexes", 'x', func() {
|
||||
se.showObjectList(se.indexKind())
|
||||
}).
|
||||
AddItem("Manage Views", "View, create, edit, and delete views", 'v', func() {
|
||||
se.showObjectList(se.viewKind())
|
||||
}).
|
||||
AddItem("Manage Sequences", "View, create, edit, and delete sequences", 'u', func() {
|
||||
se.showObjectList(se.sequenceKind())
|
||||
}).
|
||||
AddItem("Manage Scripts", "View, create, edit, and delete SQL scripts", 'c', func() {
|
||||
se.showObjectList(se.scriptKind())
|
||||
}).
|
||||
AddItem("Import & Merge", "Import and merge schema from another database", 'i', func() {
|
||||
se.showImportScreen()
|
||||
}).
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// Data operations for indexes, views, sequences, scripts and domain/table assignment.
|
||||
|
||||
func (se *SchemaEditor) schemaAt(schemaIndex int) (*models.Schema, error) {
|
||||
if schemaIndex < 0 || schemaIndex >= len(se.db.Schemas) {
|
||||
return nil, errors.New("schema not found")
|
||||
}
|
||||
return se.db.Schemas[schemaIndex], nil
|
||||
}
|
||||
|
||||
func (se *SchemaEditor) tableAt(schemaIndex, tableIndex int) (*models.Schema, *models.Table, error) {
|
||||
schema, err := se.schemaAt(schemaIndex)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if tableIndex < 0 || tableIndex >= len(schema.Tables) {
|
||||
return nil, nil, errors.New("table not found")
|
||||
}
|
||||
return schema, schema.Tables[tableIndex], nil
|
||||
}
|
||||
|
||||
// splitList splits a comma separated list, trimming blanks and dropping empty entries.
|
||||
func splitList(s string) []string {
|
||||
parts := make([]string, 0)
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// SaveIndex adds an index to a table. When oldName is non-empty the index of that
|
||||
// name is replaced (and renamed if needed).
|
||||
func (se *SchemaEditor) SaveIndex(schemaIndex, tableIndex int, oldName string, idx *models.Index) error {
|
||||
schema, table, err := se.tableAt(schemaIndex, tableIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx.Name = strings.TrimSpace(idx.Name)
|
||||
if idx.Name == "" {
|
||||
return errors.New("index name is required")
|
||||
}
|
||||
if len(idx.Columns) == 0 {
|
||||
return errors.New("index needs at least one column")
|
||||
}
|
||||
for _, c := range idx.Columns {
|
||||
if _, ok := table.Columns[c]; !ok {
|
||||
return fmt.Errorf("column %q not found in table %s", c, table.Name)
|
||||
}
|
||||
}
|
||||
if _, exists := table.Indexes[idx.Name]; exists && idx.Name != oldName {
|
||||
return fmt.Errorf("index %q already exists", idx.Name)
|
||||
}
|
||||
if table.Indexes == nil {
|
||||
table.Indexes = make(map[string]*models.Index)
|
||||
}
|
||||
if oldName != "" {
|
||||
delete(table.Indexes, oldName)
|
||||
}
|
||||
idx.Table = table.Name
|
||||
idx.Schema = schema.Name
|
||||
table.Indexes[idx.Name] = idx
|
||||
table.UpdateDate()
|
||||
se.db.UpdateDate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteIndex removes an index from a table.
|
||||
func (se *SchemaEditor) DeleteIndex(schemaIndex, tableIndex int, name string) bool {
|
||||
_, table, err := se.tableAt(schemaIndex, tableIndex)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if _, ok := table.Indexes[name]; !ok {
|
||||
return false
|
||||
}
|
||||
delete(table.Indexes, name)
|
||||
table.UpdateDate()
|
||||
se.db.UpdateDate()
|
||||
return true
|
||||
}
|
||||
|
||||
// SaveView adds a view to a schema, or replaces the one at position at (use -1 to add).
|
||||
func (se *SchemaEditor) SaveView(schemaIndex, at int, v *models.View) error {
|
||||
schema, err := se.schemaAt(schemaIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.Name = strings.TrimSpace(v.Name)
|
||||
if v.Name == "" {
|
||||
return errors.New("view name is required")
|
||||
}
|
||||
if strings.TrimSpace(v.Definition) == "" {
|
||||
return errors.New("view definition is required")
|
||||
}
|
||||
for i, o := range schema.Views {
|
||||
if i != at && o.Name == v.Name {
|
||||
return fmt.Errorf("view %q already exists", v.Name)
|
||||
}
|
||||
}
|
||||
v.Schema = schema.Name
|
||||
if at >= 0 && at < len(schema.Views) {
|
||||
schema.Views[at] = v
|
||||
} else {
|
||||
schema.Views = append(schema.Views, v)
|
||||
}
|
||||
schema.UpdateDate()
|
||||
se.db.UpdateDate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteView removes the view at position at.
|
||||
func (se *SchemaEditor) DeleteView(schemaIndex, at int) bool {
|
||||
schema, err := se.schemaAt(schemaIndex)
|
||||
if err != nil || at < 0 || at >= len(schema.Views) {
|
||||
return false
|
||||
}
|
||||
schema.Views = append(schema.Views[:at], schema.Views[at+1:]...)
|
||||
schema.UpdateDate()
|
||||
se.db.UpdateDate()
|
||||
return true
|
||||
}
|
||||
|
||||
// SaveSequence adds a sequence to a schema, or replaces the one at position at (use -1 to add).
|
||||
func (se *SchemaEditor) SaveSequence(schemaIndex, at int, s *models.Sequence) error {
|
||||
schema, err := se.schemaAt(schemaIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Name = strings.TrimSpace(s.Name)
|
||||
if s.Name == "" {
|
||||
return errors.New("sequence name is required")
|
||||
}
|
||||
if s.IncrementBy == 0 {
|
||||
return errors.New("increment must not be zero")
|
||||
}
|
||||
for i, o := range schema.Sequences {
|
||||
if i != at && o.Name == s.Name {
|
||||
return fmt.Errorf("sequence %q already exists", s.Name)
|
||||
}
|
||||
}
|
||||
s.Schema = schema.Name
|
||||
if at >= 0 && at < len(schema.Sequences) {
|
||||
schema.Sequences[at] = s
|
||||
} else {
|
||||
schema.Sequences = append(schema.Sequences, s)
|
||||
}
|
||||
schema.UpdateDate()
|
||||
se.db.UpdateDate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSequence removes the sequence at position at.
|
||||
func (se *SchemaEditor) DeleteSequence(schemaIndex, at int) bool {
|
||||
schema, err := se.schemaAt(schemaIndex)
|
||||
if err != nil || at < 0 || at >= len(schema.Sequences) {
|
||||
return false
|
||||
}
|
||||
schema.Sequences = append(schema.Sequences[:at], schema.Sequences[at+1:]...)
|
||||
schema.UpdateDate()
|
||||
se.db.UpdateDate()
|
||||
return true
|
||||
}
|
||||
|
||||
// SaveScript adds a script to a schema, or replaces the one at position at (use -1 to add).
|
||||
func (se *SchemaEditor) SaveScript(schemaIndex, at int, s *models.Script) error {
|
||||
schema, err := se.schemaAt(schemaIndex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Name = strings.TrimSpace(s.Name)
|
||||
if s.Name == "" {
|
||||
return errors.New("script name is required")
|
||||
}
|
||||
if strings.TrimSpace(s.SQL) == "" {
|
||||
return errors.New("script SQL is required")
|
||||
}
|
||||
for i, o := range schema.Scripts {
|
||||
if i != at && o.Name == s.Name {
|
||||
return fmt.Errorf("script %q already exists", s.Name)
|
||||
}
|
||||
}
|
||||
s.Schema = schema.Name
|
||||
if at >= 0 && at < len(schema.Scripts) {
|
||||
schema.Scripts[at] = s
|
||||
} else {
|
||||
schema.Scripts = append(schema.Scripts, s)
|
||||
}
|
||||
schema.UpdateDate()
|
||||
se.db.UpdateDate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteScript removes the script at position at.
|
||||
func (se *SchemaEditor) DeleteScript(schemaIndex, at int) bool {
|
||||
schema, err := se.schemaAt(schemaIndex)
|
||||
if err != nil || at < 0 || at >= len(schema.Scripts) {
|
||||
return false
|
||||
}
|
||||
schema.Scripts = append(schema.Scripts[:at], schema.Scripts[at+1:]...)
|
||||
schema.UpdateDate()
|
||||
se.db.UpdateDate()
|
||||
return true
|
||||
}
|
||||
|
||||
// AssignTableToDomain adds a reference to schemaName.tableName to the domain at domainIndex.
|
||||
func (se *SchemaEditor) AssignTableToDomain(domainIndex int, schemaName, tableName string) error {
|
||||
if domainIndex < 0 || domainIndex >= len(se.db.Domains) {
|
||||
return errors.New("domain not found")
|
||||
}
|
||||
domain := se.db.Domains[domainIndex]
|
||||
var table *models.Table
|
||||
for _, s := range se.db.Schemas {
|
||||
if s.Name != schemaName {
|
||||
continue
|
||||
}
|
||||
for _, t := range s.Tables {
|
||||
if t.Name == tableName {
|
||||
table = t
|
||||
}
|
||||
}
|
||||
}
|
||||
if table == nil {
|
||||
return fmt.Errorf("table %s.%s not found", schemaName, tableName)
|
||||
}
|
||||
for _, dt := range domain.Tables {
|
||||
if dt.SchemaName == schemaName && dt.TableName == tableName {
|
||||
return fmt.Errorf("table %s.%s is already in domain %s", schemaName, tableName, domain.Name)
|
||||
}
|
||||
}
|
||||
dt := models.InitDomainTable(tableName, schemaName)
|
||||
dt.RefTable = table
|
||||
dt.Sequence = uint(len(domain.Tables))
|
||||
domain.Tables = append(domain.Tables, dt)
|
||||
se.db.UpdateDate()
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnassignTableFromDomain removes the reference to schemaName.tableName from the domain.
|
||||
func (se *SchemaEditor) UnassignTableFromDomain(domainIndex int, schemaName, tableName string) bool {
|
||||
if domainIndex < 0 || domainIndex >= len(se.db.Domains) {
|
||||
return false
|
||||
}
|
||||
domain := se.db.Domains[domainIndex]
|
||||
for i, dt := range domain.Tables {
|
||||
if dt.SchemaName == schemaName && dt.TableName == tableName {
|
||||
domain.Tables = append(domain.Tables[:i], domain.Tables[i+1:]...)
|
||||
se.db.UpdateDate()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
func newTestEditor() *SchemaEditor {
|
||||
db := models.InitDatabase("test")
|
||||
schema := models.InitSchema("public")
|
||||
table := models.InitTable("users", "public")
|
||||
table.Columns["id"] = models.InitColumn("id", "users", "public")
|
||||
table.Columns["email"] = models.InitColumn("email", "users", "public")
|
||||
schema.Tables = append(schema.Tables, table)
|
||||
db.Schemas = append(db.Schemas, schema)
|
||||
return &SchemaEditor{db: db}
|
||||
}
|
||||
|
||||
func TestSaveIndex(t *testing.T) {
|
||||
se := newTestEditor()
|
||||
table := se.db.Schemas[0].Tables[0]
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
old string
|
||||
idx *models.Index
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", "", &models.Index{Name: "idx_email", Columns: []string{"email"}, Unique: true}, false},
|
||||
{"duplicate", "", &models.Index{Name: "idx_email", Columns: []string{"email"}}, true},
|
||||
{"missing name", "", &models.Index{Columns: []string{"email"}}, true},
|
||||
{"no columns", "", &models.Index{Name: "idx_none"}, true},
|
||||
{"unknown column", "", &models.Index{Name: "idx_bad", Columns: []string{"nope"}}, true},
|
||||
{"rename", "idx_email", &models.Index{Name: "idx_email2", Columns: []string{"email", "id"}}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := se.SaveIndex(0, 0, tt.old, tt.idx); (err != nil) != tt.wantErr {
|
||||
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, ok := table.Indexes["idx_email"]; ok {
|
||||
t.Error("renamed index should be gone under old name")
|
||||
}
|
||||
if idx := table.Indexes["idx_email2"]; idx == nil || idx.Table != "users" || idx.Schema != "public" {
|
||||
t.Errorf("unexpected renamed index: %+v", idx)
|
||||
}
|
||||
if !se.DeleteIndex(0, 0, "idx_email2") || se.DeleteIndex(0, 0, "idx_email2") {
|
||||
t.Error("delete should succeed once")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveViewSequenceScript(t *testing.T) {
|
||||
se := newTestEditor()
|
||||
schema := se.db.Schemas[0]
|
||||
|
||||
if err := se.SaveView(0, -1, &models.View{Name: "v", Definition: "select 1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := se.SaveView(0, -1, &models.View{Name: "v", Definition: "select 2"}); err == nil {
|
||||
t.Error("duplicate view accepted")
|
||||
}
|
||||
if err := se.SaveView(0, 0, &models.View{Name: "v", Definition: "select 3"}); err != nil {
|
||||
t.Errorf("editing in place should not conflict: %v", err)
|
||||
}
|
||||
if err := se.SaveView(0, -1, &models.View{Name: "w"}); err == nil {
|
||||
t.Error("view without definition accepted")
|
||||
}
|
||||
if len(schema.Views) != 1 || schema.Views[0].Definition != "select 3" || schema.Views[0].Schema != "public" {
|
||||
t.Errorf("unexpected views: %+v", schema.Views)
|
||||
}
|
||||
if !se.DeleteView(0, 0) || se.DeleteView(0, 0) {
|
||||
t.Error("view delete mismatch")
|
||||
}
|
||||
|
||||
if err := se.SaveSequence(0, -1, &models.Sequence{Name: "s", IncrementBy: 1, StartValue: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := se.SaveSequence(0, -1, &models.Sequence{Name: "z"}); err == nil {
|
||||
t.Error("zero increment accepted")
|
||||
}
|
||||
if !se.DeleteSequence(0, 0) || len(schema.Sequences) != 0 {
|
||||
t.Error("sequence delete failed")
|
||||
}
|
||||
|
||||
if err := se.SaveScript(0, -1, &models.Script{Name: "init", SQL: "select 1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := se.SaveScript(0, -1, &models.Script{Name: "empty"}); err == nil {
|
||||
t.Error("script without SQL accepted")
|
||||
}
|
||||
if err := se.SaveScript(5, -1, &models.Script{Name: "x", SQL: "y"}); err == nil {
|
||||
t.Error("bad schema index accepted")
|
||||
}
|
||||
if !se.DeleteScript(0, 0) || len(schema.Scripts) != 0 {
|
||||
t.Error("script delete failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainTableAssignment(t *testing.T) {
|
||||
se := newTestEditor()
|
||||
se.createDomainNoUI("core")
|
||||
|
||||
if err := se.AssignTableToDomain(0, "public", "users"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := se.AssignTableToDomain(0, "public", "users"); err == nil {
|
||||
t.Error("duplicate assignment accepted")
|
||||
}
|
||||
if err := se.AssignTableToDomain(0, "public", "missing"); err == nil {
|
||||
t.Error("unknown table accepted")
|
||||
}
|
||||
if err := se.AssignTableToDomain(3, "public", "users"); err == nil {
|
||||
t.Error("bad domain index accepted")
|
||||
}
|
||||
dt := se.db.Domains[0].Tables[0]
|
||||
if dt.RefTable != se.db.Schemas[0].Tables[0] {
|
||||
t.Error("RefTable not linked")
|
||||
}
|
||||
if !se.UnassignTableFromDomain(0, "public", "users") || se.UnassignTableFromDomain(0, "public", "users") {
|
||||
t.Error("unassign mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func (se *SchemaEditor) createDomainNoUI(name string) {
|
||||
se.db.Domains = append(se.db.Domains, models.InitDomain(name))
|
||||
}
|
||||
|
||||
func TestSplitList(t *testing.T) {
|
||||
got := splitList(" a, b,, c ,")
|
||||
if len(got) != 3 || got[0] != "a" || got[2] != "c" {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
)
|
||||
|
||||
// objectLocation identifies where a new object is created: a schema, and for indexes also a table.
|
||||
type objectLocation struct {
|
||||
label string
|
||||
schemaIndex int
|
||||
tableIndex int
|
||||
}
|
||||
|
||||
// objectRow is one existing object shown in an object list.
|
||||
type objectRow struct {
|
||||
cells []string
|
||||
schemaIndex int
|
||||
tableIndex int
|
||||
at int // position within the schema slice (views, sequences, scripts)
|
||||
name string // map key (indexes)
|
||||
}
|
||||
|
||||
// objectKind describes how a kind of schema object is listed and edited.
|
||||
type objectKind struct {
|
||||
page string
|
||||
title string
|
||||
singular string
|
||||
headers []string
|
||||
rows func() []objectRow
|
||||
locations func() []objectLocation
|
||||
// buildForm adds the editable fields to the form for row (nil when creating) and
|
||||
// returns a function that validates and saves the values at the given location.
|
||||
buildForm func(form *tview.Form, row *objectRow) func(loc objectLocation) error
|
||||
remove func(row objectRow) bool
|
||||
}
|
||||
|
||||
func (se *SchemaEditor) schemaLocations() []objectLocation {
|
||||
locs := make([]objectLocation, 0, len(se.db.Schemas))
|
||||
for si, s := range se.db.Schemas {
|
||||
locs = append(locs, objectLocation{label: s.Name, schemaIndex: si, tableIndex: -1})
|
||||
}
|
||||
return locs
|
||||
}
|
||||
|
||||
func (se *SchemaEditor) tableLocations() []objectLocation {
|
||||
locs := make([]objectLocation, 0)
|
||||
for si, s := range se.db.Schemas {
|
||||
for ti, t := range s.Tables {
|
||||
locs = append(locs, objectLocation{label: s.Name + "." + t.Name, schemaIndex: si, tableIndex: ti})
|
||||
}
|
||||
}
|
||||
return locs
|
||||
}
|
||||
|
||||
func (se *SchemaEditor) indexKind() objectKind {
|
||||
return objectKind{
|
||||
page: "indexes",
|
||||
title: "Manage Indexes",
|
||||
singular: "Index",
|
||||
headers: []string{"Name", "Schema", "Table", "Type", "Unique", "Columns"},
|
||||
locations: se.tableLocations,
|
||||
rows: func() []objectRow {
|
||||
var rows []objectRow
|
||||
for si, s := range se.db.Schemas {
|
||||
for ti, t := range s.Tables {
|
||||
for _, name := range sortedKeys(t.Indexes) {
|
||||
idx := t.Indexes[name]
|
||||
rows = append(rows, objectRow{
|
||||
cells: []string{idx.Name, s.Name, t.Name, idx.Type, strconv.FormatBool(idx.Unique), strings.Join(idx.Columns, ",")},
|
||||
schemaIndex: si, tableIndex: ti, name: name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
},
|
||||
buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error {
|
||||
idx := models.InitIndex("", "", "")
|
||||
idx.Type = "btree"
|
||||
if row != nil {
|
||||
idx = se.db.Schemas[row.schemaIndex].Tables[row.tableIndex].Indexes[row.name]
|
||||
}
|
||||
name, columns, typ, where := idx.Name, strings.Join(idx.Columns, ", "), idx.Type, idx.Where
|
||||
unique := idx.Unique
|
||||
form.AddInputField("Name", name, 40, nil, func(v string) { name = v })
|
||||
form.AddInputField("Columns (comma separated)", columns, 50, nil, func(v string) { columns = v })
|
||||
form.AddInputField("Type", typ, 20, nil, func(v string) { typ = v })
|
||||
form.AddCheckbox("Unique", unique, func(v bool) { unique = v })
|
||||
form.AddInputField("Where", where, 50, nil, func(v string) { where = v })
|
||||
return func(loc objectLocation) error {
|
||||
oldName := ""
|
||||
if row != nil {
|
||||
oldName = row.name
|
||||
}
|
||||
next := *idx
|
||||
next.Name, next.Columns, next.Type, next.Unique, next.Where = name, splitList(columns), typ, unique, where
|
||||
return se.SaveIndex(loc.schemaIndex, loc.tableIndex, oldName, &next)
|
||||
}
|
||||
},
|
||||
remove: func(r objectRow) bool { return se.DeleteIndex(r.schemaIndex, r.tableIndex, r.name) },
|
||||
}
|
||||
}
|
||||
|
||||
func (se *SchemaEditor) viewKind() objectKind {
|
||||
return objectKind{
|
||||
page: "views",
|
||||
title: "Manage Views",
|
||||
singular: "View",
|
||||
headers: []string{"Name", "Schema", "Description"},
|
||||
locations: se.schemaLocations,
|
||||
rows: func() []objectRow {
|
||||
var rows []objectRow
|
||||
for si, s := range se.db.Schemas {
|
||||
for i, v := range s.Views {
|
||||
rows = append(rows, objectRow{cells: []string{v.Name, s.Name, v.Description}, schemaIndex: si, at: i})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
},
|
||||
buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error {
|
||||
view := models.InitView("", "")
|
||||
at := -1
|
||||
if row != nil {
|
||||
view, at = se.db.Schemas[row.schemaIndex].Views[row.at], row.at
|
||||
}
|
||||
name, desc, def := view.Name, view.Description, view.Definition
|
||||
form.AddInputField("Name", name, 40, nil, func(v string) { name = v })
|
||||
form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v })
|
||||
form.AddTextArea("Definition (SQL)", def, 60, 8, 0, func(v string) { def = v })
|
||||
return func(loc objectLocation) error {
|
||||
next := *view
|
||||
next.Name, next.Description, next.Definition = name, desc, def
|
||||
return se.SaveView(loc.schemaIndex, at, &next)
|
||||
}
|
||||
},
|
||||
remove: func(r objectRow) bool { return se.DeleteView(r.schemaIndex, r.at) },
|
||||
}
|
||||
}
|
||||
|
||||
func (se *SchemaEditor) sequenceKind() objectKind {
|
||||
return objectKind{
|
||||
page: "sequences",
|
||||
title: "Manage Sequences",
|
||||
singular: "Sequence",
|
||||
headers: []string{"Name", "Schema", "Start", "Increment", "Cycle", "Description"},
|
||||
locations: se.schemaLocations,
|
||||
rows: func() []objectRow {
|
||||
var rows []objectRow
|
||||
for si, s := range se.db.Schemas {
|
||||
for i, q := range s.Sequences {
|
||||
rows = append(rows, objectRow{
|
||||
cells: []string{q.Name, s.Name, strconv.FormatInt(q.StartValue, 10), strconv.FormatInt(q.IncrementBy, 10), strconv.FormatBool(q.Cycle), q.Description}, schemaIndex: si, at: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
},
|
||||
buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error {
|
||||
seq := models.InitSequence("", "")
|
||||
at := -1
|
||||
if row != nil {
|
||||
seq, at = se.db.Schemas[row.schemaIndex].Sequences[row.at], row.at
|
||||
}
|
||||
name, desc := seq.Name, seq.Description
|
||||
start, incr := strconv.FormatInt(seq.StartValue, 10), strconv.FormatInt(seq.IncrementBy, 10)
|
||||
minV, maxV := strconv.FormatInt(seq.MinValue, 10), strconv.FormatInt(seq.MaxValue, 10)
|
||||
cycle := seq.Cycle
|
||||
form.AddInputField("Name", name, 40, nil, func(v string) { name = v })
|
||||
form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v })
|
||||
form.AddInputField("Start", start, 20, nil, func(v string) { start = v })
|
||||
form.AddInputField("Increment", incr, 20, nil, func(v string) { incr = v })
|
||||
form.AddInputField("Min (0 = none)", minV, 20, nil, func(v string) { minV = v })
|
||||
form.AddInputField("Max (0 = none)", maxV, 20, nil, func(v string) { maxV = v })
|
||||
form.AddCheckbox("Cycle", cycle, func(v bool) { cycle = v })
|
||||
return func(loc objectLocation) error {
|
||||
next := *seq
|
||||
next.Name, next.Description, next.Cycle = name, desc, cycle
|
||||
for _, f := range []struct {
|
||||
label string
|
||||
text string
|
||||
dst *int64
|
||||
}{{"start", start, &next.StartValue}, {"increment", incr, &next.IncrementBy}, {"min", minV, &next.MinValue}, {"max", maxV, &next.MaxValue}} {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(f.text), 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s must be an integer", f.label)
|
||||
}
|
||||
*f.dst = n
|
||||
}
|
||||
return se.SaveSequence(loc.schemaIndex, at, &next)
|
||||
}
|
||||
},
|
||||
remove: func(r objectRow) bool { return se.DeleteSequence(r.schemaIndex, r.at) },
|
||||
}
|
||||
}
|
||||
|
||||
func (se *SchemaEditor) scriptKind() objectKind {
|
||||
return objectKind{
|
||||
page: "scripts",
|
||||
title: "Manage Scripts",
|
||||
singular: "Script",
|
||||
headers: []string{"Name", "Schema", "Version", "Priority", "Description"},
|
||||
locations: se.schemaLocations,
|
||||
rows: func() []objectRow {
|
||||
var rows []objectRow
|
||||
for si, s := range se.db.Schemas {
|
||||
for i, sc := range s.Scripts {
|
||||
rows = append(rows, objectRow{cells: []string{sc.Name, s.Name, sc.Version, strconv.Itoa(sc.Priority), sc.Description}, schemaIndex: si, at: i})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
},
|
||||
buildForm: func(form *tview.Form, row *objectRow) func(objectLocation) error {
|
||||
script := models.InitScript("")
|
||||
at := -1
|
||||
if row != nil {
|
||||
script, at = se.db.Schemas[row.schemaIndex].Scripts[row.at], row.at
|
||||
}
|
||||
name, desc, version, sql, rollback := script.Name, script.Description, script.Version, script.SQL, script.Rollback
|
||||
priority, runAfter := strconv.Itoa(script.Priority), strings.Join(script.RunAfter, ", ")
|
||||
form.AddInputField("Name", name, 40, nil, func(v string) { name = v })
|
||||
form.AddInputField("Description", desc, 50, nil, func(v string) { desc = v })
|
||||
form.AddInputField("Version", version, 20, nil, func(v string) { version = v })
|
||||
form.AddInputField("Priority", priority, 10, nil, func(v string) { priority = v })
|
||||
form.AddInputField("Run after (comma separated)", runAfter, 50, nil, func(v string) { runAfter = v })
|
||||
form.AddTextArea("SQL", sql, 60, 8, 0, func(v string) { sql = v })
|
||||
form.AddTextArea("Rollback SQL", rollback, 60, 4, 0, func(v string) { rollback = v })
|
||||
return func(loc objectLocation) error {
|
||||
prio, err := strconv.Atoi(strings.TrimSpace(priority))
|
||||
if err != nil {
|
||||
return fmt.Errorf("priority must be an integer")
|
||||
}
|
||||
next := *script
|
||||
next.Name, next.Description, next.Version, next.Priority = name, desc, version, prio
|
||||
next.RunAfter, next.SQL, next.Rollback = splitList(runAfter), sql, rollback
|
||||
return se.SaveScript(loc.schemaIndex, at, &next)
|
||||
}
|
||||
},
|
||||
remove: func(r objectRow) bool { return se.DeleteScript(r.schemaIndex, r.at) },
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// showObjectList displays all objects of a kind across schemas.
|
||||
func (se *SchemaEditor) showObjectList(k objectKind) {
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow)
|
||||
title := tview.NewTextView().SetText("[::b]" + k.title).SetDynamicColors(true).SetTextAlign(tview.AlignCenter)
|
||||
|
||||
table := tview.NewTable().SetBorders(true).SetSelectable(true, false).SetFixed(1, 0)
|
||||
for i, h := range k.headers {
|
||||
table.SetCell(0, i, tview.NewTableCell(h).SetTextColor(tcell.ColorYellow).SetSelectable(false).SetAlign(tview.AlignLeft))
|
||||
}
|
||||
rows := k.rows()
|
||||
for r, row := range rows {
|
||||
for c, text := range row.cells {
|
||||
table.SetCell(r+1, c, tview.NewTableCell(text).SetSelectable(true))
|
||||
}
|
||||
}
|
||||
table.SetTitle(" " + k.title[len("Manage "):] + " ").SetBorder(true).SetTitleAlign(tview.AlignLeft)
|
||||
|
||||
back := func() {
|
||||
se.pages.SwitchToPage("main")
|
||||
se.pages.RemovePage(k.page)
|
||||
}
|
||||
btnNew := tview.NewButton("New " + k.singular + " [n]").SetSelectedFunc(func() { se.showObjectForm(k, nil) })
|
||||
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(back)
|
||||
btnNew.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyBacktab:
|
||||
se.app.SetFocus(table)
|
||||
return nil
|
||||
case tcell.KeyTab:
|
||||
se.app.SetFocus(btnBack)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
btnBack.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyBacktab:
|
||||
se.app.SetFocus(btnNew)
|
||||
return nil
|
||||
case tcell.KeyTab:
|
||||
se.app.SetFocus(table)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
btnFlex := tview.NewFlex().AddItem(btnNew, 0, 1, true).AddItem(btnBack, 0, 1, false)
|
||||
|
||||
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch {
|
||||
case event.Key() == tcell.KeyEscape, event.Rune() == 'b':
|
||||
back()
|
||||
return nil
|
||||
case event.Key() == tcell.KeyTab:
|
||||
se.app.SetFocus(btnNew)
|
||||
return nil
|
||||
case event.Key() == tcell.KeyEnter:
|
||||
if row, _ := table.GetSelection(); row > 0 && row <= len(rows) {
|
||||
se.showObjectForm(k, &rows[row-1])
|
||||
return nil
|
||||
}
|
||||
case event.Rune() == 'n':
|
||||
se.showObjectForm(k, nil)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
flex.AddItem(title, 1, 0, false).AddItem(table, 0, 1, true).AddItem(btnFlex, 1, 0, false)
|
||||
se.pages.AddPage(k.page, flex, true, true)
|
||||
}
|
||||
|
||||
// showObjectForm shows the create (row == nil) or edit form for an object.
|
||||
func (se *SchemaEditor) showObjectForm(k objectKind, row *objectRow) {
|
||||
formPage := k.page + "-form"
|
||||
form := tview.NewForm()
|
||||
errView := tview.NewTextView().SetDynamicColors(true)
|
||||
|
||||
locs := k.locations()
|
||||
loc := objectLocation{schemaIndex: -1, tableIndex: -1}
|
||||
switch {
|
||||
case row != nil:
|
||||
loc = objectLocation{schemaIndex: row.schemaIndex, tableIndex: row.tableIndex}
|
||||
case len(locs) > 0:
|
||||
loc = locs[0]
|
||||
labels := make([]string, len(locs))
|
||||
for i, l := range locs {
|
||||
labels[i] = l.label
|
||||
}
|
||||
form.AddDropDown("Location", labels, 0, func(_ string, i int) { loc = locs[i] })
|
||||
}
|
||||
|
||||
save := k.buildForm(form, row)
|
||||
|
||||
closeForm := func() {
|
||||
se.pages.RemovePage(formPage)
|
||||
se.pages.RemovePage(k.page)
|
||||
se.showObjectList(k)
|
||||
}
|
||||
form.AddButton("Save", func() {
|
||||
if err := save(loc); err != nil {
|
||||
errView.SetText("[red]" + tview.Escape(err.Error()))
|
||||
return
|
||||
}
|
||||
closeForm()
|
||||
})
|
||||
if row != nil {
|
||||
form.AddButton("Delete", func() {
|
||||
modal := tview.NewModal().
|
||||
SetText(fmt.Sprintf("Delete %s '%s'? This action cannot be undone.", strings.ToLower(k.singular), row.cells[0])).
|
||||
AddButtons([]string{"Cancel", "Delete"}).
|
||||
SetDoneFunc(func(_ int, label string) {
|
||||
se.pages.RemovePage(formPage + "-delete")
|
||||
if label == "Delete" {
|
||||
k.remove(*row)
|
||||
closeForm()
|
||||
}
|
||||
})
|
||||
se.pages.AddAndSwitchToPage(formPage+"-delete", modal, true)
|
||||
})
|
||||
}
|
||||
form.AddButton("Back", closeForm)
|
||||
|
||||
verb := "New"
|
||||
if row != nil {
|
||||
verb = "Edit"
|
||||
}
|
||||
form.SetBorder(true).SetTitle(" " + verb + " " + k.singular + " ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.showExitConfirmation(formPage, k.page)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
if len(locs) == 0 && row == nil {
|
||||
errView.SetText("[red]No schema/table available. Create one first.")
|
||||
}
|
||||
flex := tview.NewFlex().SetDirection(tview.FlexRow).AddItem(form, 0, 1, true).AddItem(errView, 1, 0, false)
|
||||
se.pages.AddPage(formPage, flex, true, true)
|
||||
}
|
||||
|
||||
// showDomainTables lists the tables assigned to a domain and allows assigning/unassigning.
|
||||
func (se *SchemaEditor) showDomainTables(domainIndex int) {
|
||||
if domainIndex < 0 || domainIndex >= len(se.db.Domains) {
|
||||
return
|
||||
}
|
||||
domain := se.db.Domains[domainIndex]
|
||||
page := "domain-tables"
|
||||
list := tview.NewList().ShowSecondaryText(true)
|
||||
refresh := func() {
|
||||
se.pages.RemovePage(page)
|
||||
se.showDomainTables(domainIndex)
|
||||
}
|
||||
|
||||
for _, dt := range domain.Tables {
|
||||
dt := dt
|
||||
list.AddItem(dt.SchemaName+"."+dt.TableName, "Enter to remove from domain", 0, func() {
|
||||
se.UnassignTableFromDomain(domainIndex, dt.SchemaName, dt.TableName)
|
||||
refresh()
|
||||
})
|
||||
}
|
||||
list.AddItem("[Assign Table]", "Add a table to this domain", 'a', func() {
|
||||
se.showAssignDomainTable(domainIndex, refresh)
|
||||
})
|
||||
list.AddItem("[Back]", "Return to domain", 'b', func() {
|
||||
se.pages.RemovePage(page)
|
||||
})
|
||||
list.SetBorder(true).SetTitle(" Domain " + domain.Name + " - Tables ").SetTitleAlign(tview.AlignLeft)
|
||||
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage(page)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
se.pages.AddPage(page, list, true, true)
|
||||
}
|
||||
|
||||
// showAssignDomainTable shows a form to pick a table not yet in the domain.
|
||||
func (se *SchemaEditor) showAssignDomainTable(domainIndex int, done func()) {
|
||||
page := "assign-domain-table"
|
||||
domain := se.db.Domains[domainIndex]
|
||||
var options []string
|
||||
var refs []models.DomainTable
|
||||
for _, s := range se.db.Schemas {
|
||||
for _, t := range s.Tables {
|
||||
taken := false
|
||||
for _, dt := range domain.Tables {
|
||||
taken = taken || (dt.SchemaName == s.Name && dt.TableName == t.Name)
|
||||
}
|
||||
if !taken {
|
||||
options = append(options, s.Name+"."+t.Name)
|
||||
refs = append(refs, models.DomainTable{SchemaName: s.Name, TableName: t.Name})
|
||||
}
|
||||
}
|
||||
}
|
||||
form := tview.NewForm()
|
||||
selected := 0
|
||||
form.AddDropDown("Table", options, 0, func(_ string, i int) { selected = i })
|
||||
form.AddButton("Assign", func() {
|
||||
if len(refs) > 0 {
|
||||
_ = se.AssignTableToDomain(domainIndex, refs[selected].SchemaName, refs[selected].TableName)
|
||||
}
|
||||
se.pages.RemovePage(page)
|
||||
done()
|
||||
})
|
||||
form.AddButton("Back", func() { se.pages.RemovePage(page) })
|
||||
form.SetBorder(true).SetTitle(" Assign Table ").SetTitleAlign(tview.AlignLeft)
|
||||
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
se.pages.RemovePage(page)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
se.pages.AddPage(page, form, true, true)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Test Coverage Plans
|
||||
|
||||
Baseline: 51.0% total statements (2026-10-03, after PRs #41-#43).
|
||||
Scope: pgsql, sqlexec, template, plus non-reader/writer packages. Other readers/writers deferred.
|
||||
|
||||
## Order
|
||||
|
||||
| # | Plan | Package(s) | Now |
|
||||
|---|------|-----------|-----|
|
||||
| 1 | [pgsql.md](pgsql.md) | readers/pgsql, writers/pgsql, pkg/pgsql | 16.0 / 74.0 / 87.8 |
|
||||
| 2 | [sqlexec.md](sqlexec.md) | writers/sqlexec | 19.4 |
|
||||
| 3 | [template.md](template.md) | writers/template | 8.5 |
|
||||
| 4 | [models.md](models.md) | pkg/models | 20.4 |
|
||||
| 5 | [cmd.md](cmd.md) | cmd/relspec, pkg/jobs | 49.3 / 72.0 |
|
||||
| 6 | [ui.md](ui.md) | pkg/ui | 3.8 |
|
||||
| 7 | [diff-merge.md](diff-merge.md) | pkg/diff, pkg/merge | 65.5 / 75.1 |
|
||||
| 8 | [sqltypes.md](sqltypes.md) | pkg/sqltypes | 67.0 |
|
||||
|
||||
## Conventions
|
||||
|
||||
- Same package as code under test; table-driven; must pass `-race`.
|
||||
- Existing data first: `tests/assets/*`, `examples/*.dbml`, `tests/postgres/init.sql`, `tests/postgres/issue21`. Generate new data only where listed under "Data needed".
|
||||
- New fixtures go in `tests/assets/<format>/` or package `testdata/`.
|
||||
- Live-DB tests: skip unless the env var is set (pattern in `pkg/readers/pgsql/reader_test.go`). Use `tests/dbtest/dbtest.sh` (podman/docker; see `tests/dbtest/README.md`):
|
||||
- `dbtest.sh up|down <postgres|mssql|mysql|all>`
|
||||
- `eval "$(dbtest.sh env postgres)"` sets `RELSPEC_TEST_PG_CONN` (mssql: `RELSPEC_TEST_MSSQL_CONN`, mysql: `RELSPEC_TEST_MYSQL_CONN`)
|
||||
- `dbtest.sh test <db> [pkgs]` runs up, `go test`, down
|
||||
- Fixtures for live DBs: postgres `tests/postgres/init.sql`, mssql `test_data/mssql/test_schema.sql`, mysql `tests/dbtest/init/mysql.sql`.
|
||||
- Prerequisite: working container networking (currently blocked until reboot into matching kernel; `tun` module).
|
||||
- Prefer pure-function tests over DB tests wherever the logic can be isolated.
|
||||
- Output assertions must not depend on map order (see memory: map iteration determinism).
|
||||
|
||||
## Targets
|
||||
|
||||
| Package | Target |
|
||||
|---------|--------|
|
||||
| pgsql (all three) | >= 85 |
|
||||
| sqlexec | >= 80 |
|
||||
| template | >= 85 |
|
||||
| models | >= 80 |
|
||||
| cmd/relspec | >= 65 |
|
||||
| jobs | >= 85 |
|
||||
| ui | >= 40 (data ops/pure helpers; screens via smoke tests) |
|
||||
| diff, merge | >= 85 |
|
||||
| sqltypes | >= 85 |
|
||||
|
||||
## Verify
|
||||
|
||||
- `go test -race -coverprofile=c.out ./pkg/<pkg>/` then `go tool cover -func=c.out`
|
||||
- `make test` before commit
|
||||
@@ -0,0 +1,29 @@
|
||||
# Plan: cmd/relspec (49.3%) and pkg/jobs (72.0%)
|
||||
|
||||
## Existing
|
||||
- cmd tests: convert_from_list, diff_sqldir, dry_run (#41), job, merge_from_list, templ_from_list
|
||||
- jobs: `jobs_test.go`
|
||||
|
||||
## cmd/relspec
|
||||
|
||||
| Area | Gap | Approach |
|
||||
|------|-----|----------|
|
||||
| convert | `readDatabaseForConvert` 22%, `writeDatabase` 36%, `validateWriteTarget` 44%, `loadExtraFields`, `getSchemaNames`, `stderrWarn` | Table-driven per format using `tests/assets`; unsupported format, missing package, bad extra-fields JSON/empty/non-bun, schema filter not found, dctx multi-schema |
|
||||
| merge | `readDatabaseForMerge` 18%, `writeDatabaseForMerge` 14%, `expandPath`, `parseSkipTables`, `isMergeOutputFormat` | Table-driven formats; globs; skip-list parsing |
|
||||
| diff | `runDiff`, `readDatabase`, `maskPasswordInDiff` | File-based inputs; password masking cases |
|
||||
| inspect | `runInspect`, `readDatabaseForInspect`, `filterDatabaseBySchema` | File-based input; schema filter |
|
||||
| scripts | `runScriptsList` | Use `pkg/readers/sqldir` fixtures; execute path live via dbtest postgres |
|
||||
| assets | `runAssetsList`, `runAssetsExecute` | List against temp dir; execute live via dbtest postgres |
|
||||
| edit | `runEdit`, `readDatabaseForEdit`, `writeDatabaseForEdit` | Test read/write helpers only; skip TUI loop |
|
||||
| report | state dir, load/save state, token, machine id, `submitReport` | Temp HOME; submit against `httptest` server; never hit real endpoint |
|
||||
| root/main | `printVersionHeader`, `hasSilentFlag` | Pure |
|
||||
| dry-run | merge/split paths | Add merge dry-run and split dry-run tests (convert covered) |
|
||||
|
||||
## pkg/jobs
|
||||
- `ResolvedLogPolicy`, `Dir`, `validateTemplInput` (0%), `validateOutput` (44%): table-driven valid/invalid job definitions.
|
||||
- Existing job files: `examples/jobs`.
|
||||
|
||||
## Live DB cases (dbtest)
|
||||
- `runScriptsExecute`, `runAssetsExecute`, job script-exec, `readDatabaseForConvert/Merge/Inspect` for pgsql, and pgsql merge/convert output: `tests/dbtest/dbtest.sh test postgres ./cmd/relspec/`.
|
||||
- mssql source reads (convert/inspect): `dbtest.sh up mssql`, env `RELSPEC_TEST_MSSQL_CONN`; fixture `test_data/mssql/test_schema.sql`.
|
||||
- Skip when env var unset.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Plan: pkg/diff (65.5%) and pkg/merge (75.1%)
|
||||
|
||||
## Existing
|
||||
- diff: `diff_test.go`, `formatters_test.go`
|
||||
- merge: `merge_test.go`
|
||||
|
||||
## pkg/diff
|
||||
|
||||
| Func | Now | Cases |
|
||||
|------|-----|-------|
|
||||
| `compareSchemaDetails` | 0% | Description/owner/options changed |
|
||||
| `compareConstraintDetails`, `normalizeConstraintAction` | 0% | Columns, referenced table, on-update/on-delete variants and case/default normalisation |
|
||||
| `compareRelationshipDetails` | 0% | Changed endpoints/type |
|
||||
| `compareViews`, `compareViewDetails` | 0% | Added/removed/changed definition |
|
||||
| `compareSequences`, `compareSequenceDetails` | 0% | Added/removed/changed increment/min/max/start |
|
||||
|
||||
Data: pair `examples/test_schema.dbml` and `test_schema_modified.dbml`; add view/sequence changes in code-built fixtures.
|
||||
|
||||
## pkg/merge
|
||||
|
||||
| Func | Now | Cases |
|
||||
|------|-----|-------|
|
||||
| `mergeSequences`, `cloneSequence` | 33% / 0% | New, existing, conflicting; clone is deep |
|
||||
| `cloneSchema` | 48% | Views, sequences, scripts, indexes cloned independently |
|
||||
| `extractTypeParts` | 48% | Precision/scale, arrays, schema-qualified, no modifiers |
|
||||
| `GetColumnTypeConflictSummary`, `min` | 0% | Limit truncation, zero conflicts |
|
||||
|
||||
## Live DB cases (dbtest)
|
||||
- pgsql live diff/merge against a real DB: `dbtest.sh test postgres`; reuse `tests/postgres/init.sql` as the live side and `examples/test_schema*.dbml` as the desired side.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Plan: pkg/models (20.4%)
|
||||
|
||||
## Existing
|
||||
- `directives_test.go`
|
||||
|
||||
## Gaps
|
||||
|
||||
| File | Funcs | Cases |
|
||||
|------|-------|-------|
|
||||
| `models.go` | All `SQLName` methods, `UpdateDate`, `GetPrimaryKey`, `columnLess`, `GetForeignKeys` | Case handling, empty/nil maps, composite PK ordering, FK filtering |
|
||||
| `models.go` | `Init*` constructors (Database, Schema, Table, Column, Index, Relation, Relationship, Constraint, Script, View, Sequence, Domain, DomainTable, Enum) | Maps/slices non-nil, name set, defaults |
|
||||
| `sorting.go` | 20 Sort* funcs | By name and by sequence; ties; map variants return sorted slice; input not mutated where documented |
|
||||
| `flatview.go` | ToFlatColumns, ToFlatTables, ToFlatConstraints, ToFlatRelationships | Multi-schema, empty db, deterministic order |
|
||||
| `summaryview.go` | ToSummary | Counts across object types |
|
||||
| `directives.go` | `directiveFromAny` (22%) | Each input type branch, invalid type |
|
||||
|
||||
## Data needed
|
||||
- One shared in-test builder for a multi-schema Database (reuse `tests/assets/dbml/complex.dbml` via reader only if no import cycle; otherwise build in code).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Plan: PostgreSQL
|
||||
|
||||
## Tooling
|
||||
- Live tests: `tests/dbtest/dbtest.sh test postgres` (defaults to readers/pgsql, writers/pgsql, writers/sqlexec) or `up postgres` + `eval "$(tests/dbtest/dbtest.sh env postgres)"`.
|
||||
- Isolation: each live test creates and drops its own schema; shared fixture DB comes from `init.sql`.
|
||||
|
||||
## Existing
|
||||
- Reader tests: `pkg/readers/pgsql/reader_test.go` (live tests skipped without `RELSPEC_TEST_PG_CONN`; pure tests: MapDataType, ParseIndexDefinition, DeriveRelationship, composite FK)
|
||||
- Writer tests: diff_statements, directives, extensions, generated_column, migration_writer, serial_sequence
|
||||
- Data: `tests/postgres/init.sql`, `tests/postgres/issue21`, `tests/assets/dbml/*`, `examples/test_schema*.dbml`
|
||||
|
||||
## readers/pgsql (16.0%)
|
||||
|
||||
| Item | Gap | Approach |
|
||||
|------|-----|----------|
|
||||
| `normalizePostgresDefault` (queries.go) | 0% | Pure; table-driven: casts, nextval, functions, quoted literals, NULL |
|
||||
| `countColumns/Constraints/Indexes` | 0% | Pure; build Database fixtures |
|
||||
| `ReadDatabase/ReadSchema/ReadTable` | ~0% | Live; run against `init.sql` DB; assert counts, PK/FK/unique/check/index, views, sequences, extensions |
|
||||
| `query*` (11 funcs) | 0% | Covered via live ReadDatabase; add one live case per object type |
|
||||
| `close` | 0% | Live; connection released after read and on error |
|
||||
|
||||
Data needed: extend `tests/postgres/init.sql` (loaded by dbtest on `up`; apply changes with `dbtest.sh restart postgres`) with a view, sequence, check constraint, partial index, extension, composite FK (verify what already exists first).
|
||||
|
||||
## writers/pgsql (74.0%)
|
||||
|
||||
| Item | Gap | Approach |
|
||||
|------|-----|----------|
|
||||
| `extractTableNameFromCreate`, `extractStatementContext`, `extractSQLStringValue`, `parseQualifiedIdent`, `firstBareIdent`, `firstIdentAfterKeyword`, `stripQuotes`, `buildStmtContext`, `detectStatementType`, `truncateStatement` | 0% | Pure; table-driven; quoted/qualified/unquoted idents, each statement type, long statements |
|
||||
| `getCurrentTimestamp`, `finishReport`, `writeReport` | 0% | Report written to temp file; JSON shape, counts, failed statements |
|
||||
| `executeStatements`, `executeDatabaseSQL` | 0% | Live; success, failure with continue-on-error, failure stop, report output |
|
||||
| `generateLiveDiffStatements` | 28.6% | Live; empty DB, drifted DB, identical DB |
|
||||
| `currentColumnHasDescription`, `ExecuteCommentColumn` | 0% | Migration writer fixtures with comments added/removed/changed |
|
||||
| `template_functions.go` `filter`, `mapFunc` | 0% | Pure |
|
||||
|
||||
Reuse `tests/integration/failed_statements_example.txt` for failed-statement report cases. Ad-hoc SQL setup: `dbtest.sh exec postgres <file>`.
|
||||
|
||||
## pkg/pgsql (87.8%)
|
||||
- Spot-check uncovered funcs after the above; add keyword/datatype edge cases only.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Plan: writers/sqlexec (19.4%)
|
||||
|
||||
## Existing
|
||||
- `writer_test.go`: constructor, nil DB, missing conn string, empty scripts, script sorting, embed directives
|
||||
|
||||
## Gaps
|
||||
|
||||
| Item | Now | Approach |
|
||||
|------|-----|----------|
|
||||
| `Options` | 0% | Trivial getter |
|
||||
| `WriteDatabase` | 31.2% | Multi-schema; error from one schema aborts; context/connect failure |
|
||||
| `executeScripts` | 0% | Live via dbtest postgres (`RELSPEC_TEST_PG_CONN`); ordering by priority/sequence, failing script reports script name, empty SQL skipped, transaction/partial-apply behaviour as implemented |
|
||||
| `WriteSchema` | partial | Connection error path, success path live |
|
||||
|
||||
## Data needed
|
||||
- Small script set (3-4 scripts, mixed priority, one failing) as fixtures; check `tests/assets` and `pkg/readers/sqldir` testdata first.
|
||||
- Cleanup: each live test uses a throwaway schema and drops it.
|
||||
|
||||
## Decision
|
||||
- Live-only via `tests/dbtest/dbtest.sh test postgres ./pkg/writers/sqlexec/`; no connection interface or mock.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Plan: pkg/sqltypes (67.0%)
|
||||
|
||||
## Existing
|
||||
- array types, fromstring, sql types, yaml/xml, struct json, uuid integration tests
|
||||
|
||||
## Gaps
|
||||
|
||||
| Area | Funcs | Approach |
|
||||
|------|-------|----------|
|
||||
| Array types | MarshalYAML/UnmarshalYAML/MarshalXML/UnmarshalXML across each array type; some `UnmarshalJSON/MarshalJSON` | One round-trip test per array type (reuse helper from `sql_types_yaml_xml_test.go`) |
|
||||
| Scalar types | `Value` (3 types), `MarshalJSON/UnmarshalJSON` for date, `Int64` (31%), `Float64` (40%) | Valid, null, invalid string, overflow |
|
||||
| Constructors | `SqlTimeStampNow`, `SqlDateNow`, `SqlTimeNow`, `NewSql`, `NewSqlFloat32`, `ToJSONDT` | Assert non-zero/valid and approximately now |
|
||||
|
||||
No data needed.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Plan: writers/template (8.5%)
|
||||
|
||||
## Existing
|
||||
- `writer_test.go`: deterministic table index values only
|
||||
|
||||
## Approach
|
||||
Pure helper functions; one test file per source file, table-driven. Then render tests through the writer.
|
||||
|
||||
| File | Funcs | Cases |
|
||||
|------|-------|-------|
|
||||
| `filters.go` | FilterTables, FilterTablesByPattern, FilterColumns, FilterColumnsByType, FilterPrimaryKeys, FilterForeignKeys, FilterUniqueConstraints, FilterCheckConstraints, FilterNullable, FilterNotNull, matchPattern | Empty input, no match, glob patterns, nil maps |
|
||||
| `formatters.go` | ToJSON, ToJSONPretty, ToYAML, Indent, IndentWith, Escape, EscapeQuotes, Comment, QuoteString, UnquoteString | Empty string, multiline, special chars, marshal failure |
|
||||
| `loop_helpers.go` | Enumerate, Batch, Chunk, Reverse, First, Last, Skip, Take, Concat, Unique, SortBy, GroupBy, CountIf, getFieldValue, compareValues | Empty, n > len, n <= 0, non-slice input, missing field |
|
||||
| `safe_access.go` | Get, GetOr, GetPath, GetPathOr, SafeIndex, SafeIndexOr, Has, HasPath, Keys, Merge, Pick, Omit, SliceContains, IndexOf, Pluck | nil, missing key, nested path, out-of-range |
|
||||
| `string_helpers.go` | ToUpper, ToLower, ToCamelCase, and rest | Empty, snake/kebab/space input, unicode |
|
||||
| `errors.go` | Error, Unwrap, NewTemplate{Load,Parse,Execute}Error | errors.Is/As, message contents |
|
||||
| `funcmap.go` | BuildFuncMap | Every registered name resolves and is callable |
|
||||
| `type_mappers.go`, `template_data.go` | check after above | |
|
||||
| `writer.go` | WriteDatabase/Schema/Table, modes | Template load/parse/execute error paths; per-table, per-schema, whole-db modes; output to file vs stdout |
|
||||
|
||||
## Data needed
|
||||
- 2-3 small template fixtures in `pkg/writers/template/testdata/` (valid, parse error, execute error).
|
||||
- Schema input: reuse `tests/assets/dbml/simple.dbml` / `complex.dbml`.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Plan: pkg/ui (3.8%)
|
||||
|
||||
## Existing
|
||||
- `object_dataops_test.go` (indexes, views, sequences, scripts, domain assignment)
|
||||
- Rules: `pkg/ui/ui_rules.md`
|
||||
|
||||
## Layers
|
||||
|
||||
| Layer | Files | Testable? | Approach |
|
||||
|-------|-------|-----------|----------|
|
||||
| Data ops | column_, relation_, domain_, schema_, table_, database_dataops.go | Yes, pure | CRUD tests per file: create, duplicate, update/rename, delete, not-found, bounds, UpdateDate side effects |
|
||||
| Pure helpers | `sortedKeys`, `schemaLocations`, `tableLocations`, `getColumnNames`, `parseSkipTablesUI`, help-text getters | Yes | Table-driven |
|
||||
| Kind definitions | `indexKind/viewKind/sequenceKind/scriptKind` | Yes | Assert row builders and form-to-model mapping without rendering |
|
||||
| Load/save | `loadDatabase`, `saveDatabase`, `createNewDatabase`, `importAndMergeDatabase`, `performMerge` | Partly | Temp files from `tests/assets`; verify format dispatch and error paths; avoid UI dialogs |
|
||||
| Screens | *_screens.go, dialogs.go, main_menu.go | Yes, via simulation | tview app on tcell SimulationScreen; inject key events; assert navigation, form submit mutates model, cancel leaves it unchanged, delete confirm paths |
|
||||
|
||||
## Order
|
||||
1. Data ops (largest gain, no tview)
|
||||
2. Pure helpers and kinds
|
||||
3. Load/save logic
|
||||
4. Screen tests on simulation screen (menu, lists, forms, confirm dialogs, load/save)
|
||||
|
||||
## Decision
|
||||
- Screen smoke tests via tview simulation screen are in scope (tcell `SimulationScreen`); drive keys/events, assert no panic and expected state.
|
||||
|
||||
## Live DB cases (dbtest)
|
||||
- Load/save and import-merge from a live pgsql source: `dbtest.sh up postgres`; skip when `RELSPEC_TEST_PG_CONN` unset.
|
||||
@@ -0,0 +1,45 @@
|
||||
# dbtest: container test databases
|
||||
|
||||
Podman (preferred) or Docker. One tool for postgres, mssql, mysql.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Action |
|
||||
|---|---|
|
||||
| `./tests/dbtest/dbtest.sh up <db\|all>` | Start, wait for ready, run init |
|
||||
| `down <db\|all>` | Stop and remove |
|
||||
| `restart <db>` | Fresh container |
|
||||
| `status <db\|all>` | State and connection string |
|
||||
| `env <db\|all>` | Print `export` line for the test env var |
|
||||
| `test <db> [pkgs]` | up, `go test`, down |
|
||||
| `shell <db>` | Interactive client |
|
||||
| `exec <db> <file>` | Run SQL file |
|
||||
| `logs <db>` | Container logs |
|
||||
|
||||
## Databases
|
||||
|
||||
| db | Port | Env var | Init | Default test pkgs |
|
||||
|---|---|---|---|---|
|
||||
| postgres | 5439 | `RELSPEC_TEST_PG_CONN` | `tests/postgres/init.sql` | readers/pgsql, writers/pgsql, writers/sqlexec |
|
||||
| mssql | 1439 | `RELSPEC_TEST_MSSQL_CONN` | `test_data/mssql/test_schema.sql` (creates `RelSpecTest`) | readers/mssql, writers/mssql |
|
||||
| mysql | 3309 | `RELSPEC_TEST_MYSQL_CONN` | `tests/dbtest/init/mysql.sql` | none (no Go driver/reader yet) |
|
||||
|
||||
## Env
|
||||
|
||||
| Var | Effect |
|
||||
|---|---|
|
||||
| `DBTEST_RUNTIME` | Force `podman` or `docker` |
|
||||
| `DBTEST_TIMEOUT` | Ready wait, seconds (default 120) |
|
||||
| `DBTEST_KEEP=1` | Keep container after `test` |
|
||||
| `DBTEST_GOFLAGS` | Extra `go test` flags |
|
||||
|
||||
## Add a database
|
||||
|
||||
1. Add `dbs/<name>.sh` defining: `DB_NAME DB_IMAGE DB_CONTAINER DB_PORT DB_INTERNAL_PORT DB_ENV DB_INIT_MOUNT DB_CONN_VAR DB_CONN DB_DEFAULT_PKGS` and functions `db_ready db_post_init db_shell db_exec_file`.
|
||||
2. Add the name to `ALL_DBS` in `dbtest.sh`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Containers are named `relspec-test-<db>`; existing `tests/postgres/*.sh` and `make docker-*` use the same postgres name/port 5439 and remain independent.
|
||||
- mssql needs ~2GB RAM and takes longer to become ready.
|
||||
- Tests skip when the env var is unset.
|
||||
@@ -0,0 +1,17 @@
|
||||
# SQL Server container definition (sourced by dbtest.sh)
|
||||
DB_NAME=mssql
|
||||
DB_IMAGE=mcr.microsoft.com/mssql/server:2022-latest
|
||||
DB_CONTAINER=relspec-test-mssql
|
||||
DB_PORT=1439
|
||||
DB_INTERNAL_PORT=1433
|
||||
DB_ENV=(-e ACCEPT_EULA=Y -e "MSSQL_SA_PASSWORD=StrongPassword123!" -e MSSQL_PID=Express)
|
||||
DB_INIT_MOUNT="$ROOT/test_data/mssql/test_schema.sql:/init/test_schema.sql"
|
||||
DB_CONN_VAR=RELSPEC_TEST_MSSQL_CONN
|
||||
DB_CONN="sqlserver://sa:StrongPassword123!@localhost:1439?database=RelSpecTest"
|
||||
DB_DEFAULT_PKGS="./pkg/readers/mssql/ ./pkg/writers/mssql/"
|
||||
|
||||
_sqlcmd() { rt exec -i "$DB_CONTAINER" /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'StrongPassword123!' "$@"; }
|
||||
db_ready() { _sqlcmd -Q "SELECT 1" >/dev/null 2>&1; }
|
||||
db_post_init() { _sqlcmd -b -i /init/test_schema.sql >/dev/null; }
|
||||
db_shell() { rt exec -it "$DB_CONTAINER" /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'StrongPassword123!' -d RelSpecTest "$@"; }
|
||||
db_exec_file() { _sqlcmd -b -d RelSpecTest < "$1"; }
|
||||
@@ -0,0 +1,16 @@
|
||||
# MySQL container definition (sourced by dbtest.sh)
|
||||
DB_NAME=mysql
|
||||
DB_IMAGE=docker.io/library/mysql:8.4
|
||||
DB_CONTAINER=relspec-test-mysql
|
||||
DB_PORT=3309
|
||||
DB_INTERNAL_PORT=3306
|
||||
DB_ENV=(-e MYSQL_ROOT_PASSWORD=relspec_root_password -e MYSQL_DATABASE=relspec_test -e MYSQL_USER=relspec -e MYSQL_PASSWORD=relspec_test_password)
|
||||
DB_INIT_MOUNT="$ROOT/tests/dbtest/init/mysql.sql:/docker-entrypoint-initdb.d/init.sql"
|
||||
DB_CONN_VAR=RELSPEC_TEST_MYSQL_CONN
|
||||
DB_CONN="relspec:relspec_test_password@tcp(localhost:3309)/relspec_test"
|
||||
DB_DEFAULT_PKGS=""
|
||||
|
||||
db_ready() { rt exec "$DB_CONTAINER" mysqladmin ping -h 127.0.0.1 -urelspec -prelspec_test_password --silent >/dev/null 2>&1; }
|
||||
db_post_init() { :; }
|
||||
db_shell() { rt exec -it "$DB_CONTAINER" mysql -urelspec -prelspec_test_password relspec_test "$@"; }
|
||||
db_exec_file() { rt exec -i "$DB_CONTAINER" mysql -urelspec -prelspec_test_password relspec_test < "$1"; }
|
||||
@@ -0,0 +1,16 @@
|
||||
# Postgres container definition (sourced by dbtest.sh)
|
||||
DB_NAME=postgres
|
||||
DB_IMAGE=docker.io/library/postgres:16-alpine
|
||||
DB_CONTAINER=relspec-test-postgres
|
||||
DB_PORT=5439
|
||||
DB_INTERNAL_PORT=5432
|
||||
DB_ENV=(-e POSTGRES_USER=relspec -e POSTGRES_PASSWORD=relspec_test_password -e POSTGRES_DB=relspec_test)
|
||||
DB_INIT_MOUNT="$ROOT/tests/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql"
|
||||
DB_CONN_VAR=RELSPEC_TEST_PG_CONN
|
||||
DB_CONN="postgres://relspec:relspec_test_password@localhost:5439/relspec_test"
|
||||
DB_DEFAULT_PKGS="./pkg/readers/pgsql/ ./pkg/writers/pgsql/ ./pkg/writers/sqlexec/"
|
||||
|
||||
db_ready() { rt exec "$DB_CONTAINER" pg_isready -U relspec -d relspec_test >/dev/null 2>&1; }
|
||||
db_post_init() { :; }
|
||||
db_shell() { rt exec -it "$DB_CONTAINER" psql -U relspec -d relspec_test "$@"; }
|
||||
db_exec_file() { rt exec -i "$DB_CONTAINER" psql -v ON_ERROR_STOP=1 -U relspec -d relspec_test < "$1"; }
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reusable podman/docker test database tool for postgres, mssql and mysql.
|
||||
# Usage: dbtest.sh <command> <db> [args]
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
TIMEOUT="${DBTEST_TIMEOUT:-120}"
|
||||
ALL_DBS=(postgres mssql mysql)
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
dbtest.sh <command> <db|all> [args]
|
||||
|
||||
commands:
|
||||
up <db> start container, wait until ready, run init
|
||||
down <db> stop and remove container
|
||||
restart <db> down + up
|
||||
status <db> running state and connection string
|
||||
env <db> print 'export VAR=conn' (use: eval "\$(dbtest.sh env postgres)")
|
||||
logs <db> container logs
|
||||
shell <db> interactive client
|
||||
exec <db> <file> run a SQL file against the database
|
||||
test <db> [pkgs] up, run go tests with conn env set, down (keep with DBTEST_KEEP=1)
|
||||
list supported databases
|
||||
|
||||
dbs: ${ALL_DBS[*]}
|
||||
env: DBTEST_RUNTIME=podman|docker DBTEST_TIMEOUT=secs DBTEST_KEEP=1 DBTEST_GOFLAGS=...
|
||||
USAGE
|
||||
}
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
log() { echo "[dbtest] $*" >&2; }
|
||||
|
||||
detect_runtime() {
|
||||
if [ -n "${DBTEST_RUNTIME:-}" ]; then echo "$DBTEST_RUNTIME"; return; fi
|
||||
if command -v podman >/dev/null 2>&1; then echo podman
|
||||
elif command -v docker >/dev/null 2>&1; then echo docker
|
||||
else die "neither podman nor docker is installed"; fi
|
||||
}
|
||||
RUNTIME="$(detect_runtime)"
|
||||
rt() { "$RUNTIME" "$@"; }
|
||||
|
||||
load_db() {
|
||||
local f="$HERE/dbs/${1:-}.sh"
|
||||
[ -f "$f" ] || die "unknown db '${1:-}' (supported: ${ALL_DBS[*]})"
|
||||
# shellcheck disable=SC1090
|
||||
source "$f"
|
||||
}
|
||||
|
||||
is_running() { [ "$(rt inspect -f '{{.State.Running}}' "$DB_CONTAINER" 2>/dev/null || true)" = "true" ]; }
|
||||
|
||||
cmd_up() {
|
||||
if is_running; then
|
||||
log "$DB_NAME already running"
|
||||
else
|
||||
rt rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true
|
||||
log "starting $DB_NAME ($DB_IMAGE) on port $DB_PORT using $RUNTIME"
|
||||
rt run -d --name "$DB_CONTAINER" "${DB_ENV[@]}" \
|
||||
-p "$DB_PORT:$DB_INTERNAL_PORT" \
|
||||
-v "$DB_INIT_MOUNT:ro,Z" "$DB_IMAGE" >/dev/null
|
||||
fi
|
||||
log "waiting for $DB_NAME (max ${TIMEOUT}s)"
|
||||
local i=0
|
||||
until db_ready; do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge "$TIMEOUT" ]; then
|
||||
rt logs --tail 50 "$DB_CONTAINER" >&2 || true
|
||||
die "$DB_NAME did not become ready"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
db_post_init
|
||||
log "$DB_NAME ready: $DB_CONN_VAR=$DB_CONN"
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
rt rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true
|
||||
log "$DB_NAME removed"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
if is_running; then echo "$DB_NAME: running ($DB_CONTAINER, port $DB_PORT)"; else echo "$DB_NAME: stopped"; fi
|
||||
echo "$DB_CONN_VAR=$DB_CONN"
|
||||
}
|
||||
|
||||
cmd_test() {
|
||||
local pkgs="${*:-$DB_DEFAULT_PKGS}"
|
||||
[ -n "$pkgs" ] || die "no test packages for $DB_NAME; pass packages as arguments"
|
||||
cmd_up
|
||||
[ -n "${DBTEST_KEEP:-}" ] || trap cmd_down EXIT
|
||||
export "$DB_CONN_VAR=$DB_CONN"
|
||||
cd "$ROOT"
|
||||
# shellcheck disable=SC2086
|
||||
go test -count=1 ${DBTEST_GOFLAGS:-} $pkgs
|
||||
}
|
||||
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
""|-h|--help|help) usage; exit 0 ;;
|
||||
list) printf '%s\n' "${ALL_DBS[@]}"; exit 0 ;;
|
||||
esac
|
||||
shift
|
||||
target="${1:-}"
|
||||
[ -n "$target" ] || die "missing <db>"
|
||||
shift || true
|
||||
|
||||
run_one() {
|
||||
load_db "$1"
|
||||
shift
|
||||
case "$cmd" in
|
||||
up) cmd_up ;;
|
||||
down) cmd_down ;;
|
||||
restart) cmd_down; cmd_up ;;
|
||||
status) cmd_status ;;
|
||||
env) echo "export $DB_CONN_VAR='$DB_CONN'" ;;
|
||||
logs) rt logs "$DB_CONTAINER" ;;
|
||||
shell) db_shell "$@" ;;
|
||||
exec) [ -f "${1:-}" ] || die "usage: exec <db> <file>"; db_exec_file "$1" ;;
|
||||
test) cmd_test "$@" ;;
|
||||
*) usage; die "unknown command '$cmd'" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "$target" = "all" ]; then
|
||||
case "$cmd" in up|down|restart|status|env) ;; *) die "'all' is only valid for up/down/restart/status/env" ;; esac
|
||||
for d in "${ALL_DBS[@]}"; do (run_one "$d"); done
|
||||
else
|
||||
run_one "$target" "$@"
|
||||
fi
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Minimal MySQL fixture for relspec container tests.
|
||||
-- Extend when a MySQL reader/writer is added.
|
||||
CREATE TABLE users (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_users_email (email)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE posts (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT NULL,
|
||||
KEY idx_posts_user (user_id),
|
||||
CONSTRAINT fk_posts_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE VIEW v_user_posts AS
|
||||
SELECT u.id AS user_id, u.email, p.id AS post_id, p.title
|
||||
FROM users u JOIN posts p ON p.user_id = u.id;
|
||||
Reference in New Issue
Block a user