feat(cli): watch mode for convert (#39) #48
@@ -42,8 +42,17 @@ relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path
|
|||||||
|
|
||||||
# Multiple input files merged
|
# Multiple input files merged
|
||||||
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
|
relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml
|
||||||
|
|
||||||
|
# Watch mode: regenerate whenever the source file(s) change (Ctrl-C to stop)
|
||||||
|
relspec convert --from dbml --from-path schema.dbml --to gorm --to-path models/ --package models --watch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--watch` works with `--from-path` and `--from-list` (not live database
|
||||||
|
connections or `--dry-run`). Source files are polled every `--watch-interval`
|
||||||
|
(default 500ms), a directory source is watched recursively, and the output path
|
||||||
|
is ignored so generating into the source tree does not loop. Conversion errors
|
||||||
|
are printed and watching continues.
|
||||||
|
|
||||||
PostgreSQL connections opened by relspec set `application_name` by default to
|
PostgreSQL connections opened by relspec set `application_name` by default to
|
||||||
`relspecgo/<version>` (with component suffixes internally, e.g. readers/writers).
|
`relspecgo/<version>` (with component suffixes internally, e.g. readers/writers).
|
||||||
If you need a custom value, provide `application_name` explicitly in the connection
|
If you need a custom value, provide `application_name` explicitly in the connection
|
||||||
|
|||||||
+17
-1
@@ -59,6 +59,8 @@ var (
|
|||||||
convertContinueOnError bool
|
convertContinueOnError bool
|
||||||
convertExtraFields string
|
convertExtraFields string
|
||||||
convertDryRun bool
|
convertDryRun bool
|
||||||
|
convertWatch bool
|
||||||
|
convertWatchInterval time.Duration
|
||||||
)
|
)
|
||||||
|
|
||||||
var convertCmd = &cobra.Command{
|
var convertCmd = &cobra.Command{
|
||||||
@@ -167,7 +169,11 @@ Examples:
|
|||||||
|
|
||||||
# Convert SQLite to PostgreSQL SQL
|
# Convert SQLite to PostgreSQL SQL
|
||||||
relspec convert --from sqlite --from-path database.db \
|
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,
|
RunE: runConvert,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +195,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(&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")
|
err := convertCmd.MarkFlagRequired("from")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
|
||||||
@@ -204,6 +213,13 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runConvert(cmd *cobra.Command, args []string) error {
|
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, "\n=== RelSpec Schema Converter ===\n")
|
||||||
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
|
||||||
|
|
||||||
|
|||||||
@@ -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