diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9c64e41..ad05127 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
@@ -44,7 +44,7 @@ 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
@@ -52,7 +52,7 @@ jobs:
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"
@@ -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 }}
@@ -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
@@ -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
@@ -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
@@ -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
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 69d4a98..d860717 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bccc9a4..1d89d8c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/cmd/deepdiffdb/main.go b/cmd/deepdiffdb/main.go
index a23b864..f243dfa 100644
--- a/cmd/deepdiffdb/main.go
+++ b/cmd/deepdiffdb/main.go
@@ -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":
@@ -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")
@@ -463,11 +466,39 @@ 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.")
}
@@ -475,6 +506,9 @@ func runFullDiff(args []string) error {
// 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),
@@ -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)
}
@@ -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.
@@ -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 {
@@ -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)
diff --git a/go.mod b/go.mod
index 664c904..ec9913c 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/iamvirul/deepdiff-db
-go 1.25.10
+go 1.25.13
require (
github.com/go-sql-driver/mysql v1.9.3
@@ -12,7 +12,7 @@ require (
github.com/testcontainers/testcontainers-go/modules/mssql v0.41.0
github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
- golang.org/x/sync v0.20.0
+ golang.org/x/sync v0.21.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.40.1
)
@@ -85,7 +85,7 @@ require (
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
- golang.org/x/text v0.37.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.66.10 // indirect
diff --git a/go.sum b/go.sum
index 1c4b2af..c748cf8 100644
--- a/go.sum
+++ b/go.sum
@@ -204,12 +204,12 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
-golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
-golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -219,12 +219,12 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
-golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
diff --git a/internal/cli/display.go b/internal/cli/display.go
index 4978649..6c179c3 100644
--- a/internal/cli/display.go
+++ b/internal/cli/display.go
@@ -107,6 +107,51 @@ func (d *Display) PrintConflictComparison(prod, dev *resolve.RowData, diffs []re
fmt.Fprintln(d.writer)
}
+// PrintRowDiff displays a side-by-side comparison of a row's column differences.
+func (d *Display) PrintRowDiff(table, key, status string, diffs []resolve.ColumnDiff) {
+ d.PrintDoubleLine()
+ fmt.Fprintf(d.writer, "Table: %s | Key: %s (%s)\n", table, key, status)
+ d.PrintDoubleLine()
+
+ // Calculate column widths
+ colWidth := 20
+ valWidth := 24
+
+ // Print header
+ fmt.Fprintf(d.writer, " %-*s | %-*s | %-*s\n",
+ colWidth, "Column",
+ valWidth, "Production",
+ valWidth, "Development")
+ fmt.Fprintf(d.writer, " %s+%s+%s\n",
+ strings.Repeat("-", colWidth+1),
+ strings.Repeat("-", valWidth+2),
+ strings.Repeat("-", valWidth+2))
+
+ // Print each column
+ for _, diff := range diffs {
+ prodStr := formatValueForDisplay(diff.ProdVal, valWidth)
+ devStr := formatValueForDisplay(diff.DevVal, valWidth)
+
+ marker := ""
+ if diff.Differs {
+ marker = " *"
+ }
+
+ fmt.Fprintf(d.writer, " %-*s | %-*s | %-*s%s\n",
+ colWidth, truncate(diff.Column, colWidth),
+ valWidth, prodStr,
+ valWidth, devStr,
+ marker)
+ }
+
+ fmt.Fprintln(d.writer)
+ if hasDifferences(diffs) {
+ fmt.Fprintln(d.writer, " * indicates columns that differ")
+ }
+ d.PrintDoubleLine()
+ fmt.Fprintln(d.writer)
+}
+
// PrintSummary displays the resolution summary.
func (d *Display) PrintSummary(summary resolve.ResolutionSummary, outputPath string) {
d.PrintDoubleLine()
diff --git a/internal/content/resolve/fetch.go b/internal/content/resolve/fetch.go
index 3885609..403638e 100644
--- a/internal/content/resolve/fetch.go
+++ b/internal/content/resolve/fetch.go
@@ -29,8 +29,26 @@ type ColumnDiff struct {
Differs bool
}
-// FetchConflictRows fetches both production and development row data for a conflict.
-// Returns nil for a row if it doesn't exist in that database.
+// FindTableByCanonicalName searches a schema for a table matching tableName,
+// trying exact match first, then canonical (case-folded) match.
+func FindTableByCanonicalName(sch *schema.Schema, tableName string) (schema.Table, bool) {
+ if sch == nil || len(sch.Tables) == 0 {
+ return schema.Table{}, false
+ }
+ if tbl, ok := sch.Tables[tableName]; ok {
+ return tbl, true
+ }
+ canon := schema.CanonicalIdent(tableName)
+ for name, tbl := range sch.Tables {
+ if schema.CanonicalIdent(name) == canon {
+ return tbl, true
+ }
+ }
+ return schema.Table{}, false
+}
+
+// FetchConflictRows fetches both production and development row data for a conflict
+// using a single driver. Delegates to FetchConflictRowsCrossEngine.
func FetchConflictRows(
ctx context.Context,
prodDB, devDB *sql.DB,
@@ -38,36 +56,40 @@ func FetchConflictRows(
prodSchema, devSchema *schema.Schema,
conflict content.Conflict,
) (prod, dev *RowData, err error) {
- // Get table schema from both databases
- prodTable, prodExists := prodSchema.Tables[conflict.Table]
- devTable, devExists := devSchema.Tables[conflict.Table]
+ return FetchConflictRowsCrossEngine(ctx, prodDB, devDB, driver, driver, prodSchema, devSchema, conflict)
+}
+
+// FetchConflictRowsCrossEngine fetches both production and development row data for a conflict,
+// supporting independent drivers (e.g. postgres vs oracle) and case-insensitive table matching.
+// Returns nil for a row if it doesn't exist in that database.
+func FetchConflictRowsCrossEngine(
+ ctx context.Context,
+ prodDB, devDB *sql.DB,
+ prodDriver, devDriver string,
+ prodSchema, devSchema *schema.Schema,
+ conflict content.Conflict,
+) (prod, dev *RowData, err error) {
+ // Match table schema from both databases case-insensitively
+ prodTable, prodExists := FindTableByCanonicalName(prodSchema, conflict.Table)
+ devTable, devExists := FindTableByCanonicalName(devSchema, conflict.Table)
if !prodExists && !devExists {
return nil, nil, fmt.Errorf("table %s not found in either database", conflict.Table)
}
- // Use dev table schema as reference (it should have all columns)
- var refTable schema.Table
- if devExists {
- refTable = devTable
- } else {
- refTable = prodTable
- }
-
- // Get ordered column list
- columns := getOrderedColumns(refTable)
-
- // Fetch from production
+ // Fetch from production using prod-specific table and columns
if prodExists {
- prod, err = fetchRowData(ctx, prodDB, driver, prodTable, columns, conflict.Key)
+ columns := getOrderedColumns(prodTable)
+ prod, err = FetchRowData(ctx, prodDB, prodDriver, prodTable, columns, conflict.Key)
if err != nil && !isNoRowsError(err) {
return nil, nil, fmt.Errorf("fetch prod row: %w", err)
}
}
- // Fetch from development
+ // Fetch from development using dev-specific table and columns
if devExists {
- dev, err = fetchRowData(ctx, devDB, driver, devTable, columns, conflict.Key)
+ columns := getOrderedColumns(devTable)
+ dev, err = FetchRowData(ctx, devDB, devDriver, devTable, columns, conflict.Key)
if err != nil && !isNoRowsError(err) {
return nil, nil, fmt.Errorf("fetch dev row: %w", err)
}
@@ -76,8 +98,8 @@ func FetchConflictRows(
return prod, dev, nil
}
-// fetchRowData fetches a single row from the database.
-func fetchRowData(
+// FetchRowData fetches a single row from the database using its primary key.
+func FetchRowData(
ctx context.Context,
db *sql.DB,
driver string,
@@ -139,52 +161,65 @@ func fetchRowData(
}
// CompareRows compares two rows and returns the differences.
+// It matches columns case-insensitively across engines (e.g. PostgreSQL "email"
+// matches Oracle "EMAIL") while labeling with the prod-side column name.
// Returns a list of column differences, with Differs=true for columns that don't match.
func CompareRows(prod, dev *RowData) []ColumnDiff {
if prod == nil && dev == nil {
return nil
}
- // Collect all columns from both rows
- colSet := make(map[string]bool)
- if prod != nil {
- for _, col := range prod.Columns {
- colSet[col] = true
- }
- }
+ var diffs []ColumnDiff
+ matchedDev := make(map[string]bool)
+
+ // Build dev canonical lookup map
+ devByCanonical := make(map[string]string)
if dev != nil {
for _, col := range dev.Columns {
- colSet[col] = true
+ devByCanonical[schema.CanonicalIdent(col)] = col
}
}
- // Sort columns for consistent output
- columns := make([]string, 0, len(colSet))
- for col := range colSet {
- columns = append(columns, col)
- }
- sort.Strings(columns)
-
- // Compare each column
- diffs := make([]ColumnDiff, 0, len(columns))
- for _, col := range columns {
- var prodVal, devVal any
- if prod != nil {
- prodVal = prod.Values[col]
- }
- if dev != nil {
- devVal = dev.Values[col]
+ if prod != nil {
+ for _, pCol := range prod.Columns {
+ canon := schema.CanonicalIdent(pCol)
+ var devVal any
+ differs := true
+ if dCol, ok := devByCanonical[canon]; ok {
+ matchedDev[dCol] = true
+ if dev != nil {
+ devVal = dev.Values[dCol]
+ differs = !valuesEqual(prod.Values[pCol], devVal)
+ }
+ }
+ diffs = append(diffs, ColumnDiff{
+ Column: pCol,
+ ProdVal: prod.Values[pCol],
+ DevVal: devVal,
+ Differs: differs,
+ })
}
+ }
- differs := !valuesEqual(prodVal, devVal)
- diffs = append(diffs, ColumnDiff{
- Column: col,
- ProdVal: prodVal,
- DevVal: devVal,
- Differs: differs,
- })
+ // Dev columns that do not exist in prod
+ if dev != nil {
+ for _, dCol := range dev.Columns {
+ if !matchedDev[dCol] {
+ diffs = append(diffs, ColumnDiff{
+ Column: dCol,
+ ProdVal: nil,
+ DevVal: dev.Values[dCol],
+ Differs: true,
+ })
+ }
+ }
}
+ // Sort columns alphabetically for deterministic output
+ sort.Slice(diffs, func(i, j int) bool {
+ return strings.ToLower(diffs[i].Column) < strings.ToLower(diffs[j].Column)
+ })
+
return diffs
}
@@ -226,9 +261,11 @@ func splitKey(key string, expected int) ([]string, error) {
func quoteIdent(driver, ident string) string {
switch driver {
case "mysql":
- return "`" + ident + "`"
- case "postgres", "postgresql":
- return `"` + ident + `"`
+ return "`" + strings.ReplaceAll(ident, "`", "``") + "`"
+ case "postgres", "postgresql", "sqlite", "oracle":
+ return "\"" + strings.ReplaceAll(ident, "\"", "\"\"") + "\""
+ case "mssql":
+ return "[" + strings.ReplaceAll(ident, "]", "]]") + "]"
default:
return ident
}
diff --git a/internal/content/resolve/rowdiff.go b/internal/content/resolve/rowdiff.go
new file mode 100644
index 0000000..14b0699
--- /dev/null
+++ b/internal/content/resolve/rowdiff.go
@@ -0,0 +1,289 @@
+package resolve
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/iamvirul/deepdiff-db/internal/content"
+ "github.com/iamvirul/deepdiff-db/internal/schema"
+)
+
+// RowDiffStatus represents the state of a row difference.
+type RowDiffStatus string
+
+const (
+ // StatusUpdated indicates the row exists in both prod and dev with differing values.
+ StatusUpdated RowDiffStatus = "updated"
+ // StatusAdded indicates the row exists only in development.
+ StatusAdded RowDiffStatus = "added"
+ // StatusRemoved indicates the row exists only in production.
+ StatusRemoved RowDiffStatus = "removed"
+)
+
+// RowDiff represents the column-level differences for a single row.
+type RowDiff struct {
+ Table string `json:"table"`
+ Key string `json:"key"`
+ Status RowDiffStatus `json:"status"`
+ Columns []ColumnDiff `json:"columns"`
+ DiffColumns []string `json:"differing_columns,omitempty"`
+}
+
+// TableRowDiff represents all row differences for a specific table.
+type TableRowDiff struct {
+ Table string `json:"table"`
+ Rows []RowDiff `json:"rows"`
+}
+
+// RowDiffReport contains all row differences grouped by table.
+type RowDiffReport struct {
+ Tables []TableRowDiff `json:"tables"`
+}
+
+// HasChanges reports whether any row differences exist in the report.
+func (r RowDiffReport) HasChanges() bool {
+ for _, t := range r.Tables {
+ if len(t.Rows) > 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// TotalRows returns the total count of differing rows in the report.
+func (r RowDiffReport) TotalRows() int {
+ count := 0
+ for _, t := range r.Tables {
+ count += len(t.Rows)
+ }
+ return count
+}
+
+// RowDiffOptions controls which row diffs are fetched.
+type RowDiffOptions struct {
+ TableFilter string // optional filter for a specific table
+ KeyFilter string // optional filter for a specific primary key
+ StatusFilter string // "updated", "added", "removed", or "all" (default: "updated")
+ Limit int // max rows to inspect per table (0 = unlimited)
+}
+
+// FetchRowDiffReport fetches column-level row data and differences for conflicting / changed rows.
+// It supports cross-engine setups where table or column casing differs between prod and dev.
+func FetchRowDiffReport(
+ ctx context.Context,
+ prodDB, devDB *sql.DB,
+ prodDriver, devDriver string,
+ prodSchema, devSchema *schema.Schema,
+ dataDiff content.DataDiff,
+ conflicts content.Conflicts,
+ opts RowDiffOptions,
+) (RowDiffReport, error) {
+ report := RowDiffReport{}
+ statusFilter := strings.ToLower(strings.TrimSpace(opts.StatusFilter))
+ if statusFilter == "" {
+ statusFilter = string(StatusUpdated)
+ }
+
+ includeUpdated := statusFilter == "all" || statusFilter == string(StatusUpdated)
+ includeAdded := statusFilter == "all" || statusFilter == string(StatusAdded)
+ includeRemoved := statusFilter == "all" || statusFilter == string(StatusRemoved)
+
+ // Direct single-key lookup optimization when table and key are specified
+ if opts.TableFilter != "" && opts.KeyFilter != "" && len(dataDiff.Tables) == 0 {
+ conflict := content.Conflict{Table: opts.TableFilter, Key: opts.KeyFilter}
+ prod, dev, err := FetchConflictRowsCrossEngine(
+ ctx, prodDB, devDB, prodDriver, devDriver, prodSchema, devSchema, conflict,
+ )
+ if err != nil {
+ return report, fmt.Errorf("fetch row data for %s key %s: %w", opts.TableFilter, opts.KeyFilter, err)
+ }
+ if prod != nil || dev != nil {
+ diffs := CompareRows(prod, dev)
+ var diffCols []string
+ for _, cd := range diffs {
+ if cd.Differs {
+ diffCols = append(diffCols, cd.Column)
+ }
+ }
+ st := StatusUpdated
+ if prod == nil {
+ st = StatusAdded
+ } else if dev == nil {
+ st = StatusRemoved
+ }
+ report.Tables = append(report.Tables, TableRowDiff{
+ Table: opts.TableFilter,
+ Rows: []RowDiff{
+ {
+ Table: opts.TableFilter,
+ Key: opts.KeyFilter,
+ Status: st,
+ Columns: diffs,
+ DiffColumns: diffCols,
+ },
+ },
+ })
+ }
+ return report, nil
+ }
+
+ for _, td := range dataDiff.Tables {
+ if opts.TableFilter != "" && schema.CanonicalIdent(td.Table) != schema.CanonicalIdent(opts.TableFilter) {
+ continue
+ }
+
+ tableRows := TableRowDiff{Table: td.Table}
+ count := 0
+
+ // Helper to fetch and append a row diff
+ fetchRow := func(key string, status RowDiffStatus) error {
+ if opts.KeyFilter != "" && key != opts.KeyFilter {
+ return nil
+ }
+ if opts.Limit > 0 && count >= opts.Limit {
+ return nil
+ }
+
+ conflict := content.Conflict{Table: td.Table, Key: key}
+ prod, dev, err := FetchConflictRowsCrossEngine(
+ ctx, prodDB, devDB, prodDriver, devDriver, prodSchema, devSchema, conflict,
+ )
+ if err != nil {
+ return fmt.Errorf("fetch row %s.%s: %w", td.Table, key, err)
+ }
+
+ diffs := CompareRows(prod, dev)
+ var diffCols []string
+ for _, cd := range diffs {
+ if cd.Differs {
+ diffCols = append(diffCols, cd.Column)
+ }
+ }
+
+ tableRows.Rows = append(tableRows.Rows, RowDiff{
+ Table: td.Table,
+ Key: key,
+ Status: status,
+ Columns: diffs,
+ DiffColumns: diffCols,
+ })
+ count++
+ return nil
+ }
+
+ if includeUpdated {
+ for _, k := range td.Updated {
+ if err := fetchRow(k, StatusUpdated); err != nil {
+ return report, err
+ }
+ if opts.Limit > 0 && count >= opts.Limit {
+ break
+ }
+ }
+ }
+
+ if includeRemoved {
+ for _, k := range td.Removed {
+ if err := fetchRow(k, StatusRemoved); err != nil {
+ return report, err
+ }
+ if opts.Limit > 0 && count >= opts.Limit {
+ break
+ }
+ }
+ }
+
+ if includeAdded {
+ for _, k := range td.Added {
+ if err := fetchRow(k, StatusAdded); err != nil {
+ return report, err
+ }
+ if opts.Limit > 0 && count >= opts.Limit {
+ break
+ }
+ }
+ }
+
+ if len(tableRows.Rows) > 0 {
+ report.Tables = append(report.Tables, tableRows)
+ }
+ }
+
+ return report, nil
+}
+
+// WriteRowDiffReport writes the row diff report to row_diff.json and row_diff.txt in outDir.
+func WriteRowDiffReport(report RowDiffReport, outDir string) error {
+ if err := os.MkdirAll(outDir, 0o750); err != nil {
+ return fmt.Errorf("ensure output dir: %w", err)
+ }
+
+ // 1. JSON
+ data, err := json.MarshalIndent(report, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal row diff json: %w", err)
+ }
+ if err := os.WriteFile(filepath.Join(outDir, "row_diff.json"), data, 0o600); err != nil {
+ return fmt.Errorf("write row_diff.json: %w", err)
+ }
+
+ // 2. Text
+ txt := FormatRowDiffText(report)
+ if err := os.WriteFile(filepath.Join(outDir, "row_diff.txt"), []byte(txt), 0o600); err != nil {
+ return fmt.Errorf("write row_diff.txt: %w", err)
+ }
+
+ return nil
+}
+
+// FormatRowDiffText returns a human-readable text representation of row differences.
+func FormatRowDiffText(report RowDiffReport) string {
+ var b strings.Builder
+ if !report.HasChanges() {
+ b.WriteString("No row-level differences found.\n")
+ return b.String()
+ }
+
+ colWidth := 20
+ valWidth := 24
+
+ for _, trd := range report.Tables {
+ for _, rd := range trd.Rows {
+ b.WriteString(strings.Repeat("=", 75) + "\n")
+ fmt.Fprintf(&b, "Table: %s | Key: %s (%s)\n", rd.Table, rd.Key, rd.Status)
+ b.WriteString(strings.Repeat("=", 75) + "\n")
+ fmt.Fprintf(&b, " %-*s | %-*s | %-*s\n",
+ colWidth, "Column",
+ valWidth, "Production",
+ valWidth, "Development")
+ fmt.Fprintf(&b, " %s+%s+%s\n",
+ strings.Repeat("-", colWidth+1),
+ strings.Repeat("-", valWidth+2),
+ strings.Repeat("-", valWidth+2))
+
+ for _, cd := range rd.Columns {
+ prodStr := FormatValue(cd.ProdVal)
+ devStr := FormatValue(cd.DevVal)
+ marker := ""
+ if cd.Differs {
+ marker = " *"
+ }
+ fmt.Fprintf(&b, " %-*s | %-*s | %-*s%s\n",
+ colWidth, cd.Column,
+ valWidth, prodStr,
+ valWidth, devStr,
+ marker)
+ }
+
+ b.WriteString("\n * indicates columns that differ\n")
+ b.WriteString(strings.Repeat("=", 75) + "\n\n")
+ }
+ }
+
+ return b.String()
+}
diff --git a/internal/report/html/generator.go b/internal/report/html/generator.go
index fe9852a..f648247 100644
--- a/internal/report/html/generator.go
+++ b/internal/report/html/generator.go
@@ -63,6 +63,9 @@ func templateFuncs() template.FuncMap {
"join": func(items []string, sep string) string {
return strings.Join(items, sep)
},
+ "formatVal": func(v any) string {
+ return resolve.FormatValue(v)
+ },
"formatTime": func(t time.Time) string {
return t.Format("2006-01-02 15:04:05 MST")
},
@@ -175,11 +178,17 @@ func BuildReportData(
data.TableDiffs = buildTableDiffs(dataDiff, opts)
}
+ // Process row diff report
+ if opts.RowDiffReport != nil && opts.RowDiffReport.HasChanges() {
+ data.RowDiffReport = opts.RowDiffReport
+ data.HasRowDiffs = true
+ }
+
// Process conflicts with resolution details
if conflicts != nil && conflicts.HasConflicts() {
data.Conflicts = conflicts
data.HasConflicts = true
- data.ConflictItems = buildConflictItemsWithResolutions(conflicts, resolutions)
+ data.ConflictItems = buildConflictItemsWithResolutionsAndRowDiffs(conflicts, resolutions, opts.RowDiffReport)
}
// Process resolutions and build breakdown
@@ -438,6 +447,16 @@ func buildTableDiffs(dataDiff *content.DataDiff, opts *ReportOptions) []TableDif
td.UpdatedKeys = limitKeys(t.Updated, maxKeys)
}
+ if opts.RowDiffReport != nil {
+ for _, trd := range opts.RowDiffReport.Tables {
+ if schema.CanonicalIdent(trd.Table) == schema.CanonicalIdent(t.Table) {
+ td.RowDiffs = trd.Rows
+ td.HasRowDiffs = len(trd.Rows) > 0
+ break
+ }
+ }
+ }
+
diffs = append(diffs, td)
}
@@ -449,8 +468,9 @@ func buildTableDiffs(dataDiff *content.DataDiff, opts *ReportOptions) []TableDif
return diffs
}
-// buildConflictItemsWithResolutions converts conflicts to display format with resolution details.
-func buildConflictItemsWithResolutions(conflicts *content.Conflicts, resolutions []resolve.Resolution) []ConflictDisplay {
+
+// buildConflictItemsWithResolutionsAndRowDiffs converts conflicts to display format with resolution and row diff details.
+func buildConflictItemsWithResolutionsAndRowDiffs(conflicts *content.Conflicts, resolutions []resolve.Resolution, rowReport *resolve.RowDiffReport) []ConflictDisplay {
var items []ConflictDisplay
// Build a map of resolutions by table+key for quick lookup
@@ -460,6 +480,17 @@ func buildConflictItemsWithResolutions(conflicts *content.Conflicts, resolutions
resolutionMap[key] = r
}
+ // Build a map of row diffs by table+key for quick lookup
+ rowDiffMap := make(map[string]resolve.RowDiff)
+ if rowReport != nil {
+ for _, t := range rowReport.Tables {
+ for _, rd := range t.Rows {
+ rowDiffMap[rd.Table+":"+rd.Key] = rd
+ rowDiffMap[schema.CanonicalIdent(rd.Table)+":"+rd.Key] = rd
+ }
+ }
+ }
+
for _, c := range conflicts.Conflicts {
item := ConflictDisplay{
Table: c.Table,
@@ -477,6 +508,16 @@ func buildConflictItemsWithResolutions(conflicts *content.Conflicts, resolutions
item.IsResolved = res.Resolved
}
+ if rd, ok := rowDiffMap[key]; ok {
+ item.ColumnDiffs = rd.Columns
+ item.DiffColumns = rd.DiffColumns
+ item.HasColumnDiffs = len(rd.Columns) > 0
+ } else if rd, ok := rowDiffMap[schema.CanonicalIdent(c.Table)+":"+c.Key]; ok {
+ item.ColumnDiffs = rd.Columns
+ item.DiffColumns = rd.DiffColumns
+ item.HasColumnDiffs = len(rd.Columns) > 0
+ }
+
items = append(items, item)
}
diff --git a/internal/report/html/template.go b/internal/report/html/template.go
index f3e5d9b..1650735 100644
--- a/internal/report/html/template.go
+++ b/internal/report/html/template.go
@@ -592,6 +592,15 @@ const reportTemplate = `
overflow: hidden;
}
+ .conflict-item-card {
+ border-bottom: 1px solid var(--border);
+ background: var(--bg);
+ }
+
+ .conflict-item-card:last-child {
+ border-bottom: none;
+ }
+
.conflict-item {
display: grid;
grid-template-columns: 140px 1fr 200px 100px;
@@ -600,12 +609,78 @@ const reportTemplate = `
padding: 12px 16px;
background: var(--bg);
font-size: 13px;
+ cursor: pointer;
}
.conflict-item:hover {
background: var(--bg-secondary);
}
+ .conflict-item-body {
+ padding: 0 16px 14px 16px;
+ background: var(--bg-secondary);
+ border-top: 1px dashed var(--border);
+ }
+
+ .row-data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+ margin-top: 10px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ overflow: hidden;
+ }
+
+ .row-data-table th, .row-data-table td {
+ padding: 7px 12px;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .row-data-table th {
+ background: var(--bg-tertiary);
+ font-weight: 600;
+ color: var(--text-secondary);
+ }
+
+ .row-data-table tr:last-child td {
+ border-bottom: none;
+ }
+
+ .diff-row-changed {
+ background: rgba(234, 88, 12, 0.08);
+ }
+
+ .diff-star {
+ color: #ea580c;
+ font-weight: bold;
+ }
+
+ .rowdiff-card {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ margin-bottom: 16px;
+ overflow: hidden;
+ }
+
+ .rowdiff-header {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 16px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ font-size: 13px;
+ font-weight: 500;
+ }
+
+ .rowdiff-body {
+ padding: 12px 16px;
+ }
+
.conflict-table {
font-family: var(--font-mono);
font-weight: 500;
@@ -873,21 +948,26 @@ const reportTemplate = `
-
+
{{if .HasSchemaDiff}}
{{range .SchemaChanges}}
@@ -1035,7 +1115,7 @@ const reportTemplate = `
{{end}}
-
+
{{if .HasDataDiff}}
{{range .ConflictItems}}
-
-
{{.Table}}
-
{{.Key}}
-
- {{.ProdHash}}
- →
- {{.DevHash}}
-
-
- {{if .Strategy}}
-
{{.Strategy}}
- {{end}}
- {{if .IsResolved}}
- {{if eq .Decision "keep_prod"}}
Keep Source
- {{else if eq .Decision "use_dev"}}
Use Target
+
+
+
{{.Table}}
+
{{.Key}}
+
+ {{.ProdHash}}
+ →
+ {{.DevHash}}
+
+
+ {{if .Strategy}}
+ {{.Strategy}}
+ {{end}}
+ {{if .IsResolved}}
+ {{if eq .Decision "keep_prod"}}Keep Source
+ {{else if eq .Decision "use_dev"}}Use Target
+ {{else}}Pending{{end}}
{{else}}Pending{{end}}
- {{else}}Pending{{end}}
+
+
+ {{if .HasColumnDiffs}}
+
+
+
+
+ | Column |
+ Production |
+ Development |
+
+
+
+ {{range .ColumnDiffs}}
+
+ | {{.Column}}{{if .Differs}} *{{end}} |
+ {{formatVal .ProdVal}} |
+ {{formatVal .DevVal}} |
+
+ {{end}}
+
+
+ {{end}}
{{end}}
@@ -1140,6 +1278,49 @@ const reportTemplate = `
{{end}}
+ {{if .HasRowDiffs}}
+
+
+
+
+
+ {{range .RowDiffReport.Tables}}
+ {{range .Rows}}
+
+
+
+
+
+
+ | Column |
+ Production |
+ Development |
+
+
+
+ {{range .Columns}}
+
+ | {{.Column}}{{if .Differs}} *{{end}} |
+ {{formatVal .ProdVal}} |
+ {{formatVal .DevVal}} |
+
+ {{end}}
+
+
+
+
+ {{end}}
+ {{end}}
+
+
+ {{end}}
+
{{if .HasMigration}}
@@ -1185,8 +1366,17 @@ const reportTemplate = `
function filterConflicts() {
const v = document.getElementById('conflict-filter').value;
- document.querySelectorAll('#conflict-list .conflict-item').forEach(r => {
- r.style.display = !v || r.dataset.table === v ? '' : 'none';
+ document.querySelectorAll('#conflict-list .conflict-item-card').forEach(r => {
+ const show = !v || r.dataset.table === v;
+ r.style.display = show ? '' : 'none';
+ });
+ }
+
+ function filterRowDiffs() {
+ const v = document.getElementById('rowdiff-filter').value;
+ document.querySelectorAll('#rowdiff-list .rowdiff-card').forEach(r => {
+ const show = !v || r.dataset.table === v;
+ r.style.display = show ? '' : 'none';
});
}
@@ -1216,9 +1406,21 @@ const reportTemplate = `
const cf = document.getElementById('conflict-filter');
if (cf) {
const ts = new Set();
- document.querySelectorAll('#conflict-list .conflict-item').forEach(i => ts.add(i.dataset.table));
+ document.querySelectorAll('#conflict-list .conflict-item-card').forEach(i => ts.add(i.dataset.table));
ts.forEach(t => { const o = document.createElement('option'); o.value = t; o.textContent = t; cf.appendChild(o); });
}
+
+ const rf = document.getElementById('rowdiff-filter');
+ if (rf) {
+ const rts = new Set();
+ document.querySelectorAll('#rowdiff-list .rowdiff-card').forEach(i => rts.add(i.dataset.table));
+ rts.forEach(t => { const o = document.createElement('option'); o.value = t; o.textContent = t; rf.appendChild(o); });
+ }
+
+ const hash = window.location.hash.replace('#', '');
+ if (hash && document.getElementById('tab-' + hash)) {
+ switchTab(hash);
+ }
});