Skip to content

Commit

Permalink
test: replace evanphx/json-patch dependency with custom patch apply m…
Browse files Browse the repository at this point in the history
…ethod
  • Loading branch information
wI2L committed Nov 12, 2023
1 parent e8e9ed7 commit b9ab68b
Show file tree
Hide file tree
Showing 9 changed files with 310 additions and 39 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ Using the option with the following pointers list, we can ignore some of the fie
jsondiff.Ignores("/A", "/B", "/C")
```

The resulting patch is empty, because all changes and ignored.
The resulting patch is empty, because all changes are ignored.

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

Expand Down
176 changes: 176 additions & 0 deletions apply.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package jsondiff

import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"unicode"

"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)

// apply applies the patch to the given source document.
// If valid is true, the document is validated prior to
// the application of the patch.
func (p Patch) apply(src []byte, valid bool) ([]byte, error) {
if valid && !json.Valid(src) {
return nil, fmt.Errorf("invalid source document")
}
// Make a copy of the source document which
// will receive the patch mutations.
tgt := bytes.Clone(src)

for _, op := range p {
dp, err := toDotPath(op.Path, src)
if err != nil {
return nil, err
}
switch op.Type {
case OperationAdd:
tgt, err = add(tgt, dp, op.Value)
case OperationRemove:
tgt, err = sjson.DeleteBytes(tgt, dp)
case OperationReplace:
tgt, err = replace(tgt, dp, op.Value)
case OperationMove, OperationCopy:
// First fetch the value from the source path,
// and then add it to the destination path.
fp, err := toDotPath(op.From, src)
if err != nil {
return nil, err
}
tgt, err = add(tgt, dp, op.Value)
if err != nil {
break // bail out to interpret error
}
// Finally, if the operation was a move, the
// source value must be deleted.
if op.Type == OperationMove {
tgt, err = sjson.DeleteBytes(tgt, fp)
}
case OperationTest:
r := gjson.GetBytes(tgt, dp)
if !r.Exists() {
return nil, fmt.Errorf("invalid patch: %q value is not set", op.Path)
}
}
if err != nil {
return nil, fmt.Errorf("failed to apply op: %w", err)
}
}
return tgt, nil
}

func replace(tgt []byte, path string, val interface{}) ([]byte, error) {
if path == "@this" {
return json.Marshal(val)
}
return sjson.SetBytesOptions(tgt, path, val, &sjson.Options{
Optimistic: true,
ReplaceInPlace: true,
})
}

func add(tgt []byte, path string, val interface{}) ([]byte, error) {
if path == "@this" {
// Unsupported by the sjson package.
// Since an empty path represent the root
// document, we can simply marshal the value
// and return it as-is.
return json.Marshal(val)
}
// If we're dealing with an array indice, we want to
// "insert" the element instead of replacing it.
// We insert a null value manually where the new element
// is supposed to be (before the current element), and
// finally replace the placeholder with the new value.
if isArrayIndex(path) {
r := gjson.GetBytes(tgt, path)
if r.Index > 0 {
tgt = append(tgt[:r.Index], append([]byte(`null,`), tgt[r.Index:]...)...)
}
}
return sjson.SetBytesOptions(tgt, path, val, &sjson.Options{ReplaceInPlace: true})
}

func isArrayIndex(path string) bool {
i := strings.LastIndexByte(path, '.')
if i == -1 {
if path != "" && unicode.IsDigit(rune(path[0])) {
return true
}
return false
}
if i != 0 && path[i-1] == '\\' {
return false
}
if i < len(path) && unicode.IsDigit(rune(path[i+1])) {
return true
}
return false
}

// dotPath converts the given JSON Pointer string to the
// dot-path notation used by tidwall/sjson package.
// The source document is required in order to distinguish
// // numeric object keys from array indices
func toDotPath(path string, src []byte) (string, error) {
if path == "" {
// @this returns the current element.
// It is used to retrieve the root element.
return "@this", nil
}
fragments, err := parsePointer(path)
if err != nil {
return "", fmt.Errorf("failed to parse path: %w", err)
}
sb := strings.Builder{}

for i, f := range fragments {
var key string
if len(f) != 0 && unicode.IsDigit(rune(f[0])) {
// The fragment starts with a digit, which
// indicate that it might be a number.
if _, err := strconv.ParseInt(f, 10, 64); err == nil {
// The number is valid, but it could either be an
// array indice or an object key.
// Since the JSON Pointer RFC does not differentiate
// between the two, we have to look up the value to
// know what we're dealing with.
p := sb.String()
if p == "" {
p = "@this"
}
r := gjson.GetBytes(src, p)
switch {
case r.IsArray():
// Write array indice as-is.
key = f
case r.IsObject():
// Force the number as an object key, by
// preceding it with a colon character.
key = ":" + f
default:
return "", fmt.Errorf("unexpected value type at path: %s", sb.String())
}
}
} else if f == "-" && i == len(fragments)-1 {
// If the last fragment is the "-" character,
// it indicates that the value is a nonexistent
// element to append to the array.
key = "-1"
} else {
key = rfc6901Unescaper.Replace(f)
key = strings.Replace(key, ".", `\.`, -1)
}
if i != 0 {
// Add separator character
sb.WriteByte('.')
}
sb.WriteString(key)
}
return sb.String(), nil
}
82 changes: 82 additions & 0 deletions apply_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package jsondiff

import "testing"

func Test_toDotPath(t *testing.T) {
for _, tc := range []struct {
ptr string
json string
path string
}{
{
"",
`{}`,
"@this",
},
{
"/a/b/c",
`{"a":{"b":{"c":1}}}`,
"a.b.c",
},
{
"/a/1/c",
`{"a":[null,{"c":1}]}`,
"a.1.c",
},
{
"/a/123/b",
`{"a":{"123":{"b":1"}}}`,
"a.:123.b",
},
{
"/1",
`["a","b","c"]`,
"1",
},
{
"/0",
`{"0":"a"}`,
":0",
},
{
"/a/-",
`{"a":[1,2,3]}`,
"a.-1",
},
} {
s, err := toDotPath(tc.ptr, []byte(tc.json))
if err != nil {
t.Error(err)
}
if s != tc.path {
t.Errorf("got %q, want %q", s, tc.path)
}
}
}

func Test_isArrayIndex(t *testing.T) {
for _, tc := range []struct {
path string
isIndex bool
}{
{"a.b.c", false},
{"", false},
{"a.b.:124", false},
{"a.-1", false},
{"0.1.a", false},
{"0.1.2.:3", false},
{"a\\.b", false},
{"0.1\\.2", false},
{"0", true},
{"a.b.1", true},
{"0.1.2", true},
} {
b := isArrayIndex(tc.path)
if tc.isIndex && !b {
t.Errorf("expected path %q to be an array index", tc.path)
}
if !tc.isIndex && b {
t.Errorf("expected path %q to not be an array index", tc.path)
}
}
}
32 changes: 10 additions & 22 deletions differ_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package jsondiff

import (
"bytes"
"encoding/json"
"fmt"
"os"
Expand All @@ -9,8 +10,6 @@ import (
"sort"
"strings"
"testing"

jsonpatch "github.com/evanphx/json-patch/v5"
)

var testNameReplacer = strings.NewReplacer(",", "", "(", "", ")", "")
Expand All @@ -27,13 +26,9 @@ type testcase struct {

type patchGetter func(tc *testcase) Patch

func skipApplyTest() Option {
return func(o *Differ) { o.opts.skipApplyTest = true }
}

func TestArrayCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/array.json") }
func TestObjectCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/object.json") }
func TestRootCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/root.json", skipApplyTest()) }
func TestRootCases(t *testing.T) { runCasesFromFile(t, "testdata/tests/root.json") }

func TestDiffer_Reset(t *testing.T) {
d := &Differ{
Expand Down Expand Up @@ -113,7 +108,7 @@ func runTestCases(t *testing.T, cases []testcase, opts ...Option) {
return tc.Patch
}, opts...)
})
if len(tc.Ignores) != 0 {
if tc.Ignores != nil {
name = fmt.Sprintf("%s_with_ignore", name)
xopts := append(opts, Ignores(tc.Ignores...)) //nolint:gocritic

Expand All @@ -129,9 +124,6 @@ func runTestCases(t *testing.T, cases []testcase, opts ...Option) {
func runTestCase(t *testing.T, tc testcase, pc patchGetter, opts ...Option) {
t.Helper()

jsonpatch.SupportNegativeIndices = false
jsonpatch.AccumulatedCopySizeLimit = 0

afterBytes, err := json.Marshal(tc.After)
if err != nil {
t.Error(err)
Expand All @@ -142,8 +134,7 @@ func runTestCase(t *testing.T, tc testcase, pc patchGetter, opts ...Option) {
d = d.WithOpts(opts...)
d.Compare(tc.Before, tc.After)

patch := d.Patch()
wantPatch := pc(&tc)
patch, wantPatch := d.Patch(), pc(&tc)

if patch != nil {
t.Logf("\n%s", patch)
Expand Down Expand Up @@ -171,11 +162,10 @@ func runTestCase(t *testing.T, tc testcase, pc patchGetter, opts ...Option) {
}
}
}
// Unsupported cases of patch application test:
// Unsupported cases:
// * the Ignores() option is enabled
// * explicitly disabled for test cases file
// * explicitly disabled for individual test case
if d.opts.ignores != nil || d.opts.skipApplyTest || tc.SkipApplyTest {
if d.opts.ignores != nil || tc.SkipApplyTest {
return
}
mustMarshal := func(v any) []byte {
Expand All @@ -189,16 +179,14 @@ func runTestCase(t *testing.T, tc testcase, pc patchGetter, opts ...Option) {
// Validate that the patch is fundamentally correct by
// applying it to the source document, and compare the
// result with the expected document.
patchObj, err := jsonpatch.DecodePatch(mustMarshal(patch))
if err != nil {
t.Errorf("failed to decode patch: %s", err)
}
b, err := patchObj.Apply(mustMarshal(tc.Before))
b, err := patch.apply(mustMarshal(tc.Before), false)
if err != nil {
t.Errorf("failed to apply patch: %s", err)
}
if !jsonpatch.Equal(b, mustMarshal(tc.After)) {
if !bytes.Equal(b, mustMarshal(tc.After)) {
t.Errorf("patch does not produce the expected changes")
t.Logf("got: %s", string(b))
t.Logf("want: %s", string(mustMarshal(tc.After)))
}
}

Expand Down
10 changes: 8 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ module github.com/wI2L/jsondiff

go 1.21

require github.com/evanphx/json-patch/v5 v5.7.0
require (
github.com/tidwall/gjson v1.17.0
github.com/tidwall/sjson v1.2.5
)

require github.com/pkg/errors v0.9.1 // indirect
require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
)
13 changes: 9 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
github.com/evanphx/json-patch/v5 v5.7.0 h1:nJqP7uwL84RJInrohHfW0Fx3awjbm8qZeFv0nW9SYGc=
github.com/evanphx/json-patch/v5 v5.7.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
Loading

0 comments on commit b9ab68b

Please sign in to comment.