From 01e1a698aa0194f68f830479f3e0591dc4db8a2a Mon Sep 17 00:00:00 2001 From: Magnus Holm Date: Mon, 28 Apr 2025 11:41:23 +0200 Subject: [PATCH] feat: add ExactDiffReporter --- differ.go | 97 +++++++++++++++++++++--------- exact_diff_test.go | 144 +++++++++++++++++++++++++++++++++++++++++++++ options.go | 31 +++++++++- 3 files changed, 243 insertions(+), 29 deletions(-) create mode 100644 exact_diff_test.go diff --git a/differ.go b/differ.go index e6e9571..be97dd9 100644 --- a/differ.go +++ b/differ.go @@ -1,8 +1,9 @@ package mendoza import ( - "github.com/sanity-io/mendoza/internal/mendoza" "unicode/utf8" + + "github.com/sanity-io/mendoza/internal/mendoza" ) type differ struct { @@ -50,6 +51,7 @@ func (options *Options) CreatePatch(left, right interface{}) (Patch, error) { left: leftList, right: rightList, hashIndex: hashIndex, + options: options, } return differ.build(), nil } @@ -143,7 +145,7 @@ func (d *differ) build() Patch { }, } - d.reconstruct(0, reqs) + d.reconstruct(d.options.exactDiffReporter, 0, reqs) req := reqs[0] @@ -170,26 +172,30 @@ func (req *request) update(patch Patch, size int, outputKey string) { } } -func (d *differ) reconstruct(idx int, reqs []request) { - if len(reqs) == 0 { - return - } - +// reconstruct is the main entry point for calculating the diff of a value in the right-side document. +func (d *differ) reconstruct(reporter ExactDiffReporter, idx int, reqs []request) { entry := d.right.Entries[idx] - if entry.IsNonEmptyMap() { - d.reconstructMap(idx, reqs) - return - } + if len(reqs) > 0 { + if entry.IsNonEmptyMap() { + d.reconstructMap(reporter, idx, reqs) + // We return here because `reconstructMap` reports exact diffs recursively. + return + } - if entry.IsNonEmptySlice() { - d.reconstructSlice(idx, reqs) - return + if entry.IsNonEmptySlice() { + d.reconstructSlice(reporter, idx, reqs) + // We return here because `reconstructMap` reports exact diffs recursively. + return + } + + if rightString, ok := entry.Value.(string); ok { + d.reconstructString(idx, rightString, reqs) + } } - if rightString, ok := entry.Value.(string); ok { - d.reconstructString(idx, rightString, reqs) - return + if reporter != nil { + reporter.Report(entry.Value) } } @@ -275,6 +281,8 @@ func (mc *mapCandidate) init(contextIdx int, requestIdx int) { mc.contextIdx = contextIdx } +// insertAlias is invoked when we find that one value in the left map matches another value in the right map. +// This will be invoked even if the key is different. func (mc *mapCandidate) insertAlias(target mendoza.Reference, source mendoza.Reference, size int) { current, currentOk := mc.alias[target.Key] @@ -285,7 +293,6 @@ func (mc *mapCandidate) insertAlias(target mendoza.Reference, source mendoza.Ref fieldIdx: source.Index, sameKey: true, } - return } if currentOk { @@ -304,6 +311,7 @@ func (mc *mapCandidate) insertAlias(target mendoza.Reference, source mendoza.Ref fieldIdx: source.Index, sameKey: false, } + return } func (mc *mapCandidate) IsMissing(reference mendoza.Reference) bool { @@ -315,9 +323,10 @@ func (mc *mapCandidate) RegisterRequest(childIdx int, childRef mendoza.Reference mc.seenKeys[childRef.Key] = struct{}{} } -func (d *differ) reconstructMap(idx int, reqs []request) { +func (d *differ) reconstructMap(reporter ExactDiffReporter, idx int, reqs []request) { // right-index -> list of requests fieldRequests := [][]request{} + fieldIsSame := []bool{} // The input here is a list of requests. Each requests has _context_ and _primary_ which looks like this: // @@ -372,7 +381,8 @@ func (d *differ) reconstructMap(idx int, reqs []request) { for it := d.right.Iter(idx); !it.IsDone(); it.Next() { fieldEntry := it.GetEntry() - fieldRequests = append(fieldRequests, nil) + + isSame := false for _, otherIdx := range d.hashIndex.Data[fieldEntry.Hash] { otherEntry := d.left.Entries[otherIdx] @@ -381,9 +391,15 @@ func (d *differ) reconstructMap(idx int, reqs []request) { cand := &candidates[candIdx] if cand.contextIdx == otherEntry.Parent { cand.insertAlias(fieldEntry.Reference, otherEntry.Reference, fieldEntry.Size) + if fieldEntry.Reference.Key == otherEntry.Reference.Key { + isSame = true + } } } } + + fieldRequests = append(fieldRequests, nil) + fieldIsSame = append(fieldIsSame, isSame) } // Now build the requests @@ -424,7 +440,18 @@ func (d *differ) reconstructMap(idx int, reqs []request) { } for it := d.right.Iter(idx); !it.IsDone(); it.Next() { - d.reconstruct(it.GetIndex(), fieldRequests[it.GetEntry().Reference.Index]) + ref := it.GetEntry().Reference + fieldIdx := ref.Index + shouldReport := reporter != nil && !fieldIsSame[fieldIdx] + var childReporter ExactDiffReporter + if shouldReport { + childReporter = reporter + reporter.EnterField(ref.Key) + } + d.reconstruct(childReporter, it.GetIndex(), fieldRequests[fieldIdx]) + if shouldReport { + reporter.LeaveField(ref.Key) + } } for _, cand := range candidates { @@ -530,10 +557,6 @@ func (d *differ) reconstructMap(idx int, reqs []request) { } } -type sliceRequestData struct { - candidates map[int]*sliceCandidate -} - type sliceAlias struct { elementIdx int prevIsAdjacent bool @@ -597,9 +620,10 @@ func (sc *sliceCandidate) insertAlias(target mendoza.Reference, source mendoza.R } } -func (d *differ) reconstructSlice(idx int, reqs []request) { +func (d *differ) reconstructSlice(reporter ExactDiffReporter, idx int, reqs []request) { // right-index -> requests elementRequests := [][]request{} + elementIsSame := []bool{} candidates := make([]sliceCandidate, 0, len(reqs)) @@ -615,7 +639,8 @@ func (d *differ) reconstructSlice(idx int, reqs []request) { for it := d.right.Iter(idx); !it.IsDone(); it.Next() { elementEntry := it.GetEntry() - elementRequests = append(elementRequests, nil) + + isSame := false for _, otherIdx := range d.hashIndex.Data[elementEntry.Hash] { otherEntry := d.left.Entries[otherIdx] @@ -624,9 +649,15 @@ func (d *differ) reconstructSlice(idx int, reqs []request) { cand := &candidates[candIdx] if cand.contextIdx == otherEntry.Parent { cand.insertAlias(elementEntry.Reference, otherEntry.Reference, elementEntry.Size) + if elementEntry.Reference.Index == otherEntry.Reference.Index { + isSame = true + } } } } + + elementRequests = append(elementRequests, nil) + elementIsSame = append(elementIsSame, isSame) } // Now build the requests @@ -658,7 +689,17 @@ func (d *differ) reconstructSlice(idx int, reqs []request) { } for it := d.right.Iter(idx); !it.IsDone(); it.Next() { - d.reconstruct(it.GetIndex(), elementRequests[it.GetEntry().Reference.Index]) + ref := it.GetEntry().Reference + shouldReport := reporter != nil && !elementIsSame[ref.Index] + var childReporter ExactDiffReporter + if shouldReport { + childReporter = reporter + reporter.EnterElement(ref.Index) + } + d.reconstruct(childReporter, it.GetIndex(), elementRequests[ref.Index]) + if shouldReport { + reporter.LeaveElement(ref.Index) + } } for _, cand := range candidates { diff --git a/exact_diff_test.go b/exact_diff_test.go new file mode 100644 index 0000000..17d7152 --- /dev/null +++ b/exact_diff_test.go @@ -0,0 +1,144 @@ +package mendoza_test + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sanity-io/mendoza" +) + +type exactDiffEntry struct { + path []string + val interface{} +} + +type exactDiffReporter struct { + entries []exactDiffEntry + + currentPath []string +} + +func (r *exactDiffReporter) EnterField(key string) { + r.currentPath = append(r.currentPath, key) +} + +func (r *exactDiffReporter) LeaveField(key string) { + r.currentPath = r.currentPath[:len(r.currentPath)-1] +} + +func (r *exactDiffReporter) EnterElement(idx int) { + r.currentPath = append(r.currentPath, strconv.Itoa(idx)) +} + +func (r *exactDiffReporter) LeaveElement(_ int) { + r.currentPath = r.currentPath[:len(r.currentPath)-1] +} + +func (r *exactDiffReporter) Report(val interface{}) { + entry := exactDiffEntry{ + path: make([]string, len(r.currentPath)), + val: val, + } + copy(entry.path, r.currentPath) + r.entries = append(r.entries, entry) +} + +func TestExactDiff(t *testing.T) { + type testCase struct { + name string + left interface{} + right interface{} + result []exactDiffEntry + } + + for _, tc := range []testCase{ + { + name: "float no diff", + left: map[string]interface{}{"a": 1.0, "b": 2.0, "c": 3.0, "d": 4.0}, + right: map[string]interface{}{"a": 1.0, "b": 2.0, "c": 3.0, "d": 4.0}, + result: []exactDiffEntry{}, + }, + { + name: "float single field diff", + left: map[string]interface{}{"a": 1.0, "b": 3.0, "c": 3.0, "d": 4.0}, + right: map[string]interface{}{"a": 1.0, "b": 2.0, "c": 3.0, "d": 4.0}, + result: []exactDiffEntry{ + { + path: []string{"b"}, + val: 2.0, + }, + }, + }, + { + name: "map changes values", + left: map[string]interface{}{"a": 1.0, "b": 2.0}, + right: map[string]interface{}{"a": 1.0, "b": map[string]interface{}{"c": 3.0, "d": 4.0}}, + result: []exactDiffEntry{ + { + path: []string{"b", "c"}, + val: 3.0, + }, + { + path: []string{"b", "d"}, + val: 4.0, + }, + }, + }, + { + name: "map update", + left: map[string]interface{}{"a": 1.0}, + right: map[string]interface{}{"a": 1.0, "b": map[string]interface{}{"c": []interface{}{0.0}}}, + result: []exactDiffEntry{ + { + path: []string{"b"}, + val: map[string]interface{}{"c": []interface{}{0.0}}, + }, + }, + }, + { + name: "slice no diff", + left: map[string]interface{}{"a": 1.0, "b": []interface{}{1.0, 2.0}}, + right: map[string]interface{}{"a": 1.0, "b": []interface{}{1.0, 2.0}}, + result: []exactDiffEntry{}, + }, + { + name: "slice one element diff", + left: map[string]interface{}{"a": 1.0, "b": []interface{}{1.0, 2.0}}, + right: map[string]interface{}{"a": 1.0, "b": []interface{}{2.0, 2.0}}, + result: []exactDiffEntry{ + { + path: []string{"b", "0"}, + val: 2.0, + }, + }, + }, + { + name: "string no diff", + left: map[string]interface{}{"a": 1.0, "b": "hello", "c": 3.0, "d": 4.0}, + right: map[string]interface{}{"a": 1.0, "b": "hello", "c": 3.0, "d": 4.0}, + result: []exactDiffEntry{}, + }, + { + name: "string single field diff", + left: map[string]interface{}{"a": 1.0, "b": "hello", "c": 3.0, "d": 4.0}, + right: map[string]interface{}{"a": 1.0, "b": "world", "c": 3.0, "d": 4.0}, + result: []exactDiffEntry{ + { + path: []string{"b"}, + val: "world", + }, + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + reporter := exactDiffReporter{entries: make([]exactDiffEntry, 0)} + opts := mendoza.DefaultOptions.WithExactDiffReporter(&reporter) + _, err := opts.CreatePatch(tc.left, tc.right) + require.NoError(t, err) + require.EqualValues(t, tc.result, reporter.entries) + }) + } +} diff --git a/options.go b/options.go index c816420..1ea5268 100644 --- a/options.go +++ b/options.go @@ -1,7 +1,8 @@ package mendoza type Options struct { - convertFunc func(value interface{}) interface{} + convertFunc func(value interface{}) interface{} + exactDiffReporter ExactDiffReporter } // The default options. @@ -15,3 +16,31 @@ func (options Options) WithConvertFunc(convertFunc func(value interface{}) inter options.convertFunc = convertFunc return options } + +// WithExactDiffReporter registers a reporter which is invoked on every path in the right-side document which +// is not exactly the same in the left-side document. +// +// When this is used with CreateDoublePatch it will be invoked for both documents. +func (options Options) WithExactDiffReporter(exactDiffReporter ExactDiffReporter) Options { + options.exactDiffReporter = exactDiffReporter + return options +} + +// ExactDiffReporter uses the visitor pattern for reporting exact differences (i.e. places where +// the value is not exactly the same in both versions). EnterField and EnterElement will be called +// while the diff is calculated and Report is called when the current value is determined to be different. +type ExactDiffReporter interface { + // EnterField is called when the differ visits a field in an object. + EnterField(key string) + // LeaveField is called when the differ leave a field. This will always be paired up with a call to EnterField. + LeaveField(key string) + + // EnterElement is called when the differ visits an element in an array. + EnterElement(idx int) + // LeaveElement is called when the differ leaves an element. This will always be paired up with a call to EnterElement. + LeaveElement(idx int) + + // Report is invoked when the value, which is located at the path as described by EnterField and EnterElement + // is not exactly equivalent in the left-side document. + Report(val interface{}) +}