Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Various improvements to performance and stability #36

Merged
merged 11 commits into from
May 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.17
go-version: '1.20'

- name: Build
run: |
Expand Down
56 changes: 56 additions & 0 deletions v2/bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package jsonpatch_test

import (
"encoding/json"
"testing"

"gomodules.xyz/jsonpatch/v2"
)

func BenchmarkCreatePatch(b *testing.B) {
cases := []struct {
name string
a, b string
}{
{
"complex",
superComplexBase,
superComplexA,
},
{
"large array",
largeArray(1000),
largeArray(1000),
},
{
"simple",
simpleA,
simpleB,
},
}

for _, tt := range cases {
b.Run(tt.name, func(b *testing.B) {
at := []byte(tt.a)
bt := []byte(tt.b)
for n := 0; n < b.N; n++ {
_, _ = jsonpatch.CreatePatch(at, bt)
}
})
}
}

func largeArray(size int) string {
type nested struct {
A, B string
}
type example struct {
Objects []nested
}
a := example{}
for i := 0; i < size; i++ {
a.Objects = append(a.Objects, nested{A: "a", B: "b"})
}
res, _ := json.Marshal(a)
return string(res)
}
63 changes: 63 additions & 0 deletions v2/fuzz_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package jsonpatch_test

import (
"encoding/json"
"testing"

jp "github.com/evanphx/json-patch"
"github.com/stretchr/testify/assert"
"gomodules.xyz/jsonpatch/v2"
)

func FuzzCreatePatch(f *testing.F) {
add := func(a, b string) {
f.Add([]byte(a), []byte(b))
}
add(simpleA, simpleB)
add(superComplexBase, superComplexA)
add(hyperComplexBase, hyperComplexA)
add(arraySrc, arrayDst)
add(empty, simpleA)
add(point, lineString)
f.Fuzz(func(t *testing.T, a, b []byte) {
checkFuzz(t, a, b)
})
}

func checkFuzz(t *testing.T, src, dst []byte) {
t.Logf("Test: %v -> %v", string(src), string(dst))
patch, err := jsonpatch.CreatePatch(src, dst)
if err != nil {
// Ok to error, src or dst may be invalid
t.Skip()
}

// Applying library only works with arrays and structs, no primitives
// We still do CreatePatch to make sure it doesn't panic
if isPrimitive(src) || isPrimitive(dst) {
return
}

for _, p := range patch {
if p.Path == "" {
// json-patch doesn't handle this properly, but it is valid
return
}
}

data, err := json.Marshal(patch)
assert.Nil(t, err)

t.Logf("Applying patch %v", string(data))
p2, err := jp.DecodePatch(data)
assert.Nil(t, err)

d2, err := p2.Apply(src)
assert.Nil(t, err)

assert.JSONEq(t, string(dst), string(d2))
}

func isPrimitive(data []byte) bool {
return data[0] != '{' && data[0] != '['
}
8 changes: 7 additions & 1 deletion v2/go.mod
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
module gomodules.xyz/jsonpatch/v2

go 1.12
go 1.20

require (
github.com/evanphx/json-patch v0.5.2
github.com/stretchr/testify v1.3.0
)

require (
github.com/davecgh/go-spew v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)
160 changes: 34 additions & 126 deletions v2/jsonpatch.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package jsonpatch

import (
"bytes"
"encoding/json"
"fmt"
"reflect"
Expand All @@ -24,21 +23,28 @@ func (j *Operation) Json() string {
}

func (j *Operation) MarshalJSON() ([]byte, error) {
var b bytes.Buffer
b.WriteString("{")
b.WriteString(fmt.Sprintf(`"op":"%s"`, j.Operation))
b.WriteString(fmt.Sprintf(`,"path":"%s"`, j.Path))
// Consider omitting Value for non-nullable operations.
if j.Value != nil || j.Operation == "replace" || j.Operation == "add" {
v, err := json.Marshal(j.Value)
if err != nil {
return nil, err
}
b.WriteString(`,"value":`)
b.Write(v)
}
b.WriteString("}")
return b.Bytes(), nil
// Ensure for add and replace we emit `value: null`
if j.Value == nil && (j.Operation == "replace" || j.Operation == "add") {
return json.Marshal(struct {
Operation string `json:"op"`
Path string `json:"path"`
Value interface{} `json:"value"`
}{
Operation: j.Operation,
Path: j.Path,
})
}
// otherwise just marshal normally. We cannot literally do json.Marshal(j) as it would be recursively
// calling this function.
return json.Marshal(struct {
Operation string `json:"op"`
Path string `json:"path"`
Value interface{} `json:"value,omitempty"`
}{
Operation: j.Operation,
Path: j.Path,
Value: j.Value,
})
}

type ByPath []Operation
Expand Down Expand Up @@ -149,9 +155,6 @@ func makePath(path string, newPart interface{}) string {
if path == "" {
return "/" + key
}
if strings.HasSuffix(path, "/") {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this part not needed?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test case for this is:

	emptyKeyA = `{"":[0]}`
	emptyKeyB = `{"":[]}`

The patch should be

[
    {
        "op": "remove",
        "path": "//0"
    }
]

Before this change, we would have the path /0. which was not correct.

I am not sure why it was originally added. Removal of this code did not impact any existing test cases, and the fuzz tester should verify we round-trip successfully. So I think the only case this code was hit was for the empty key edge case

return path + key
}
return path + "/" + key
}

Expand Down Expand Up @@ -211,22 +214,18 @@ func handleValues(av, bv interface{}, p string, patch []Operation) ([]Operation,
}
case []interface{}:
bt := bv.([]interface{})
if isSimpleArray(at) && isSimpleArray(bt) {
patch = append(patch, compareEditDistance(at, bt, p)...)
} else {
n := min(len(at), len(bt))
for i := len(at) - 1; i >= n; i-- {
patch = append(patch, NewOperation("remove", makePath(p, i), nil))
}
for i := n; i < len(bt); i++ {
patch = append(patch, NewOperation("add", makePath(p, i), bt[i]))
}
for i := 0; i < n; i++ {
var err error
patch, err = handleValues(at[i], bt[i], makePath(p, i), patch)
if err != nil {
return nil, err
}
n := min(len(at), len(bt))
for i := len(at) - 1; i >= n; i-- {
patch = append(patch, NewOperation("remove", makePath(p, i), nil))
}
for i := n; i < len(bt); i++ {
patch = append(patch, NewOperation("add", makePath(p, i), bt[i]))
}
for i := 0; i < n; i++ {
var err error
patch, err = handleValues(at[i], bt[i], makePath(p, i), patch)
if err != nil {
return nil, err
}
}
default:
Expand All @@ -235,100 +234,9 @@ func handleValues(av, bv interface{}, p string, patch []Operation) ([]Operation,
return patch, nil
}

func isBasicType(a interface{}) bool {
switch a.(type) {
case string, float64, bool:
default:
return false
}
return true
}

func isSimpleArray(a []interface{}) bool {
for i := range a {
switch a[i].(type) {
case string, float64, bool:
default:
val := reflect.ValueOf(a[i])
if val.Kind() == reflect.Map {
for _, k := range val.MapKeys() {
av := val.MapIndex(k)
if av.Kind() == reflect.Ptr || av.Kind() == reflect.Interface {
if av.IsNil() {
continue
}
av = av.Elem()
}
if av.Kind() != reflect.String && av.Kind() != reflect.Float64 && av.Kind() != reflect.Bool {
return false
}
}
return true
}
return false
}
}
return true
}

// https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm
// Adapted from https://github.com/texttheater/golang-levenshtein
func compareEditDistance(s, t []interface{}, p string) []Operation {
m := len(s)
n := len(t)

d := make([][]int, m+1)
for i := 0; i <= m; i++ {
d[i] = make([]int, n+1)
d[i][0] = i
}
for j := 0; j <= n; j++ {
d[0][j] = j
}

for j := 1; j <= n; j++ {
for i := 1; i <= m; i++ {
if reflect.DeepEqual(s[i-1], t[j-1]) {
d[i][j] = d[i-1][j-1] // no op required
} else {
del := d[i-1][j] + 1
add := d[i][j-1] + 1
rep := d[i-1][j-1] + 1
d[i][j] = min(rep, min(add, del))
}
}
}

return backtrace(s, t, p, m, n, d)
}

func min(x int, y int) int {
if y < x {
return y
}
return x
}

func backtrace(s, t []interface{}, p string, i int, j int, matrix [][]int) []Operation {
if i > 0 && matrix[i-1][j]+1 == matrix[i][j] {
op := NewOperation("remove", makePath(p, i-1), nil)
return append([]Operation{op}, backtrace(s, t, p, i-1, j, matrix)...)
}
if j > 0 && matrix[i][j-1]+1 == matrix[i][j] {
op := NewOperation("add", makePath(p, i), t[j-1])
return append([]Operation{op}, backtrace(s, t, p, i, j-1, matrix)...)
}
if i > 0 && j > 0 && matrix[i-1][j-1]+1 == matrix[i][j] {
if isBasicType(s[0]) {
op := NewOperation("replace", makePath(p, i-1), t[j-1])
return append([]Operation{op}, backtrace(s, t, p, i-1, j-1, matrix)...)
}

p2, _ := handleValues(s[i-1], t[j-1], makePath(p, i-1), []Operation{})
return append(p2, backtrace(s, t, p, i-1, j-1, matrix)...)
}
if i > 0 && j > 0 && matrix[i-1][j-1] == matrix[i][j] {
return backtrace(s, t, p, i-1, j-1, matrix)
}
return []Operation{}
}
13 changes: 13 additions & 0 deletions v2/jsonpatch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,15 @@ var (
}`
)

var (
emptyKeyA = `{"":[0]}`
emptyKeyB = `{"":[]}`
)

var (
specialChars = string([]byte{123, 34, 92, 98, 34, 58, 91, 93, 125})
)

func TestCreatePatch(t *testing.T) {
cases := []struct {
name string
Expand Down Expand Up @@ -881,6 +890,10 @@ func TestCreatePatch(t *testing.T) {
{"Array at root", `[{"asdf":"qwerty"}]`, `[{"asdf":"bla"},{"asdf":"zzz"}]`},
{"Empty array at root", `[]`, `[{"asdf":"bla"},{"asdf":"zzz"}]`},
{"Null Key uses replace operation", nullKeyA, nullKeyB},
// empty key
{"Empty key", emptyKeyA, emptyKeyB},
// special chars
{"Special chars", empty, specialChars},
}

for _, c := range cases {
Expand Down