Skip to content

Commit 8f21cb2

Browse files
committed
feat: add MergePatch generation
1 parent 2447970 commit 8f21cb2

23 files changed

+483
-27
lines changed

.codecov.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ coverage:
44
# Prevent small variations of coverage from failing CI.
55
project:
66
default:
7-
threshold: 3%
7+
threshold: 5%
88
patch: off

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ The resulting patch is empty, because all changes are ignored.
384384

385385
[Run this example](https://pkg.go.dev/github.com/wI2L/jsondiff#example-Ignores).
386386

387-
> See the actual [testcases](testdata/tests/options/ignore.json) for more examples.
387+
> See the actual [testcases](testdata/tests/jsonpatch/options/ignore.json) for more examples.
388388
389389
#### MarshalFunc / UnmarshalFunc
390390

differ.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,6 @@ func (d *Differ) compareObjects(ptr pointer, src, tgt map[string]interface{}, do
238238
cmpSet[k] |= 1 << 1
239239
}
240240
keys := make([]string, 0, len(cmpSet))
241-
242241
for k := range cmpSet {
243242
keys = append(keys, k)
244243
}

differ_test.go

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ type testcase struct {
2626

2727
type patchGetter func(tc *testcase) Patch
2828

29-
func TestRFCCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/rfc.json", Factorize(), LCS()) } // https://datatracker.ietf.org/doc/html/rfc6902#appendix-A
30-
func TestArrayCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/array.json") }
31-
func TestObjectCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/object.json") }
32-
func TestRootCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/root.json") }
29+
func TestRFCCases(t *testing.T) {
30+
runCasesFromFile(t, "testdata/tests/jsonpatch/rfc.json", Factorize(), LCS())
31+
} // https://datatracker.ietf.org/doc/html/rfc6902#appendix-A
32+
func TestArrayCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/jsonpatch/array.json") }
33+
func TestObjectCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/jsonpatch/object.json") }
34+
func TestRootCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/jsonpatch/root.json") }
3335

3436
func TestDiffer_Reset(t *testing.T) {
3537
d := &Differ{
@@ -59,28 +61,24 @@ func TestDiffer_Reset(t *testing.T) {
5961
}
6062

6163
func TestOptions(t *testing.T) {
62-
makeopts := func(opts ...Option) []Option { return opts }
64+
makeOpts := func(opts ...Option) []Option { return opts }
6365

6466
for _, tc := range []struct {
65-
testfile string
67+
testFile string
6668
options []Option
6769
}{
68-
{"testdata/tests/options/invertible.json", makeopts(Invertible())},
69-
{"testdata/tests/options/factorization.json", makeopts(Factorize())},
70-
{"testdata/tests/options/rationalization.json", makeopts(Rationalize())},
71-
{"testdata/tests/options/equivalence.json", makeopts(Equivalent())},
72-
{"testdata/tests/options/ignore.json", makeopts()},
73-
{"testdata/tests/options/lcs.json", makeopts(LCS(), Factorize())},
74-
{"testdata/tests/options/all.json", makeopts(Factorize(), Rationalize(), Invertible(), Equivalent())},
75-
{"testdata/tests/options/lcs+equivalence.json", makeopts(LCS(), Equivalent())},
70+
{"testdata/tests/jsonpatch/options/invertible.json", makeOpts(Invertible())},
71+
{"testdata/tests/jsonpatch/options/factorization.json", makeOpts(Factorize())},
72+
{"testdata/tests/jsonpatch/options/rationalization.json", makeOpts(Rationalize())},
73+
{"testdata/tests/jsonpatch/options/equivalence.json", makeOpts(Equivalent())},
74+
{"testdata/tests/jsonpatch/options/ignore.json", makeOpts()},
75+
{"testdata/tests/jsonpatch/options/lcs.json", makeOpts(LCS(), Factorize())},
76+
{"testdata/tests/jsonpatch/options/all.json", makeOpts(Factorize(), Rationalize(), Invertible(), Equivalent())},
77+
{"testdata/tests/jsonpatch/options/lcs+equivalence.json", makeOpts(LCS(), Equivalent())},
7678
} {
77-
var (
78-
ext = filepath.Ext(tc.testfile)
79-
base = filepath.Base(tc.testfile)
80-
name = strings.TrimSuffix(base, ext)
81-
)
79+
name := strings.TrimSuffix(filepath.Base(tc.testFile), filepath.Ext(tc.testFile))
8280
t.Run(name, func(t *testing.T) {
83-
runCasesFromFile(t, tc.testfile, tc.options...)
81+
runCasesFromFile(t, tc.testFile, tc.options...)
8482
})
8583
}
8684
}
@@ -112,12 +110,12 @@ func runTestCases(t *testing.T, cases []testcase, opts ...Option) {
112110
})
113111
if tc.Ignores != nil {
114112
name = fmt.Sprintf("%s_with_ignore", name)
115-
xopts := append(opts, Ignores(tc.Ignores...)) //nolint:gocritic
113+
extendedOpts := append(opts, Ignores(tc.Ignores...)) //nolint:gocritic
116114

117115
t.Run(name, func(t *testing.T) {
118116
runTestCase(t, tc, func(tc *testcase) Patch {
119117
return tc.PartialPatch
120-
}, xopts...)
118+
}, extendedOpts...)
121119
})
122120
}
123121
}

example_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,35 @@ func ExampleUnmarshalFunc() {
244244
// {"value":3.14159,"op":"replace","path":"/B"}
245245
// {"value":true,"op":"replace","path":"/C"}
246246
}
247+
248+
func ExampleMergePatch() {
249+
src := map[string]interface{}{
250+
"foo": "baz",
251+
"bar": []string{"a", "b", "c"},
252+
"baz": 3.14159,
253+
}
254+
tgt := map[string]interface{}{
255+
"foo": "bar",
256+
"bar": []string{"y", "y", "z"},
257+
}
258+
patch, err := jsondiff.MergePatch(src, tgt)
259+
if err != nil {
260+
log.Fatal(err)
261+
}
262+
fmt.Println(string(patch))
263+
// Output:
264+
// {"bar":["y","y","z"],"baz":null,"foo":"bar"}
265+
}
266+
267+
func ExampleMergePatchJSON() {
268+
src := `{"a":[1,2,3],"b":{"foo":"bar"}}`
269+
tgt := `{"a":[1,2,3],"c":[1,2,3],"d":{"foo":"bar"}}`
270+
271+
patch, err := jsondiff.MergePatchJSON([]byte(src), []byte(tgt))
272+
if err != nil {
273+
log.Fatal(err)
274+
}
275+
fmt.Println(string(patch))
276+
// Output:
277+
// {"b":null,"c":[1,2,3],"d":{"foo":"bar"}}
278+
}

merge.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package jsondiff
2+
3+
import (
4+
"encoding/json"
5+
)
6+
7+
// MergePatch returns a JSON Merge Patch (RFC 7386)
8+
// of the differences between the JSON representations
9+
// of the given values.
10+
func MergePatch(src, tgt interface{}) ([]byte, error) {
11+
opts := options{
12+
marshal: json.Marshal,
13+
unmarshal: json.Unmarshal,
14+
}
15+
si, _, err := marshalUnmarshal(src, opts)
16+
if err != nil {
17+
return nil, err
18+
}
19+
ti, _, err := marshalUnmarshal(tgt, opts)
20+
if err != nil {
21+
return nil, err
22+
}
23+
patch := mergePatch(si, ti)
24+
if patch == nil {
25+
return nil, nil
26+
}
27+
return json.Marshal(patch)
28+
}
29+
30+
// MergePatchJSON compares the given JSON documents
31+
// and returns the differences relative to the former
32+
// as a JSON Merge Patch (RFC 7386)
33+
func MergePatchJSON(src, tgt []byte) ([]byte, error) {
34+
var si, ti interface{}
35+
if err := json.Unmarshal(src, &si); err != nil {
36+
return nil, err
37+
}
38+
if err := json.Unmarshal(tgt, &ti); err != nil {
39+
return nil, err
40+
}
41+
patch := mergePatch(si, ti)
42+
if patch == nil {
43+
return nil, nil
44+
}
45+
return json.Marshal(patch)
46+
}
47+
48+
func mergePatch(src, tgt interface{}) interface{} {
49+
if src == nil || tgt == nil {
50+
return tgt
51+
}
52+
// If the target is not of the same type as the source,
53+
// or both are not objects, the patch replaces the entire
54+
// source with the target.
55+
// https://datatracker.ietf.org/doc/html/rfc7386#section-2
56+
if jsonTypeSwitch(src) != jsonObject || jsonTypeSwitch(tgt) != jsonObject {
57+
return tgt
58+
}
59+
sm := src.(map[string]interface{})
60+
tm := tgt.(map[string]interface{})
61+
62+
cmpSet := make(map[string]uint8, max(len(sm), len(tm)))
63+
64+
for k := range sm {
65+
cmpSet[k] |= 1 << 0
66+
}
67+
for k := range tm {
68+
cmpSet[k] |= 1 << 1
69+
}
70+
keys := make([]string, 0, len(cmpSet))
71+
for k := range cmpSet {
72+
keys = append(keys, k)
73+
}
74+
sortStrings(keys)
75+
76+
patch := make(map[string]interface{}, len(sm))
77+
78+
for _, k := range keys {
79+
v := cmpSet[k]
80+
inOld := v&(1<<0) != 0
81+
inNew := v&(1<<1) != 0
82+
83+
switch {
84+
case inOld && inNew:
85+
if !deepEqual(sm[k], tm[k]) {
86+
patch[k] = mergePatch(sm[k], tm[k])
87+
}
88+
case inOld:
89+
// Null values in the merge patch are given
90+
// special meaning to indicate the removal
91+
// of existing values in the target.
92+
// https://datatracker.ietf.org/doc/html/rfc7386#section-1
93+
patch[k] = nil
94+
case inNew:
95+
patch[k] = tm[k]
96+
}
97+
}
98+
return patch
99+
}

merge_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package jsondiff
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func Test_mergePatch(t *testing.T) {
12+
type testcase struct {
13+
Name string `json:"name"`
14+
B interface{} `json:"before"`
15+
A interface{} `json:"after"`
16+
P interface{} `json:"patch"`
17+
}
18+
for _, testFile := range []string{
19+
"testdata/tests/mergepatch/rfc.json",
20+
"testdata/tests/mergepatch/array.json",
21+
"testdata/tests/mergepatch/object.json",
22+
} {
23+
name := strings.TrimSuffix(filepath.Base(testFile), filepath.Ext(testFile))
24+
25+
t.Run(name, func(t *testing.T) {
26+
b, err := os.ReadFile(testFile)
27+
if err != nil {
28+
t.Fatal(err)
29+
}
30+
var cases []testcase
31+
if err := json.Unmarshal(b, &cases); err != nil {
32+
t.Fatal(err)
33+
}
34+
for _, tc := range cases {
35+
t.Run(tc.Name, func(t *testing.T) {
36+
patch := mergePatch(tc.B, tc.A)
37+
if !deepEqual(tc.P, patch) {
38+
t.Errorf("got %v, want %v", patch, tc.P)
39+
t.Logf("source: %v", tc.B)
40+
t.Logf("target %v", tc.A)
41+
}
42+
})
43+
}
44+
})
45+
}
46+
}

patch_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func TestPatch_Invert(t *testing.T) {
5555
}
5656
})
5757
t.Run("object", func(t *testing.T) {
58-
cases, err := testCasesFromFile(t, "testdata/tests/object.json")
58+
cases, err := testCasesFromFile(t, "testdata/tests/jsonpatch/object.json")
5959
if err != nil {
6060
t.Fatal(err)
6161
}
@@ -64,7 +64,7 @@ func TestPatch_Invert(t *testing.T) {
6464
}
6565
})
6666
t.Run("array", func(t *testing.T) {
67-
cases, err := testCasesFromFile(t, "testdata/tests/array.json")
67+
cases, err := testCasesFromFile(t, "testdata/tests/jsonpatch/array.json")
6868
if err != nil {
6969
t.Fatal(err)
7070
}

0 commit comments

Comments
 (0)