Merge remote-tracking branch 'origin/master' into issue-38-batch-processing
# Conflicts: # README.md
This commit is contained in:
+28
-1
@@ -22,6 +22,7 @@ import (
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/graphql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/json"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/mssql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/mysql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/pgsql"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/prisma"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers/sqlite"
|
||||
@@ -37,6 +38,7 @@ import (
|
||||
wgraphql "git.warky.dev/wdevs/relspecgo/pkg/writers/graphql"
|
||||
wjson "git.warky.dev/wdevs/relspecgo/pkg/writers/json"
|
||||
wmssql "git.warky.dev/wdevs/relspecgo/pkg/writers/mssql"
|
||||
wmysql "git.warky.dev/wdevs/relspecgo/pkg/writers/mysql"
|
||||
wpgsql "git.warky.dev/wdevs/relspecgo/pkg/writers/pgsql"
|
||||
wprisma "git.warky.dev/wdevs/relspecgo/pkg/writers/prisma"
|
||||
wsqlite "git.warky.dev/wdevs/relspecgo/pkg/writers/sqlite"
|
||||
@@ -59,6 +61,8 @@ var (
|
||||
convertContinueOnError bool
|
||||
convertExtraFields string
|
||||
convertDryRun bool
|
||||
convertWatch bool
|
||||
convertWatchInterval time.Duration
|
||||
)
|
||||
|
||||
var convertCmd = &cobra.Command{
|
||||
@@ -167,7 +171,11 @@ Examples:
|
||||
|
||||
# Convert SQLite to PostgreSQL SQL
|
||||
relspec convert --from sqlite --from-path database.db \
|
||||
--to pgsql --to-path schema.sql`,
|
||||
--to pgsql --to-path schema.sql
|
||||
|
||||
# Regenerate GORM models every time the DBML file changes
|
||||
relspec convert --from dbml --from-path schema.dbml \
|
||||
--to gorm --to-path models/ --package models --watch`,
|
||||
RunE: runConvert,
|
||||
}
|
||||
|
||||
@@ -189,6 +197,9 @@ func init() {
|
||||
|
||||
convertCmd.Flags().BoolVar(&convertDryRun, "dry-run", false, "Read and validate the input and print the plan without writing any output")
|
||||
|
||||
convertCmd.Flags().BoolVar(&convertWatch, "watch", false, "Watch the source files (--from-path or --from-list) and regenerate the output whenever they change")
|
||||
convertCmd.Flags().DurationVar(&convertWatchInterval, "watch-interval", 500*time.Millisecond, "Polling interval used by --watch")
|
||||
|
||||
err := convertCmd.MarkFlagRequired("from")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
|
||||
@@ -204,6 +215,13 @@ func init() {
|
||||
}
|
||||
|
||||
func runConvert(cmd *cobra.Command, args []string) error {
|
||||
if convertWatch {
|
||||
return runConvertWatch(cmd.Context(), os.Stderr, func() error { return runConvertOnce(cmd) })
|
||||
}
|
||||
return runConvertOnce(cmd)
|
||||
}
|
||||
|
||||
func runConvertOnce(cmd *cobra.Command) error {
|
||||
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Converter ===\n")
|
||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||
|
||||
@@ -400,6 +418,12 @@ func readDatabaseForConvert(dbType, filePath, connString string) (*models.Databa
|
||||
}
|
||||
reader = mssql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "mysql", "mariadb":
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("connection string is required for MySQL format")
|
||||
}
|
||||
reader = mysql.NewReader(newReaderOptions("", connString))
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
// SQLite can use either file path or connection string
|
||||
dbPath := filePath
|
||||
@@ -474,6 +498,9 @@ func writeDatabase(db *models.Database, dbType, outputPath, packageName, schemaF
|
||||
case "mssql", "sqlserver", "mssql2016", "mssql2017", "mssql2019", "mssql2022":
|
||||
writer = wmssql.NewWriter(writerOpts)
|
||||
|
||||
case "mysql", "mariadb":
|
||||
writer = wmysql.NewWriter(writerOpts)
|
||||
|
||||
case "sqlite", "sqlite3":
|
||||
writer = wsqlite.NewWriter(writerOpts)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ func newWriterOptions(outputPath, packageName string, flattenSchema bool, nullab
|
||||
FlattenSchema: flattenSchema,
|
||||
NullableTypes: nullableTypes,
|
||||
NullableArrays: nullableArrays,
|
||||
TypeMappings: typeMappings,
|
||||
Prisma7: prisma7,
|
||||
ContinueOnError: continueOnError,
|
||||
StrictDirectives: strictDirectives,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/buildinfo"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||
)
|
||||
|
||||
// version/buildDate mirror pkg/buildinfo so existing call sites keep working.
|
||||
@@ -17,6 +18,8 @@ var (
|
||||
noVersion bool
|
||||
silent bool
|
||||
strictDirectives bool
|
||||
typeMapFlags []string
|
||||
typeMappings map[string]string
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
@@ -28,6 +31,11 @@ bidirectional conversion between various database schema formats.
|
||||
It reads database schemas from multiple sources (live databases, DBML,
|
||||
DCTX, DrawDB, etc.) and writes them to various formats (GORM, Bun,
|
||||
JSON, YAML, SQL, etc.).`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
typeMappings, err = writers.ParseTypeMappings(typeMapFlags)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -45,6 +53,7 @@ func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(reportCmd)
|
||||
rootCmd.PersistentFlags().BoolVar(&prisma7, "prisma7", false, "Use Prisma 7 generator conventions when reading/writing Prisma schemas")
|
||||
rootCmd.PersistentFlags().StringArrayVar(&typeMapFlags, "type-map", nil, "Override a SQL-to-Go type mapping for bun/gorm output as sqltype=gotype (repeatable), e.g. --type-map uuid=uuid.UUID --type-map numeric=decimal.Decimal")
|
||||
rootCmd.PersistentFlags().BoolVar(&noVersion, "no-version", false, "Suppress the RelSpec version header")
|
||||
rootCmd.PersistentFlags().BoolVar(&silent, "silent", false, "Suppress progress and status messages (errors are still shown)")
|
||||
rootCmd.PersistentFlags().BoolVar(&strictDirectives, "strict-directives", false, "Fail on unknown or untranslatable DBML dialect directives (@postgres:, @sqlite:, …)")
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// watchSnapshot maps a file path to its modification time and size.
|
||||
type watchSnapshot map[string]string
|
||||
|
||||
// takeWatchSnapshot records the state of every file under the given paths.
|
||||
// Directories are walked recursively. Anything at or below the excluded path
|
||||
// (typically the output path) is skipped so regenerating output does not
|
||||
// retrigger the watcher. Missing paths are simply absent from the snapshot, so
|
||||
// creating them later counts as a change.
|
||||
func takeWatchSnapshot(paths []string, exclude string) watchSnapshot {
|
||||
snap := watchSnapshot{}
|
||||
exclude = absPathOrSelf(exclude)
|
||||
for _, root := range paths {
|
||||
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if exclude != "" && isWithin(absPathOrSelf(p), exclude) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
snap[p] = fmt.Sprintf("%d-%d", info.ModTime().UnixNano(), info.Size())
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func (s watchSnapshot) equal(o watchSnapshot) bool {
|
||||
if len(s) != len(o) {
|
||||
return false
|
||||
}
|
||||
for k, v := range s {
|
||||
if o[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func absPathOrSelf(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
return abs
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// isWithin reports whether path equals dir or is located below it.
|
||||
func isWithin(path, dir string) bool {
|
||||
if path == dir {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(path, dir+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// watchLoop runs fn once immediately and again whenever the watched paths
|
||||
// change, until ctx is cancelled. Changes are debounced: fn runs only after
|
||||
// the snapshot has stayed unchanged for one poll interval. Errors from fn are
|
||||
// reported to w and do not stop the loop.
|
||||
func watchLoop(ctx context.Context, w io.Writer, paths []string, exclude string, interval time.Duration, fn func() error) {
|
||||
run := func() {
|
||||
if err := fn(); err != nil {
|
||||
fmt.Fprintf(w, "Error: %v\n", err)
|
||||
}
|
||||
fmt.Fprintf(w, "Watching for changes (Ctrl-C to stop)...\n")
|
||||
}
|
||||
|
||||
last := takeWatchSnapshot(paths, exclude)
|
||||
run()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
cur := takeWatchSnapshot(paths, exclude)
|
||||
if cur.equal(last) {
|
||||
continue
|
||||
}
|
||||
// Debounce: wait until writes settle.
|
||||
for settled := false; !settled; {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(interval):
|
||||
}
|
||||
next := takeWatchSnapshot(paths, exclude)
|
||||
settled = next.equal(cur)
|
||||
cur = next
|
||||
}
|
||||
fmt.Fprintf(w, "\nChange detected, regenerating...\n")
|
||||
last = cur
|
||||
run()
|
||||
}
|
||||
}
|
||||
|
||||
// runConvertWatch runs the conversion once and then again whenever the source
|
||||
// files change, until interrupted.
|
||||
func runConvertWatch(parent context.Context, w io.Writer, run func() error) error {
|
||||
var paths []string
|
||||
switch {
|
||||
case len(convertFromList) > 0:
|
||||
paths = convertFromList
|
||||
case convertSourcePath != "":
|
||||
paths = []string{convertSourcePath}
|
||||
default:
|
||||
return fmt.Errorf("--watch requires --from-path or --from-list (live database connections cannot be watched)")
|
||||
}
|
||||
if convertDryRun {
|
||||
return fmt.Errorf("--watch cannot be combined with --dry-run")
|
||||
}
|
||||
if convertWatchInterval <= 0 {
|
||||
return fmt.Errorf("--watch-interval must be positive")
|
||||
}
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
watchLoop(ctx, w, paths, convertTargetPath, convertWatchInterval, run)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchSnapshotExcludesOutput(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out")
|
||||
if err := os.MkdirAll(out, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Join(dir, "schema.dbml")
|
||||
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := takeWatchSnapshot([]string{dir}, out)
|
||||
if err := os.WriteFile(filepath.Join(out, "gen.go"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after := takeWatchSnapshot([]string{dir}, out); !before.equal(after) {
|
||||
t.Errorf("writing into the excluded output path changed the snapshot")
|
||||
}
|
||||
if err := os.WriteFile(src, []byte("changed"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after := takeWatchSnapshot([]string{dir}, out); before.equal(after) {
|
||||
t.Errorf("modifying a source file did not change the snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchLoopRerunsOnChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "schema.dbml")
|
||||
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var runs atomic.Int32
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
watchLoop(ctx, io.Discard, []string{src}, "", 10*time.Millisecond, func() error {
|
||||
runs.Add(1)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool { return runs.Load() == 1 })
|
||||
if err := os.WriteFile(src, []byte("changed content"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitFor(t, func() bool { return runs.Load() == 2 })
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestRunConvertWatchValidation(t *testing.T) {
|
||||
oldPath, oldList, oldDry, oldInt := convertSourcePath, convertFromList, convertDryRun, convertWatchInterval
|
||||
defer func() {
|
||||
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = oldPath, oldList, oldDry, oldInt
|
||||
}()
|
||||
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = "", nil, false, time.Second
|
||||
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
|
||||
t.Error("expected error without --from-path/--from-list")
|
||||
}
|
||||
convertSourcePath, convertDryRun = "x.dbml", true
|
||||
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
|
||||
t.Error("expected error combining --watch with --dry-run")
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met in time")
|
||||
}
|
||||
Reference in New Issue
Block a user