Compare commits

...
Author SHA1 Message Date
Hermes AgentandClaude Sonnet 5.5 d36806047b feat(cli): add --watch mode to convert (#39)
Poll source files/directories and regenerate output on change.
Output path is excluded from watching; errors don't stop the loop.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:29:42 +02:00
warkanum b38f53c603 docs(tests): add test coverage plans 2026-10-03 10:01:48 +02:00
warkanum ccba53c494 test: add podman/docker dbtest tool for postgres, mssql and mysql 2026-10-03 10:01:48 +02:00
sgcommand 53327b9a5a feat(ui): TUI indexes, views, sequences, scripts, domain/table assignment (#40) (#43)
Co-authored-by: SG Command <sgcommand@warky.dev>
2026-10-03 07:27:25 +00:00
sgcommand 734b14d48d docs: add usage examples for each format combination (#42)
Co-authored-by: SG Command <sgcommand@warky.dev>
2026-10-03 07:27:03 +00:00
sgcommand 938f0ed51f feat(cli): add --dry-run to convert, merge and split (#41)
Co-authored-by: SG Command <sgcommand@warky.dev>
2026-10-03 07:26:54 +00:00
warkanum 6e2e7eb19e feat(release): add rerelease target to move latest tag 2026-10-02 23:41:42 +02:00
29 changed files with 2096 additions and 16 deletions
+9 -1
View File
@@ -1,4 +1,4 @@
.PHONY: all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version godoc vet fmt fmt-check staticcheck govulncheck check
.PHONY: all build test test-unit test-integration lint coverage clean install help docker-up docker-down docker-test docker-test-integration start stop release release-version rerelease godoc vet fmt fmt-check staticcheck govulncheck check
# Binary name
BINARY_NAME=relspec
@@ -260,5 +260,13 @@ release-version: lint fmt-check ## Run lint and format check, then auto-incremen
git push origin HEAD "$$NEXT"; \
echo "Pushed $$NEXT — release workflow triggered"
rerelease: lint fmt-check ## Move the latest tag to HEAD and force push it
@TAG=$$(git describe --tags --abbrev=0 2>/dev/null); \
if [ -z "$$TAG" ]; then echo "No existing tags found"; exit 1; fi; \
echo "Moving $$TAG to $$(git rev-parse --short HEAD)"; \
git tag -f -a "$$TAG" -m "Release $$TAG" HEAD; \
git push --force origin "$$TAG"; \
echo "Pushed $$TAG — release workflow triggered"
help: ## Display this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
+11
View File
@@ -23,6 +23,8 @@ go install -v git.warky.dev/wdevs/relspecgo/cmd/relspec@latest
| **Readers** | `bun` `dbml` `dctx` `drawdb` `drizzle` `gorm` `graphql` `json` `mssql` `pgsql` `prisma` `sqldir` `sqlite` `typeorm` `yaml` |
| **Writers** | `bun` `dbml` `dctx` `drawdb` `drizzle` `gorm` `graphql` `json` `mssql` `pgsql` `prisma` `sqlexec` `sqlite` `template` `typeorm` `yaml` |
See [docs/FORMAT_EXAMPLES.md](docs/FORMAT_EXAMPLES.md) for usage examples covering every format.
## Commands
### `convert` — Schema conversion
@@ -40,8 +42,17 @@ relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path
# Multiple input files merged
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
# Watch mode: regenerate whenever the source file(s) change (Ctrl-C to stop)
relspec convert --from dbml --from-path schema.dbml --to gorm --to-path models/ --package models --watch
```
`--watch` works with `--from-path` and `--from-list` (not live database
connections or `--dry-run`). Source files are polled every `--watch-interval`
(default 500ms), a directory source is watched recursively, and the output path
is ignored so generating into the source tree does not loop. Conversion errors
are printed and watching continues.
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
+129 -15
View File
@@ -3,6 +3,7 @@ package main
import (
stdjson "encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
@@ -57,6 +58,9 @@ var (
convertNullableArrays string
convertContinueOnError bool
convertExtraFields string
convertDryRun bool
convertWatch bool
convertWatchInterval time.Duration
)
var convertCmd = &cobra.Command{
@@ -165,7 +169,11 @@ Examples:
# Convert SQLite to PostgreSQL SQL
relspec convert --from sqlite --from-path database.db \
--to pgsql --to-path schema.sql`,
--to pgsql --to-path schema.sql
# Regenerate GORM models every time the DBML file changes
relspec convert --from dbml --from-path schema.dbml \
--to gorm --to-path models/ --package models --watch`,
RunE: runConvert,
}
@@ -185,6 +193,11 @@ 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")
convertCmd.Flags().BoolVar(&convertWatch, "watch", false, "Watch the source files (--from-path or --from-list) and regenerate the output whenever they change")
convertCmd.Flags().DurationVar(&convertWatchInterval, "watch-interval", 500*time.Millisecond, "Polling interval used by --watch")
err := convertCmd.MarkFlagRequired("from")
if err != nil {
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
@@ -200,6 +213,13 @@ func init() {
}
func runConvert(cmd *cobra.Command, args []string) error {
if convertWatch {
return runConvertWatch(cmd.Context(), os.Stderr, func() error { return runConvertOnce(cmd) })
}
return runConvertOnce(cmd)
}
func runConvertOnce(cmd *cobra.Command) error {
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Converter ===\n")
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
@@ -240,6 +260,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 +444,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 +554,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))
+158
View File
@@ -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)
}
}
+35
View File
@@ -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
+23
View File
@@ -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)
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
// watchSnapshot maps a file path to its modification time and size.
type watchSnapshot map[string]string
// takeWatchSnapshot records the state of every file under the given paths.
// Directories are walked recursively. Anything at or below the excluded path
// (typically the output path) is skipped so regenerating output does not
// retrigger the watcher. Missing paths are simply absent from the snapshot, so
// creating them later counts as a change.
func takeWatchSnapshot(paths []string, exclude string) watchSnapshot {
snap := watchSnapshot{}
exclude = absPathOrSelf(exclude)
for _, root := range paths {
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if exclude != "" && isWithin(absPathOrSelf(p), exclude) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
snap[p] = fmt.Sprintf("%d-%d", info.ModTime().UnixNano(), info.Size())
return nil
})
}
return snap
}
func (s watchSnapshot) equal(o watchSnapshot) bool {
if len(s) != len(o) {
return false
}
for k, v := range s {
if o[k] != v {
return false
}
}
return true
}
func absPathOrSelf(p string) string {
if p == "" {
return ""
}
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
// isWithin reports whether path equals dir or is located below it.
func isWithin(path, dir string) bool {
if path == dir {
return true
}
return strings.HasPrefix(path, dir+string(filepath.Separator))
}
// watchLoop runs fn once immediately and again whenever the watched paths
// change, until ctx is cancelled. Changes are debounced: fn runs only after
// the snapshot has stayed unchanged for one poll interval. Errors from fn are
// reported to w and do not stop the loop.
func watchLoop(ctx context.Context, w io.Writer, paths []string, exclude string, interval time.Duration, fn func() error) {
run := func() {
if err := fn(); err != nil {
fmt.Fprintf(w, "Error: %v\n", err)
}
fmt.Fprintf(w, "Watching for changes (Ctrl-C to stop)...\n")
}
last := takeWatchSnapshot(paths, exclude)
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
cur := takeWatchSnapshot(paths, exclude)
if cur.equal(last) {
continue
}
// Debounce: wait until writes settle.
for settled := false; !settled; {
select {
case <-ctx.Done():
return
case <-time.After(interval):
}
next := takeWatchSnapshot(paths, exclude)
settled = next.equal(cur)
cur = next
}
fmt.Fprintf(w, "\nChange detected, regenerating...\n")
last = cur
run()
}
}
// runConvertWatch runs the conversion once and then again whenever the source
// files change, until interrupted.
func runConvertWatch(parent context.Context, w io.Writer, run func() error) error {
var paths []string
switch {
case len(convertFromList) > 0:
paths = convertFromList
case convertSourcePath != "":
paths = []string{convertSourcePath}
default:
return fmt.Errorf("--watch requires --from-path or --from-list (live database connections cannot be watched)")
}
if convertDryRun {
return fmt.Errorf("--watch cannot be combined with --dry-run")
}
if convertWatchInterval <= 0 {
return fmt.Errorf("--watch-interval must be positive")
}
if parent == nil {
parent = context.Background()
}
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
defer stop()
watchLoop(ctx, w, paths, convertTargetPath, convertWatchInterval, run)
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestWatchSnapshotExcludesOutput(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out")
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "schema.dbml")
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
before := takeWatchSnapshot([]string{dir}, out)
if err := os.WriteFile(filepath.Join(out, "gen.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if after := takeWatchSnapshot([]string{dir}, out); !before.equal(after) {
t.Errorf("writing into the excluded output path changed the snapshot")
}
if err := os.WriteFile(src, []byte("changed"), 0o644); err != nil {
t.Fatal(err)
}
if after := takeWatchSnapshot([]string{dir}, out); before.equal(after) {
t.Errorf("modifying a source file did not change the snapshot")
}
}
func TestWatchLoopRerunsOnChange(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "schema.dbml")
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
var runs atomic.Int32
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
watchLoop(ctx, io.Discard, []string{src}, "", 10*time.Millisecond, func() error {
runs.Add(1)
return nil
})
}()
waitFor(t, func() bool { return runs.Load() == 1 })
if err := os.WriteFile(src, []byte("changed content"), 0o644); err != nil {
t.Fatal(err)
}
waitFor(t, func() bool { return runs.Load() == 2 })
cancel()
<-done
}
func TestRunConvertWatchValidation(t *testing.T) {
oldPath, oldList, oldDry, oldInt := convertSourcePath, convertFromList, convertDryRun, convertWatchInterval
defer func() {
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = oldPath, oldList, oldDry, oldInt
}()
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = "", nil, false, time.Second
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
t.Error("expected error without --from-path/--from-list")
}
convertSourcePath, convertDryRun = "x.dbml", true
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
t.Error("expected error combining --watch with --dry-run")
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition not met in time")
}
+103
View File
@@ -0,0 +1,103 @@
# Format Usage Examples
Examples for `relspec convert` covering the file-based reader and writer
formats. The "Writers" and "Readers" sections below were run against
`examples/test_schema.dbml`. The cross-format and live-database examples were
not run; they follow the flags shown in `relspec convert --help` and require
matching input files or reachable databases.
Any reader can be combined with any writer: pick `--from`/`--from-path` for the
source and `--to`/`--to-path` for the target. Add `--silent` to suppress progress
output.
## Writers: DBML to every format
```bash
S="--from dbml --from-path examples/test_schema.dbml"
relspec convert $S --to json --to-path schema.json
relspec convert $S --to yaml --to-path schema.yaml
relspec convert $S --to dctx --to-path schema.dctx
relspec convert $S --to drawdb --to-path schema.drawdb.json
relspec convert $S --to graphql --to-path schema.graphql
relspec convert $S --to prisma --to-path schema.prisma
relspec convert $S --to pgsql --to-path schema.pg.sql
relspec convert $S --to mssql --to-path schema.mssql.sql
relspec convert $S --to sqlite --to-path schema.sqlite.sql
relspec convert $S --to drizzle --to-path schema.ts
relspec convert $S --to typeorm --to-path entities.ts
relspec convert $S --to gorm --to-path models.go --package models
relspec convert $S --to bun --to-path models.go --package models
```
Notes:
- Code-generation writers (`gorm`, `bun`) take `--package`. They also accept
`--types baselib|stdlib|sqltypes` to choose the nullable type package.
- When `--to-path` is a directory it must already exist.
- `sqlite` output automatically flattens `schema.table` names. Use
`--flatten-schema` for other formats if the target has no schema support.
- `dctx` supports a single schema only; use `--schema <name>` to select one.
## Readers: file-based formats into DBML (or JSON where noted)
```bash
relspec convert --from json --from-path schema.json --to dbml --to-path out.dbml
relspec convert --from yaml --from-path schema.yaml --to dbml --to-path out.dbml
relspec convert --from dctx --from-path schema.dctx --to dbml --to-path out.dbml
relspec convert --from drawdb --from-path schema.drawdb.json --to dbml --to-path out.dbml
relspec convert --from graphql --from-path schema.graphql --to dbml --to-path out.dbml
relspec convert --from prisma --from-path schema.prisma --to dbml --to-path out.dbml
relspec convert --from drizzle --from-path schema.ts --to dbml --to-path out.dbml
relspec convert --from typeorm --from-path entities.ts --to dbml --to-path out.dbml
relspec convert --from bun --from-path models.go --to dbml --to-path out.dbml
relspec convert --from gorm --from-path models.go --to json --to-path out.json
```
Code-first readers (`gorm`, `bun`, `drizzle`, `typeorm`) accept a single file or a
directory of model files.
> Known issue: reading GORM models and writing DBML currently panics in the DBML
> writer (`pkg/writers/dbml/writer.go`, `constraintToDBML`). Use another target
> such as JSON until this is fixed.
## Cross-format combinations
```bash
# ORM models to SQL DDL
relspec convert --from gorm --from-path models.go --to pgsql --to-path schema.sql
# Prisma to Drizzle
relspec convert --from prisma --from-path schema.prisma --to drizzle --to-path schema.ts
# DrawDB diagram to GraphQL
relspec convert --from drawdb --from-path diagram.json --to graphql --to-path schema.graphql
# Merge several files while converting
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
```
## Live databases
These need a reachable database:
```bash
# PostgreSQL
relspec convert --from pgsql --from-conn "postgres://user:pass@localhost:5432/mydb" \
--to dbml --to-path schema.dbml
# SQL Server
relspec convert --from mssql --from-conn "<mssql connection string>" \
--to json --to-path schema.json
# SQLite database file (--from-conn takes the file path)
relspec convert --from sqlite --from-conn ./app.db --to dbml --to-path schema.dbml
```
## Formats outside `convert`
- `sqldir` (SQL script directory reader) and `sqlexec` (SQL execution writer) are
used by `relspec scripts` and `relspec job`, and `sqldir` by `relspec diff`.
See [SCRIPTS_COMMAND.md](SCRIPTS_COMMAND.md) and [JOB_FILES.md](JOB_FILES.md).
- The `template` writer is exposed through `relspec templ`. See
[TEMPLATE_MODE.md](TEMPLATE_MODE.md).
+4
View File
@@ -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)
})
+12
View File
@@ -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()
}).
+263
View File
@@ -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
}
+136
View File
@@ -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)
}
}
+476
View File
@@ -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)
}
+50
View File
@@ -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
+29
View File
@@ -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.
+29
View File
@@ -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.
+18
View File
@@ -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).
+38
View File
@@ -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.
+20
View File
@@ -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.
+14
View File
@@ -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.
+23
View File
@@ -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`.
+27
View File
@@ -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.
+45
View File
@@ -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.
+17
View File
@@ -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"; }
+16
View File
@@ -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"; }
+16
View File
@@ -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"; }
+130
View File
@@ -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
+21
View File
@@ -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;