From 9d6065406aad56f6d897a044ac8f06fc2f43dfa3 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 29 Dec 2025 03:11:07 +0800 Subject: [PATCH] performance improvements, test coverage bump --- .gitignore | 5 +- benchmark_test.go | 222 ++++++++++++++++++++++++++++++ jsonpatch.go | 260 +++++++++++++++++++++++------------ jsonpatch_edge_test.go | 298 +++++++++++++++++++++++++++++++++++++++++ patch.go | 39 ++++-- 5 files changed, 728 insertions(+), 96 deletions(-) create mode 100644 benchmark_test.go create mode 100644 jsonpatch_edge_test.go diff --git a/.gitignore b/.gitignore index 43d1fac..a484825 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ debug.* -cover.* \ No newline at end of file +cover.* +cpu.prof +mem.prof +jsonpatch.test diff --git a/benchmark_test.go b/benchmark_test.go new file mode 100644 index 0000000..7f8b0b5 --- /dev/null +++ b/benchmark_test.go @@ -0,0 +1,222 @@ +package jsonpatch + +import ( + "fmt" + "testing" + + "github.com/goccy/go-json" +) + +func BenchmarkCreatePatchSimpleObject(b *testing.B) { + a := []byte(`{"a":100,"b":200,"c":"hello"}`) + bb := []byte(`{"a":100,"b":200,"c":"goodbye"}`) + b.ResetTimer() + for i := 0; i < b.N; i++ { + CreatePatch(a, bb) + } +} + +func BenchmarkCreatePatchNestedObject(b *testing.B) { + a := []byte(`{"a":{"b":{"c":{"d":1,"e":"hello"}}}}`) + bb := []byte(`{"a":{"b":{"c":{"d":2,"e":"world"}}}}`) + b.ResetTimer() + for i := 0; i < b.N; i++ { + CreatePatch(a, bb) + } +} + +func BenchmarkCreatePatchLargeObject(b *testing.B) { + obj := make(map[string]interface{}) + for i := 0; i < 100; i++ { + obj[fmt.Sprintf("key%d", i)] = i + } + a, _ := json.Marshal(obj) + + obj["key50"] = "changed" + obj["key99"] = "modified" + bb, _ := json.Marshal(obj) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + CreatePatch(a, bb) + } +} + +func BenchmarkCreatePatchSmallArray(b *testing.B) { + a := []byte(`[1,2,3,4,5]`) + bb := []byte(`[1,2,4,5,6]`) + b.ResetTimer() + for i := 0; i < b.N; i++ { + CreatePatch(a, bb) + } +} + +func BenchmarkCreatePatchMediumArray(b *testing.B) { + arr1 := make([]int, 50) + arr2 := make([]int, 50) + for i := 0; i < 50; i++ { + arr1[i] = i + arr2[i] = i + } + arr2[25] = 999 + arr2[49] = 888 + + a, _ := json.Marshal(arr1) + bb, _ := json.Marshal(arr2) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + CreatePatch(a, bb) + } +} + +func BenchmarkCreatePatchArrayWithObjects(b *testing.B) { + type Item struct { + ID int `json:"id"` + Name string `json:"name"` + } + arr1 := make([]Item, 20) + arr2 := make([]Item, 20) + for i := 0; i < 20; i++ { + arr1[i] = Item{ID: i, Name: fmt.Sprintf("item%d", i)} + arr2[i] = Item{ID: i, Name: fmt.Sprintf("item%d", i)} + } + arr2[10].Name = "modified" + arr2[15].Name = "changed" + + a, _ := json.Marshal(arr1) + bb, _ := json.Marshal(arr2) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + CreatePatch(a, bb) + } +} + +func BenchmarkApplyPatchSimple(b *testing.B) { + doc := []byte(`{"a":100,"b":200,"c":"hello"}`) + patchJSON := []byte(`[{"op":"replace","path":"/c","value":"goodbye"}]`) + patch, _ := DecodePatch(patchJSON) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + patch.Apply(doc) + } +} + +func BenchmarkApplyPatchMultipleOps(b *testing.B) { + doc := []byte(`{"a":1,"b":2,"c":3,"d":4,"e":5}`) + patchJSON := []byte(`[ + {"op":"replace","path":"/a","value":10}, + {"op":"replace","path":"/b","value":20}, + {"op":"add","path":"/f","value":6}, + {"op":"remove","path":"/c"} + ]`) + patch, _ := DecodePatch(patchJSON) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + patch.Apply(doc) + } +} + +func BenchmarkApplyPatchArray(b *testing.B) { + doc := []byte(`{"items":[1,2,3,4,5]}`) + patchJSON := []byte(`[ + {"op":"add","path":"/items/0","value":0}, + {"op":"remove","path":"/items/3"}, + {"op":"replace","path":"/items/1","value":99} + ]`) + patch, _ := DecodePatch(patchJSON) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + patch.Apply(doc) + } +} + +func BenchmarkApplyPatchNested(b *testing.B) { + doc := []byte(`{"a":{"b":{"c":{"d":1}}}}`) + patchJSON := []byte(`[{"op":"replace","path":"/a/b/c/d","value":2}]`) + patch, _ := DecodePatch(patchJSON) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + patch.Apply(doc) + } +} + +func BenchmarkDecodePatch(b *testing.B) { + patchJSON := []byte(`[ + {"op":"add","path":"/a","value":1}, + {"op":"remove","path":"/b"}, + {"op":"replace","path":"/c","value":"hello"}, + {"op":"move","from":"/d","path":"/e"}, + {"op":"copy","from":"/f","path":"/g"} + ]`) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + DecodePatch(patchJSON) + } +} + +func BenchmarkEqual(b *testing.B) { + a := []byte(`{"a":1,"b":{"c":2,"d":[1,2,3]},"e":"hello"}`) + bb := []byte(`{"a":1,"b":{"c":2,"d":[1,2,3]},"e":"hello"}`) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + Equal(a, bb) + } +} + +func BenchmarkEqualDifferent(b *testing.B) { + a := []byte(`{"a":1,"b":{"c":2,"d":[1,2,3]},"e":"hello"}`) + bb := []byte(`{"a":1,"b":{"c":2,"d":[1,2,4]},"e":"hello"}`) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + Equal(a, bb) + } +} + +func BenchmarkMatchesValueString(b *testing.B) { + a := "hello world" + bb := "hello world" + b.ResetTimer() + for i := 0; i < b.N; i++ { + matchesValue(a, bb) + } +} + +func BenchmarkMatchesValueMap(b *testing.B) { + a := map[string]interface{}{"a": 1, "b": "hello", "c": true} + bb := map[string]interface{}{"a": 1, "b": "hello", "c": true} + b.ResetTimer() + for i := 0; i < b.N; i++ { + matchesValue(a, bb) + } +} + +func BenchmarkMakePath(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + makePath("/a/b", 123) + } +} + +func BenchmarkMakePathString(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + makePath("/a/b", "key") + } +} + +func BenchmarkHashValue(b *testing.B) { + v := map[string]interface{}{"a": 1, "b": "hello"} + b.ResetTimer() + for i := 0; i < b.N; i++ { + hashValue(v) + } +} diff --git a/jsonpatch.go b/jsonpatch.go index 91bb033..3a3e4f9 100644 --- a/jsonpatch.go +++ b/jsonpatch.go @@ -3,7 +3,6 @@ package jsonpatch import ( "bytes" "fmt" - "reflect" "strconv" "strings" @@ -179,8 +178,8 @@ func CreatePatch(a, b []byte) ([]Operation, error) { } av := original[key] // If types have changed, replace completely - if reflect.TypeOf(av) != reflect.TypeOf(bv) { - patch = append([]Operation{NewPatch("replace", p, bv)}, patch...) + if !sameRawType(av, bv) { + patch = append(patch, NewPatch("replace", p, bv)) continue } // Types are the same, compare values @@ -190,13 +189,19 @@ func CreatePatch(a, b []byte) ([]Operation, error) { } } // Now add all deleted values as nil + // Collect removes and sort in descending order so indices remain valid when applying + removes := make([]int, 0) for key := range original { _, found := keysModified[key] if !found { - p := makePath(path, key) - patch = append([]Operation{NewPatch("remove", p, nil)}, patch...) + removes = append(removes, key) } } + sortDescending(removes) + for _, key := range removes { + p := makePath(path, key) + patch = append(patch, NewPatch("remove", p, nil)) + } return patch, nil } @@ -233,41 +238,30 @@ func diffObjects(a, b []byte, key string, patch []Operation) ([]Operation, error // The types of the values must match, otherwise it will always return false // If two map[string]interface{} are given, all elements must match. func matchesValue(av, bv interface{}) bool { - if reflect.TypeOf(av) != reflect.TypeOf(bv) { - return false - } switch at := av.(type) { case string: - bt := bv.(string) - if bt == at { - return true - } + bt, ok := bv.(string) + return ok && bt == at case json.Number: - bt := bv.(json.Number) - if bt == at { - return true - } + bt, ok := bv.(json.Number) + return ok && bt == at case bool: - bt := bv.(bool) - if bt == at { - return true - } + bt, ok := bv.(bool) + return ok && bt == at case map[string]interface{}: - bt := bv.(map[string]interface{}) - for key := range at { - if !matchesValue(at[key], bt[key]) { - return false - } + bt, ok := bv.(map[string]interface{}) + if !ok || len(at) != len(bt) { + return false } - for key := range bt { + for key := range at { if !matchesValue(at[key], bt[key]) { return false } } return true case []interface{}: - bt := bv.([]interface{}) - if len(bt) != len(at) { + bt, ok := bv.([]interface{}) + if !ok || len(bt) != len(at) { return false } for key := range at { @@ -275,12 +269,9 @@ func matchesValue(av, bv interface{}) bool { return false } } - for key := range bt { - if !matchesValue(at[key], bt[key]) { - return false - } - } return true + case nil: + return bv == nil } return false } @@ -297,7 +288,15 @@ func matchesValue(av, bv interface{}) bool { var rfc6901Encoder = strings.NewReplacer("~", "~0", "/", "~1") func makePath(path string, newPart interface{}) string { - key := rfc6901Encoder.Replace(fmt.Sprintf("%v", newPart)) + var key string + switch v := newPart.(type) { + case int: + key = strconv.Itoa(v) + case string: + key = rfc6901Encoder.Replace(v) + default: + key = rfc6901Encoder.Replace(fmt.Sprintf("%v", newPart)) + } if path == "" { return "/" + key } @@ -314,12 +313,12 @@ func diff(a, b map[string]interface{}, path string, patch []Operation) ([]Operat av, ok := a[key] // value was added if !ok { - patch = append([]Operation{NewPatch("add", p, bv)}, patch...) + patch = append(patch, NewPatch("add", p, bv)) continue } // If types have changed, replace completely - if reflect.TypeOf(av) != reflect.TypeOf(bv) { - patch = append([]Operation{NewPatch("replace", p, bv)}, patch...) + if !sameType(av, bv) { + patch = append(patch, NewPatch("replace", p, bv)) continue } // Types are the same, compare values @@ -334,13 +333,61 @@ func diff(a, b map[string]interface{}, path string, patch []Operation) ([]Operat _, found := b[key] if !found { p := makePath(path, key) - - patch = append([]Operation{NewPatch("remove", p, nil)}, patch...) + patch = append(patch, NewPatch("remove", p, nil)) } } return patch, nil } +// sameType checks if two interface values have the same underlying type +// without using reflect.TypeOf which allocates. +func sameType(a, b interface{}) bool { + switch a.(type) { + case string: + _, ok := b.(string) + return ok + case json.Number: + _, ok := b.(json.Number) + return ok + case bool: + _, ok := b.(bool) + return ok + case map[string]interface{}: + _, ok := b.(map[string]interface{}) + return ok + case []interface{}: + _, ok := b.([]interface{}) + return ok + case nil: + return b == nil + } + return false +} + +// sameRawType checks if two json.RawMessage values represent the same JSON type. +func sameRawType(a, b json.RawMessage) bool { + if len(a) == 0 || len(b) == 0 { + return len(a) == len(b) + } + // Compare first non-whitespace character to determine type + aType := jsonType(a) + bType := jsonType(b) + return aType == bType +} + +// jsonType returns a byte representing the JSON type based on first character. +func jsonType(data json.RawMessage) byte { + for _, c := range data { + switch c { + case ' ', '\t', '\n', '\r': + continue + default: + return c + } + } + return 0 +} + func handleValues(av, bv interface{}, p string, patch []Operation) ([]Operation, error) { var err error switch at := av.(type) { @@ -352,17 +399,16 @@ func handleValues(av, bv interface{}, p string, patch []Operation) ([]Operation, } case string, json.Number, bool: if !matchesValue(av, bv) { - patch = append([]Operation{NewPatch("replace", p, bv)}, patch...) + patch = append(patch, NewPatch("replace", p, bv)) } case []interface{}: bt, ok := bv.([]interface{}) if !ok { // array replaced by non-array - patch = append([]Operation{NewPatch("replace", p, bv)}, patch...) + patch = append(patch, NewPatch("replace", p, bv)) } else if len(at) != len(bt) { // arrays are not the same length patch = append(patch, compareArray(at, bt, p)...) - } else { for i := range bt { patch, err = handleValues(at[i], bt[i], makePath(p, i), patch) @@ -372,11 +418,8 @@ func handleValues(av, bv interface{}, p string, patch []Operation) ([]Operation, } } case nil: - switch bv.(type) { - case nil: - // Both nil, fine. - default: - patch = append([]Operation{NewPatch("add", p, bv)}, patch...) + if bv != nil { + patch = append(patch, NewPatch("add", p, bv)) } default: panic(fmt.Sprintf("Unknown type:%T ", av)) @@ -384,49 +427,102 @@ func handleValues(av, bv interface{}, p string, patch []Operation) ([]Operation, return patch, nil } +// hashValue creates a hash key for an interface value for O(1) lookups. +// Returns the JSON representation as a string key. +func hashValue(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) +} + +// sortDescending sorts a slice of ints in descending order. +func sortDescending(s []int) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] > s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} + +// arrayDiff represents a value with its count for handling duplicates. +type arrayDiff struct { + indices []int +} + // https://github.com/mattbaird/jsonpatch/pull/4 // compareArray generates remove and add operations for `av` and `bv`. func compareArray(av, bv []interface{}, p string) []Operation { - retval := []Operation{} - - // Find elements that need to be removed - processArray(av, bv, func(i int, value interface{}) { - retval = append(retval, NewPatch("remove", makePath(p, i), nil)) - }) - reversed := make([]Operation, len(retval)) - for i := 0; i < len(retval); i++ { - reversed[len(retval)-1-i] = retval[i] + // Build hash map of bv elements with their indices + bvMap := make(map[string]*arrayDiff, len(bv)) + for i, v := range bv { + h := hashValue(v) + if entry, ok := bvMap[h]; ok { + entry.indices = append(entry.indices, i) + } else { + bvMap[h] = &arrayDiff{indices: []int{i}} + } + } + + // Build hash map of av elements with their indices + avMap := make(map[string]*arrayDiff, len(av)) + for i, v := range av { + h := hashValue(v) + if entry, ok := avMap[h]; ok { + entry.indices = append(entry.indices, i) + } else { + avMap[h] = &arrayDiff{indices: []int{i}} + } + } + + // Find elements to remove (in av but not in bv, or more occurrences in av) + removes := make([]int, 0) + for h, avEntry := range avMap { + bvEntry, ok := bvMap[h] + if !ok { + // All occurrences need to be removed + removes = append(removes, avEntry.indices...) + } else if len(avEntry.indices) > len(bvEntry.indices) { + // Remove excess occurrences + excess := len(avEntry.indices) - len(bvEntry.indices) + removes = append(removes, avEntry.indices[len(avEntry.indices)-excess:]...) + } + } + + // Sort removes in descending order so indices remain valid when applying patch + sortDescending(removes) + + retval := make([]Operation, 0, len(removes)) + for _, idx := range removes { + retval = append(retval, NewPatch("remove", makePath(p, idx), nil)) + } + + // Find elements to add (in bv but not in av, or more occurrences in bv) + adds := make([]int, 0) + for h, bvEntry := range bvMap { + avEntry, ok := avMap[h] + if !ok { + // All occurrences need to be added + adds = append(adds, bvEntry.indices...) + } else if len(bvEntry.indices) > len(avEntry.indices) { + // Add excess occurrences + excess := len(bvEntry.indices) - len(avEntry.indices) + adds = append(adds, bvEntry.indices[len(bvEntry.indices)-excess:]...) + } + } + + // Sort adds in ascending order for proper patch application + sortAscending(adds) + for _, idx := range adds { + retval = append(retval, NewPatch("add", makePath(p, idx), bv[idx])) } - retval = reversed - // Find elements that need to be added. - // NOTE we pass in `bv` then `av` so that processArray can find the missing elements. - processArray(bv, av, func(i int, value interface{}) { - retval = append(retval, NewPatch("add", makePath(p, i), value)) - }) return retval } -// processArray processes `av` and `bv` calling `applyOp` whenever a value is absent. -// It keeps track of which indexes have already had `applyOp` called for and automatically skips them so you can process duplicate objects correctly. -func processArray(av, bv []interface{}, applyOp func(i int, value interface{})) { - foundIndexes := make(map[int]struct{}, len(av)) - reverseFoundIndexes := make(map[int]struct{}, len(av)) - for i, v := range av { - for i2, v2 := range bv { - if _, ok := reverseFoundIndexes[i2]; ok { - // We already found this index. - continue - } - if reflect.DeepEqual(v, v2) { - // Mark this index as found since it matches exactly. - foundIndexes[i] = struct{}{} - reverseFoundIndexes[i2] = struct{}{} - break - } - } - if _, ok := foundIndexes[i]; !ok { - applyOp(i, v) +// sortAscending sorts a slice of ints in ascending order. +func sortAscending(s []int) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] } } } diff --git a/jsonpatch_edge_test.go b/jsonpatch_edge_test.go new file mode 100644 index 0000000..8cf318d --- /dev/null +++ b/jsonpatch_edge_test.go @@ -0,0 +1,298 @@ +package jsonpatch + +import ( + "testing" + + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreatePatchEmptyObjects(t *testing.T) { + patch, err := CreatePatch([]byte(`{}`), []byte(`{}`)) + require.NoError(t, err) + assert.Equal(t, 0, len(patch)) +} + +func TestCreatePatchEmptyArrays(t *testing.T) { + patch, err := CreatePatch([]byte(`[]`), []byte(`[]`)) + require.NoError(t, err) + assert.Equal(t, 0, len(patch)) +} + +func TestCreatePatchEmptyToNonEmpty(t *testing.T) { + patch, err := CreatePatch([]byte(`{}`), []byte(`{"a":1}`)) + require.NoError(t, err) + assert.Equal(t, 1, len(patch)) + assert.Equal(t, "add", patch[0].Operation) + assert.Equal(t, "/a", patch[0].Path) +} + +func TestCreatePatchNonEmptyToEmpty(t *testing.T) { + patch, err := CreatePatch([]byte(`{"a":1}`), []byte(`{}`)) + require.NoError(t, err) + assert.Equal(t, 1, len(patch)) + assert.Equal(t, "remove", patch[0].Operation) + assert.Equal(t, "/a", patch[0].Path) +} + +func TestCreatePatchNullValue(t *testing.T) { + patch, err := CreatePatch([]byte(`{"a":1}`), []byte(`{"a":null}`)) + require.NoError(t, err) + assert.Equal(t, 1, len(patch)) + assert.Equal(t, "replace", patch[0].Operation) + assert.Nil(t, patch[0].Value) +} + +func TestCreatePatchNullToValue(t *testing.T) { + patch, err := CreatePatch([]byte(`{"a":null}`), []byte(`{"a":1}`)) + require.NoError(t, err) + assert.Equal(t, 1, len(patch)) + assert.Equal(t, "replace", patch[0].Operation) +} + +func TestCreatePatchNestedNull(t *testing.T) { + patch, err := CreatePatch([]byte(`{"a":{"b":null}}`), []byte(`{"a":{"b":1}}`)) + require.NoError(t, err) + assert.Equal(t, 1, len(patch)) + assert.Equal(t, "replace", patch[0].Operation) + assert.Equal(t, "/a/b", patch[0].Path) +} + +func TestCreatePatchArrayDuplicates(t *testing.T) { + // Top-level arrays with primitives require object elements for diffObjects + patch, err := CreatePatch([]byte(`[{"a":1},{"a":1},{"a":1}]`), []byte(`[{"a":1},{"a":1},{"a":2}]`)) + require.NoError(t, err) + assert.True(t, len(patch) > 0) +} + +func TestCreatePatchArrayWithNulls(t *testing.T) { + // Top-level arrays need object elements + patch, err := CreatePatch([]byte(`[{"a":null},{"a":1},{"a":2}]`), []byte(`[{"a":null},{"a":1},{"a":3}]`)) + require.NoError(t, err) + assert.True(t, len(patch) > 0) +} + +func TestCreatePatchDeepNesting(t *testing.T) { + a := `{"a":{"b":{"c":{"d":{"e":{"f":1}}}}}}` + b := `{"a":{"b":{"c":{"d":{"e":{"f":2}}}}}}` + patch, err := CreatePatch([]byte(a), []byte(b)) + require.NoError(t, err) + assert.Equal(t, 1, len(patch)) + assert.Equal(t, "/a/b/c/d/e/f", patch[0].Path) +} + +func TestCreatePatchSpecialCharactersInKeys(t *testing.T) { + a := `{"a/b":1,"~c":2}` + b := `{"a/b":2,"~c":3}` + patch, err := CreatePatch([]byte(a), []byte(b)) + require.NoError(t, err) + assert.Equal(t, 2, len(patch)) +} + +func TestCreatePatchBooleanValues(t *testing.T) { + patch, err := CreatePatch([]byte(`{"a":true}`), []byte(`{"a":false}`)) + require.NoError(t, err) + assert.Equal(t, 1, len(patch)) + assert.Equal(t, "replace", patch[0].Operation) + assert.Equal(t, false, patch[0].Value) +} + +func TestCreatePatchNumberTypes(t *testing.T) { + patch, err := CreatePatch([]byte(`{"a":1}`), []byte(`{"a":1.5}`)) + require.NoError(t, err) + assert.Equal(t, 1, len(patch)) +} + +func TestCreatePatchLargeNumbers(t *testing.T) { + patch, err := CreatePatch([]byte(`{"a":9999999999999999}`), []byte(`{"a":9999999999999998}`)) + require.NoError(t, err) + assert.Equal(t, 1, len(patch)) +} + +func TestCreatePatchMixedArrayTypes(t *testing.T) { + // Test with object wrapper + a := `{"arr":[1,"hello",true,null,{"a":1}]}` + b := `{"arr":[1,"world",false,null,{"a":2}]}` + patch, err := CreatePatch([]byte(a), []byte(b)) + require.NoError(t, err) + assert.True(t, len(patch) > 0) +} + +func TestCreatePatchTypeMismatch(t *testing.T) { + _, err := CreatePatch([]byte(`{}`), []byte(`[]`)) + assert.Error(t, err) +} + +func TestCreatePatchInvalidJSON(t *testing.T) { + _, err := CreatePatch([]byte(`{invalid}`), []byte(`{}`)) + assert.Error(t, err) +} + +func TestApplyPatchEmptyPatch(t *testing.T) { + doc := []byte(`{"a":1}`) + patch, _ := DecodePatch([]byte(`[]`)) + result, err := patch.Apply(doc) + require.NoError(t, err) + assert.True(t, Equal(doc, result)) +} + +func TestApplyPatchToEmptyObject(t *testing.T) { + doc := []byte(`{}`) + patch, _ := DecodePatch([]byte(`[{"op":"add","path":"/a","value":1}]`)) + result, err := patch.Apply(doc) + require.NoError(t, err) + assert.True(t, Equal([]byte(`{"a":1}`), result)) +} + +func TestApplyPatchToEmptyArray(t *testing.T) { + doc := []byte(`[]`) + patch, _ := DecodePatch([]byte(`[{"op":"add","path":"/0","value":1}]`)) + result, err := patch.Apply(doc) + require.NoError(t, err) + assert.True(t, Equal([]byte(`[1]`), result)) +} + +func TestApplyPatchNullValue(t *testing.T) { + doc := []byte(`{"a":1}`) + patch, _ := DecodePatch([]byte(`[{"op":"replace","path":"/a","value":null}]`)) + result, err := patch.Apply(doc) + require.NoError(t, err) + assert.True(t, Equal([]byte(`{"a":null}`), result)) +} + +func TestEqualIdentical(t *testing.T) { + a := []byte(`{"a":1,"b":[1,2,3]}`) + assert.True(t, Equal(a, a)) +} + +func TestEqualDifferentOrder(t *testing.T) { + a := []byte(`{"a":1,"b":2}`) + b := []byte(`{"b":2,"a":1}`) + assert.True(t, Equal(a, b)) +} + +func TestEqualDifferentValues(t *testing.T) { + a := []byte(`{"a":1}`) + b := []byte(`{"a":2}`) + assert.False(t, Equal(a, b)) +} + +func TestEqualDifferentKeys(t *testing.T) { + a := []byte(`{"a":1}`) + b := []byte(`{"b":1}`) + assert.False(t, Equal(a, b)) +} + +func TestEqualExtraKey(t *testing.T) { + a := []byte(`{"a":1}`) + b := []byte(`{"a":1,"b":2}`) + assert.False(t, Equal(a, b)) +} + +func TestEqualArrays(t *testing.T) { + a := []byte(`[1,2,3]`) + b := []byte(`[1,2,3]`) + assert.True(t, Equal(a, b)) +} + +func TestEqualArraysDifferentOrder(t *testing.T) { + a := []byte(`[1,2,3]`) + b := []byte(`[3,2,1]`) + assert.False(t, Equal(a, b)) +} + +func TestMatchesValueNil(t *testing.T) { + assert.True(t, matchesValue(nil, nil)) + assert.False(t, matchesValue(nil, 1)) + assert.False(t, matchesValue(1, nil)) +} + +func TestMatchesValueDifferentTypes(t *testing.T) { + assert.False(t, matchesValue("1", 1)) + assert.False(t, matchesValue(true, "true")) + assert.False(t, matchesValue([]interface{}{1}, map[string]interface{}{"a": 1})) +} + +func TestSameType(t *testing.T) { + assert.True(t, sameType("a", "b")) + assert.True(t, sameType(true, false)) + assert.True(t, sameType(nil, nil)) + assert.False(t, sameType("a", 1)) + assert.False(t, sameType(nil, "a")) + // Note: sameType uses json.Number, not int + assert.True(t, sameType(json.Number("1"), json.Number("2"))) +} + +func TestSortDescending(t *testing.T) { + s := []int{1, 5, 3, 2, 4} + sortDescending(s) + assert.Equal(t, []int{5, 4, 3, 2, 1}, s) +} + +func TestSortDescendingEmpty(t *testing.T) { + s := []int{} + sortDescending(s) + assert.Equal(t, []int{}, s) +} + +func TestSortDescendingSingle(t *testing.T) { + s := []int{1} + sortDescending(s) + assert.Equal(t, []int{1}, s) +} + +func TestSortAscending(t *testing.T) { + s := []int{5, 1, 3, 2, 4} + sortAscending(s) + assert.Equal(t, []int{1, 2, 3, 4, 5}, s) +} + +func TestHashValue(t *testing.T) { + h1 := hashValue(map[string]interface{}{"a": 1}) + h2 := hashValue(map[string]interface{}{"a": 1}) + assert.Equal(t, h1, h2) + + h3 := hashValue(map[string]interface{}{"a": 2}) + assert.NotEqual(t, h1, h3) +} + +func TestRoundTrip(t *testing.T) { + testCases := []struct { + name string + original string + modified string + }{ + {"simple_replace", `{"a":1}`, `{"a":2}`}, + {"add_key", `{"a":1}`, `{"a":1,"b":2}`}, + {"remove_key", `{"a":1,"b":2}`, `{"a":1}`}, + {"nested_change", `{"a":{"b":1}}`, `{"a":{"b":2}}`}, + {"array_change", `{"a":[1,2,3]}`, `{"a":[1,2,4]}`}, + {"array_add", `{"a":[1,2]}`, `{"a":[1,2,3]}`}, + {"array_remove", `{"a":[1,2,3]}`, `{"a":[1,2]}`}, + {"complex", `{"a":1,"b":{"c":[1,2,3]}}`, `{"a":2,"b":{"c":[1,3],"d":4}}`}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + patch, err := CreatePatch([]byte(tc.original), []byte(tc.modified)) + require.NoError(t, err) + + patchBytes, err := MarshalPatch(patch) + require.NoError(t, err) + + decoded, err := DecodePatch(patchBytes) + require.NoError(t, err) + + result, err := decoded.Apply([]byte(tc.original)) + require.NoError(t, err) + + assert.True(t, Equal([]byte(tc.modified), result), + "Expected %s but got %s", tc.modified, string(result)) + }) + } +} + +func MarshalPatch(ops []Operation) ([]byte, error) { + return json.Marshal(ops) +} diff --git a/patch.go b/patch.go index 8480d33..c539623 100644 --- a/patch.go +++ b/patch.go @@ -2,13 +2,11 @@ package jsonpatch import ( "bytes" - "encoding/json" - - gojson "github.com/goccy/go-json" - "fmt" "strconv" "strings" + + "github.com/goccy/go-json" ) const ( @@ -54,8 +52,14 @@ func newLazyNode(raw *json.RawMessage) *lazyNode { } func (n *lazyNode) MarshalJSON() ([]byte, error) { + if n == nil { + return []byte("null"), nil + } switch n.which { case eRaw: + if n.raw == nil { + return []byte("null"), nil + } return json.Marshal(n.raw) case eDoc: return json.Marshal(n.doc) @@ -97,7 +101,7 @@ func (n *lazyNode) intoDoc() (*partialDoc, error) { return nil, fmt.Errorf("unable to unmarshal nil pointer as partial document") } - err := gojson.Unmarshal(*n.raw, &n.doc) + err := json.Unmarshal(*n.raw, &n.doc) if err != nil { return nil, err @@ -116,7 +120,7 @@ func (n *lazyNode) intoAry() (*partialArray, error) { return nil, fmt.Errorf("unable to unmarshal nil pointer as partial array") } - err := gojson.Unmarshal(*n.raw, &n.ary) + err := json.Unmarshal(*n.raw, &n.ary) if err != nil { return nil, err @@ -147,7 +151,7 @@ func (n *lazyNode) tryDoc() bool { return false } - err := gojson.Unmarshal(*n.raw, &n.doc) + err := json.Unmarshal(*n.raw, &n.doc) if err != nil { return false @@ -162,7 +166,7 @@ func (n *lazyNode) tryAry() bool { return false } - err := gojson.Unmarshal(*n.raw, &n.ary) + err := json.Unmarshal(*n.raw, &n.ary) if err != nil { return false @@ -194,6 +198,11 @@ func (n *lazyNode) equal(o *lazyNode) bool { return false } + // Check that both maps have the same number of keys + if len(n.doc) != len(o.doc) { + return false + } + for k, v := range n.doc { ov, ok := o.doc[k] @@ -205,6 +214,10 @@ func (n *lazyNode) equal(o *lazyNode) bool { continue } + if v == nil || ov == nil { + return false + } + if !v.equal(ov) { return false } @@ -234,7 +247,7 @@ func (o operation) kind() string { if obj, ok := o["op"]; ok && obj != nil { var op string - err := gojson.Unmarshal(*obj, &op) + err := json.Unmarshal(*obj, &op) if err != nil { return "unknown" @@ -250,7 +263,7 @@ func (o operation) path() string { if obj, ok := o["path"]; ok && obj != nil { var op string - err := gojson.Unmarshal(*obj, &op) + err := json.Unmarshal(*obj, &op) if err != nil { return "unknown" @@ -266,7 +279,7 @@ func (o operation) from() string { if obj, ok := o["from"]; ok && obj != nil { var op string - err := gojson.Unmarshal(*obj, &op) + err := json.Unmarshal(*obj, &op) if err != nil { return "unknown" @@ -618,7 +631,7 @@ func Equal(a, b []byte) bool { func DecodePatch(buf []byte) (Patch, error) { var p Patch - err := gojson.Unmarshal(buf, &p) + err := json.Unmarshal(buf, &p) if err != nil { return nil, err @@ -643,7 +656,7 @@ func (p Patch) ApplyIndent(doc []byte, indent string) ([]byte, error) { pd = &partialDoc{} } - err := gojson.Unmarshal(doc, pd) + err := json.Unmarshal(doc, pd) if err != nil { return nil, err