Skip to content

Commit

Permalink
Merge pull request #52 from clagraff/master
Browse files Browse the repository at this point in the history
Allow arrays for CreateMergePatch
  • Loading branch information
evanphx authored Mar 22, 2018
2 parents ed7cfba + ce89457 commit afac545
Show file tree
Hide file tree
Showing 2 changed files with 215 additions and 23 deletions.
102 changes: 90 additions & 12 deletions merge.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jsonpatch

import (
"bytes"
"encoding/json"
"fmt"
"reflect"
Expand Down Expand Up @@ -89,6 +90,7 @@ func pruneAryNulls(ary *partialArray) *partialArray {

var errBadJSONDoc = fmt.Errorf("Invalid JSON Document")
var errBadJSONPatch = fmt.Errorf("Invalid JSON Patch")
var errBadMergeTypes = fmt.Errorf("Mismatched JSON Documents")

// MergeMergePatches merges two merge patches together, such that
// applying this resulting merged merge patch to a document yields the same
Expand Down Expand Up @@ -160,30 +162,106 @@ func doMergePatch(docData, patchData []byte, mergeMerge bool) ([]byte, error) {
return json.Marshal(doc)
}

// CreateMergePatch creates a merge patch as specified in http://tools.ietf.org/html/draft-ietf-appsawg-json-merge-patch-07
//
// 'a' is original, 'b' is the modified document. Both are to be given as json encoded content.
// The function will return a mergeable json document with differences from a to b.
//
// An error will be returned if any of the two documents are invalid.
func CreateMergePatch(a, b []byte) ([]byte, error) {
aI := map[string]interface{}{}
bI := map[string]interface{}{}
err := json.Unmarshal(a, &aI)
// resemblesJSONArray indicates whether the byte-slice "appears" to be
// a JSON array or not.
// False-positives are possible, as this function does not check the internal
// structure of the array. It only checks that the outer syntax is present and
// correct.
func resemblesJSONArray(input []byte) bool {
input = bytes.TrimSpace(input)

hasPrefix := bytes.HasPrefix(input, []byte("["))
hasSuffix := bytes.HasSuffix(input, []byte("]"))

return hasPrefix && hasSuffix
}

// CreateMergePatch will return a merge patch document capable of converting
// the original document(s) to the modified document(s).
// The parameters can be bytes of either two JSON Documents, or two arrays of
// JSON documents.
// The merge patch returned follows the specification defined at http://tools.ietf.org/html/draft-ietf-appsawg-json-merge-patch-07
func CreateMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) {
originalResemblesArray := resemblesJSONArray(originalJSON)
modifiedResemblesArray := resemblesJSONArray(modifiedJSON)

// Do both byte-slices seem like JSON arrays?
if originalResemblesArray && modifiedResemblesArray {
return createArrayMergePatch(originalJSON, modifiedJSON)
}

// Are both byte-slices are not arrays? Then they are likely JSON objects...
if !originalResemblesArray && !modifiedResemblesArray {
return createObjectMergePatch(originalJSON, modifiedJSON)
}

// None of the above? Then return an error because of mismatched types.
return nil, errBadMergeTypes
}

// createObjectMergePatch will return a merge-patch document capable of
// converting the original document to the modified document.
func createObjectMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) {
originalDoc := map[string]interface{}{}
modifiedDoc := map[string]interface{}{}

err := json.Unmarshal(originalJSON, &originalDoc)
if err != nil {
return nil, errBadJSONDoc
}
err = json.Unmarshal(b, &bI)

err = json.Unmarshal(modifiedJSON, &modifiedDoc)
if err != nil {
return nil, errBadJSONDoc
}
dest, err := getDiff(aI, bI)

dest, err := getDiff(originalDoc, modifiedDoc)
if err != nil {
return nil, err
}

return json.Marshal(dest)
}

// createArrayMergePatch will return an array of merge-patch documents capable
// of converting the original document to the modified document for each
// pair of JSON documents provided in the arrays.
// Arrays of mismatched sizes will result in an error.
func createArrayMergePatch(originalJSON, modifiedJSON []byte) ([]byte, error) {
originalDocs := []json.RawMessage{}
modifiedDocs := []json.RawMessage{}

err := json.Unmarshal(originalJSON, &originalDocs)
if err != nil {
return nil, errBadJSONDoc
}

err = json.Unmarshal(modifiedJSON, &modifiedDocs)
if err != nil {
return nil, errBadJSONDoc
}

total := len(originalDocs)
if len(modifiedDocs) != total {
return nil, errBadJSONDoc
}

result := []json.RawMessage{}
for i := 0; i < len(originalDocs); i++ {
original := originalDocs[i]
modified := modifiedDocs[i]

patch, err := createObjectMergePatch(original, modified)
if err != nil {
return nil, err
}

result = append(result, json.RawMessage(patch))
}

return json.Marshal(result)
}

// Returns true if the array matches (must be json types).
// As is idiomatic for go, an empty array is not the same as a nil array.
func matchesArray(a, b []interface{}) bool {
Expand Down
136 changes: 125 additions & 11 deletions merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,50 @@ func TestMergePatchFailRFCCases(t *testing.T) {

}

func TestMergeReplaceKey(t *testing.T) {
func TestResembleJSONArray(t *testing.T) {
testCases := []struct {
input []byte
expected bool
}{
// Failure cases
{input: []byte(``), expected: false},
{input: []byte(`not an array`), expected: false},
{input: []byte(`{"foo": "bar"}`), expected: false},
{input: []byte(`{"fizz": ["buzz"]}`), expected: false},
{input: []byte(`[bad suffix`), expected: false},
{input: []byte(`bad prefix]`), expected: false},
{input: []byte(`][`), expected: false},

// Valid cases
{input: []byte(`[]`), expected: true},
{input: []byte(`["foo", "bar"]`), expected: true},
{input: []byte(`[["foo", "bar"]]`), expected: true},
{input: []byte(`[not valid syntax]`), expected: true},

// Valid cases with whitespace
{input: []byte(` []`), expected: true},
{input: []byte(`[] `), expected: true},
{input: []byte(` [] `), expected: true},
{input: []byte(` [ ] `), expected: true},
{input: []byte("\t[]"), expected: true},
{input: []byte("[]\n"), expected: true},
{input: []byte("\n\t\r[]"), expected: true},
}

for _, test := range testCases {
result := resemblesJSONArray(test.input)
if result != test.expected {
t.Errorf(
`expected "%t" but received "%t" for case: "%s"`,
test.expected,
result,
string(test.input),
)
}
}
}

func TestCreateMergePatchReplaceKey(t *testing.T) {
doc := `{ "title": "hello", "nested": {"one": 1, "two": 2} }`
pat := `{ "title": "goodbye", "nested": {"one": 2, "two": 2} }`

Expand All @@ -201,7 +244,7 @@ func TestMergeReplaceKey(t *testing.T) {
}
}

func TestMergeGetArray(t *testing.T) {
func TestCreateMergePatchGetArray(t *testing.T) {
doc := `{ "title": "hello", "array": ["one", "two"], "notmatch": [1, 2, 3] }`
pat := `{ "title": "hello", "array": ["one", "two", "three"], "notmatch": [1, 2, 3] }`

Expand All @@ -218,7 +261,7 @@ func TestMergeGetArray(t *testing.T) {
}
}

func TestMergeGetObjArray(t *testing.T) {
func TestCreateMergePatchGetObjArray(t *testing.T) {
doc := `{ "title": "hello", "array": [{"banana": true}, {"evil": false}], "notmatch": [{"one":1}, {"two":2}, {"three":3}] }`
pat := `{ "title": "hello", "array": [{"banana": false}, {"evil": true}], "notmatch": [{"one":1}, {"two":2}, {"three":3}] }`

Expand All @@ -235,7 +278,7 @@ func TestMergeGetObjArray(t *testing.T) {
}
}

func TestMergeDeleteKey(t *testing.T) {
func TestCreateMergePatchDeleteKey(t *testing.T) {
doc := `{ "title": "hello", "nested": {"one": 1, "two": 2} }`
pat := `{ "title": "hello", "nested": {"one": 1} }`

Expand All @@ -253,7 +296,7 @@ func TestMergeDeleteKey(t *testing.T) {
}
}

func TestMergeEmptyArray(t *testing.T) {
func TestCreateMergePatchEmptyArray(t *testing.T) {
doc := `{ "array": null }`
pat := `{ "array": [] }`

Expand Down Expand Up @@ -288,7 +331,7 @@ func TestCreateMergePatchNil(t *testing.T) {
}
}

func TestMergeObjArray(t *testing.T) {
func TestCreateMergePatchObjArray(t *testing.T) {
doc := `{ "array": [ {"a": {"b": 2}}, {"a": {"b": 3}} ]}`
exp := `{}`

Expand All @@ -304,7 +347,78 @@ func TestMergeObjArray(t *testing.T) {
}
}

func TestMergeComplexMatch(t *testing.T) {
func TestCreateMergePatchSameOuterArray(t *testing.T) {
doc := `[{"foo": "bar"}]`
pat := doc
exp := `[{}]`

res, err := CreateMergePatch([]byte(doc), []byte(pat))

if err != nil {
t.Errorf("Unexpected error: %s, %s", err, string(res))
}

if !compareJSON(exp, string(res)) {
t.Fatalf("Outer array was not unmodified")
}
}

func TestCreateMergePatchModifiedOuterArray(t *testing.T) {
doc := `[{"name": "John"}, {"name": "Will"}]`
pat := `[{"name": "Jane"}, {"name": "Will"}]`
exp := `[{"name": "Jane"}, {}]`

res, err := CreateMergePatch([]byte(doc), []byte(pat))

if err != nil {
t.Errorf("Unexpected error: %s, %s", err, string(res))
}

if !compareJSON(exp, string(res)) {
t.Fatalf("Expected %s but received %s", exp, res)
}
}

func TestCreateMergePatchMismatchedOuterArray(t *testing.T) {
doc := `[{"name": "John"}, {"name": "Will"}]`
pat := `[{"name": "Jane"}]`

_, err := CreateMergePatch([]byte(doc), []byte(pat))

if err == nil {
t.Errorf("Expected error due to array length differences but received none")
}
}

func TestCreateMergePatchMismatchedOuterTypes(t *testing.T) {
doc := `[{"name": "John"}]`
pat := `{"name": "Jane"}`

_, err := CreateMergePatch([]byte(doc), []byte(pat))

if err == nil {
t.Errorf("Expected error due to mismatched types but received none")
}
}

func TestCreateMergePatchNoDifferences(t *testing.T) {
doc := `{ "title": "hello", "nested": {"one": 1, "two": 2} }`
pat := doc

exp := `{}`

res, err := CreateMergePatch([]byte(doc), []byte(pat))

if err != nil {
t.Errorf("Unexpected error: %s, %s", err, string(res))
}

if !compareJSON(exp, string(res)) {
t.Fatalf("Key was not replaced")
}
}

func TestCreateMergePatchComplexMatch(t *testing.T) {
doc := `{"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4], "nested": {"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4]} }`
empty := `{}`
res, err := CreateMergePatch([]byte(doc), []byte(doc))
Expand All @@ -319,7 +433,7 @@ func TestMergeComplexMatch(t *testing.T) {
}
}

func TestMergeComplexAddAll(t *testing.T) {
func TestCreateMergePatchComplexAddAll(t *testing.T) {
doc := `{"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4], "nested": {"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4]} }`
empty := `{}`
res, err := CreateMergePatch([]byte(empty), []byte(doc))
Expand All @@ -333,7 +447,7 @@ func TestMergeComplexAddAll(t *testing.T) {
}
}

func TestMergeComplexRemoveAll(t *testing.T) {
func TestCreateMergePatchComplexRemoveAll(t *testing.T) {
doc := `{"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4], "nested": {"hello": "world","t": true ,"f": false, "n": null,"i": 123,"pi": 3.1416,"a": [1, 2, 3, 4]} }`
exp := `{"a":null,"f":null,"hello":null,"i":null,"n":null,"nested":null,"pi":null,"t":null}`
empty := `{}`
Expand All @@ -355,7 +469,7 @@ func TestMergeComplexRemoveAll(t *testing.T) {
*/
}

func TestMergeObjectWithInnerArray(t *testing.T) {
func TestCreateMergePatchObjectWithInnerArray(t *testing.T) {
stateString := `{
"OuterArray": [
{
Expand All @@ -379,7 +493,7 @@ func TestMergeObjectWithInnerArray(t *testing.T) {
}
}

func TestMergeReplaceKeyNotEscape(t *testing.T) {
func TestCreateMergePatchReplaceKeyNotEscape(t *testing.T) {
doc := `{ "title": "hello", "nested": {"title/escaped": 1, "two": 2} }`
pat := `{ "title": "goodbye", "nested": {"title/escaped": 2, "two": 2} }`

Expand Down

0 comments on commit afac545

Please sign in to comment.