From af826e88ed74b0c3c6a85cf667ff39b3f781ac1d Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Sun, 21 Apr 2024 18:41:54 +0200 Subject: [PATCH 1/8] feat: allow to have equality between null and empty array --- .../matcher/CompositeJsonMatcher.java | 36 ++++++++----------- .../deblock/jsondiff/matcher/JsonMatcher.java | 1 - .../LenientJsonArrayPartialMatcher.java | 7 +++- .../LenientJsonObjectPartialMatcher.java | 8 ++++- .../LenientNumberPrimitivePartialMatcher.java | 6 ++++ .../matcher/NullEqualsEmptyArrayMatcher.java | 26 ++++++++++++++ .../jsondiff/matcher/PartialJsonMatcher.java | 3 ++ .../StrictJsonArrayPartialMatcher.java | 6 ++++ .../StrictJsonObjectPartialMatcher.java | 6 ++++ .../StrictPrimitivePartialMatcher.java | 6 ++++ .../java/com/deblock/jsondiff/Sample.java | 5 +-- 11 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcher.java diff --git a/src/main/java/com/deblock/jsondiff/matcher/CompositeJsonMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/CompositeJsonMatcher.java index 1db9e13..e8d83ff 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/CompositeJsonMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/CompositeJsonMatcher.java @@ -6,32 +6,24 @@ import tools.jackson.databind.node.ObjectNode; import tools.jackson.databind.node.ValueNode; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + public class CompositeJsonMatcher implements JsonMatcher { - private final PartialJsonMatcher jsonArrayPartialMatcher; - private final PartialJsonMatcher jsonObjectPartialMatcher; - private final PartialJsonMatcher primitivePartialMatcher; + private final List> matchers; - public CompositeJsonMatcher( - PartialJsonMatcher jsonArrayPartialMatcher, - PartialJsonMatcher jsonObjectPartialMatcher, - PartialJsonMatcher primitivePartialMatcher - ) { - this.jsonArrayPartialMatcher = jsonArrayPartialMatcher; - this.jsonObjectPartialMatcher = jsonObjectPartialMatcher; - this.primitivePartialMatcher = primitivePartialMatcher; + public CompositeJsonMatcher(PartialJsonMatcher ...jsonArrayPartialMatcher) { + this.matchers = new ArrayList<>(); + Arrays.stream(jsonArrayPartialMatcher).forEach(it -> this.matchers.add((PartialJsonMatcher) it)); } @Override public JsonDiff diff(Path path, JsonNode expected, JsonNode received) { - if (expected instanceof ObjectNode expectedObjectNode && received instanceof ObjectNode receivedObjectNode) { - return this.jsonObjectPartialMatcher.jsonDiff(path, expectedObjectNode, receivedObjectNode, this); - } else if (expected instanceof ArrayNode expectedArrayNode && received instanceof ArrayNode receivedArrayNode) { - return this.jsonArrayPartialMatcher.jsonDiff(path, expectedArrayNode, receivedArrayNode, this); - } else if (expected instanceof ValueNode expectedValueNode && received instanceof ValueNode receivedValueNode){ - return this.primitivePartialMatcher.jsonDiff(path, expectedValueNode, receivedValueNode, this); - } else { - return new UnMatchedPrimaryDiff(path, expected, received); - } + return this.matchers.stream() + .filter(matcher -> matcher.manage(expected, received)) + .findFirst() + .map(matcher -> matcher.jsonDiff(path, expected, received, this)) + .orElseGet(() -> new UnMatchedPrimaryDiff(path, expected, received)); } - -} +} \ No newline at end of file diff --git a/src/main/java/com/deblock/jsondiff/matcher/JsonMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/JsonMatcher.java index 066e8ca..a2d34d5 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/JsonMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/JsonMatcher.java @@ -6,5 +6,4 @@ public interface JsonMatcher { JsonDiff diff(Path path, JsonNode expected, JsonNode received); - } diff --git a/src/main/java/com/deblock/jsondiff/matcher/LenientJsonArrayPartialMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/LenientJsonArrayPartialMatcher.java index 0f479ab..d3f5dab 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/LenientJsonArrayPartialMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/LenientJsonArrayPartialMatcher.java @@ -57,6 +57,11 @@ public JsonDiff jsonDiff(Path path, ArrayNode expectedArrayNode, ArrayNode recie return diff; } + @Override + public boolean manage(JsonNode expected, JsonNode received) { + return expected.isArray() && received.isArray(); + } + private double maxSimilarityRate(Map.Entry> entry) { return entry.getValue().values().stream().mapToDouble(JsonDiff::similarityRate).max().orElse(0); } @@ -137,4 +142,4 @@ public boolean containsNode(JsonNode node) { return nodeCounter.containsKey(node); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/deblock/jsondiff/matcher/LenientJsonObjectPartialMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/LenientJsonObjectPartialMatcher.java index f3f65c3..b0b0d0f 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/LenientJsonObjectPartialMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/LenientJsonObjectPartialMatcher.java @@ -2,6 +2,7 @@ import com.deblock.jsondiff.diff.JsonDiff; import com.deblock.jsondiff.diff.JsonObjectDiff; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ObjectNode; public class LenientJsonObjectPartialMatcher implements PartialJsonMatcher { @@ -26,4 +27,9 @@ public JsonDiff jsonDiff(Path path, ObjectNode expectedJson, ObjectNode received return jsonDiff; } -} + + @Override + public boolean manage(JsonNode expected, JsonNode received) { + return expected.isObject() && received.isObject(); + } +} \ No newline at end of file diff --git a/src/main/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcher.java index a2bb13d..cf63709 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcher.java @@ -3,6 +3,7 @@ import com.deblock.jsondiff.diff.JsonDiff; import com.deblock.jsondiff.diff.MatchedPrimaryDiff; import com.deblock.jsondiff.diff.UnMatchedPrimaryDiff; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.NumericNode; import tools.jackson.databind.node.ValueNode; @@ -25,4 +26,9 @@ public JsonDiff jsonDiff(Path path, ValueNode expectedValue, ValueNode receivedV return delegated.jsonDiff(path, expectedValue, receivedValue, jsonMatcher); } + + @Override + public boolean manage(JsonNode expected, JsonNode received) { + return expected.isValueNode() && received.isValueNode(); + } } diff --git a/src/main/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcher.java new file mode 100644 index 0000000..35716c0 --- /dev/null +++ b/src/main/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcher.java @@ -0,0 +1,26 @@ +package com.deblock.jsondiff.matcher; + +import com.deblock.jsondiff.diff.JsonDiff; +import com.deblock.jsondiff.diff.MatchedPrimaryDiff; +import com.deblock.jsondiff.diff.UnMatchedPrimaryDiff; +import tools.jackson.databind.JsonNode; + +public class NullEqualsEmptyArrayMatcher implements PartialJsonMatcher { + + @Override + public JsonDiff jsonDiff(Path path, JsonNode expectedJson, JsonNode receivedJson, JsonMatcher jsonMatcher) { + if ( + (expectedJson.isNull() && receivedJson.isEmpty()) + || (receivedJson.isNull() && expectedJson.isEmpty()) + ) { + return new MatchedPrimaryDiff(path, expectedJson); + } + return new UnMatchedPrimaryDiff(path, expectedJson, receivedJson); + } + + @Override + public boolean manage(JsonNode expected, JsonNode received) { + return (expected.isNull() && received.isArray()) + || (received.isNull() && expected.isArray()); + } +} diff --git a/src/main/java/com/deblock/jsondiff/matcher/PartialJsonMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/PartialJsonMatcher.java index d91cd8b..6d04e46 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/PartialJsonMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/PartialJsonMatcher.java @@ -5,4 +5,7 @@ public interface PartialJsonMatcher { JsonDiff jsonDiff(Path path, T expectedJson, T receivedJson, JsonMatcher jsonMatcher); + + boolean manage(JsonNode expected, JsonNode received); + } diff --git a/src/main/java/com/deblock/jsondiff/matcher/StrictJsonArrayPartialMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/StrictJsonArrayPartialMatcher.java index 9e7c73b..923786b 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/StrictJsonArrayPartialMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/StrictJsonArrayPartialMatcher.java @@ -2,6 +2,7 @@ import com.deblock.jsondiff.diff.JsonArrayDiff; import com.deblock.jsondiff.diff.JsonDiff; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ArrayNode; import java.util.Comparator; @@ -35,4 +36,9 @@ public JsonDiff jsonDiff(Path path, ArrayNode expectedValues, ArrayNode received } return diff; } + + @Override + public boolean manage(JsonNode expected, JsonNode received) { + return expected.isArray() && received.isArray(); + } } diff --git a/src/main/java/com/deblock/jsondiff/matcher/StrictJsonObjectPartialMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/StrictJsonObjectPartialMatcher.java index ba7291a..0103bc9 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/StrictJsonObjectPartialMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/StrictJsonObjectPartialMatcher.java @@ -2,6 +2,7 @@ import com.deblock.jsondiff.diff.JsonDiff; import com.deblock.jsondiff.diff.JsonObjectDiff; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ObjectNode; public class StrictJsonObjectPartialMatcher implements PartialJsonMatcher { @@ -37,4 +38,9 @@ public JsonDiff jsonDiff(Path path, ObjectNode expectedJson, ObjectNode received return jsonDiff; } + + @Override + public boolean manage(JsonNode expected, JsonNode received) { + return expected.isObject() && received.isObject(); + } } diff --git a/src/main/java/com/deblock/jsondiff/matcher/StrictPrimitivePartialMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/StrictPrimitivePartialMatcher.java index 8c7df2e..c6e4601 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/StrictPrimitivePartialMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/StrictPrimitivePartialMatcher.java @@ -3,6 +3,7 @@ import com.deblock.jsondiff.diff.JsonDiff; import com.deblock.jsondiff.diff.MatchedPrimaryDiff; import com.deblock.jsondiff.diff.UnMatchedPrimaryDiff; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.ValueNode; import java.util.Objects; @@ -17,4 +18,9 @@ public JsonDiff jsonDiff(Path path, ValueNode expectedValue, ValueNode receivedV return new UnMatchedPrimaryDiff(path, expectedValue, receivedValue); } } + + @Override + public boolean manage(JsonNode expected, JsonNode received) { + return expected.isValueNode() && received.isValueNode(); + } } diff --git a/src/test/java/com/deblock/jsondiff/Sample.java b/src/test/java/com/deblock/jsondiff/Sample.java index 5cb7ca4..b456ff0 100644 --- a/src/test/java/com/deblock/jsondiff/Sample.java +++ b/src/test/java/com/deblock/jsondiff/Sample.java @@ -6,12 +6,13 @@ public class Sample { public static void main(String[] args) { - final var expectedJson = "{\"array\": [{\"b\": [1]}, {\"a\": [1, 5]}]}"; - final var receivedJson = "{\"array\": [{\"a\": [1]}]}"; + final var expectedJson = "{\"array\": []}"; + final var receivedJson = "{}"; // define your matcher // CompositeJsonMatcher use other matcher to perform matching on objects, list or primitive final var jsonMatcher = new CompositeJsonMatcher( + new NullEqualsEmptyArrayMatcher(), new LenientJsonArrayPartialMatcher(), // comparing array using lenient mode (ignore array order and extra items) new LenientJsonObjectPartialMatcher(), // comparing object using lenient mode (ignoring extra properties) new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) // comparing primitive types and manage numbers (100.00 == 100) From cacc78d312430bf9d03d17d5becc8224e8aaddfa Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Thu, 1 Jan 2026 19:19:40 +0100 Subject: [PATCH 2/8] fix: update tests for new CompositeJsonMatcher API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix CompositeJsonMatcherTest to configure manage() method on mocks - Add comprehensive tests for NullEqualsEmptyArrayMatcher 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../matcher/CompositeJsonMatcherTest.java | 61 ++++++- .../NullEqualsEmptyArrayMatcherTest.java | 154 ++++++++++++++++++ 2 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 src/test/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcherTest.java diff --git a/src/test/java/com/deblock/jsondiff/matcher/CompositeJsonMatcherTest.java b/src/test/java/com/deblock/jsondiff/matcher/CompositeJsonMatcherTest.java index c44adf4..bfadf03 100644 --- a/src/test/java/com/deblock/jsondiff/matcher/CompositeJsonMatcherTest.java +++ b/src/test/java/com/deblock/jsondiff/matcher/CompositeJsonMatcherTest.java @@ -1,11 +1,13 @@ package com.deblock.jsondiff.matcher; import com.deblock.jsondiff.diff.JsonDiff; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.node.*; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; public class CompositeJsonMatcherTest { private final static Path path = Path.ROOT.add(Path.PathItem.of("property")); @@ -16,10 +18,20 @@ public void shouldCallTheArrayMatcherIfTheTwoObjectAreArray() { final var array2 = new ArrayNode(null); final var arrayMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + final var objectMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + final var primitiveMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + + Mockito.when(arrayMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isArray() && ((JsonNode)inv.getArgument(1)).isArray()); + Mockito.when(objectMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isObject() && ((JsonNode)inv.getArgument(1)).isObject()); + Mockito.when(primitiveMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isValueNode() && ((JsonNode)inv.getArgument(1)).isValueNode()); + final var compositeMatcher = new CompositeJsonMatcher( arrayMatcher, - (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class), - (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class) + objectMatcher, + primitiveMatcher ); final var expectedJsonDiff = Mockito.mock(JsonDiff.class); Mockito.when(arrayMatcher.jsonDiff(path, array1, array2, compositeMatcher)).thenReturn(expectedJsonDiff); @@ -34,11 +46,21 @@ public void shouldCallTheObjectMatcherIfTheTwoObjectAreObject() { final var object1 = new ObjectNode(null); final var object2 = new ObjectNode(null); + final var arrayMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); final var objectMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + final var primitiveMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + + Mockito.when(arrayMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isArray() && ((JsonNode)inv.getArgument(1)).isArray()); + Mockito.when(objectMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isObject() && ((JsonNode)inv.getArgument(1)).isObject()); + Mockito.when(primitiveMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isValueNode() && ((JsonNode)inv.getArgument(1)).isValueNode()); + final var compositeMatcher = new CompositeJsonMatcher( - (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class), + arrayMatcher, objectMatcher, - (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class) + primitiveMatcher ); final var expectedJsonDiff = Mockito.mock(JsonDiff.class); Mockito.when(objectMatcher.jsonDiff(path, object1, object2, compositeMatcher)).thenReturn(expectedJsonDiff); @@ -53,10 +75,20 @@ public void shouldCallThePrimitiveMatcherIfTheTwoObjectAreValue() { final var value1 = StringNode.valueOf(""); final var value2 = IntNode.valueOf(10); + final var arrayMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + final var objectMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); final var primitiveMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + + Mockito.when(arrayMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isArray() && ((JsonNode)inv.getArgument(1)).isArray()); + Mockito.when(objectMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isObject() && ((JsonNode)inv.getArgument(1)).isObject()); + Mockito.when(primitiveMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isValueNode() && ((JsonNode)inv.getArgument(1)).isValueNode()); + final var compositeMatcher = new CompositeJsonMatcher( - (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class), - (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class), + arrayMatcher, + objectMatcher, primitiveMatcher ); final var expectedJsonDiff = Mockito.mock(JsonDiff.class); @@ -72,10 +104,21 @@ public void shouldReturnANonMatchWhenTypesAreDifferent() { final var value1 = StringNode.valueOf(""); final var value2 = new ObjectNode(null); + final var arrayMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + final var objectMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + final var primitiveMatcher = (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class); + + Mockito.when(arrayMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isArray() && ((JsonNode)inv.getArgument(1)).isArray()); + Mockito.when(objectMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isObject() && ((JsonNode)inv.getArgument(1)).isObject()); + Mockito.when(primitiveMatcher.manage(any(), any())).thenAnswer(inv -> + ((JsonNode)inv.getArgument(0)).isValueNode() && ((JsonNode)inv.getArgument(1)).isValueNode()); + final var compositeMatcher = new CompositeJsonMatcher( - (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class), - (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class), - (PartialJsonMatcher) Mockito.mock(PartialJsonMatcher.class) + arrayMatcher, + objectMatcher, + primitiveMatcher ); final var result = compositeMatcher.diff(path, value1, value2); diff --git a/src/test/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcherTest.java b/src/test/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcherTest.java new file mode 100644 index 0000000..5a31e27 --- /dev/null +++ b/src/test/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcherTest.java @@ -0,0 +1,154 @@ +package com.deblock.jsondiff.matcher; + +import com.deblock.jsondiff.diff.MatchedPrimaryDiff; +import com.deblock.jsondiff.diff.UnMatchedPrimaryDiff; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.NullNode; +import tools.jackson.databind.node.ObjectNode; + +import static org.junit.jupiter.api.Assertions.*; + +public class NullEqualsEmptyArrayMatcherTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Path ROOT = Path.ROOT; + private final NullEqualsEmptyArrayMatcher matcher = new NullEqualsEmptyArrayMatcher(); + + @Test + public void manage_shouldReturnTrue_whenExpectedIsNullAndReceivedIsArray() { + final var nullNode = NullNode.getInstance(); + final var arrayNode = MAPPER.createArrayNode(); + + assertTrue(matcher.manage(nullNode, arrayNode)); + } + + @Test + public void manage_shouldReturnTrue_whenExpectedIsArrayAndReceivedIsNull() { + final var arrayNode = MAPPER.createArrayNode(); + final var nullNode = NullNode.getInstance(); + + assertTrue(matcher.manage(arrayNode, nullNode)); + } + + @Test + public void manage_shouldReturnFalse_whenBothAreNull() { + final var nullNode1 = NullNode.getInstance(); + final var nullNode2 = NullNode.getInstance(); + + assertFalse(matcher.manage(nullNode1, nullNode2)); + } + + @Test + public void manage_shouldReturnFalse_whenBothAreArrays() { + final var array1 = MAPPER.createArrayNode(); + final var array2 = MAPPER.createArrayNode(); + + assertFalse(matcher.manage(array1, array2)); + } + + @Test + public void manage_shouldReturnFalse_whenExpectedIsNullAndReceivedIsObject() { + final var nullNode = NullNode.getInstance(); + final var objectNode = MAPPER.createObjectNode(); + + assertFalse(matcher.manage(nullNode, objectNode)); + } + + @Test + public void jsonDiff_shouldReturnMatch_whenNullVsEmptyArray() { + final var nullNode = NullNode.getInstance(); + final var emptyArray = MAPPER.createArrayNode(); + + final var result = matcher.jsonDiff(ROOT, nullNode, emptyArray, null); + + assertInstanceOf(MatchedPrimaryDiff.class, result); + assertEquals(100.0, result.similarityRate()); + } + + @Test + public void jsonDiff_shouldReturnMatch_whenEmptyArrayVsNull() { + final var emptyArray = MAPPER.createArrayNode(); + final var nullNode = NullNode.getInstance(); + + final var result = matcher.jsonDiff(ROOT, emptyArray, nullNode, null); + + assertInstanceOf(MatchedPrimaryDiff.class, result); + assertEquals(100.0, result.similarityRate()); + } + + @Test + public void jsonDiff_shouldReturnUnMatch_whenNullVsNonEmptyArray() { + final var nullNode = NullNode.getInstance(); + final var nonEmptyArray = MAPPER.createArrayNode(); + nonEmptyArray.add(1); + + final var result = matcher.jsonDiff(ROOT, nullNode, nonEmptyArray, null); + + assertInstanceOf(UnMatchedPrimaryDiff.class, result); + assertEquals(0.0, result.similarityRate()); + } + + @Test + public void jsonDiff_shouldReturnUnMatch_whenNonEmptyArrayVsNull() { + final var nonEmptyArray = MAPPER.createArrayNode(); + nonEmptyArray.add("value"); + final var nullNode = NullNode.getInstance(); + + final var result = matcher.jsonDiff(ROOT, nonEmptyArray, nullNode, null); + + assertInstanceOf(UnMatchedPrimaryDiff.class, result); + assertEquals(0.0, result.similarityRate()); + } + + @Test + public void integrationTest_shouldMatchNullAndEmptyArrayInJson() { + final var expected = "{\"items\": null}"; + final var received = "{\"items\": []}"; + + final var jsonMatcher = new CompositeJsonMatcher( + new NullEqualsEmptyArrayMatcher(), + new LenientJsonArrayPartialMatcher(), + new LenientJsonObjectPartialMatcher(), + new StrictPrimitivePartialMatcher() + ); + + final var diff = com.deblock.jsondiff.DiffGenerator.diff(expected, received, jsonMatcher); + + assertEquals(100.0, diff.similarityRate()); + } + + @Test + public void integrationTest_shouldMatchEmptyArrayAndNullInJson() { + final var expected = "{\"items\": []}"; + final var received = "{\"items\": null}"; + + final var jsonMatcher = new CompositeJsonMatcher( + new NullEqualsEmptyArrayMatcher(), + new LenientJsonArrayPartialMatcher(), + new LenientJsonObjectPartialMatcher(), + new StrictPrimitivePartialMatcher() + ); + + final var diff = com.deblock.jsondiff.DiffGenerator.diff(expected, received, jsonMatcher); + + assertEquals(100.0, diff.similarityRate()); + } + + @Test + public void integrationTest_shouldNotMatchNullAndNonEmptyArray() { + final var expected = "{\"items\": null}"; + final var received = "{\"items\": [1, 2, 3]}"; + + final var jsonMatcher = new CompositeJsonMatcher( + new NullEqualsEmptyArrayMatcher(), + new LenientJsonArrayPartialMatcher(), + new LenientJsonObjectPartialMatcher(), + new StrictPrimitivePartialMatcher() + ); + + final var diff = com.deblock.jsondiff.DiffGenerator.diff(expected, received, jsonMatcher); + + assertTrue(diff.similarityRate() < 100.0); + } +} From 210374a8a42499519ae3f282626996defac96ecf Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Thu, 1 Jan 2026 19:26:05 +0100 Subject: [PATCH 3/8] refactor: move integration tests to dedicated folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create integration test package - Move NullEqualsEmptyArrayMatcher integration tests to new class - Add tests for nested structures (deeply nested, inside arrays, multiple fields) - Keep only unit tests in NullEqualsEmptyArrayMatcherTest 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...qualsEmptyArrayMatcherIntegrationTest.java | 87 +++++++++++++++++++ .../NullEqualsEmptyArrayMatcherTest.java | 50 ----------- 2 files changed, 87 insertions(+), 50 deletions(-) create mode 100644 src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java diff --git a/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java b/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java new file mode 100644 index 0000000..1b55840 --- /dev/null +++ b/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java @@ -0,0 +1,87 @@ +package com.deblock.jsondiff.integration; + +import com.deblock.jsondiff.DiffGenerator; +import com.deblock.jsondiff.matcher.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class NullEqualsEmptyArrayMatcherIntegrationTest { + + private final CompositeJsonMatcher jsonMatcher = new CompositeJsonMatcher( + new NullEqualsEmptyArrayMatcher(), + new LenientJsonArrayPartialMatcher(), + new LenientJsonObjectPartialMatcher(), + new StrictPrimitivePartialMatcher() + ); + + @Test + public void shouldMatchNullAndEmptyArray() { + final var expected = "{\"items\": null}"; + final var received = "{\"items\": []}"; + + final var diff = DiffGenerator.diff(expected, received, jsonMatcher); + + assertEquals(100.0, diff.similarityRate()); + } + + @Test + public void shouldMatchEmptyArrayAndNull() { + final var expected = "{\"items\": []}"; + final var received = "{\"items\": null}"; + + final var diff = DiffGenerator.diff(expected, received, jsonMatcher); + + assertEquals(100.0, diff.similarityRate()); + } + + @Test + public void shouldNotMatchNullAndNonEmptyArray() { + final var expected = "{\"items\": null}"; + final var received = "{\"items\": [1, 2, 3]}"; + + final var diff = DiffGenerator.diff(expected, received, jsonMatcher); + + assertTrue(diff.similarityRate() < 100.0); + } + + @Test + public void shouldMatchNullAndEmptyArrayInNestedObject() { + final var expected = "{\"data\": {\"items\": null, \"name\": \"test\"}}"; + final var received = "{\"data\": {\"items\": [], \"name\": \"test\"}}"; + + final var diff = DiffGenerator.diff(expected, received, jsonMatcher); + + assertEquals(100.0, diff.similarityRate()); + } + + @Test + public void shouldMatchNullAndEmptyArrayInDeeplyNestedStructure() { + final var expected = "{\"level1\": {\"level2\": {\"level3\": {\"items\": null}}}}"; + final var received = "{\"level1\": {\"level2\": {\"level3\": {\"items\": []}}}}"; + + final var diff = DiffGenerator.diff(expected, received, jsonMatcher); + + assertEquals(100.0, diff.similarityRate()); + } + + @Test + public void shouldMatchNullAndEmptyArrayInsideArray() { + final var expected = "{\"data\": [{\"items\": null}, {\"items\": [1]}]}"; + final var received = "{\"data\": [{\"items\": []}, {\"items\": [1]}]}"; + + final var diff = DiffGenerator.diff(expected, received, jsonMatcher); + + assertEquals(100.0, diff.similarityRate()); + } + + @Test + public void shouldMatchMultipleNullAndEmptyArrayFields() { + final var expected = "{\"array1\": null, \"array2\": null, \"value\": \"test\"}"; + final var received = "{\"array1\": [], \"array2\": [], \"value\": \"test\"}"; + + final var diff = DiffGenerator.diff(expected, received, jsonMatcher); + + assertEquals(100.0, diff.similarityRate()); + } +} diff --git a/src/test/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcherTest.java b/src/test/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcherTest.java index 5a31e27..56954e9 100644 --- a/src/test/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcherTest.java +++ b/src/test/java/com/deblock/jsondiff/matcher/NullEqualsEmptyArrayMatcherTest.java @@ -101,54 +101,4 @@ public void jsonDiff_shouldReturnUnMatch_whenNonEmptyArrayVsNull() { assertEquals(0.0, result.similarityRate()); } - @Test - public void integrationTest_shouldMatchNullAndEmptyArrayInJson() { - final var expected = "{\"items\": null}"; - final var received = "{\"items\": []}"; - - final var jsonMatcher = new CompositeJsonMatcher( - new NullEqualsEmptyArrayMatcher(), - new LenientJsonArrayPartialMatcher(), - new LenientJsonObjectPartialMatcher(), - new StrictPrimitivePartialMatcher() - ); - - final var diff = com.deblock.jsondiff.DiffGenerator.diff(expected, received, jsonMatcher); - - assertEquals(100.0, diff.similarityRate()); - } - - @Test - public void integrationTest_shouldMatchEmptyArrayAndNullInJson() { - final var expected = "{\"items\": []}"; - final var received = "{\"items\": null}"; - - final var jsonMatcher = new CompositeJsonMatcher( - new NullEqualsEmptyArrayMatcher(), - new LenientJsonArrayPartialMatcher(), - new LenientJsonObjectPartialMatcher(), - new StrictPrimitivePartialMatcher() - ); - - final var diff = com.deblock.jsondiff.DiffGenerator.diff(expected, received, jsonMatcher); - - assertEquals(100.0, diff.similarityRate()); - } - - @Test - public void integrationTest_shouldNotMatchNullAndNonEmptyArray() { - final var expected = "{\"items\": null}"; - final var received = "{\"items\": [1, 2, 3]}"; - - final var jsonMatcher = new CompositeJsonMatcher( - new NullEqualsEmptyArrayMatcher(), - new LenientJsonArrayPartialMatcher(), - new LenientJsonObjectPartialMatcher(), - new StrictPrimitivePartialMatcher() - ); - - final var diff = com.deblock.jsondiff.DiffGenerator.diff(expected, received, jsonMatcher); - - assertTrue(diff.similarityRate() < 100.0); - } } From 50dc7c5c84dcdc205f433dbdc134f72c5994f8a3 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Thu, 1 Jan 2026 19:28:00 +0100 Subject: [PATCH 4/8] test: add tests for empty array vs missing property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document that missing property does NOT match empty array (intentional design decision per PR description) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...qualsEmptyArrayMatcherIntegrationTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java b/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java index 1b55840..9a320cb 100644 --- a/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java +++ b/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java @@ -84,4 +84,29 @@ public void shouldMatchMultipleNullAndEmptyArrayFields() { assertEquals(100.0, diff.similarityRate()); } + + @Test + public void shouldNotMatchEmptyArrayAndMissingProperty() { + // Note: This case is intentionally NOT supported by NullEqualsEmptyArrayMatcher + // Missing property is different from null or empty array + final var expected = "{\"items\": []}"; + final var received = "{}"; + + final var diff = DiffGenerator.diff(expected, received, jsonMatcher); + + assertTrue(diff.similarityRate() < 100.0, "Empty array should not match missing property"); + } + + @Test + public void shouldNotMatchMissingPropertyAndEmptyArray() { + // Note: This case is intentionally NOT supported by NullEqualsEmptyArrayMatcher + final var expected = "{}"; + final var received = "{\"items\": []}"; + + final var diff = DiffGenerator.diff(expected, received, jsonMatcher); + + // With LenientJsonObjectPartialMatcher, extra properties are ignored + // So this should match 100% (expected has no requirements) + assertEquals(100.0, diff.similarityRate()); + } } From f16e618a291c1851a0a8e161021e696104b9aecd Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Thu, 1 Jan 2026 19:31:51 +0100 Subject: [PATCH 5/8] docs: update README for 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Improve English and structure - Update installation to version 2.0.0 with Java 21/Jackson 3.x note - Add Quick Start section - Document all available matchers in tables - Add NullEqualsEmptyArrayMatcher section - Add Creating Custom Matchers section - Update examples for new CompositeJsonMatcher API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 291 +++++++++++++----- ...qualsEmptyArrayMatcherIntegrationTest.java | 5 - 2 files changed, 212 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index e79ca47..b366fb6 100644 --- a/README.md +++ b/README.md @@ -1,131 +1,264 @@ # Java json-diff -A customizable lib to perform a json-diff +A customizable library to perform JSON comparisons with detailed diff output. -## Why Use json-diff library +## Why Use json-diff? -The goal of this library is to provide a readable diff between two json file. +This library provides: -In addition to the differential, a similarity score is calculated. -This score can be used to compare several json with each other and find the two most similar. - -The way to compare json is completely customisable. - -2 way to display diff are provided by default (patch file, text file). And you can easily create your own formatter. +- **Readable diffs** between two JSON documents +- **Similarity scoring** (0-100) to compare multiple JSON documents and find the most similar ones +- **Fully customizable** comparison modes (strict, lenient, or mixed) +- **Multiple output formats** (patch file, text) with the ability to create custom formatters ## Installation -maven: +**Maven:** ```xml io.github.deblockt json-diff - 1.1.0 + 2.0.0 ``` -gradle: +**Gradle:** ```gradle -implementation 'io.github.deblockt:json-diff:1.1.0' +implementation 'io.github.deblockt:json-diff:2.0.0' ``` -## Usage +> **Note:** Version 2.0.0 requires Java 21+ and uses Jackson 3.x + +## Quick Start -example: ```java -final var expectedJson = "{\"additionalProperty\":\"a\", \"foo\": \"bar\", \"bar\": \"bar\", \"numberMatch\": 10.0, \"numberUnmatched\": 10.01, \"arrayMatch\": [{\"b\":\"a\"}], \"arrayUnmatched\": [{\"b\":\"a\"}]}"; -final var receivedJson = "{\"foo\": \"foo\", \"bar\": \"bar\", \"numberMatch\": 10, \"numberUnmatched\": 10.02, \"arrayMatch\": [{\"b\":\"a\"}], \"arrayUnmatched\": {\"b\":\"b\"}}"; +final var expectedJson = "{\"name\": \"John\", \"age\": 30, \"city\": \"Paris\"}"; +final var receivedJson = "{\"name\": \"Jane\", \"age\": 30, \"country\": \"France\"}"; -// define your matcher -// CompositeJsonMatcher use other matcher to perform matching on objects, list or primitive +// Define your matcher final var jsonMatcher = new CompositeJsonMatcher( - new LenientJsonArrayPartialMatcher(), // comparing array using lenient mode (ignore array order and extra items) - new LenientJsonObjectPartialMatcher(), // comparing object using lenient mode (ignoring extra properties) - new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) // comparing primitive types and manage numbers (100.00 == 100) + new LenientJsonArrayPartialMatcher(), + new LenientJsonObjectPartialMatcher(), + new StrictPrimitivePartialMatcher() ); -// generate a diff -final var jsondiff = DiffGenerator.diff(expectedJson, receivedJson, jsonMatcher); +// Generate the diff +final var diff = DiffGenerator.diff(expectedJson, receivedJson, jsonMatcher); -// use the viewer to collect diff data -final var errorsResult= OnlyErrorDiffViewer.from(jsondiff); +// Display errors +System.out.println(OnlyErrorDiffViewer.from(diff)); -// print the diff result -System.out.println(errorsResult); -// print a similarity ratio between expected and received json (0 <= ratio <= 100) -System.out.println(jsondiff.similarityRate()); +// Get similarity score (0-100) +System.out.println("Similarity: " + diff.similarityRate() + "%"); ``` -Result: -``` -The property "$.additionalProperty" is not found -The property "$.numberUnmatched" didn't match. Expected 10.01, Received: 10.02 -The property "$.arrayUnmatched" didn't match. Expected [{"b":"a"}], Received: {"b":"b"} -The property "$.foo" didn't match. Expected "bar", Received: "foo" -76.0 -``` +## Output Formats + +### Error List (OnlyErrorDiffViewer) -You can also generate a patch file using this viewer: ```java -final var patch = PatchDiffViewer.from(jsondiff); +final var errors = OnlyErrorDiffViewer.from(diff); +System.out.println(errors); +``` + +Output: +``` +The property "$.city" is not found +The property "$.name" didn't match. Expected "John", Received: "Jane" +``` -// use the viewer to collect diff data -final var patchFile= PatchDiffViewer.from(jsondiff); +### Patch Format (PatchDiffViewer) -// print the diff result -System.out.println(patchFile); +```java +final var patch = PatchDiffViewer.from(diff); +System.out.println(patch); ``` -Result: -``` diff +Output: +```diff --- actual +++ expected @@ @@ { -+ "additionalProperty": "a", - "bar": "bar", -- "numberUnmatched": 10.02, -+ "numberUnmatched": 10.01, -- "arrayUnmatched": {"b":"b"}, -+ "arrayUnmatched": [{"b":"a"}], -- "foo": "foo", -+ "foo": "bar", - "numberMatch": 10.0, - "arrayMatch": [ - { - "b": "a" - } - ] + "age": 30, ++ "city": "Paris", +- "country": "France", +- "name": "Jane", ++ "name": "John" } ``` -### Comparison mode +## Comparison Modes + +`CompositeJsonMatcher` accepts multiple matchers that handle different JSON types. The order matters: the first matcher that can handle a comparison will be used. -You can use many comparison mode to compare you json: +### Lenient Mode -If you want compare json using *lenient* comparison: -```java -final var fullLenient = new CompositeJsonMatcher( - new LenientJsonArrayPartialMatcher(), // comparing array using lenient mode (ignore array order and extra items) - new LenientJsonObjectPartialMatcher(), // comparing object using lenient mode (ignoring extra properties) - new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) // comparing primitive types and manage numbers (100.00 == 100) +Ignores extra properties and array order: + +```java +final var lenientMatcher = new CompositeJsonMatcher( + new LenientJsonArrayPartialMatcher(), // Ignores array order and extra items + new LenientJsonObjectPartialMatcher(), // Ignores extra properties + new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) // 10.0 == 10 ); ``` -If you want compare json using *strict* comparison: -```java +### Strict Mode + +Requires exact matches: + +```java final var strictMatcher = new CompositeJsonMatcher( - new StrictJsonArrayPartialMatcher(), // comparing array using strict mode (object should have same properties/value) - new StrictJsonObjectPartialMatcher(), // comparing object using strict mode (array should have same item on same orders) - new StrictPrimitivePartialMatcher() // comparing primitive types (values should be strictly equals type and value) + new StrictJsonArrayPartialMatcher(), // Same items in same order + new StrictJsonObjectPartialMatcher(), // Same properties, no extras + new StrictPrimitivePartialMatcher() // Exact type and value match +); +``` + +### Mixed Mode + +You can combine matchers for custom behavior: + +```java +final var mixedMatcher = new CompositeJsonMatcher( + new LenientJsonArrayPartialMatcher(), // Lenient on arrays + new StrictJsonObjectPartialMatcher(), // Strict on objects + new StrictPrimitivePartialMatcher() +); +``` + +## Available Matchers + +### Array Matchers + +| Matcher | Description | +|---------|-------------| +| `LenientJsonArrayPartialMatcher` | Ignores array order and extra items | +| `StrictJsonArrayPartialMatcher` | Requires same items in same order | + +### Object Matchers + +| Matcher | Description | +|---------|-------------| +| `LenientJsonObjectPartialMatcher` | Ignores extra properties in received JSON | +| `StrictJsonObjectPartialMatcher` | Requires exact same properties | + +### Primitive Matchers + +| Matcher | Description | +|---------|-------------| +| `StrictPrimitivePartialMatcher` | Exact type and value match | +| `LenientNumberPrimitivePartialMatcher` | Numbers are equal if values match (`10.0 == 10`) | + +### Special Matchers + +| Matcher | Description | +|---------|-------------| +| `NullEqualsEmptyArrayMatcher` | Treats `null` and `[]` as equivalent | + +## Treating Null as Empty Array + +The `NullEqualsEmptyArrayMatcher` allows you to consider `null` values and empty arrays `[]` as equivalent. This is useful when different systems represent "no data" differently. + +```java +final var jsonMatcher = new CompositeJsonMatcher( + new NullEqualsEmptyArrayMatcher(), // Must be first to handle null vs [] + new LenientJsonArrayPartialMatcher(), + new LenientJsonObjectPartialMatcher(), + new StrictPrimitivePartialMatcher() +); + +// These will match with 100% similarity: +// {"items": null} vs {"items": []} +// {"items": []} vs {"items": null} + +final var diff = DiffGenerator.diff( + "{\"items\": null}", + "{\"items\": []}", + jsonMatcher ); + +System.out.println(diff.similarityRate()); // 100.0 ``` -You can mix matcher. For example, be lenient on array and strict on object: -```java -final var matcher = new CompositeJsonMatcher( - new LenientJsonArrayPartialMatcher(), // comparing array using lenient mode (ignore array order and extra items) - new StrictJsonObjectPartialMatcher(), // comparing object using strict mode (array should have same item on same orders) - new StrictPrimitivePartialMatcher() // comparing primitive types (values should be strictly equals type and value) +**Important:** +- Place `NullEqualsEmptyArrayMatcher` **before** other matchers in the constructor +- This matcher only handles `null` vs empty array `[]`, not missing properties +- Non-empty arrays do not match `null` + +## Advanced Example + +```java +final var expectedJson = """ + { + "additionalProperty": "a", + "foo": "bar", + "bar": "bar", + "numberMatch": 10.0, + "numberUnmatched": 10.01, + "arrayMatch": [{"b": "a"}], + "arrayUnmatched": [{"b": "a"}] + } + """; + +final var receivedJson = """ + { + "foo": "foo", + "bar": "bar", + "numberMatch": 10, + "numberUnmatched": 10.02, + "arrayMatch": [{"b": "a"}], + "arrayUnmatched": {"b": "b"} + } + """; + +final var jsonMatcher = new CompositeJsonMatcher( + new LenientJsonArrayPartialMatcher(), + new LenientJsonObjectPartialMatcher(), + new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) ); + +final var diff = DiffGenerator.diff(expectedJson, receivedJson, jsonMatcher); + +System.out.println(OnlyErrorDiffViewer.from(diff)); +System.out.println("Similarity: " + diff.similarityRate() + "%"); +``` + +Output: +``` +The property "$.additionalProperty" is not found +The property "$.numberUnmatched" didn't match. Expected 10.01, Received: 10.02 +The property "$.arrayUnmatched" didn't match. Expected [{"b":"a"}], Received: {"b":"b"} +The property "$.foo" didn't match. Expected "bar", Received: "foo" + +Similarity: 76.0% ``` + +## Creating Custom Matchers + +You can create custom matchers by implementing the `PartialJsonMatcher` interface: + +```java +public class MyCustomMatcher implements PartialJsonMatcher { + + @Override + public boolean manage(JsonNode expected, JsonNode received) { + // Return true if this matcher should handle this comparison + return /* your condition */; + } + + @Override + public JsonDiff jsonDiff(Path path, JsonNode expected, JsonNode received, JsonMatcher jsonMatcher) { + // Return your diff result + if (/* values match */) { + return new MatchedPrimaryDiff(path, expected); + } + return new UnMatchedPrimaryDiff(path, expected, received); + } +} +``` + +## License + +This project is licensed under the MIT License. diff --git a/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java b/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java index 9a320cb..6b1bc27 100644 --- a/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java +++ b/src/test/java/com/deblock/jsondiff/integration/NullEqualsEmptyArrayMatcherIntegrationTest.java @@ -87,8 +87,6 @@ public void shouldMatchMultipleNullAndEmptyArrayFields() { @Test public void shouldNotMatchEmptyArrayAndMissingProperty() { - // Note: This case is intentionally NOT supported by NullEqualsEmptyArrayMatcher - // Missing property is different from null or empty array final var expected = "{\"items\": []}"; final var received = "{}"; @@ -99,14 +97,11 @@ public void shouldNotMatchEmptyArrayAndMissingProperty() { @Test public void shouldNotMatchMissingPropertyAndEmptyArray() { - // Note: This case is intentionally NOT supported by NullEqualsEmptyArrayMatcher final var expected = "{}"; final var received = "{\"items\": []}"; final var diff = DiffGenerator.diff(expected, received, jsonMatcher); - // With LenientJsonObjectPartialMatcher, extra properties are ignored - // So this should match 100% (expected has no requirements) assertEquals(100.0, diff.similarityRate()); } } From 14844df54367bd463fedcc3a27cad2d744506d62 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Thu, 1 Jan 2026 19:33:01 +0100 Subject: [PATCH 6/8] docs: mention easy custom matcher creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b366fb6..109d63d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This library provides: - **Readable diffs** between two JSON documents - **Similarity scoring** (0-100) to compare multiple JSON documents and find the most similar ones -- **Fully customizable** comparison modes (strict, lenient, or mixed) +- **Fully customizable** comparison modes (strict, lenient, or mixed) with easy-to-create custom matchers - **Multiple output formats** (patch file, text) with the ability to create custom formatters ## Installation From e5480c81b650ff2c165b8889e4a7e71b83be5193 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Thu, 1 Jan 2026 19:34:31 +0100 Subject: [PATCH 7/8] docs: add output to Quick Start example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 109d63d..c44e5b3 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,14 @@ System.out.println(OnlyErrorDiffViewer.from(diff)); System.out.println("Similarity: " + diff.similarityRate() + "%"); ``` +Output: +``` +The property "$.city" is not found +The property "$.name" didn't match. Expected "John", Received: "Jane" + +Similarity: 50.0% +``` + ## Output Formats ### Error List (OnlyErrorDiffViewer) From d59b1449bdf4a15ad7914eda05db2a627ac352a0 Mon Sep 17 00:00:00 2001 From: Thomas Deblock Date: Thu, 1 Jan 2026 19:41:26 +0100 Subject: [PATCH 8/8] refactor: simplify LenientNumberPrimitivePartialMatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove delegation pattern - matcher now only handles numbers. Use alongside StrictPrimitivePartialMatcher for full primitive support. Before: new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) After: new LenientNumberPrimitivePartialMatcher(), new StrictPrimitivePartialMatcher() - Remove delegated field and constructor parameter - manage() now returns true only for numeric nodes - Add Javadoc with usage example - Update all tests and documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 28 ++++--- .../LenientNumberPrimitivePartialMatcher.java | 37 +++++---- .../java/com/deblock/jsondiff/Sample.java | 3 +- ...ientNumberPrimitivePartialMatcherTest.java | 80 ++++++++++++------- .../jsondiff/viewer/PatchDiffViewerTest.java | 3 +- 5 files changed, 92 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index c44e5b3..e0c718b 100644 --- a/README.md +++ b/README.md @@ -100,18 +100,6 @@ Output: `CompositeJsonMatcher` accepts multiple matchers that handle different JSON types. The order matters: the first matcher that can handle a comparison will be used. -### Lenient Mode - -Ignores extra properties and array order: - -```java -final var lenientMatcher = new CompositeJsonMatcher( - new LenientJsonArrayPartialMatcher(), // Ignores array order and extra items - new LenientJsonObjectPartialMatcher(), // Ignores extra properties - new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) // 10.0 == 10 -); -``` - ### Strict Mode Requires exact matches: @@ -124,6 +112,19 @@ final var strictMatcher = new CompositeJsonMatcher( ); ``` +### Lenient Mode + +Ignores extra properties and array order: + +```java +final var lenientMatcher = new CompositeJsonMatcher( + new LenientJsonArrayPartialMatcher(), // Ignores array order and extra items + new LenientJsonObjectPartialMatcher(), // Ignores extra properties + new LenientNumberPrimitivePartialMatcher(), // 10.0 == 10 + new StrictPrimitivePartialMatcher() // Other primitives +); +``` + ### Mixed Mode You can combine matchers for custom behavior: @@ -224,7 +225,8 @@ final var receivedJson = """ final var jsonMatcher = new CompositeJsonMatcher( new LenientJsonArrayPartialMatcher(), new LenientJsonObjectPartialMatcher(), - new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) + new LenientNumberPrimitivePartialMatcher(), + new StrictPrimitivePartialMatcher() ); final var diff = DiffGenerator.diff(expectedJson, receivedJson, jsonMatcher); diff --git a/src/main/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcher.java b/src/main/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcher.java index cf63709..e471359 100644 --- a/src/main/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcher.java +++ b/src/main/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcher.java @@ -4,31 +4,38 @@ import com.deblock.jsondiff.diff.MatchedPrimaryDiff; import com.deblock.jsondiff.diff.UnMatchedPrimaryDiff; import tools.jackson.databind.JsonNode; -import tools.jackson.databind.node.NumericNode; import tools.jackson.databind.node.ValueNode; +/** + * A matcher that compares numeric values leniently. + * Two numbers are considered equal if their decimal values are equal, + * regardless of their representation (e.g., 10.0 == 10). + * + *

This matcher only handles numeric nodes. For other primitive types, + * add {@link StrictPrimitivePartialMatcher} to your {@link CompositeJsonMatcher}.

+ * + *

Example usage:

+ *
+ * new CompositeJsonMatcher(
+ *     new LenientJsonArrayPartialMatcher(),
+ *     new LenientJsonObjectPartialMatcher(),
+ *     new LenientNumberPrimitivePartialMatcher(),
+ *     new StrictPrimitivePartialMatcher()
+ * );
+ * 
+ */ public class LenientNumberPrimitivePartialMatcher implements PartialJsonMatcher { - private final PartialJsonMatcher delegated; - - public LenientNumberPrimitivePartialMatcher(PartialJsonMatcher delegated) { - this.delegated = delegated; - } @Override public JsonDiff jsonDiff(Path path, ValueNode expectedValue, ValueNode receivedValue, JsonMatcher jsonMatcher) { - if (expectedValue instanceof NumericNode && receivedValue instanceof NumericNode) { - if (expectedValue.decimalValue().compareTo(receivedValue.decimalValue()) != 0) { - return new UnMatchedPrimaryDiff(path, expectedValue, receivedValue); - } else { - return new MatchedPrimaryDiff(path, expectedValue); - } + if (expectedValue.decimalValue().compareTo(receivedValue.decimalValue()) != 0) { + return new UnMatchedPrimaryDiff(path, expectedValue, receivedValue); } - - return delegated.jsonDiff(path, expectedValue, receivedValue, jsonMatcher); + return new MatchedPrimaryDiff(path, expectedValue); } @Override public boolean manage(JsonNode expected, JsonNode received) { - return expected.isValueNode() && received.isValueNode(); + return expected.isNumber() && received.isNumber(); } } diff --git a/src/test/java/com/deblock/jsondiff/Sample.java b/src/test/java/com/deblock/jsondiff/Sample.java index b456ff0..daf3426 100644 --- a/src/test/java/com/deblock/jsondiff/Sample.java +++ b/src/test/java/com/deblock/jsondiff/Sample.java @@ -15,7 +15,8 @@ public static void main(String[] args) { new NullEqualsEmptyArrayMatcher(), new LenientJsonArrayPartialMatcher(), // comparing array using lenient mode (ignore array order and extra items) new LenientJsonObjectPartialMatcher(), // comparing object using lenient mode (ignoring extra properties) - new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) // comparing primitive types and manage numbers (100.00 == 100) + new LenientNumberPrimitivePartialMatcher(), // comparing numbers leniently (100.00 == 100) + new StrictPrimitivePartialMatcher() // comparing other primitive types strictly ); // generate a diff diff --git a/src/test/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcherTest.java b/src/test/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcherTest.java index 72695ac..1218fd4 100644 --- a/src/test/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcherTest.java +++ b/src/test/java/com/deblock/jsondiff/matcher/LenientNumberPrimitivePartialMatcherTest.java @@ -1,26 +1,66 @@ package com.deblock.jsondiff.matcher; -import com.deblock.jsondiff.diff.JsonDiff; import tools.jackson.databind.node.DecimalNode; import tools.jackson.databind.node.IntNode; import tools.jackson.databind.node.StringNode; +import tools.jackson.databind.node.BooleanNode; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import java.math.BigDecimal; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; public class LenientNumberPrimitivePartialMatcherTest { private final static Path expectedPath = Path.ROOT.add(Path.PathItem.of("property")); + private final LenientNumberPrimitivePartialMatcher matcher = new LenientNumberPrimitivePartialMatcher(); @Test - public void shouldReturnAMatchIfNodeAreStrictEqualsNumbers() { + void manage_shouldReturnTrue_whenBothNodesAreNumbers() { + final var number1 = IntNode.valueOf(10); + final var number2 = DecimalNode.valueOf(BigDecimal.valueOf(20)); + + assertTrue(matcher.manage(number1, number2)); + } + + @Test + void manage_shouldReturnFalse_whenExpectedIsNotNumber() { + final var string = StringNode.valueOf("test"); + final var number = IntNode.valueOf(10); + + assertFalse(matcher.manage(string, number)); + } + + @Test + void manage_shouldReturnFalse_whenReceivedIsNotNumber() { + final var number = IntNode.valueOf(10); + final var string = StringNode.valueOf("test"); + + assertFalse(matcher.manage(number, string)); + } + + @Test + void manage_shouldReturnFalse_whenBothAreStrings() { + final var string1 = StringNode.valueOf("test1"); + final var string2 = StringNode.valueOf("test2"); + + assertFalse(matcher.manage(string1, string2)); + } + + @Test + void manage_shouldReturnFalse_whenBothAreBooleans() { + final var bool1 = BooleanNode.TRUE; + final var bool2 = BooleanNode.FALSE; + + assertFalse(matcher.manage(bool1, bool2)); + } + + @Test + void shouldReturnAMatchIfNodeAreStrictEqualsNumbers() { final var number1 = DecimalNode.valueOf(BigDecimal.valueOf(101, 1)); final var number2 = DecimalNode.valueOf(BigDecimal.valueOf(101, 1)); - final var jsonDiff = new LenientNumberPrimitivePartialMatcher(null) - .jsonDiff(expectedPath, number1, number2, Mockito.mock(JsonMatcher.class)); + final var jsonDiff = matcher.jsonDiff(expectedPath, number1, number2, Mockito.mock(JsonMatcher.class)); assertEquals(100, jsonDiff.similarityRate()); assertEquals(expectedPath, jsonDiff.path()); @@ -30,12 +70,11 @@ public void shouldReturnAMatchIfNodeAreStrictEqualsNumbers() { } @Test - public void shouldReturnAMatchIfNodeAreEqualsNumbersWithDifferentType() { + void shouldReturnAMatchIfNodeAreEqualsNumbersWithDifferentType() { final var number1 = IntNode.valueOf(100); final var number2 = DecimalNode.valueOf(BigDecimal.valueOf(1000, 1)); - final var jsonDiff = new LenientNumberPrimitivePartialMatcher(null) - .jsonDiff(expectedPath, number1, number2, Mockito.mock(JsonMatcher.class)); + final var jsonDiff = matcher.jsonDiff(expectedPath, number1, number2, Mockito.mock(JsonMatcher.class)); assertEquals(100, jsonDiff.similarityRate()); assertEquals(expectedPath, jsonDiff.path()); @@ -45,12 +84,11 @@ public void shouldReturnAMatchIfNodeAreEqualsNumbersWithDifferentType() { } @Test - public void shouldReturnANonMatchIfNodeAreEqualsNumbersWithDifferentDecimalValue() { + void shouldReturnANonMatchIfNodeAreEqualsNumbersWithDifferentDecimalValue() { final var number1 = DecimalNode.valueOf(BigDecimal.valueOf(1000, 1)); final var number2 = DecimalNode.valueOf(BigDecimal.valueOf(1001, 1)); - final var jsonDiff = new LenientNumberPrimitivePartialMatcher(null) - .jsonDiff(expectedPath, number1, number2, Mockito.mock(JsonMatcher.class)); + final var jsonDiff = matcher.jsonDiff(expectedPath, number1, number2, Mockito.mock(JsonMatcher.class)); assertEquals(0, jsonDiff.similarityRate()); assertEquals(expectedPath, jsonDiff.path()); @@ -60,12 +98,11 @@ public void shouldReturnANonMatchIfNodeAreEqualsNumbersWithDifferentDecimalValue } @Test - public void shouldReturnANonMatchIfNodeAreEqualsNumbersWithDifferentIntValue() { + void shouldReturnANonMatchIfNodeAreEqualsNumbersWithDifferentIntValue() { final var number1 = DecimalNode.valueOf(BigDecimal.valueOf(1000, 1)); final var number2 = DecimalNode.valueOf(BigDecimal.valueOf(2000, 1)); - final var jsonDiff = new LenientNumberPrimitivePartialMatcher(null) - .jsonDiff(expectedPath, number1, number2, Mockito.mock(JsonMatcher.class)); + final var jsonDiff = matcher.jsonDiff(expectedPath, number1, number2, Mockito.mock(JsonMatcher.class)); assertEquals(0, jsonDiff.similarityRate()); assertEquals(expectedPath, jsonDiff.path()); @@ -73,19 +110,4 @@ public void shouldReturnANonMatchIfNodeAreEqualsNumbersWithDifferentIntValue() { .assertPrimaryNonMatching(expectedPath) .validate(jsonDiff); } - - @Test - public void shouldCallTheDelegatedIfNodeHaveDifferentType() { - final var value1 = IntNode.valueOf(100); - final var value2 = StringNode.valueOf("100"); - final var jsonMatcher = Mockito.mock(JsonMatcher.class); - final var delegated = Mockito.mock(PartialJsonMatcher.class); - final var expectedJsonDiff = Mockito.mock(JsonDiff.class); - Mockito.when(delegated.jsonDiff(expectedPath, value1, value2, jsonMatcher)).thenReturn(expectedJsonDiff); - - final var jsonDiff = new LenientNumberPrimitivePartialMatcher(delegated) - .jsonDiff(expectedPath, value1, value2, jsonMatcher); - - assertEquals(expectedJsonDiff, jsonDiff); - } } diff --git a/src/test/java/com/deblock/jsondiff/viewer/PatchDiffViewerTest.java b/src/test/java/com/deblock/jsondiff/viewer/PatchDiffViewerTest.java index 59b3c25..84206e5 100644 --- a/src/test/java/com/deblock/jsondiff/viewer/PatchDiffViewerTest.java +++ b/src/test/java/com/deblock/jsondiff/viewer/PatchDiffViewerTest.java @@ -25,7 +25,8 @@ public void diffTestUsingLenientDiff() throws IOException { final var jsonMatcher = new CompositeJsonMatcher( new LenientJsonArrayPartialMatcher(), new LenientJsonObjectPartialMatcher(), - new LenientNumberPrimitivePartialMatcher(new StrictPrimitivePartialMatcher()) + new LenientNumberPrimitivePartialMatcher(), + new StrictPrimitivePartialMatcher() ); final var jsondiff = DiffGenerator.diff(expectedJson, actualJson, jsonMatcher);