Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,76 @@ Is a superset of (or second item is a subset of a first one):
{"a": 1, "c": 3}
```

## Presence Check Feature

The library supports a special `<<PRESENCE>>` feature that allows you to check for the existence of values without caring about their actual content. When the second argument contains the special string `"<<PRESENCE>>"` as a value, it will check if a corresponding value exists in the first argument.

### How it works:
- If the value is present and not `null` (including `false`, `0`, or empty strings), it's considered a match
- If the value is missing or `null`, it's considered `NoMatch`

### Examples:

**Basic presence check:**
```go
// This returns FullMatch because "name" exists in the first JSON
jsondiff.Compare(
[]byte(`{"name": "John", "age": 30}`),
[]byte(`{"name": "<<PRESENCE>>", "age": "<<PRESENCE>>"}`),
nil
)
```

**Missing field detection:**
```go
// This returns NoMatch because "email" is missing in the first JSON
jsondiff.Compare(
[]byte(`{"name": "John"}`),
[]byte(`{"name": "<<PRESENCE>>", "email": "<<PRESENCE>>"}`),
nil
)
```

**Mixed exact and presence matching:**
```go
// This returns FullMatch - "name" just needs to be present, "age" must be exactly 30
jsondiff.Compare(
[]byte(`{"name": "John", "age": 30, "city": "NYC"}`),
[]byte(`{"name": "<<PRESENCE>>", "age": 30, "city": "<<PRESENCE>>"}`),
nil
)
```

**Array presence checks:**
```go
// This returns FullMatch because all three elements are present
jsondiff.Compare(
[]byte(`["value1", "value2", "value3"]`),
[]byte(`["<<PRESENCE>>", "<<PRESENCE>>", "<<PRESENCE>>"]`),
nil
)
```

**Null values are NOT considered present:**
```go
// This returns NoMatch because null values are treated as missing
jsondiff.Compare(
[]byte(`{"field": null}`),
[]byte(`{"field": "<<PRESENCE>>"}`),
nil
)
```

**Superset matching with presence:**
```go
// This returns SupersetMatch - first JSON has extra fields
jsondiff.Compare(
[]byte(`{"name": "John", "age": 30, "city": "NYC", "country": "USA"}`),
[]byte(`{"name": "<<PRESENCE>>", "age": "<<PRESENCE>>"}`),
nil
)
```

Library API documentation can be found on godoc.org: https://godoc.org/github.com/nsf/jsondiff

You can try **LIVE** version here (compiled to wasm): https://nosmileface.dev/jsondiff
Expand Down
39 changes: 39 additions & 0 deletions jsondiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,29 @@ func (ctx *context) printCollectionDiff(cfg *collectionConfig, it dualIterator)
func (ctx *context) printDiff(a, b interface{}) string {
var buf bytes.Buffer

// Check for special <<PRESENCE>> value in b
if bStr, ok := b.(string); ok && bStr == "<<PRESENCE>>" {
// If printDiff is called, it means the key was present in both sides
// (the dual iterator only calls printDiff when aOK && bOK are true)
// However, null values should be treated as missing for presence checks
if a == nil {
// Value is null, treat as missing - this is NoMatch
ctx.tag(&buf, &ctx.opts.Removed)
ctx.writeValue(&buf, nil, false)
buf.WriteString(" (expected presence, but value is null)")
ctx.result(NoMatch)
} else {
// Value is present and not null - this is a match
if !ctx.opts.SkipMatches {
ctx.tag(&buf, &ctx.opts.Normal)
ctx.writeValue(&buf, a, true)
buf.WriteString(" (presence confirmed)")
ctx.result(FullMatch)
}
}
return ctx.finalize(&buf)
}

if a == nil || b == nil {
// either is nil, means there are just two cases:
// 1. both are nil => match
Expand Down Expand Up @@ -628,6 +651,16 @@ func (ctx *context) printDiff(a, b interface{}) string {
// The rest of the difference types mean that one of or both JSON documents are
// invalid JSON.
//
// Special Feature: Presence Check
// If the second argument (b) contains the special string "<<PRESENCE>>" as a value,
// it will check if a corresponding value exists in the first argument (a). If the
// value is present and not null, it's considered a match. If the value is missing
// or null, it's considered NoMatch. For example:
//
// Compare(`{"name": "John"}`, `{"name": "<<PRESENCE>>"}`, nil) // FullMatch
// Compare(`{}`, `{"name": "<<PRESENCE>>"}`, nil) // NoMatch
// Compare(`{"name": null}`, `{"name": "<<PRESENCE>>"}`, nil) // NoMatch
//
// Returned string uses a format similar to pretty printed JSON to show the
// human-readable difference between provided JSON documents. It is important
// to understand that returned format is not a valid JSON and is not meant
Expand Down Expand Up @@ -658,6 +691,12 @@ func CompareStreams(a, b io.Reader, opts *Options) (Difference, string) {

var buf bytes.Buffer

// Use default options if none provided
if opts == nil {
defaultOpts := Options{}
opts = &defaultOpts
}

ctx := context{opts: opts}
buf.WriteString(ctx.printDiff(av, bv))
return ctx.diff, buf.String()
Expand Down
221 changes: 221 additions & 0 deletions jsondiff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,224 @@ func TestCompareFloatsWithEpsilon(t *testing.T) {
}
}
}

func TestPresenceFeature(t *testing.T) {
tests := []struct {
name string
a string
b string
expected Difference
}{
{
name: "presence check with existing value",
a: `{"name": "John", "age": 30}`,
b: `{"name": "<<PRESENCE>>", "age": "<<PRESENCE>>"}`,
expected: FullMatch,
},
{
name: "presence check with missing value",
a: `{"name": "John"}`,
b: `{"name": "<<PRESENCE>>", "age": "<<PRESENCE>>"}`,
expected: NoMatch,
},
{
name: "presence check in array",
a: `["value1", "value2", "value3"]`,
b: `["<<PRESENCE>>", "<<PRESENCE>>", "<<PRESENCE>>"]`,
expected: FullMatch,
},
{
name: "presence check in shorter array",
a: `["value1", "value2"]`,
b: `["<<PRESENCE>>", "<<PRESENCE>>", "<<PRESENCE>>"]`,
expected: NoMatch,
},
{
name: "mixed presence and exact match",
a: `{"name": "John", "age": 30, "city": "NYC"}`,
b: `{"name": "<<PRESENCE>>", "age": 30, "city": "<<PRESENCE>>"}`,
expected: FullMatch,
},
{
name: "mixed presence with mismatch",
a: `{"name": "John", "age": 25, "city": "NYC"}`,
b: `{"name": "<<PRESENCE>>", "age": 30, "city": "<<PRESENCE>>"}`,
expected: NoMatch,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff, _ := Compare([]byte(tt.a), []byte(tt.b), nil)
if diff != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, diff)
}
})
}
}

func TestPresencePartialMatches(t *testing.T) {
tests := []struct {
name string
a string
b string
expected Difference
}{
{
name: "superset match - extra properties in a",
a: `{"name": "John", "age": 30, "city": "NYC", "country": "USA"}`,
b: `{"name": "<<PRESENCE>>", "age": "<<PRESENCE>>"}`,
expected: SupersetMatch,
},
{
name: "partial presence with missing required field",
a: `{"name": "John", "city": "NYC"}`,
b: `{"name": "<<PRESENCE>>", "age": "<<PRESENCE>>", "city": "<<PRESENCE>>"}`,
expected: NoMatch,
},
{
name: "nested object with presence check",
a: `{"user": {"name": "John", "email": "john@example.com"}, "status": "active"}`,
b: `{"user": "<<PRESENCE>>", "status": "<<PRESENCE>>"}`,
expected: FullMatch,
},
{
name: "nested object with missing nested field",
a: `{"user": {"name": "John"}, "status": "active"}`,
b: `{"user": {"name": "<<PRESENCE>>", "email": "<<PRESENCE>>"}, "status": "<<PRESENCE>>"}`,
expected: NoMatch,
},
{
name: "array with partial presence",
a: `["value1", "value2"]`,
b: `["<<PRESENCE>>", "<<PRESENCE>>", "<<PRESENCE>>"]`,
expected: NoMatch,
},
{
name: "array superset with presence",
a: `["value1", "value2", "value3", "value4"]`,
b: `["<<PRESENCE>>", "<<PRESENCE>>"]`,
expected: SupersetMatch,
},
{
name: "mixed exact and presence with partial match",
a: `{"name": "John", "age": 25, "city": "NYC"}`,
b: `{"name": "<<PRESENCE>>", "age": 30, "city": "<<PRESENCE>>", "country": "<<PRESENCE>>"}`,
expected: NoMatch,
},
{
name: "complex nested structure with presence",
a: `{"users": [{"name": "John"}, {"name": "Jane"}], "count": 2}`,
b: `{"users": "<<PRESENCE>>", "count": "<<PRESENCE>>"}`,
expected: FullMatch,
},
{
name: "empty object vs presence requirements",
a: `{}`,
b: `{"required": "<<PRESENCE>>"}`,
expected: NoMatch,
},
{
name: "empty array vs presence requirements",
a: `[]`,
b: `["<<PRESENCE>>"]`,
expected: NoMatch,
},
{
name: "null value vs presence requirement",
a: `{"field": null}`,
b: `{"field": "<<PRESENCE>>"}`,
expected: NoMatch, // null is not considered present
},
{
name: "boolean false vs presence requirement",
a: `{"active": false}`,
b: `{"active": "<<PRESENCE>>"}`,
expected: FullMatch, // false is considered present
},
{
name: "zero number vs presence requirement",
a: `{"count": 0}`,
b: `{"count": "<<PRESENCE>>"}`,
expected: FullMatch, // 0 is considered present
},
{
name: "empty string vs presence requirement",
a: `{"name": ""}`,
b: `{"name": "<<PRESENCE>>"}`,
expected: FullMatch, // empty string is considered present
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff, output := Compare([]byte(tt.a), []byte(tt.b), nil)
if diff != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, diff)
t.Errorf("Output: %s", output)
}
})
}
}

func TestPresenceEdgeCases(t *testing.T) {
tests := []struct {
name string
a string
b string
expected Difference
}{
{
name: "presence check with literal <<PRESENCE>> string",
a: `{"message": "<<PRESENCE>>"}`,
b: `{"message": "<<PRESENCE>>"}`,
expected: FullMatch, // both have literal string, should match exactly
},
{
name: "presence check vs literal string mismatch",
a: `{"message": "hello"}`,
b: `{"message": "<<PRESENCE>>"}`,
expected: FullMatch, // presence check should pass
},
{
name: "literal string vs presence check (reversed)",
a: `{"message": "<<PRESENCE>>"}`,
b: `{"message": "hello"}`,
expected: NoMatch, // exact match required, different strings
},
{
name: "deeply nested presence check",
a: `{"level1": {"level2": {"level3": {"value": "exists"}}}}`,
b: `{"level1": {"level2": {"level3": {"value": "<<PRESENCE>>"}}}}`,
expected: FullMatch,
},
{
name: "presence check in mixed array types",
a: `[1, "string", true, {"key": "value"}, [1,2,3]]`,
b: `["<<PRESENCE>>", "<<PRESENCE>>", "<<PRESENCE>>", "<<PRESENCE>>", "<<PRESENCE>>"]`,
expected: FullMatch,
},
{
name: "presence check with complex object",
a: `{"data": {"users": [{"id": 1}, {"id": 2}], "total": 2}}`,
b: `{"data": "<<PRESENCE>>"}`,
expected: FullMatch,
},
{
name: "multiple presence checks with one missing",
a: `{"a": 1, "b": 2}`,
b: `{"a": "<<PRESENCE>>", "b": "<<PRESENCE>>", "c": "<<PRESENCE>>"}`,
expected: NoMatch,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
diff, output := Compare([]byte(tt.a), []byte(tt.b), nil)
if diff != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, diff)
t.Errorf("Output: %s", output)
}
})
}
}