Files
MyGoNavi/internal/resultdiff/engine.go

451 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package resultdiff
import (
"fmt"
"sort"
"strings"
)
// ComputeDiff 对两侧已装载行做 key 对齐与字段级 diff。
func ComputeDiff(
leftRows []map[string]interface{},
leftCols []string,
rightRows []map[string]interface{},
rightCols []string,
keyColumns []string,
compareColumns []string,
ignoreColumns []string,
opts CompareOptions,
includeSameRows bool,
) (Summary, []DiffRow, error) {
keys := normalizeColumnList(keyColumns)
if len(keys) == 0 {
return Summary{}, nil, fmt.Errorf("key columns required")
}
leftColSet := columnSet(leftCols, leftRows)
rightColSet := columnSet(rightCols, rightRows)
common, leftOnly, rightOnly := splitColumns(leftColSet, rightColSet)
ignoreSet := toLowerSet(ignoreColumns)
keySet := toLowerSet(keys)
compared := resolveComparedColumns(common, compareColumns, ignoreSet, keySet)
if len(compared) == 0 {
// 至少允许只比 key 存在性;字段变更将始终为空
compared = nil
}
leftMap, leftUnmatched := indexByKeys(leftRows, keys, opts)
rightMap, rightUnmatched := indexByKeys(rightRows, keys, opts)
summary := Summary{
LeftRowCount: len(leftRows),
RightRowCount: len(rightRows),
CommonColumns: common,
LeftOnlyColumns: leftOnly,
RightOnlyColumns: rightOnly,
ChangedColumnFreq: map[string]int{},
KeyColumns: keys,
ComparedColumns: compared,
IncludeSameRows: includeSameRows,
}
var rows []DiffRow
seen := make(map[string]struct{}, len(leftMap)+len(rightMap))
// 先遍历 leftremoved / changed / same
for key, leftRow := range leftMap {
seen[key] = struct{}{}
rightRow, ok := rightMap[key]
if !ok {
summary.Removed++
rows = append(rows, DiffRow{
Kind: DiffKindRemoved,
Keys: pickKeys(leftRow, keys),
Left: leftRow,
Right: nil,
})
continue
}
changes := diffFields(leftRow, rightRow, compared, opts)
if len(changes) == 0 {
summary.Same++
if includeSameRows {
rows = append(rows, DiffRow{
Kind: DiffKindSame,
Keys: pickKeys(leftRow, keys),
Left: leftRow,
Right: rightRow,
})
}
continue
}
summary.Changed++
for _, ch := range changes {
summary.ChangedColumnFreq[ch.Name]++
}
rows = append(rows, DiffRow{
Kind: DiffKindChanged,
Keys: pickKeys(leftRow, keys),
Left: leftRow,
Right: rightRow,
ChangedFields: changes,
})
}
// right 独有 → added
for key, rightRow := range rightMap {
if _, ok := seen[key]; ok {
continue
}
summary.Added++
rows = append(rows, DiffRow{
Kind: DiffKindAdded,
Keys: pickKeys(rightRow, keys),
Left: nil,
Right: rightRow,
})
}
for _, row := range leftUnmatched {
summary.Unmatched++
rows = append(rows, DiffRow{
Kind: DiffKindUnmatched,
Keys: pickKeys(row, keys),
Left: row,
Side: "left",
})
}
for _, row := range rightUnmatched {
summary.Unmatched++
rows = append(rows, DiffRow{
Kind: DiffKindUnmatched,
Keys: pickKeys(row, keys),
Right: row,
Side: "right",
})
}
// 稳定排序kind 优先级 + key 字符串
sort.SliceStable(rows, func(i, j int) bool {
pi, pj := kindOrder(rows[i].Kind), kindOrder(rows[j].Kind)
if pi != pj {
return pi < pj
}
return formatKeyMap(rows[i].Keys, keys, opts) < formatKeyMap(rows[j].Keys, keys, opts)
})
return summary, rows, nil
}
// FilterPage 按 kind / 变更列过滤并分页。
func FilterPage(all []DiffRow, kinds []string, changedColumn string, offset, limit int, includeSame bool) PageResult {
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = DefaultPageLimit
}
if limit > MaxPageLimit {
limit = MaxPageLimit
}
kindSet := map[string]struct{}{}
for _, k := range kinds {
k = strings.TrimSpace(strings.ToLower(k))
if k != "" {
kindSet[k] = struct{}{}
}
}
// 默认排除 same
defaultExcludeSame := len(kindSet) == 0 && !includeSame
changedColumn = strings.TrimSpace(changedColumn)
filtered := make([]DiffRow, 0, len(all))
for _, row := range all {
if defaultExcludeSame && row.Kind == DiffKindSame {
continue
}
if len(kindSet) > 0 {
if _, ok := kindSet[string(row.Kind)]; !ok {
continue
}
}
if changedColumn != "" {
if row.Kind != DiffKindChanged {
continue
}
found := false
for _, ch := range row.ChangedFields {
if strings.EqualFold(ch.Name, changedColumn) {
found = true
break
}
}
if !found {
continue
}
}
filtered = append(filtered, row)
}
total := len(filtered)
if offset >= total {
return PageResult{Total: total, Offset: offset, Limit: limit, Rows: []DiffRow{}}
}
end := offset + limit
if end > total {
end = total
}
return PageResult{
Total: total,
Offset: offset,
Limit: limit,
Rows: filtered[offset:end],
}
}
func kindOrder(k DiffKind) int {
switch k {
case DiffKindChanged:
return 0
case DiffKindAdded:
return 1
case DiffKindRemoved:
return 2
case DiffKindUnmatched:
return 3
case DiffKindSame:
return 4
default:
return 9
}
}
func normalizeColumnList(cols []string) []string {
out := make([]string, 0, len(cols))
seen := map[string]struct{}{}
for _, c := range cols {
c = strings.TrimSpace(c)
if c == "" {
continue
}
lower := strings.ToLower(c)
if _, ok := seen[lower]; ok {
continue
}
seen[lower] = struct{}{}
out = append(out, c)
}
return out
}
func toLowerSet(cols []string) map[string]struct{} {
set := make(map[string]struct{}, len(cols))
for _, c := range cols {
c = strings.TrimSpace(c)
if c == "" {
continue
}
set[strings.ToLower(c)] = struct{}{}
}
return set
}
func columnSet(explicit []string, rows []map[string]interface{}) map[string]string {
// lower -> original display name
set := map[string]string{}
for _, c := range explicit {
c = strings.TrimSpace(c)
if c == "" {
continue
}
set[strings.ToLower(c)] = c
}
for _, row := range rows {
for k := range row {
k = strings.TrimSpace(k)
if k == "" {
continue
}
lower := strings.ToLower(k)
if _, ok := set[lower]; !ok {
set[lower] = k
}
}
}
return set
}
func splitColumns(left, right map[string]string) (common, leftOnly, rightOnly []string) {
for lower, name := range left {
if _, ok := right[lower]; ok {
common = append(common, name)
} else {
leftOnly = append(leftOnly, name)
}
}
for lower, name := range right {
if _, ok := left[lower]; !ok {
rightOnly = append(rightOnly, name)
}
}
sort.Strings(common)
sort.Strings(leftOnly)
sort.Strings(rightOnly)
return common, leftOnly, rightOnly
}
func resolveComparedColumns(common []string, compareColumns []string, ignoreSet, keySet map[string]struct{}) []string {
wanted := normalizeColumnList(compareColumns)
var out []string
if len(wanted) == 0 {
for _, c := range common {
lower := strings.ToLower(c)
if _, skip := ignoreSet[lower]; skip {
continue
}
// key 列默认也参与比较(可发现 key 列本身格式差异),但通常相同
out = append(out, c)
}
return out
}
commonLower := map[string]string{}
for _, c := range common {
commonLower[strings.ToLower(c)] = c
}
for _, w := range wanted {
lower := strings.ToLower(w)
if _, skip := ignoreSet[lower]; skip {
continue
}
if name, ok := commonLower[lower]; ok {
out = append(out, name)
}
_ = keySet
}
return out
}
func indexByKeys(rows []map[string]interface{}, keyColumns []string, opts CompareOptions) (map[string]map[string]interface{}, []map[string]interface{}) {
indexed := make(map[string]map[string]interface{}, len(rows))
var unmatched []map[string]interface{}
for _, row := range rows {
key, ok := buildKey(row, keyColumns, opts)
if !ok {
unmatched = append(unmatched, row)
continue
}
// 重复 key保留首次后续进 unmatched 并标记
if _, exists := indexed[key]; exists {
unmatched = append(unmatched, row)
continue
}
indexed[key] = row
}
return indexed, unmatched
}
func buildKey(row map[string]interface{}, keyColumns []string, opts CompareOptions) (string, bool) {
parts := make([]string, 0, len(keyColumns))
for _, col := range keyColumns {
val, found := lookupValue(row, col)
if !found || val == nil {
if opts.NullEqualsEmpty {
parts = append(parts, "")
continue
}
return "", false
}
s := normalizeValue(val, opts)
if s == "" && !opts.NullEqualsEmpty {
// 空串是否算有效 key允许
}
parts = append(parts, s)
}
return strings.Join(parts, keySep), true
}
func formatKeyMap(keys map[string]interface{}, keyColumns []string, opts CompareOptions) string {
parts := make([]string, 0, len(keyColumns))
for _, col := range keyColumns {
val, _ := lookupValue(keys, col)
parts = append(parts, normalizeValue(val, opts))
}
return strings.Join(parts, keySep)
}
func pickKeys(row map[string]interface{}, keyColumns []string) map[string]interface{} {
out := make(map[string]interface{}, len(keyColumns))
for _, col := range keyColumns {
if val, ok := lookupValue(row, col); ok {
out[col] = val
} else {
out[col] = nil
}
}
return out
}
func diffFields(left, right map[string]interface{}, columns []string, opts CompareOptions) []FieldChange {
var changes []FieldChange
for _, col := range columns {
lv, _ := lookupValue(left, col)
rv, _ := lookupValue(right, col)
if valuesEqual(lv, rv, opts) {
continue
}
changes = append(changes, FieldChange{Name: col, Left: lv, Right: rv})
}
return changes
}
func lookupValue(row map[string]interface{}, col string) (interface{}, bool) {
if row == nil {
return nil, false
}
if v, ok := row[col]; ok {
return v, true
}
// case-insensitive fallback
lower := strings.ToLower(col)
for k, v := range row {
if strings.ToLower(k) == lower {
return v, true
}
}
return nil, false
}
func valuesEqual(left, right interface{}, opts CompareOptions) bool {
if left == nil && right == nil {
return true
}
ls := normalizeValue(left, opts)
rs := normalizeValue(right, opts)
if opts.NullEqualsEmpty {
if (left == nil && rs == "") || (right == nil && ls == "") {
return ls == rs
}
}
return ls == rs
}
func normalizeValue(v interface{}, opts CompareOptions) string {
if v == nil {
return ""
}
s := strings.TrimSpace(fmt.Sprintf("%v", v))
if s == "<nil>" {
s = ""
}
if opts.TrimStrings {
s = strings.TrimSpace(s)
}
if opts.IgnoreCase {
s = strings.ToLower(s)
}
return s
}