From 14fb7fe434519156f994ccceb4c917a8cac3654d Mon Sep 17 00:00:00 2001 From: rohit keshwani Date: Tue, 10 Jun 2025 23:27:53 +0530 Subject: [PATCH 1/2] support for <> --- jsondiff.go | 29 +++++++ jsondiff_test.go | 221 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) diff --git a/jsondiff.go b/jsondiff.go index ffe12cf..3698e19 100644 --- a/jsondiff.go +++ b/jsondiff.go @@ -525,6 +525,20 @@ func (ctx *context) printCollectionDiff(cfg *collectionConfig, it dualIterator) func (ctx *context) printDiff(a, b interface{}) string { var buf bytes.Buffer + // Check for special <> value in b + if bStr, ok := b.(string); ok && bStr == "<>" { + // If printDiff is called, it means the value was present in both sides + // (the dual iterator only calls printDiff when aOK && bOK are true) + // So regardless of whether a is nil or not, the presence check passes + 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 @@ -628,6 +642,15 @@ 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 "<>" as a value, +// it will check if a corresponding value exists in the first argument (a). If the +// value is present (regardless of its actual value), it's considered a match. +// If the value is missing, it's considered NoMatch. For example: +// +// Compare(`{"name": "John"}`, `{"name": "<>"}`, nil) // FullMatch +// Compare(`{}`, `{"name": "<>"}`, 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 @@ -658,6 +681,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() diff --git a/jsondiff_test.go b/jsondiff_test.go index e7743d4..dd3fb20 100644 --- a/jsondiff_test.go +++ b/jsondiff_test.go @@ -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": "<>", "age": "<>"}`, + expected: FullMatch, + }, + { + name: "presence check with missing value", + a: `{"name": "John"}`, + b: `{"name": "<>", "age": "<>"}`, + expected: NoMatch, + }, + { + name: "presence check in array", + a: `["value1", "value2", "value3"]`, + b: `["<>", "<>", "<>"]`, + expected: FullMatch, + }, + { + name: "presence check in shorter array", + a: `["value1", "value2"]`, + b: `["<>", "<>", "<>"]`, + expected: NoMatch, + }, + { + name: "mixed presence and exact match", + a: `{"name": "John", "age": 30, "city": "NYC"}`, + b: `{"name": "<>", "age": 30, "city": "<>"}`, + expected: FullMatch, + }, + { + name: "mixed presence with mismatch", + a: `{"name": "John", "age": 25, "city": "NYC"}`, + b: `{"name": "<>", "age": 30, "city": "<>"}`, + 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": "<>", "age": "<>"}`, + expected: SupersetMatch, + }, + { + name: "partial presence with missing required field", + a: `{"name": "John", "city": "NYC"}`, + b: `{"name": "<>", "age": "<>", "city": "<>"}`, + expected: NoMatch, + }, + { + name: "nested object with presence check", + a: `{"user": {"name": "John", "email": "john@example.com"}, "status": "active"}`, + b: `{"user": "<>", "status": "<>"}`, + expected: FullMatch, + }, + { + name: "nested object with missing nested field", + a: `{"user": {"name": "John"}, "status": "active"}`, + b: `{"user": {"name": "<>", "email": "<>"}, "status": "<>"}`, + expected: NoMatch, + }, + { + name: "array with partial presence", + a: `["value1", "value2"]`, + b: `["<>", "<>", "<>"]`, + expected: NoMatch, + }, + { + name: "array superset with presence", + a: `["value1", "value2", "value3", "value4"]`, + b: `["<>", "<>"]`, + expected: SupersetMatch, + }, + { + name: "mixed exact and presence with partial match", + a: `{"name": "John", "age": 25, "city": "NYC"}`, + b: `{"name": "<>", "age": 30, "city": "<>", "country": "<>"}`, + expected: NoMatch, + }, + { + name: "complex nested structure with presence", + a: `{"users": [{"name": "John"}, {"name": "Jane"}], "count": 2}`, + b: `{"users": "<>", "count": "<>"}`, + expected: FullMatch, + }, + { + name: "empty object vs presence requirements", + a: `{}`, + b: `{"required": "<>"}`, + expected: NoMatch, + }, + { + name: "empty array vs presence requirements", + a: `[]`, + b: `["<>"]`, + expected: NoMatch, + }, + { + name: "null value vs presence requirement", + a: `{"field": null}`, + b: `{"field": "<>"}`, + expected: FullMatch, // null is considered present + }, + { + name: "boolean false vs presence requirement", + a: `{"active": false}`, + b: `{"active": "<>"}`, + expected: FullMatch, // false is considered present + }, + { + name: "zero number vs presence requirement", + a: `{"count": 0}`, + b: `{"count": "<>"}`, + expected: FullMatch, // 0 is considered present + }, + { + name: "empty string vs presence requirement", + a: `{"name": ""}`, + b: `{"name": "<>"}`, + 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 <> string", + a: `{"message": "<>"}`, + b: `{"message": "<>"}`, + expected: FullMatch, // both have literal string, should match exactly + }, + { + name: "presence check vs literal string mismatch", + a: `{"message": "hello"}`, + b: `{"message": "<>"}`, + expected: FullMatch, // presence check should pass + }, + { + name: "literal string vs presence check (reversed)", + a: `{"message": "<>"}`, + 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": "<>"}}}}`, + expected: FullMatch, + }, + { + name: "presence check in mixed array types", + a: `[1, "string", true, {"key": "value"}, [1,2,3]]`, + b: `["<>", "<>", "<>", "<>", "<>"]`, + expected: FullMatch, + }, + { + name: "presence check with complex object", + a: `{"data": {"users": [{"id": 1}, {"id": 2}], "total": 2}}`, + b: `{"data": "<>"}`, + expected: FullMatch, + }, + { + name: "multiple presence checks with one missing", + a: `{"a": 1, "b": 2}`, + b: `{"a": "<>", "b": "<>", "c": "<>"}`, + 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) + } + }) + } +} From 8cacda918e81f54b5cb551b2e9c887e1e76b9099 Mon Sep 17 00:00:00 2001 From: rohit keshwani Date: Wed, 18 Jun 2025 11:51:02 +0530 Subject: [PATCH 2/2] handles null case --- README.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++ jsondiff.go | 28 ++++++++++++------- jsondiff_test.go | 2 +- 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c69d0e0..ff36b48 100644 --- a/README.md +++ b/README.md @@ -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 `<>` 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 `"<>"` 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": "<>", "age": "<>"}`), + nil +) +``` + +**Missing field detection:** +```go +// This returns NoMatch because "email" is missing in the first JSON +jsondiff.Compare( + []byte(`{"name": "John"}`), + []byte(`{"name": "<>", "email": "<>"}`), + 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": "<>", "age": 30, "city": "<>"}`), + nil +) +``` + +**Array presence checks:** +```go +// This returns FullMatch because all three elements are present +jsondiff.Compare( + []byte(`["value1", "value2", "value3"]`), + []byte(`["<>", "<>", "<>"]`), + 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": "<>"}`), + 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": "<>", "age": "<>"}`), + 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 diff --git a/jsondiff.go b/jsondiff.go index 3698e19..1fdb71a 100644 --- a/jsondiff.go +++ b/jsondiff.go @@ -527,14 +527,23 @@ func (ctx *context) printDiff(a, b interface{}) string { // Check for special <> value in b if bStr, ok := b.(string); ok && bStr == "<>" { - // If printDiff is called, it means the value was present in both sides + // If printDiff is called, it means the key was present in both sides // (the dual iterator only calls printDiff when aOK && bOK are true) - // So regardless of whether a is nil or not, the presence check passes - if !ctx.opts.SkipMatches { - ctx.tag(&buf, &ctx.opts.Normal) - ctx.writeValue(&buf, a, true) - buf.WriteString(" (presence confirmed)") - ctx.result(FullMatch) + // 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) } @@ -645,11 +654,12 @@ func (ctx *context) printDiff(a, b interface{}) string { // Special Feature: Presence Check // If the second argument (b) contains the special string "<>" as a value, // it will check if a corresponding value exists in the first argument (a). If the -// value is present (regardless of its actual value), it's considered a match. -// If the value is missing, it's considered NoMatch. For example: +// 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": "<>"}`, nil) // FullMatch // Compare(`{}`, `{"name": "<>"}`, nil) // NoMatch +// Compare(`{"name": null}`, `{"name": "<>"}`, 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 diff --git a/jsondiff_test.go b/jsondiff_test.go index dd3fb20..f8aa031 100644 --- a/jsondiff_test.go +++ b/jsondiff_test.go @@ -365,7 +365,7 @@ func TestPresencePartialMatches(t *testing.T) { name: "null value vs presence requirement", a: `{"field": null}`, b: `{"field": "<>"}`, - expected: FullMatch, // null is considered present + expected: NoMatch, // null is not considered present }, { name: "boolean false vs presence requirement",