Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ['1.24', '1.25.10']
go-version: ['1.24', '1.25.13']

steps:
- name: Checkout code
Expand Down Expand Up @@ -44,15 +44,15 @@ jobs:
continue-on-error: false

- name: Merge coverage files
if: matrix.go-version == '1.25.10'
if: matrix.go-version == '1.25.13'
run: |
# Merge coverage files using go tool cover
echo "mode: atomic" > coverage.txt
grep -h -v "^mode:" coverage-unit.txt coverage-integration-basic.txt >> coverage.txt || true
continue-on-error: false

- name: Verify coverage file exists
if: matrix.go-version == '1.25.10'
if: matrix.go-version == '1.25.13'
run: |
if [ ! -f coverage.txt ]; then
echo "Error: coverage.txt not found"
Expand All @@ -62,7 +62,7 @@ jobs:
head -5 coverage.txt

- name: Upload coverage artifact for merging
if: matrix.go-version == '1.25.10'
if: matrix.go-version == '1.25.13'
uses: actions/upload-artifact@v4
with:
name: coverage-unit-${{ github.run_id }}
Expand All @@ -81,7 +81,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.10'
go-version: '1.25.13'
cache-dependency-path: go.sum

- name: Verify dependencies
Expand Down Expand Up @@ -133,7 +133,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.10'
go-version: '1.25.13'
cache-dependency-path: go.sum

- name: Download unit test coverage artifact
Expand Down Expand Up @@ -176,7 +176,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.10'
go-version: '1.25.13'
cache-dependency-path: go.sum

- name: Run linter
Expand All @@ -198,7 +198,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.10'
go-version: '1.25.13'
cache-dependency-path: go.sum

- name: Download dependencies
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.9'
go-version-file: go.mod
cache-dependency-path: go.sum

- name: Run gosec
Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Cross-engine case-insensitive table discovery & row hashing**:
- Tables are discovered and paired across database engines that enforce different default identifier cases (e.g., PostgreSQL lowercase `"customers"` $\leftrightarrow$ Oracle UPPERCASE `"CUSTOMERS"`).
- Normalizes identifiers via canonical name folding (`schema.CanonicalIdent`) while generating dialect-specific SQL quoting for each engine.
- **Column-level row inspection (`row-diff` command and `--row-diff` flag)**:
- New CLI command `deepdiffdb row-diff` and `--row-diff` flag for `deepdiffdb diff`.
- Side-by-side terminal comparison table showing column values between production and development with differing columns flagged with `*`.
- Supports `--table`, `--key`, `--status` (`updated`, `added`, `removed`, `all`), `--limit`, and `--format` (`table` or `json`).
- Writes machine-readable `row_diff.json` and formatted text `row_diff.txt` to the output directory.
- **HTML report viewer enhancements**:
- **Data Changes Tab**: Opens automatically when data drift exists; expanding any table displays the complete column comparison table for every modified, added, or removed row.
- **Dedicated Row Data Tab**: Card view of every differing row across all tables with real-time table filtering and diff counters.
- **Conflicts Tab**: Displays conflicting keys with source and target SHA-256 hashes alongside side-by-side column difference tables.
- **Documentation & Sample Project**:
- New feature guide: `website/docs/features/cross-engine-diff.md` explaining cross-engine identifier folding and pipeline verification.
- New command guide: `website/docs/commands/row-diff.md`.
- Live interactive sample HTML report updated at `/samples/report.html`.
- New **Sample 18** (`samples/18-cross-engine-row-diff/`): PostgreSQL 16 $\leftrightarrow$ Oracle XE 21c migration verification with seeded `um_user` and `um_user_attribute` drift.

### Fixed

- **False negative "No differences found" on cross-engine migrations**:
- Fixed an issue where `deepdiffdb diff` reported zero differences between PostgreSQL and Oracle because exact string matching failed on uppercase vs. lowercase table names (e.g. `um_user_attribute` vs. `UM_USER_ATTRIBUTE`).

### Security

- **Bumped Go to 1.25.13**: Resolves Go standard library vulnerabilities reported by `govulncheck`:
- **GO-2026-6218**: Quadratic complexity in `net/url`
- **GO-2026-6091**: Context tracking in `html/template`
- **GO-2026-6090** & **GO-2026-5856**: Handshake and Encrypted Client Hello handling in `crypto/tls`
- **GO-2026-6088**: Recursion depth guard in `encoding/xml`
- **GO-2026-5972**: Maximum recursion depth in `encoding/asn1`
- **GO-2026-5039**: Input formatting in `net/textproto`
- **GO-2026-5037**: Inefficient hostname parsing in `crypto/x509`
- **GO-2026-5026**: Punycode label validation in `net/http`
- **Bumped `golang.org/x/text` to v0.39.0**:
- **GO-2026-5970**: Infinite loop on invalid input in `golang.org/x/text`
- **Updated `security.yml` workflow**:
- Switched from hardcoded older Go version (`1.25.9`) to `go-version-file: go.mod` to ensure CI security scans always run with the active patched toolchain.

## [1.4.5] - 2026-05-19

### Security
Expand Down
197 changes: 192 additions & 5 deletions cmd/deepdiffdb/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ func run(args []string) error {
return runSchemaMigrate(args[1:])
case "diff":
return runFullDiff(args[1:])
case "row-diff":
return runRowDiff(args[1:])
case "gen-pack":
return runGenPack(args[1:])
case "apply":
Expand Down Expand Up @@ -325,6 +327,7 @@ func runFullDiff(args []string) error {
fs := flag.NewFlagSet("diff", flag.ContinueOnError)
configPath := fs.String("config", "deepdiffdb.config.yaml", "Path to configuration file")
generateHTML := fs.Bool("html", false, "Generate interactive HTML report")
showRowDiff := fs.Bool("row-diff", false, "Display and generate column-level diffs for changed rows")
batchSizeFlag := fs.Int("batch-size", 0, "Rows per keyset-paginated query (0 = use config default)")
parallelFlag := fs.Int("parallel", 0, "Max tables hashed concurrently (0 = use config default)")
verbose := fs.Bool("verbose", false, "Enable verbose logging")
Expand Down Expand Up @@ -463,18 +466,49 @@ func runFullDiff(args []string) error {

log.Info("full diff complete", "tables_scanned", tablesScanned, "has_changes", dataDiff.HasChanges())
fmt.Println("Schema OK. Data diff complete.")
var rowReport *resolve.RowDiffReport
if dataDiff.HasChanges() {
fmt.Printf("Changes detected. See %s, %s, and %s\n", filepath.Join(cfg.Output.Dir, "content_diff.json"), filepath.Join(cfg.Output.Dir, "conflicts.json"), filepath.Join(cfg.Output.Dir, "summary.txt"))
if conflicts.HasConflicts() {
fmt.Printf("Warning: %d conflicts detected. Review %s\n", len(conflicts.Conflicts), filepath.Join(cfg.Output.Dir, "conflicts.json"))
}

if *showRowDiff || *generateHTML {
log.Info("fetching column-level row differences")
fetchedReport, err := resolve.FetchRowDiffReport(
ctx, prodDB, devDB, cfg.Prod.Driver, cfg.Dev.Driver,
prodSchema, devSchema, dataDiff, conflicts, resolve.RowDiffOptions{
StatusFilter: "all",
},
)
if err != nil {
log.Warn("failed to fetch row diffs", "error", err)
} else if fetchedReport.HasChanges() {
rowReport = &fetchedReport
if *showRowDiff {
if err := resolve.WriteRowDiffReport(*rowReport, cfg.Output.Dir); err != nil {
log.Warn("failed to write row diff report", "error", err)
}
display := cli.NewDisplay()
for _, trd := range rowReport.Tables {
for _, rd := range trd.Rows {
display.PrintRowDiff(rd.Table, rd.Key, string(rd.Status), rd.Columns)
}
}
fmt.Printf("Row-level column diffs written to %s and %s\n", filepath.Join(cfg.Output.Dir, "row_diff.json"), filepath.Join(cfg.Output.Dir, "row_diff.txt"))
}
}
}
} else {
fmt.Println("No data differences found.")
}

// Generate HTML report if requested
if *generateHTML {
htmlPath := filepath.Join(cfg.Output.Dir, "report.html")
reportOpts := htmlreport.DefaultReportOptions()
reportOpts.RowDiffReport = rowReport

reportData := htmlreport.BuildReportData(
fmt.Sprintf("%s:%d/%s", cfg.Prod.Host, cfg.Prod.Port, cfg.Prod.Database),
fmt.Sprintf("%s:%d/%s", cfg.Dev.Host, cfg.Dev.Port, cfg.Dev.Database),
Expand All @@ -486,11 +520,11 @@ func runFullDiff(args []string) error {
"", // No migration SQL for diff command
"", // No migration pack for diff command
tablesScanned,
nil,
reportOpts,
)
reportData.Version = version

generator := htmlreport.NewGenerator(nil)
generator := htmlreport.NewGenerator(reportOpts)
if err := generator.GenerateReport(reportData, htmlPath); err != nil {
return fmt.Errorf("generate HTML report: %w", err)
}
Expand All @@ -502,6 +536,158 @@ func runFullDiff(args []string) error {
return schemaDriftErr
}

// runRowDiff executes the "row-diff" command to inspect column-level differences for changed rows.
func runRowDiff(args []string) error {
fs := flag.NewFlagSet("row-diff", flag.ContinueOnError)
configPath := fs.String("config", "deepdiffdb.config.yaml", "Path to configuration file")
tableFlag := fs.String("table", "", "Filter by specific table name")
keyFlag := fs.String("key", "", "Filter by primary key value")
statusFlag := fs.String("status", "updated", "Row status to inspect: updated, added, removed, or all")
limitFlag := fs.Int("limit", 0, "Max rows to inspect per table (0 = unlimited)")
formatFlag := fs.String("format", "table", "Output format: table or json")
outputFlag := fs.String("output", "", "Output directory to save reports (default: config output dir)")
batchSizeFlag := fs.Int("batch-size", 0, "Rows per keyset-paginated query (0 = use config default)")
parallelFlag := fs.Int("parallel", 0, "Max tables hashed concurrently (0 = use config default)")
verbose := fs.Bool("verbose", false, "Enable verbose logging")
logFile := fs.String("log-file", "", "Write logs to file")
logLevel := fs.String("log-level", "info", "Log level: debug, info, warn, error")
logFormat := fs.String("log-format", "text", "Log format: text or json")
if err := fs.Parse(args); err != nil {
return err
}

log, logCloser, err := initializeLogger(*verbose, *logFile, *logLevel, *logFormat)
if err != nil {
return err
}
if logCloser != nil {
defer logCloser.Close()
}

log.Info("starting row-level data diff")

cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}

outDir := cfg.Output.Dir
if *outputFlag != "" {
outDir = *outputFlag
}

ctx := logger.ToContext(context.Background(), log)

prodDB, err := openDatabaseWithSpinner(ctx, cfg.Prod, "production")
if err != nil {
return fmt.Errorf("prod connection failed: %w", err)
}
defer prodDB.Close()

devDB, err := openDatabaseWithSpinner(ctx, cfg.Dev, "development")
if err != nil {
return fmt.Errorf("dev connection failed: %w", err)
}
defer devDB.Close()

log.Info("loading database schemas")
prodSchema, err := schema.LoadSchema(ctx, prodDB, cfg.Prod.Driver, cfg.Prod.Database, cfg.Ignore.Tables, schema.LoadSchemaOptions{IgnoreViews: cfg.Ignore.Views, IgnoreRoutines: cfg.Ignore.Routines, IgnoreTriggers: cfg.Ignore.Triggers, IgnoreSequences: cfg.Ignore.Sequences})
if err != nil {
return fmt.Errorf("load prod schema: %w", err)
}
devSchema, err := schema.LoadSchema(ctx, devDB, cfg.Dev.Driver, cfg.Dev.Database, cfg.Ignore.Tables, schema.LoadSchemaOptions{IgnoreViews: cfg.Ignore.Views, IgnoreRoutines: cfg.Ignore.Routines, IgnoreTriggers: cfg.Ignore.Triggers, IgnoreSequences: cfg.Ignore.Sequences})
if err != nil {
return fmt.Errorf("load dev schema: %w", err)
}

var dataDiff content.DataDiff
var conflicts content.Conflicts

// If table and key are specified, we can query the row directly without full table hashing
if *tableFlag != "" && *keyFlag != "" {
log.Info("direct row inspection for key", "table", *tableFlag, "key", *keyFlag)
} else {
ignoreColumn := content.IgnoreMatcher(cfg.Ignore.Columns)
batchSize := resolveBatchSize(*batchSizeFlag, cfg.Performance.HashBatchSize)
maxParallel := resolveParallel(*parallelFlag, cfg.Performance.MaxParallelTables)

devByCanonical := make(map[string]schema.Table, len(devSchema.Tables))
for name, devTable := range devSchema.Tables {
devByCanonical[schema.CanonicalIdent(name)] = devTable
}
sharedProdTables := make(map[string]schema.Table)
sharedDevTables := make(map[string]schema.Table)
for name, prodTable := range prodSchema.Tables {
if *tableFlag != "" && schema.CanonicalIdent(name) != schema.CanonicalIdent(*tableFlag) {
continue
}
if devTable, ok := devByCanonical[schema.CanonicalIdent(name)]; ok {
sharedProdTables[name] = prodTable
sharedDevTables[devTable.Name] = devTable
}
}

if len(sharedProdTables) == 0 {
if *tableFlag != "" {
return fmt.Errorf("table %q not found in both databases", *tableFlag)
}
fmt.Println("No matching tables found to compare.")
return nil
}

log.Info("hashing tables to detect row differences", "table_count", len(sharedProdTables))
prodHashes, err := hashTablesParallel(ctx, sharedProdTables, prodDB, cfg.Prod.Driver, ignoreColumn, batchSize, maxParallel)
if err != nil {
return fmt.Errorf("hash prod tables: %w", err)
}
devHashes, err := hashTablesParallel(ctx, sharedDevTables, devDB, cfg.Dev.Driver, ignoreColumn, batchSize, maxParallel)
if err != nil {
return fmt.Errorf("hash dev tables: %w", err)
}
dataDiff, conflicts = content.BuildDataDiff(prodSchema, devSchema, prodHashes, devHashes)
}

opts := resolve.RowDiffOptions{
TableFilter: *tableFlag,
KeyFilter: *keyFlag,
StatusFilter: *statusFlag,
Limit: *limitFlag,
}

report, err := resolve.FetchRowDiffReport(ctx, prodDB, devDB, cfg.Prod.Driver, cfg.Dev.Driver, prodSchema, devSchema, dataDiff, conflicts, opts)
if err != nil {
return fmt.Errorf("fetch row diff report: %w", err)
}

if err := resolve.WriteRowDiffReport(report, outDir); err != nil {
log.Warn("failed to write row diff report", "error", err)
}

if *formatFlag == "json" {
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return fmt.Errorf("marshal json: %w", err)
}
fmt.Println(string(data))
return nil
}

if !report.HasChanges() {
fmt.Println("No row-level differences found matching criteria.")
return nil
}

display := cli.NewDisplay()
for _, trd := range report.Tables {
for _, rd := range trd.Rows {
display.PrintRowDiff(rd.Table, rd.Key, string(rd.Status), rd.Columns)
}
}
fmt.Printf("Total differing rows inspected: %d\n", report.TotalRows())
fmt.Printf("Row diff report written to %s and %s\n", filepath.Join(outDir, "row_diff.json"), filepath.Join(outDir, "row_diff.txt"))
return nil
}

// runGenPack performs the "gen-pack" command: it loads configuration, compares prod and dev schemas, and generates a migration pack for any detected data differences while writing schema and data reports to the configured output directory.
// It opens prod and dev database connections, ensures the output directory exists, fails if schema drift is detected (after writing schema reports), computes per-table content hashes with configured ignore rules, builds the data diff and any conflicts, generates a migration pack when changes exist, writes data reports including the pack path and conflicts, and prints a brief status summary.
// It returns a non-nil error for failures during configuration loading, database connections, schema loading or reporting, hashing, pack generation, or report writing; it also returns an error when schema drift is detected so the pack is not produced.
Expand Down Expand Up @@ -1090,8 +1276,8 @@ func runResolveConflicts(args []string) error {
}

// Fetch row data for comparison
prod, dev, err := resolve.FetchConflictRows(
ctx, prodDB, devDB, cfg.Prod.Driver,
prod, dev, err := resolve.FetchConflictRowsCrossEngine(
ctx, prodDB, devDB, cfg.Prod.Driver, cfg.Dev.Driver,
prodSchema, devSchema, res.Conflict,
)
if err != nil {
Expand Down Expand Up @@ -1484,7 +1670,8 @@ Commands:
check Validate configuration and show quick summary
schema-diff Detect schema drift
schema-migrate Generate schema migration script
diff Full diff: schema + data (supports --html for interactive report)
diff Full diff: schema + data (supports --html, --row-diff)
row-diff Inspect changed row data in columns (supports --table, --key, --status, --format)
gen-pack Generate SQL migration pack (supports --html, --resume)
apply Apply migration pack (supports --resume)
resolve-conflicts Interactively resolve pending conflicts (supports --resume)
Expand Down
Loading
Loading