Compare commits

...
Author SHA1 Message Date
SG CommandandClaude Sonnet 5.5 0235a28add feat(ui): file browser and connection string builder dialogs (#44)
Enter on File Path inputs opens a file browser (load/save, extension
filter, hidden toggle, overwrite confirm). Enter on Connection String
inputs opens a builder for PostgreSQL, MSSQL and SQLite with masked
password/preview, parsing and optional connection test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 11:35:12 +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
37 changed files with 3136 additions and 15 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}'
+2
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
+112 -14
View File
@@ -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))
+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)
+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).
+186
View File
@@ -0,0 +1,186 @@
package ui
import (
"fmt"
"net"
"net/url"
"strings"
)
// ConnKind identifies the database type a connection string targets.
type ConnKind string
const (
ConnPostgres ConnKind = "postgres"
ConnMSSQL ConnKind = "mssql"
ConnSQLite ConnKind = "sqlite"
)
// connKinds lists the kinds offered by the builder dialog, in display order.
var connKinds = []ConnKind{ConnPostgres, ConnMSSQL, ConnSQLite}
// maskedPassword is substituted for the password in previews.
const maskedPassword = "****"
// ConnFields holds the editable parts of a connection string.
type ConnFields struct {
Kind ConnKind
Host string
Port string
Database string
User string
Password string
SSLMode string
FilePath string // SQLite only
// Extra keeps query parameters the builder has no field for, so that
// parsing and rebuilding an existing string does not drop them.
Extra url.Values
}
// DefaultConnFields returns sensible defaults for the given kind.
func DefaultConnFields(kind ConnKind) ConnFields {
f := ConnFields{Kind: kind}
switch kind {
case ConnPostgres:
f.Host, f.Port, f.User, f.SSLMode = "localhost", "5432", "postgres", "disable"
case ConnMSSQL:
f.Host, f.Port, f.User, f.SSLMode = "localhost", "1433", "sa", "disable"
}
return f
}
// SSLModes returns the valid SSL/encryption options for a kind.
func SSLModes(kind ConnKind) []string {
switch kind {
case ConnPostgres:
return []string{"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
case ConnMSSQL:
return []string{"disable", "false", "true"}
}
return nil
}
func (f ConnFields) sslParam() string {
if f.Kind == ConnMSSQL {
return "encrypt"
}
return "sslmode"
}
// BuildConnString renders the fields as a connection string. With mask set,
// a non-empty password is replaced by asterisks (for previews).
func BuildConnString(f ConnFields, mask bool) string {
if f.Kind == ConnSQLite {
return f.FilePath
}
u := &url.URL{Scheme: "postgres"}
if f.Kind == ConnMSSQL {
u.Scheme = "sqlserver"
}
if f.Port != "" {
u.Host = net.JoinHostPort(f.Host, f.Port)
} else {
u.Host = f.Host
}
if f.User != "" {
if f.Password != "" {
pw := f.Password
if mask {
pw = maskedPassword
}
u.User = url.UserPassword(f.User, pw)
} else {
u.User = url.User(f.User)
}
}
query := url.Values{}
for k, v := range f.Extra {
query[k] = v
}
if f.Kind == ConnMSSQL {
if f.Database != "" {
query.Set("database", f.Database)
}
} else if f.Database != "" {
u.Path = "/" + f.Database
}
if f.SSLMode != "" {
query.Set(f.sslParam(), f.SSLMode)
}
u.RawQuery = query.Encode()
out := u.String()
if mask {
// url escapes '*' in the userinfo; keep the preview readable.
out = strings.Replace(out, url.QueryEscape(maskedPassword), maskedPassword, 1)
}
return out
}
// DetectConnKind guesses the kind from a connection string's scheme. Anything
// that is not a recognised URL is treated as a SQLite file path.
func DetectConnKind(s string) ConnKind {
lower := strings.ToLower(strings.TrimSpace(s))
switch {
case strings.HasPrefix(lower, "postgres://"), strings.HasPrefix(lower, "postgresql://"):
return ConnPostgres
case strings.HasPrefix(lower, "sqlserver://"), strings.HasPrefix(lower, "mssql://"):
return ConnMSSQL
}
return ConnSQLite
}
// ParseConnString splits a connection string into fields. An empty string
// yields the defaults for hint. Missing ports fall back to the kind default.
func ParseConnString(s string, hint ConnKind) (ConnFields, error) {
s = strings.TrimSpace(s)
if s == "" {
return DefaultConnFields(hint), nil
}
kind := DetectConnKind(s)
if kind == ConnSQLite {
path := s
for _, prefix := range []string{"sqlite://", "sqlite3://"} {
path = strings.TrimPrefix(path, prefix)
}
return ConnFields{Kind: ConnSQLite, FilePath: path}, nil
}
u, err := url.Parse(s)
if err != nil {
return DefaultConnFields(kind), fmt.Errorf("invalid connection string: %w", err)
}
f := ConnFields{
Kind: kind,
Host: u.Hostname(),
Port: u.Port(),
}
if f.Port == "" {
f.Port = DefaultConnFields(kind).Port
}
if u.User != nil {
f.User = u.User.Username()
f.Password, _ = u.User.Password()
}
query := u.Query()
if kind == ConnMSSQL {
f.Database = query.Get("database")
query.Del("database")
} else {
f.Database = strings.TrimPrefix(u.Path, "/")
}
f.SSLMode = query.Get(f.sslParam())
query.Del(f.sslParam())
if len(query) > 0 {
f.Extra = query
}
return f, nil
}
+62
View File
@@ -0,0 +1,62 @@
package ui
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
_ "github.com/microsoft/go-mssqldb"
_ "modernc.org/sqlite"
)
// connTestTimeout bounds how long "Test connection" may block.
const connTestTimeout = 5 * time.Second
// TestConnection opens and pings the database described by f. Any occurrence
// of the password in the returned error is masked.
func TestConnection(f ConnFields) error {
ctx, cancel := context.WithTimeout(context.Background(), connTestTimeout)
defer cancel()
err := testConnection(ctx, f)
if err != nil && f.Password != "" {
err = fmt.Errorf("%s", strings.ReplaceAll(err.Error(), f.Password, maskedPassword))
}
return err
}
func testConnection(ctx context.Context, f ConnFields) error {
switch f.Kind {
case ConnPostgres:
conn, err := pgx.Connect(ctx, BuildConnString(f, false))
if err != nil {
return err
}
return conn.Close(ctx)
case ConnMSSQL:
return pingSQL(ctx, "sqlserver", BuildConnString(f, false))
case ConnSQLite:
if f.FilePath == "" {
return fmt.Errorf("file path is required")
}
// Opening a missing SQLite file would silently create it.
if _, err := os.Stat(f.FilePath); err != nil {
return err
}
return pingSQL(ctx, "sqlite", f.FilePath)
}
return fmt.Errorf("unsupported connection type %q", f.Kind)
}
func pingSQL(ctx context.Context, driver, dsn string) error {
db, err := sql.Open(driver, dsn)
if err != nil {
return err
}
defer db.Close()
return db.PingContext(ctx)
}
+210
View File
@@ -0,0 +1,210 @@
package ui
import (
"fmt"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// connBuilderPage is the page name of the connection string builder dialog.
const connBuilderPage = "conn-builder"
// showConnStringBuilder opens the connection string builder, pre-filled by
// parsing current. Save calls onDone with the built string; Esc/Back leaves
// the caller's input untouched.
func (se *SchemaEditor) showConnStringBuilder(current string, hint ConnKind, returnPage string, onDone func(connString string)) {
fields, err := ParseConnString(current, hint)
if err != nil {
se.showErrorDialog("Error", err.Error()+"\nStarting from defaults.")
}
title := tview.NewTextView().
SetText("[::b]Connection String Builder").
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true)
preview := tview.NewTextView()
preview.SetBorder(true).SetTitle(" Preview (password masked) ").SetTitleAlign(tview.AlignLeft)
form := tview.NewForm()
form.SetBorder(true).SetTitle(" Connection ").SetTitleAlign(tview.AlignLeft)
updatePreview := func() {
preview.SetText(tview.Escape(BuildConnString(fields, true)))
}
closeBuilder := func() {
se.pages.RemovePage(connBuilderPage)
se.pages.SwitchToPage(returnPage)
}
var render func(focus int)
render = func(focus int) {
form.Clear(false)
kindIndex := 0
kindLabels := make([]string, len(connKinds))
for i, k := range connKinds {
kindLabels[i] = string(k)
if k == fields.Kind {
kindIndex = i
}
}
form.AddDropDown("Type", kindLabels, kindIndex, func(_ string, index int) {
if connKinds[index] == fields.Kind {
return
}
fields = DefaultConnFields(connKinds[index])
render(0)
})
if fields.Kind == ConnSQLite {
form.AddInputField("File Path", fields.FilePath, 50, nil, func(v string) {
fields.FilePath = v
updatePreview()
})
if item, ok := form.GetFormItemByLabel("File Path").(*tview.InputField); ok {
item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() != tcell.KeyEnter {
return event
}
se.showFileBrowser(FileBrowserConfig{
Mode: FileBrowserLoad,
StartPath: fields.FilePath,
Extensions: FormatExtensions("sqlite"),
ReturnPage: connBuilderPage,
OnSelect: func(path string) { item.SetText(path) },
})
return nil
})
}
} else {
form.AddInputField("Host", fields.Host, 50, nil, func(v string) { fields.Host = v; updatePreview() })
form.AddInputField("Port", fields.Port, 10, tview.InputFieldInteger, func(v string) { fields.Port = v; updatePreview() })
form.AddInputField("Database", fields.Database, 50, nil, func(v string) { fields.Database = v; updatePreview() })
form.AddInputField("User", fields.User, 50, nil, func(v string) { fields.User = v; updatePreview() })
form.AddPasswordField("Password", fields.Password, 50, '*', func(v string) { fields.Password = v; updatePreview() })
label := "SSL Mode"
if fields.Kind == ConnMSSQL {
label = "Encrypt"
}
modes := SSLModes(fields.Kind)
modeIndex := -1
for i, m := range modes {
if m == fields.SSLMode {
modeIndex = i
}
}
if modeIndex < 0 {
// Keep a value parsed from an existing string even if it is not a listed option.
modes = append([]string{fields.SSLMode}, modes...)
modeIndex = 0
}
form.AddDropDown(label, modes, modeIndex, func(option string, _ int) {
fields.SSLMode = option
updatePreview()
})
}
form.AddButton("Save [F2]", connBuilderSave(se, &fields, closeBuilder, onDone))
form.AddButton("Test [F3]", func() { se.testConnectionDialog(fields) })
form.AddButton("Back [Esc]", closeBuilder)
updatePreview()
form.SetFocus(focus)
se.app.SetFocus(form)
}
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEscape:
closeBuilder()
return nil
case tcell.KeyF2:
connBuilderSave(se, &fields, closeBuilder, onDone)()
return nil
case tcell.KeyF3:
se.testConnectionDialog(fields)
return nil
}
return event
})
render(0)
flex := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(title, 1, 0, false).
AddItem(form, 0, 1, true).
AddItem(preview, 4, 0, false)
se.pages.AddAndSwitchToPage(connBuilderPage, flex, true)
se.app.SetFocus(form)
}
// connBuilderSave returns the Save action: validate, write back, close.
func connBuilderSave(se *SchemaEditor, fields *ConnFields, closeBuilder func(), onDone func(string)) func() {
return func() {
if msg := validateConnFields(*fields); msg != "" {
se.showErrorDialog("Error", msg)
return
}
result := BuildConnString(*fields, false)
closeBuilder()
onDone(result)
}
}
// validateConnFields returns a message describing the first missing required field, or "".
func validateConnFields(f ConnFields) string {
if f.Kind == ConnSQLite {
if strings.TrimSpace(f.FilePath) == "" {
return "File path is required"
}
return ""
}
if strings.TrimSpace(f.Host) == "" {
return "Host is required"
}
return ""
}
// testConnectionDialog runs TestConnection in the background and reports the result.
func (se *SchemaEditor) testConnectionDialog(fields ConnFields) {
if msg := validateConnFields(fields); msg != "" {
se.showErrorDialog("Error", msg)
return
}
go func() {
err := TestConnection(fields)
se.app.QueueUpdateDraw(func() {
if err != nil {
se.showErrorDialog("Connection Failed", fmt.Sprintf("Connection failed:\n%v", err))
return
}
se.showSuccessDialog("Connection OK", "Connection successful", nil)
})
}()
}
// attachConnStringBuilder makes Enter on the named input open the builder.
func (se *SchemaEditor) attachConnStringBuilder(form *tview.Form, label, returnPage string, format func() string) {
item, ok := form.GetFormItemByLabel(label).(*tview.InputField)
if !ok {
return
}
item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() != tcell.KeyEnter {
return event
}
hint := ConnPostgres
if format != nil && format() == "sqlite" {
hint = ConnSQLite
}
se.showConnStringBuilder(item.GetText(), hint, returnPage, func(s string) { item.SetText(s) })
return nil
})
}
+143
View File
@@ -0,0 +1,143 @@
package ui
import (
"reflect"
"strings"
"testing"
)
func TestBuildConnString(t *testing.T) {
tests := []struct {
name string
fields ConnFields
mask bool
want string
}{
{
name: "postgres defaults with db",
fields: func() ConnFields { f := DefaultConnFields(ConnPostgres); f.Database = "app"; return f }(),
want: "postgres://postgres@localhost:5432/app?sslmode=disable",
},
{
name: "postgres password unmasked",
fields: ConnFields{Kind: ConnPostgres, Host: "db", Port: "5433", Database: "x", User: "u", Password: "p@ss/w", SSLMode: "require"},
want: "postgres://u:p%40ss%2Fw@db:5433/x?sslmode=require",
},
{
name: "postgres password masked",
fields: ConnFields{Kind: ConnPostgres, Host: "db", Port: "5432", Database: "x", User: "u", Password: "secret"},
mask: true,
want: "postgres://u:****@db:5432/x",
},
{
name: "mssql",
fields: ConnFields{Kind: ConnMSSQL, Host: "sql", Port: "1433", Database: "shop", User: "sa", Password: "pw", SSLMode: "disable"},
want: "sqlserver://sa:pw@sql:1433?database=shop&encrypt=disable",
},
{
name: "sqlite is the plain path",
fields: ConnFields{Kind: ConnSQLite, FilePath: "/tmp/a b.db"},
want: "/tmp/a b.db",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := BuildConnString(tt.fields, tt.mask); got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestMaskedBuildHidesPassword(t *testing.T) {
f := ConnFields{Kind: ConnMSSQL, Host: "h", User: "u", Password: "hunter2"}
if got := BuildConnString(f, true); strings.Contains(got, "hunter2") {
t.Errorf("masked string leaks password: %q", got)
}
}
func TestParseConnString(t *testing.T) {
tests := []struct {
name string
in string
want ConnFields
}{
{
name: "postgres full",
in: "postgres://u:p%40ss@db:5433/app?sslmode=require&application_name=x",
want: ConnFields{Kind: ConnPostgres, Host: "db", Port: "5433", Database: "app", User: "u", Password: "p@ss", SSLMode: "require"},
},
{
name: "postgresql scheme, default port",
in: "postgresql://u@db/app",
want: ConnFields{Kind: ConnPostgres, Host: "db", Port: "5432", Database: "app", User: "u"},
},
{
name: "mssql",
in: "sqlserver://sa:pw@sql:1444?database=shop&encrypt=true",
want: ConnFields{Kind: ConnMSSQL, Host: "sql", Port: "1444", Database: "shop", User: "sa", Password: "pw", SSLMode: "true"},
},
{
name: "sqlite path",
in: "/data/app.db",
want: ConnFields{Kind: ConnSQLite, FilePath: "/data/app.db"},
},
{
name: "sqlite scheme",
in: "sqlite:///data/app.db",
want: ConnFields{Kind: ConnSQLite, FilePath: "/data/app.db"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseConnString(tt.in, ConnPostgres)
if err != nil {
t.Fatal(err)
}
got.Extra = nil
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %+v, want %+v", got, tt.want)
}
})
}
}
func TestParseConnStringEmptyUsesHintDefaults(t *testing.T) {
got, err := ParseConnString(" ", ConnMSSQL)
if err != nil {
t.Fatal(err)
}
if got.Kind != ConnMSSQL || got.Port != "1433" || got.Host != "localhost" {
t.Errorf("unexpected defaults: %+v", got)
}
}
func TestParseConnStringInvalid(t *testing.T) {
if _, err := ParseConnString("postgres://u:p@host:badport/db", ConnPostgres); err == nil {
t.Error("expected error for invalid port")
}
}
func TestConnStringRoundTrip(t *testing.T) {
for _, in := range []string{
"postgres://u:pw@db:5433/app?application_name=x&sslmode=require",
"sqlserver://sa:pw@sql:1433?application+name=x&database=shop&encrypt=false",
} {
f, err := ParseConnString(in, ConnPostgres)
if err != nil {
t.Fatal(err)
}
if got := BuildConnString(f, false); got != in {
t.Errorf("round trip: got %q, want %q", got, in)
}
}
}
func TestTestConnectionSQLite(t *testing.T) {
if err := TestConnection(ConnFields{Kind: ConnSQLite}); err == nil {
t.Error("expected error for empty path")
}
if err := TestConnection(ConnFields{Kind: ConnSQLite, FilePath: t.TempDir() + "/missing.db"}); err == nil {
t.Error("expected error for missing file")
}
}
+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)
})
+134
View File
@@ -0,0 +1,134 @@
package ui
import (
"os"
"path/filepath"
"sort"
"strings"
)
// FileEntry is a single row in the file browser.
type FileEntry struct {
Name string
IsDir bool
}
// formatExtensions maps a UI format name to the file extensions it reads or writes.
var formatExtensions = map[string][]string{
"dbml": {".dbml"},
"dctx": {".dctx"},
"drawdb": {".json"},
"graphql": {".graphql", ".gql"},
"json": {".json"},
"yaml": {".yaml", ".yml"},
"gorm": {".go"},
"bun": {".go"},
"drizzle": {".ts"},
"prisma": {".prisma"},
"typeorm": {".ts"},
"pgsql": {".sql"},
"sqlite": {".db", ".sqlite", ".sqlite3"},
}
// directoryFormats are formats whose reader/writer accepts a directory.
var directoryFormats = map[string]bool{
"gorm": true, "bun": true, "drizzle": true, "typeorm": true,
}
// FormatExtensions returns the extensions for a format, or nil (no filter) if unknown.
func FormatExtensions(format string) []string {
return formatExtensions[format]
}
// IsDirectoryFormat reports whether a format can be loaded from or saved to a directory.
func IsDirectoryFormat(format string) bool {
return directoryFormats[format]
}
// ExpandHome replaces a leading ~ with the user's home directory.
func ExpandHome(p string) string {
if strings.HasPrefix(p, "~") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, p[1:])
}
}
return p
}
// MatchesExtension reports whether name has one of exts (case-insensitive).
// An empty extension list matches everything.
func MatchesExtension(name string, exts []string) bool {
if len(exts) == 0 {
return true
}
ext := strings.ToLower(filepath.Ext(name))
for _, e := range exts {
if strings.ToLower(e) == ext {
return true
}
}
return false
}
// ListDir returns the entries of dir: directories first, then files that match
// exts, each group sorted case-insensitively. Hidden (dot) entries are skipped
// unless showHidden is set.
func ListDir(dir string, exts []string, showHidden bool) ([]FileEntry, error) {
items, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var dirs, files []FileEntry
for _, item := range items {
name := item.Name()
if !showHidden && strings.HasPrefix(name, ".") {
continue
}
isDir := item.IsDir()
if !isDir && item.Type()&os.ModeSymlink != 0 {
// Follow symlinks so links to directories are navigable.
if info, err := os.Stat(filepath.Join(dir, name)); err == nil {
isDir = info.IsDir()
}
}
if isDir {
dirs = append(dirs, FileEntry{Name: name, IsDir: true})
} else if MatchesExtension(name, exts) {
files = append(files, FileEntry{Name: name})
}
}
byName := func(s []FileEntry) {
sort.Slice(s, func(i, j int) bool {
return strings.ToLower(s[i].Name) < strings.ToLower(s[j].Name)
})
}
byName(dirs)
byName(files)
return append(dirs, files...), nil
}
// ResolveStart works out where the browser should open for the current input
// value. It returns the directory to show and, if the input named a file, its
// base name. Falls back to the working directory.
func ResolveStart(input string) (dir, name string) {
input = strings.TrimSpace(input)
if input != "" {
p := ExpandHome(input)
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
if info, err := os.Stat(p); err == nil && info.IsDir() {
return p, ""
}
if info, err := os.Stat(filepath.Dir(p)); err == nil && info.IsDir() {
return filepath.Dir(p), filepath.Base(p)
}
}
wd, err := os.Getwd()
if err != nil {
wd = "."
}
return wd, ""
}
+364
View File
@@ -0,0 +1,364 @@
package ui
import (
"fmt"
"os"
"path/filepath"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// FileBrowserMode selects between picking an existing path and choosing a save target.
type FileBrowserMode int
const (
FileBrowserLoad FileBrowserMode = iota
FileBrowserSave
)
// FileBrowserConfig configures the file browser dialog.
type FileBrowserConfig struct {
Mode FileBrowserMode
StartPath string // current value of the input; may be empty
Extensions []string // empty = show all files
AllowDir bool // a directory is a valid result (directory-based formats)
ReturnPage string // page to switch back to when the dialog closes
OnSelect func(path string)
}
// attachFileBrowser makes Enter on the named input open the file browser,
// filtered for the currently selected format.
func (se *SchemaEditor) attachFileBrowser(form *tview.Form, label, returnPage string, mode FileBrowserMode, format func() string) {
item, ok := form.GetFormItemByLabel(label).(*tview.InputField)
if !ok {
return
}
item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() != tcell.KeyEnter {
return event
}
f := format()
se.showFileBrowser(FileBrowserConfig{
Mode: mode,
StartPath: item.GetText(),
Extensions: FormatExtensions(f),
AllowDir: IsDirectoryFormat(f),
ReturnPage: returnPage,
OnSelect: func(path string) { item.SetText(path) },
})
return nil
})
}
// showFileBrowser displays the file browser page. Esc closes it without
// calling OnSelect, leaving the originating input unchanged.
func (se *SchemaEditor) showFileBrowser(cfg FileBrowserConfig) {
const pageName = "file-browser"
dir, startName := ResolveStart(cfg.StartPath)
showHidden := false
useFilter := len(cfg.Extensions) > 0
var entries []FileEntry // rows shown below the ".." row
title := tview.NewTextView().
SetText("[::b]Select File").
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true)
if cfg.Mode == FileBrowserSave {
title.SetText("[::b]Save As")
}
info := tview.NewTextView().SetDynamicColors(true)
fileTable := tview.NewTable().SetSelectable(true, false).SetFixed(0, 0)
fileTable.SetBorder(true)
nameInput := tview.NewInputField().SetLabel("File name: ").SetFieldWidth(0)
nameInput.SetText(startName)
closeBrowser := func() {
se.pages.RemovePage(pageName)
se.pages.SwitchToPage(cfg.ReturnPage)
}
finish := func(path string) {
closeBrowser()
cfg.OnSelect(path)
}
var refresh func()
refresh = func() {
exts := cfg.Extensions
if !useFilter {
exts = nil
}
list, err := ListDir(dir, exts, showHidden)
if err != nil {
se.showErrorDialog("Error", fmt.Sprintf("Cannot read %s: %v", dir, err))
list = nil
}
entries = list
fileTable.Clear()
fileTable.SetCell(0, 0, tview.NewTableCell("[..]").SetTextColor(tcell.ColorAqua))
for i, e := range entries {
cell := tview.NewTableCell(e.Name)
if e.IsDir {
cell.SetText(e.Name + "/").SetTextColor(tcell.ColorAqua)
}
fileTable.SetCell(i+1, 0, cell)
}
fileTable.Select(0, 0)
if len(entries) > 0 {
fileTable.Select(1, 0)
}
filterText := "all files"
if useFilter {
filterText = fmt.Sprintf("%v", cfg.Extensions)
}
hiddenText := "hidden: off"
if showHidden {
hiddenText = "hidden: on"
}
info.SetText(fmt.Sprintf("%s [yellow](%s, filter: %s)[-]", tview.Escape(dir), hiddenText, tview.Escape(filterText)))
fileTable.SetTitle(" Files ")
}
goUp := func() {
parent := filepath.Dir(dir)
if parent == dir {
return
}
prev := filepath.Base(dir)
dir = parent
refresh()
for i, e := range entries {
if e.Name == prev {
fileTable.Select(i+1, 0)
break
}
}
}
selected := func() (FileEntry, bool) {
row, _ := fileTable.GetSelection()
if row < 1 || row > len(entries) {
return FileEntry{}, false
}
return entries[row-1], true
}
// confirmOverwrite asks before replacing an existing file (not directories).
confirmOverwrite := func(path string) {
modal := tview.NewModal().
SetText(fmt.Sprintf("File already exists:\n%s\n\nOverwrite it?", path)).
AddButtons([]string{"Cancel", "Overwrite"}).
SetDoneFunc(func(_ int, label string) {
se.pages.RemovePage("overwrite-confirm")
se.pages.SwitchToPage(pageName)
if label == "Overwrite" {
finish(path)
}
})
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
se.pages.RemovePage("overwrite-confirm")
se.pages.SwitchToPage(pageName)
return nil
}
return event
})
se.pages.AddAndSwitchToPage("overwrite-confirm", modal, true)
}
chooseSave := func() {
name := nameInput.GetText()
if name == "" {
if cfg.AllowDir {
finish(dir)
return
}
se.showErrorDialog("Error", "Enter a file name")
return
}
path := filepath.Join(dir, name)
if st, err := os.Stat(path); err == nil {
if st.IsDir() {
se.showErrorDialog("Error", name+" is a directory")
return
}
confirmOverwrite(path)
return
}
finish(path)
}
// chooseHighlighted handles Select: the highlighted entry in load mode, or
// the typed name in save mode.
chooseHighlighted := func() {
if cfg.Mode == FileBrowserSave {
chooseSave()
return
}
e, ok := selected()
switch {
case ok && !e.IsDir:
finish(filepath.Join(dir, e.Name))
case ok && cfg.AllowDir:
finish(filepath.Join(dir, e.Name))
case cfg.AllowDir:
finish(dir)
default:
se.showErrorDialog("Error", "Select a file")
}
}
activate := func() {
row, _ := fileTable.GetSelection()
if row == 0 {
goUp()
return
}
e, ok := selected()
if !ok {
return
}
if e.IsDir {
dir = filepath.Join(dir, e.Name)
refresh()
return
}
if cfg.Mode == FileBrowserSave {
nameInput.SetText(e.Name)
return
}
finish(filepath.Join(dir, e.Name))
}
toggleHidden := func() { showHidden = !showHidden; refresh() }
toggleFilter := func() {
if len(cfg.Extensions) > 0 {
useFilter = !useFilter
refresh()
}
}
btnSelect := tview.NewButton("Select [s]").SetSelectedFunc(chooseHighlighted)
btnHidden := tview.NewButton("Hidden [h]").SetSelectedFunc(toggleHidden)
btnFilter := tview.NewButton("Filter [f]").SetSelectedFunc(toggleFilter)
btnBack := tview.NewButton("Back [b]").SetSelectedFunc(closeBrowser)
btnFlex := tview.NewFlex().
AddItem(btnSelect, 0, 1, false).
AddItem(btnHidden, 0, 1, false).
AddItem(btnFilter, 0, 1, false).
AddItem(btnBack, 0, 1, false)
flex := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(title, 1, 0, false).
AddItem(info, 1, 0, false).
AddItem(fileTable, 0, 1, true)
focusOrder := []tview.Primitive{fileTable}
if cfg.Mode == FileBrowserSave {
flex.AddItem(nameInput, 1, 0, false)
focusOrder = append(focusOrder, nameInput)
}
flex.AddItem(btnFlex, 1, 0, false)
focusOrder = append(focusOrder, btnSelect, btnHidden, btnFilter, btnBack)
// Circular Tab / Shift+Tab across every focusable widget.
cycle := func(event *tcell.EventKey) *tcell.EventKey {
step := 0
switch event.Key() {
case tcell.KeyTab:
step = 1
case tcell.KeyBacktab:
step = -1
default:
return event
}
for i, p := range focusOrder {
if p.HasFocus() {
se.app.SetFocus(focusOrder[(i+step+len(focusOrder))%len(focusOrder)])
break
}
}
return nil
}
fileTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event = cycle(event); event == nil {
return nil
}
switch event.Key() {
case tcell.KeyEscape:
closeBrowser()
return nil
case tcell.KeyEnter:
activate()
return nil
case tcell.KeyBackspace, tcell.KeyBackspace2, tcell.KeyLeft:
goUp()
return nil
}
switch event.Rune() {
case 's':
chooseHighlighted()
return nil
case 'h':
toggleHidden()
return nil
case 'f':
toggleFilter()
return nil
case 'b':
closeBrowser()
return nil
}
return event
})
nameInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event = cycle(event); event == nil {
return nil
}
switch event.Key() {
case tcell.KeyEscape:
closeBrowser()
return nil
case tcell.KeyEnter:
chooseSave()
return nil
}
return event
})
for _, b := range []*tview.Button{btnSelect, btnHidden, btnFilter, btnBack} {
b.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event = cycle(event); event == nil {
return nil
}
if event.Key() == tcell.KeyEscape {
closeBrowser()
return nil
}
return event
})
}
refresh()
if startName != "" {
for i, e := range entries {
if e.Name == startName {
fileTable.Select(i+1, 0)
break
}
}
}
se.pages.AddAndSwitchToPage(pageName, flex, true)
se.app.SetFocus(fileTable)
}
+124
View File
@@ -0,0 +1,124 @@
package ui
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func touch(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatal(err)
}
}
func names(entries []FileEntry) []string {
var out []string
for _, e := range entries {
if e.IsDir {
out = append(out, e.Name+"/")
} else {
out = append(out, e.Name)
}
}
return out
}
func TestMatchesExtension(t *testing.T) {
tests := []struct {
name string
exts []string
want bool
}{
{"a.dbml", []string{".dbml"}, true},
{"A.DBML", []string{".dbml"}, true},
{"a.json", []string{".dbml"}, false},
{"a.yml", []string{".yaml", ".yml"}, true},
{"noext", []string{".sql"}, false},
{"anything", nil, true},
}
for _, tt := range tests {
if got := MatchesExtension(tt.name, tt.exts); got != tt.want {
t.Errorf("MatchesExtension(%q, %v) = %v, want %v", tt.name, tt.exts, got, tt.want)
}
}
}
func TestListDirFilterAndHidden(t *testing.T) {
dir := t.TempDir()
touch(t, filepath.Join(dir, "b.dbml"))
touch(t, filepath.Join(dir, "A.dbml"))
touch(t, filepath.Join(dir, "c.json"))
touch(t, filepath.Join(dir, ".hidden.dbml"))
touch(t, filepath.Join(dir, "sub", "x.txt"))
touch(t, filepath.Join(dir, ".git", "x"))
got, err := ListDir(dir, FormatExtensions("dbml"), false)
if err != nil {
t.Fatal(err)
}
if want := []string{"sub/", "A.dbml", "b.dbml"}; !reflect.DeepEqual(names(got), want) {
t.Errorf("filtered: got %v, want %v", names(got), want)
}
got, _ = ListDir(dir, FormatExtensions("dbml"), true)
if want := []string{".git/", "sub/", ".hidden.dbml", "A.dbml", "b.dbml"}; !reflect.DeepEqual(names(got), want) {
t.Errorf("hidden: got %v, want %v", names(got), want)
}
got, _ = ListDir(dir, nil, false)
if want := []string{"sub/", "A.dbml", "b.dbml", "c.json"}; !reflect.DeepEqual(names(got), want) {
t.Errorf("no filter: got %v, want %v", names(got), want)
}
}
func TestListDirMissing(t *testing.T) {
if _, err := ListDir(filepath.Join(t.TempDir(), "nope"), nil, false); err == nil {
t.Error("expected error for missing directory")
}
}
func TestResolveStart(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "schema.dbml")
touch(t, file)
wd, _ := os.Getwd()
tests := []struct {
name string
in string
wantDir string
wantFileName string
}{
{"existing file", file, dir, "schema.dbml"},
{"directory", dir, dir, ""},
{"new file in existing dir", filepath.Join(dir, "new.dbml"), dir, "new.dbml"},
{"empty", "", wd, ""},
{"nonexistent parent", filepath.Join(dir, "no", "such", "f.dbml"), wd, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, n := ResolveStart(tt.in)
if d != tt.wantDir || n != tt.wantFileName {
t.Errorf("got (%q, %q), want (%q, %q)", d, n, tt.wantDir, tt.wantFileName)
}
})
}
}
func TestFormatExtensions(t *testing.T) {
if got := FormatExtensions("yaml"); !reflect.DeepEqual(got, []string{".yaml", ".yml"}) {
t.Errorf("yaml: %v", got)
}
if FormatExtensions("unknown") != nil {
t.Error("unknown format should not filter")
}
if !IsDirectoryFormat("gorm") || IsDirectoryFormat("json") {
t.Error("directory format detection wrong")
}
}
+63
View File
@@ -0,0 +1,63 @@
package ui
import (
"path/filepath"
"testing"
"github.com/rivo/tview"
)
func newDialogTestEditor() *SchemaEditor {
se := &SchemaEditor{app: tview.NewApplication(), pages: tview.NewPages()}
se.pages.AddPage("origin", tview.NewBox(), true, true)
return se
}
func TestFileBrowserOpensOnEachMode(t *testing.T) {
dir := t.TempDir()
touch(t, filepath.Join(dir, "a.dbml"))
for _, mode := range []FileBrowserMode{FileBrowserLoad, FileBrowserSave} {
se := newDialogTestEditor()
se.showFileBrowser(FileBrowserConfig{
Mode: mode,
StartPath: filepath.Join(dir, "a.dbml"),
Extensions: FormatExtensions("dbml"),
ReturnPage: "origin",
OnSelect: func(string) { t.Error("OnSelect must not fire without a selection") },
})
if !se.pages.HasPage("file-browser") {
t.Errorf("mode %d: file-browser page missing", mode)
}
}
}
func TestConnStringBuilderOpensForEachKind(t *testing.T) {
for _, in := range []string{
"",
"postgres://u:pw@db:5432/app?sslmode=disable",
"sqlserver://sa:pw@sql:1433?database=shop&encrypt=disable",
"/tmp/app.db",
"postgres://u:p@host:badport/db", // parse error falls back to defaults
} {
se := newDialogTestEditor()
se.showConnStringBuilder(in, ConnPostgres, "origin", func(string) {
t.Error("onDone must not fire without Save")
})
if !se.pages.HasPage(connBuilderPage) {
t.Errorf("%q: builder page missing", in)
}
}
}
func TestValidateConnFields(t *testing.T) {
if validateConnFields(ConnFields{Kind: ConnSQLite}) == "" {
t.Error("sqlite without path should be invalid")
}
if validateConnFields(ConnFields{Kind: ConnPostgres}) == "" {
t.Error("postgres without host should be invalid")
}
if msg := validateConnFields(DefaultConnFields(ConnMSSQL)); msg != "" {
t.Errorf("defaults should be valid, got %q", msg)
}
}
+12
View File
@@ -92,6 +92,9 @@ func (se *SchemaEditor) showLoadScreen() {
connString = value
})
se.attachFileBrowser(form, "File Path", "load-database", FileBrowserLoad, func() string { return currentFormat })
se.attachConnStringBuilder(form, "Connection String", "load-database", func() string { return currentFormat })
form.AddTextView("Help", getLoadHelpText(), 0, 5, true, false)
// Buttons
@@ -190,6 +193,8 @@ func (se *SchemaEditor) showSaveScreen() {
filePath = value
})
se.attachFileBrowser(form, "File Path", "save-database", FileBrowserSave, func() string { return currentFormat })
form.AddTextView("Help", getSaveHelpText(), 0, 5, true, false)
// Buttons
@@ -469,6 +474,8 @@ func getLoadHelpText() string {
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm
Database formats: pgsql (requires connection string)
Press Enter in File Path to browse files, or in Connection String to open the builder.
Examples:
- File path: ~/schemas/mydb.dbml or /path/to/schema.json
- Connection: postgres://user:pass@localhost/dbname`
@@ -520,6 +527,8 @@ func (se *SchemaEditor) showUpdateExistingDatabaseConfirm() {
func getSaveHelpText() string {
return `File-based formats: dbml, dctx, drawdb, graphql, json, yaml, gorm, bun, drizzle, prisma, typeorm, pgsql (SQL export)
Press Enter in File Path to browse for a target.
Examples:
- File: ~/schemas/mydb.dbml
- Directory (for code formats): ./models/`
@@ -570,6 +579,9 @@ func (se *SchemaEditor) showImportScreen() {
connString = value
})
se.attachFileBrowser(form, "File Path", "import-database", FileBrowserLoad, func() string { return currentFormat })
se.attachConnStringBuilder(form, "Connection String", "import-database", func() string { return currentFormat })
form.AddInputField("Skip Tables (comma-separated)", "", 50, nil, func(value string) {
skipTables = value
})
+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)
}
+12
View File
@@ -205,12 +205,24 @@ Organize UI code into these files:
- **column_screens.go** - Column editor, new column dialog
- **domain_screens.go** - Domain list, domain editor, new/edit domain dialogs
- **dialogs.go** - Confirmation dialogs (exit, delete)
- **filebrowser_screens.go** - File browser dialog (`file-browser`), opened with Enter on File Path inputs
- **connstring_screens.go** - Connection string builder dialog (`conn-builder`), opened with Enter on Connection String inputs
### Data Operations Files (Business Logic)
- **schema_dataops.go** - Schema CRUD operations (Create, Read, Update, Delete)
- **table_dataops.go** - Table CRUD operations
- **column_dataops.go** - Column CRUD operations
- **filebrowser.go** - Directory listing, extension filtering and start-path resolution (no tview)
- **connstring.go**, **connstring_check.go** - Connection string build/parse/mask and connection test (no tview)
### Input Dialogs
- **File browser** - Enter on a File Path input opens it. Up/Down move, Enter opens a directory or selects a file,
Backspace/`[..]` goes to the parent, `h` toggles hidden files, `f` toggles the format extension filter,
`s` selects, Esc/`b` cancels (input unchanged). Save mode adds a file name field and asks before overwriting.
- **Connection string builder** - Enter on a Connection String input opens it. Fields per type (PostgreSQL, MSSQL,
SQLite), masked password and preview, F2 save, F3 test connection, Esc cancels (input unchanged).
## Code Separation Rules
+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;