Skip to content

Commit 5a06a2c

Browse files
authored
Merge pull request #42 from boringSQL/feat/snapshot-diff
feat: snapshot diff improvements
2 parents 6f1501b + eda726a commit 5a06a2c

60 files changed

Lines changed: 5792 additions & 859 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Once you have the snapshot, the CLI works offline:
2929
- **Lint** - 20+ convention rules (naming, types, primary keys, timestamps, partitioning) and 13 structural audit rules (duplicate indexes, FK coverage, circular FKs, vacuum tuning)
3030
- **Migration safety** - lock type analysis, duration estimates, table rewrite detection, safe alternatives for each DDL statement
3131
- **Query validation** - SQL parsing via libpg_query, column reference checks against the actual schema, anti-pattern detection
32-
- **Schema diff** - compare snapshots over time, detect drift between live database and saved state
32+
- **Snapshot diff** - compare schema, planner stats, or activity between snapshots; detect drift against the live database
3333
- **[Multi-node stats](docs/multi-node-stats.md)** - per-replica statistics, seq_scan hotspots, routing imbalances
3434

3535
### MCP server - give your AI assistant a schema brain

cmd/dryrun/main.go

Lines changed: 12 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import (
1515

1616
"github.com/boringsql/dryrun/internal/buildinfo"
1717
"github.com/boringsql/dryrun/internal/config"
18-
"github.com/boringsql/dryrun/internal/diff"
1918
"github.com/boringsql/dryrun/internal/history"
20-
"github.com/boringsql/dryrun/internal/lint"
2119
drmcp "github.com/boringsql/dryrun/internal/mcp"
2220
"github.com/boringsql/dryrun/internal/schema"
21+
"github.com/boringsql/dryrun/pkg/diff"
22+
"github.com/boringsql/dryrun/pkg/lint"
2323
)
2424

2525
var (
@@ -304,15 +304,8 @@ func driftCmd() *cobra.Command {
304304
fmt.Printf(" %d added, %d removed, %d modified\n\n",
305305
report.AddedCount, report.RemovedCount, report.ModifiedCount)
306306

307-
for _, c := range report.Changeset.Changes {
308-
name := c.Name
309-
if c.Schema != nil {
310-
name = *c.Schema + "." + name
311-
}
312-
fmt.Printf(" [%s] %s %s\n", c.Kind, c.ObjectType, name)
313-
for _, d := range c.Details {
314-
fmt.Printf(" %s\n", d)
315-
}
307+
for _, c := range report.Delta.Changes {
308+
fmt.Printf(" %s %s\n", diff.Marker(c), diff.Describe(c))
316309
}
317310
return nil
318311
},
@@ -452,62 +445,8 @@ func snapshotCmd() *cobra.Command {
452445
}
453446
addHistFlag(listCmd)
454447

455-
var fromHash, toHash string
456-
var latest, prettyDiff bool
457-
458-
diffCmd := &cobra.Command{
459-
Use: "diff",
460-
Short: "Diff two snapshots",
461-
RunE: func(cmd *cobra.Command, args []string) error {
462-
store, err := openHistoryStore(historyDB)
463-
if err != nil {
464-
return err
465-
}
466-
defer store.Close()
467-
468-
key := resolveSnapshotKey()
469-
loadByHash := func(h string) (*schema.SchemaSnapshot, error) {
470-
return store.GetSchema(cmd.Context(), key, history.NewRefHash(h))
471-
}
472-
473-
var fromSnap *schema.SchemaSnapshot
474-
switch {
475-
case fromHash != "":
476-
fromSnap, err = loadByHash(fromHash)
477-
case latest:
478-
fromSnap, err = store.GetSchema(cmd.Context(), key, history.NewRefLatest())
479-
default:
480-
return fmt.Errorf("specify --from <hash> or --latest")
481-
}
482-
if err != nil {
483-
return err
484-
}
485-
486-
var toSnap *schema.SchemaSnapshot
487-
if toHash != "" {
488-
toSnap, err = loadByHash(toHash)
489-
} else {
490-
ctx, conn, cerr := connectDB()
491-
if cerr != nil {
492-
return cerr
493-
}
494-
defer conn.Close()
495-
toSnap, err = conn.Introspect(ctx)
496-
}
497-
if err != nil {
498-
return err
499-
}
500-
501-
changeset := diff.DiffSchemas(fromSnap, toSnap)
502-
fmt.Println(string(marshalJSON(changeset, prettyDiff)))
503-
return nil
504-
},
505-
}
506-
diffCmd.Flags().StringVar(&fromHash, "from", "", "source snapshot hash")
507-
diffCmd.Flags().StringVar(&toHash, "to", "", "target snapshot hash")
508-
diffCmd.Flags().BoolVar(&latest, "latest", false, "use latest saved snapshot as source")
448+
diffCmd := newDiffCmd(&historyDB)
509449
addHistFlag(diffCmd)
510-
diffCmd.Flags().BoolVar(&prettyDiff, "pretty", false, "pretty-print JSON")
511450

512451
cmd.AddCommand(takeCmd, listCmd, diffCmd, snapshotActivityCmd(),
513452
snapshotPushCmd(), snapshotPullCmd())
@@ -673,6 +612,13 @@ func connectDBFor(override string) (context.Context, *schema.DryRun, error) {
673612
return ctx, conn, nil
674613
}
675614

615+
func short(h string) string {
616+
if len(h) > 12 {
617+
return h[:12]
618+
}
619+
return h
620+
}
621+
676622
func marshalJSON(v any, pretty bool) []byte {
677623
if pretty {
678624
b, _ := json.MarshalIndent(v, "", " ")

cmd/dryrun/snapshot_diff.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/boringsql/dryrun/internal/history"
10+
"github.com/boringsql/dryrun/pkg/diff"
11+
)
12+
13+
// default is store-to-store, no DB connection. --live is the one exception
14+
// (schema only) and never captures behind the user's back.
15+
func newDiffCmd(historyDB *string) *cobra.Command {
16+
var (
17+
fromHash, toHash string
18+
kindFlag, nodeFlag string
19+
latest, live bool
20+
prettyDiff, jsonDiff bool
21+
minPct float64
22+
)
23+
24+
c := &cobra.Command{
25+
Use: "diff [<from> <to>]",
26+
Short: "Diff two snapshots of the same kind",
27+
Long: `Diff two snapshots of the same kind, resolved from history.db (no connection).
28+
29+
dryrun snapshot diff <from> <to> diff two snapshots by hash prefix
30+
dryrun snapshot diff --latest diff the previous capture against the latest
31+
dryrun snapshot diff latest~1 latest --kind planner
32+
same, for a specific kind
33+
dryrun snapshot diff <from> --live diff a stored snapshot against the database now (schema only)
34+
35+
latest / latest~N name a snapshot of the kind given by --kind (default schema);
36+
hash-prefix operands carry their own kind. Mixing kinds is rejected.`,
37+
Args: cobra.MaximumNArgs(2),
38+
RunE: func(cmd *cobra.Command, args []string) error {
39+
store, err := openHistoryStore(*historyDB)
40+
if err != nil {
41+
return err
42+
}
43+
defer store.Close()
44+
45+
ctx := cmd.Context()
46+
key := resolveSnapshotKey()
47+
48+
fromTok, toTok, liveTo, err := diffOperands(args, fromHash, toHash, latest, live)
49+
if err != nil {
50+
return err
51+
}
52+
53+
fromKind, fromRef, err := store.ResolveToken(ctx, key, fromTok, kindFlag, nodeFlag)
54+
if err != nil {
55+
return err
56+
}
57+
fromSnap, err := store.Get(ctx, key, fromKind, fromRef)
58+
if err != nil {
59+
return err
60+
}
61+
62+
if liveTo {
63+
if fromKind.Tag != history.KindSchema {
64+
return fmt.Errorf("--live is schema-only (it reads the catalog); %s is a %s snapshot\n"+
65+
" take a snapshot first and diff store-to-store",
66+
short(fromTok), fromKind)
67+
}
68+
lctx, conn, cerr := connectDB()
69+
if cerr != nil {
70+
return cerr
71+
}
72+
defer conn.Close()
73+
live, lerr := conn.Introspect(lctx)
74+
if lerr != nil {
75+
return lerr
76+
}
77+
env, berr := buildSnapshotDiff(fromKind, fromSnap, history.WrapSchema(live))
78+
if berr != nil {
79+
return berr
80+
}
81+
return emitDiff(env, jsonDiff, prettyDiff, minPct)
82+
}
83+
84+
toKind, toRef, err := store.ResolveToken(ctx, key, toTok, kindFlag, nodeFlag)
85+
if err != nil {
86+
return err
87+
}
88+
if fromKind.Tag != toKind.Tag {
89+
return fmt.Errorf("not comparable: %s is a %s snapshot, %s is a %s snapshot\n"+
90+
" diff snapshots of the same kind (schema↔schema, planner↔planner, activity↔activity)",
91+
short(fromTok), fromKind, short(toTok), toKind)
92+
}
93+
toSnap, err := store.Get(ctx, key, toKind, toRef)
94+
if err != nil {
95+
return err
96+
}
97+
98+
env, err := buildSnapshotDiff(fromKind, fromSnap, toSnap)
99+
if err != nil {
100+
return err
101+
}
102+
return emitDiff(env, jsonDiff, prettyDiff, minPct)
103+
},
104+
}
105+
106+
c.Flags().StringVar(&fromHash, "from", "", "source snapshot (compat; prefer positional)")
107+
c.Flags().StringVar(&toHash, "to", "", "target snapshot (compat; prefer positional)")
108+
c.Flags().BoolVar(&latest, "latest", false, "diff the previous capture against the latest (latest~1..latest)")
109+
c.Flags().StringVar(&kindFlag, "kind", "schema", "kind for latest/latest~N operands: schema|planner|activity")
110+
c.Flags().StringVar(&nodeFlag, "node", "", "activity node label (when --kind activity has multiple nodes)")
111+
c.Flags().BoolVar(&live, "live", false, "diff a stored snapshot against the live database (schema only)")
112+
c.Flags().BoolVar(&jsonDiff, "json", false, "output the SnapshotDiff as JSON")
113+
c.Flags().BoolVar(&prettyDiff, "pretty", false, "pretty-print JSON")
114+
c.Flags().Float64Var(&minPct, "min-pct", diff.DefaultMinPct, "console: hide planner/activity rows whose |Δ| is below this percent")
115+
return c
116+
}
117+
118+
// store-to-store needs two operands; --live needs exactly one.
119+
func diffOperands(args []string, fromHash, toHash string, latest, live bool) (from, to string, liveTo bool, err error) {
120+
var ops []string
121+
if len(args) > 0 {
122+
ops = append(ops, args...)
123+
} else {
124+
if fromHash != "" {
125+
ops = append(ops, fromHash)
126+
}
127+
if toHash != "" {
128+
ops = append(ops, toHash)
129+
}
130+
if latest {
131+
if len(ops) == 0 {
132+
ops = []string{"latest~1", "latest"}
133+
} else {
134+
ops = append(ops, "latest")
135+
}
136+
}
137+
}
138+
139+
if live {
140+
if len(ops) != 1 {
141+
return "", "", false, fmt.Errorf("--live diffs one stored snapshot against the database now; give exactly one snapshot")
142+
}
143+
return ops[0], "", true, nil
144+
}
145+
146+
// legacy: bare `--from <hash>` (no positional, no --to) meant diff against live
147+
if len(args) == 0 && fromHash != "" && toHash == "" && !latest {
148+
return fromHash, "", true, nil
149+
}
150+
151+
if len(ops) != 2 {
152+
return "", "", false, fmt.Errorf("specify two snapshots: `diff <from> <to>`, `--latest`, or `--from/--to` (add `--live` to diff against the database)")
153+
}
154+
return ops[0], ops[1], false, nil
155+
}
156+
157+
func buildSnapshotDiff(kind history.SnapshotKind, from, to history.StoredSnapshot) (*diff.SnapshotDiff, error) {
158+
env := &diff.SnapshotDiff{
159+
FromHash: from.ContentHash(),
160+
ToHash: to.ContentHash(),
161+
FromTakenAt: from.Timestamp(),
162+
ToTakenAt: to.Timestamp(),
163+
}
164+
switch kind.Tag {
165+
case history.KindSchema:
166+
d, err := diff.DiffSchema(from.AsSchema(), to.AsSchema())
167+
if err != nil {
168+
return nil, err
169+
}
170+
env.Kind, env.Schema = "schema", d
171+
case history.KindPlanner:
172+
d, err := diff.DiffPlanner(from.AsPlanner(), to.AsPlanner())
173+
if err != nil {
174+
return nil, err
175+
}
176+
env.Kind, env.Planner = "planner", d
177+
case history.KindActivity:
178+
d, err := diff.DiffActivity(from.AsActivity(), to.AsActivity())
179+
if err != nil {
180+
return nil, err
181+
}
182+
env.Kind, env.Activity = "activity", d
183+
default:
184+
return nil, fmt.Errorf("unsupported diff kind %s", kind)
185+
}
186+
return env, nil
187+
}
188+
189+
func emitDiff(env *diff.SnapshotDiff, jsonDiff, prettyDiff bool, minPct float64) error {
190+
if jsonDiff {
191+
fmt.Println(string(marshalJSON(env, prettyDiff)))
192+
return nil
193+
}
194+
diff.RenderConsoleMinPct(os.Stdout, env, minPct)
195+
return nil
196+
}

0 commit comments

Comments
 (0)