Skip to content

Commit

Permalink
feat: add Ignores option
Browse files Browse the repository at this point in the history
  • Loading branch information
wI2L committed Apr 27, 2023
1 parent c23f0e4 commit c0f2ef4
Show file tree
Hide file tree
Showing 12 changed files with 654 additions and 66 deletions.
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ First, get the latest version of the library using the following command:
$ go get github.com/wI2L/jsondiff@latest
```

:warning: Requires Go1.14+, due to the usage of the package [`hash/maphash`](https://golang.org/pkg/hash/maphash/).
:warning: Requires Go1.18+, due to the usage of the package [`hash/maphash`](https://golang.org/pkg/hash/maphash/), and the `any` keyword (predeclared type alias for the empty interface).

### Example use cases

Expand Down Expand Up @@ -272,6 +272,8 @@ The patch is similar to the following:

As you can see, the `remove` and `replace` operations are preceded with a `test` operation which assert/verify the `value` of the previous `path`. On the other hand, the `add` operation can be reverted to a remove operation directly and doesn't need to be preceded by a `test`.

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

Finally, as a side example, if we were to use the `Rationalize()` option in the context of the previous example, the output would be shorter, but the generated patch would still remain invertible:

```json
Expand Down Expand Up @@ -303,6 +305,56 @@ The root arrays of each document are not equal because the values differ at each

For such situations, you can use the `Equivalent()` option to instruct the diff generator to skip the generation of operations that would otherwise be added to the patch to represent the differences between the two arrays.

#### Ignores

:construction: *This option is experimental and might be revised in the future.*
<br/>
<br/>

The `Ignores()` option allows to exclude one or more JSON fields/values from the *generated diff*. The fields must be identified using the JSON Pointer (RFC6901) string syntax.

The option accepts a variadic list of JSON Pointers, which all individually represent a value in the source document. However, if the value does not exist in the source document, the value will be considered to be in the target document, which allows to *ignore* `add` operations.

For example, let's generate the diff between those two JSON documents:

```json
{
"A": "bar",
"B": "baz",
"C": "foo"
}
```

```json
{
"A": "rab",
"B": "baz",
"D": "foo"
}
```

Without the `Ignores()` option, the output patch is the following:

```json
[
{ "op": "replace", "path": "/A", "value": "rab" },
{ "op": "remove", "path": "/C" },
{ "op": "add", "path": "/D", "value": "foo" }
]
```

Using the option with the following pointers list, we can ignore some of the fields that were updated, added or removed:

```go
jsondiff.Ignores("/A", "/B", "/C")
```

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

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

> See the actual [testcases](testdata/tests/options/ignore.json) for more examples.
## Benchmarks

Performance is not the primary target of the package, instead it strives for correctness. A simple benchmark that compare the performance of available options is provided to give a rough estimate of the cost of each option. You can find the JSON documents used by this benchmark in the directory [testdata/benchs](testdata/benchs).
Expand Down
29 changes: 24 additions & 5 deletions differ.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@ type Differ struct {
opts options
}

type jsonNode struct {
ptr pointer
val interface{}
}

type options struct {
factorize bool
rationalize bool
invertible bool
equivalent bool
ignoredPtrs map[pointer]struct{}
}

// Patch returns the list of JSON patch operations
Expand Down Expand Up @@ -62,6 +68,9 @@ func (d *Differ) diff(ptr pointer, src, tgt interface{}) {
if src == nil && tgt == nil {
return
}
if _, ok := d.opts.ignoredPtrs[ptr]; ok {
return
}
if !areComparable(src, tgt) {
if ptr.isRoot() {
// If incomparable values are located at the root
Expand Down Expand Up @@ -196,9 +205,15 @@ func (d *Differ) compareObjects(ptr pointer, src, tgt map[string]interface{}) {
case inOld && inNew:
d.diff(ptr.appendKey(k), src[k], tgt[k])
case inOld && !inNew:
d.remove(ptr.appendKey(k), src[k])
p := ptr.appendKey(k)
if _, ok := d.opts.ignoredPtrs[p]; !ok {
d.remove(p, src[k])
}
case !inOld && inNew:
d.add(ptr.appendKey(k), tgt[k])
p := ptr.appendKey(k)
if _, ok := d.opts.ignoredPtrs[p]; !ok {
d.add(p, tgt[k])
}
}
}
}
Expand All @@ -213,7 +228,9 @@ func (d *Differ) compareArrays(ptr pointer, src, tgt []interface{}) {
// from the destination and the removal index
// is always equal to the original array length.
for i := size; i < len(src); i++ {
d.remove(ptr.appendIndex(size), src[i])
if _, ok := d.opts.ignoredPtrs[ptr.appendIndex(i)]; !ok {
d.remove(ptr.appendIndex(size), src[i])
}
}
if d.opts.equivalent && d.unorderedDeepEqualSlice(src, tgt) {
goto next
Expand All @@ -228,7 +245,9 @@ next:
// than the source, entries are appended to the
// destination.
for i := size; i < len(tgt); i++ {
d.add(ptr.appendKey("-"), tgt[i])
if _, ok := d.opts.ignoredPtrs[ptr.appendIndex(i)]; !ok {
d.add(ptr.appendKey("-"), tgt[i])
}
}
}

Expand All @@ -244,7 +263,7 @@ func (d *Differ) unorderedDeepEqualSlice(src, tgt []interface{}) bool {
}
for _, v := range tgt {
k := d.hasher.digest(v)
// If the digest hash if not in the Compare,
// If the digest hash is not in the compare,
// return early.
if _, ok := diff[k]; !ok {
return false
Expand Down
104 changes: 64 additions & 40 deletions differ_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package jsondiff

import (
"encoding/json"
"io/ioutil"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
Expand All @@ -12,12 +13,16 @@ import (
var testNameReplacer = strings.NewReplacer(",", "", "(", "", ")", "")

type testcase struct {
Name string `json:"name"`
Before interface{} `json:"before"`
After interface{} `json:"after"`
Patch []Operation `json:"patch"`
Name string `json:"name"`
Before interface{} `json:"before"`
After interface{} `json:"after"`
Patch Patch `json:"patch"`
IncompletePatch Patch `json:"incomplete_patch"`
Ignores []string `json:"ignores"`
}

type patchGetter func(tc *testcase) Patch

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") }
Expand All @@ -33,6 +38,7 @@ func TestOptions(t *testing.T) {
{"testdata/tests/options/factorization.json", makeopts(Factorize())},
{"testdata/tests/options/rationalization.json", makeopts(Rationalize())},
{"testdata/tests/options/equivalence.json", makeopts(Equivalent())},
{"testdata/tests/options/ignore.json", makeopts()},
{"testdata/tests/options/all.json", makeopts(Factorize(), Rationalize(), Invertible(), Equivalent())},
} {
var (
Expand All @@ -47,7 +53,7 @@ func TestOptions(t *testing.T) {
}

func runCasesFromFile(t *testing.T, filename string, opts ...Option) {
b, err := ioutil.ReadFile(filename)
b, err := os.ReadFile(filename)
if err != nil {
t.Fatal(err)
}
Expand All @@ -63,42 +69,60 @@ func runTestCases(t *testing.T, cases []testcase, opts ...Option) {
name := testNameReplacer.Replace(tc.Name)

t.Run(name, func(t *testing.T) {
beforeBytes, err := json.Marshal(tc.Before)
if err != nil {
t.Error(err)
}
d := Differ{
targetBytes: beforeBytes,
}
d.applyOpts(opts...)
d.Compare(tc.Before, tc.After)
runTestCase(t, tc, func(tc *testcase) Patch {
return tc.Patch
}, opts...)
})
if len(tc.Ignores) != 0 {
name = fmt.Sprintf("%s_with_ignore", name)
xopts := append(opts, Ignores(tc.Ignores...))

if d.patch != nil {
t.Logf("\n%s", d.patch)
}
if len(d.patch) != len(tc.Patch) {
t.Errorf("got %d patches, want %d", len(d.patch), len(tc.Patch))
return
t.Run(name, func(t *testing.T) {
runTestCase(t, tc, func(tc *testcase) Patch {
return tc.IncompletePatch
}, xopts...)
})
}
}
}

func runTestCase(t *testing.T, tc testcase, pc patchGetter, opts ...Option) {
beforeBytes, err := json.Marshal(tc.Before)
if err != nil {
t.Error(err)
}
d := Differ{
targetBytes: beforeBytes,
}
d.applyOpts(opts...)
d.Compare(tc.Before, tc.After)

wantPatch := pc(&tc)

if d.patch != nil {
t.Logf("\n%s", d.patch)
}
if len(d.patch) != len(wantPatch) {
t.Errorf("got %d patches, want %d", len(d.patch), len(wantPatch))
return
}
for i, op := range d.patch {
want := wantPatch[i]
if g, w := op.Type, want.Type; g != w {
t.Errorf("op #%d mismatch: op: got %q, want %q", i, g, w)
}
if g, w := op.Path.String(), want.Path.String(); g != w {
t.Errorf("op #%d mismatch: path: got %q, want %q", i, g, w)
}
switch want.Type {
case OperationCopy, OperationMove:
if g, w := op.From.String(), want.From.String(); g != w {
t.Errorf("op #%d mismatch: from: got %q, want %q", i, g, w)
}
for i, op := range d.patch {
want := tc.Patch[i]
if g, w := op.Type, want.Type; g != w {
t.Errorf("op #%d mismatch: op: got %q, want %q", i, g, w)
}
if g, w := op.Path.String(), want.Path.String(); g != w {
t.Errorf("op #%d mismatch: path: got %q, want %q", i, g, w)
}
switch want.Type {
case OperationCopy, OperationMove:
if g, w := op.From.String(), want.From.String(); g != w {
t.Errorf("op #%d mismatch: from: got %q, want %q", i, g, w)
}
case OperationAdd, OperationReplace:
if !reflect.DeepEqual(op.Value, want.Value) {
t.Errorf("op #%d mismatch: value: unequal", i)
}
}
case OperationAdd, OperationReplace:
if !reflect.DeepEqual(op.Value, want.Value) {
t.Errorf("op #%d mismatch: value: unequal", i)
}
})
}
}
}
9 changes: 6 additions & 3 deletions equal.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package jsondiff

import (
"fmt"
"reflect"
)

Expand All @@ -13,7 +12,7 @@ func areComparable(i1, i2 interface{}) bool {
}

// typeSwitchKind returns the reflect.Kind of
// the interface i using a type switch statement.
// the interface using a type switch statement.
func typeSwitchKind(i interface{}) reflect.Kind {
switch i.(type) {
case string:
Expand All @@ -29,7 +28,11 @@ func typeSwitchKind(i interface{}) reflect.Kind {
case map[string]interface{}:
return reflect.Map
default:
panic(fmt.Sprintf("invalid json type %T", i))
// reflect.Invalid is the zero-value of the
// reflect.Kind type and does not represent
// the actual kind of the value, it is used
// to signal an unsupported type.
return reflect.Invalid
}
}

Expand Down
59 changes: 59 additions & 0 deletions equal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,62 @@ func BenchmarkGetType(b *testing.B) {
}
})
}

func Test_typeSwitchKind(t *testing.T) {
for _, tt := range []struct {
val any
valid bool
kind reflect.Kind
}{
{
"foo",
true,
reflect.String,
},
{
false,
true,
reflect.Bool,
},
{
float32(3.14),
false,
reflect.Invalid,
},
{
nil,
true,
reflect.Ptr,
},
{
&struct{}{},
false,
reflect.Invalid,
},
{
3.14,
true,
reflect.Float64,
},
{
func() {},
false,
reflect.Invalid,
},
{
[]interface{}{},
true,
reflect.Slice,
},
{
map[string]interface{}{},
true,
reflect.Map,
},
} {
k := typeSwitchKind(tt.val)
if k != tt.kind {
t.Errorf("got %s, want %s", k, tt.kind)
}
}
}
Loading

0 comments on commit c0f2ef4

Please sign in to comment.