From 74c6ac6f28eb7ce3b1109f2424b9a6ff38b4c3c3 Mon Sep 17 00:00:00 2001 From: HerrDerb Date: Tue, 15 Jul 2025 11:38:42 +0200 Subject: [PATCH 1/2] Add smart equals for arrays --- README.md | 66 +++++++++++++++ .../com/flipkart/zjsonpatch/JsonDiff.java | 81 ++++++++++++++---- .../zjsonpatch/JsonNodeEqualsFunction.java | 10 +++ .../zjsonpatch/JsonSmartArrayDiffTest.java | 74 ++++++++++++++++ .../resources/testdata/smart-array-move.json | 84 +++++++++++++++++++ 5 files changed, 301 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/flipkart/zjsonpatch/JsonNodeEqualsFunction.java create mode 100644 src/test/java/com/flipkart/zjsonpatch/JsonSmartArrayDiffTest.java create mode 100644 src/test/resources/testdata/smart-array-move.json diff --git a/README.md b/README.md index d898f6c..f599a00 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,72 @@ The algorithm which computes this JsonPatch currently generates following operat - `replace` - `move` - `copy` +#### Smart equals for array diff + To make the generated JSON Diff patch more comprehensible a "smart equals" function can be defined for array items. + Without a specific "smart equals" function, this is the expected result of an array reorder including object modification: + + ```json + /// Without a "smart equals": + +// original +[ + {"id": 1, "val": "a"}, + {"id": 2, "val": "b"}, + {"id": 3, "val": "c"} +] +// modified +[ + {"id": 3, "val": "changed"}, + {"id": 1, "val": "a"}, + {"id": 2, "val": "b"} +] + +// resulting patch +[ + {"op": "add", "path": "/0", "value": { "id":3, "val": "changed"}}, + {"op": "remove", "path": "/3"} +] + ``` +Defining a smart equals like +```java +JsonNodeEqualsFunction jsonNodeEqualFunction = new JsonNodeEqualsFunction() { + @Override + public boolean equals(JsonNode jsonNode1, JsonNode jsonNode2) { + if (jsonNode1 == null || jsonNode2 == null) { + return false; + } + if (jsonNode1.has("id") && jsonNode2.has("id")) { + return jsonNode1.get("id").asInt() == jsonNode2.get("id").asInt(); + } + return jsonNode1.equals(jsonNode2); + } + + }; + JsonNode actualPatch = JsonDiff.asJson(first, second, DiffFlags.defaults(), jsonNodeEqualFunction); +``` +will change the resulting patch to + ```json + /// With a "smart equals": + +// original +[ + {"id": 1, "val": "a"}, + {"id": 2, "val": "b"}, + {"id": 3, "val": "c"} +] +// modified +[ + {"id": 3, "val": "changed"}, + {"id": 1, "val": "a"}, + {"id": 2, "val": "b"} +] + +// resulting patch +[ + {"op": "move", "from": "/2", "path": "/0"}, + {"op": "replace", "path": "/0/val", "value": "changed"} +] + ``` ### Apply Json Patch ```xml diff --git a/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java b/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java index 41fbc18..976781c 100644 --- a/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java +++ b/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.collections4.Equator; import org.apache.commons.collections4.ListUtils; import java.util.ArrayList; @@ -34,20 +35,64 @@ * Date: 30/07/14 */ public final class JsonDiff { + private static final JsonNodeEqualsFunction DEFAULT_EQUALS_REF_FUNCTION = JsonNodeEqualsFunction.REF_IDENTITY; + private final Equator LCS_EQUATOR = new Equator() { + @Override + public boolean equate(JsonNode o1, JsonNode o2) { + return equalsRefFunction.equals(o1, o2); + } + + @Override + public int hash(JsonNode o) { + return o.hashCode(); + } + }; + private final JsonNodeEqualsFunction equalsRefFunction; private final List diffs = new ArrayList(); private final EnumSet flags; - private JsonDiff(EnumSet flags) { + private JsonDiff(EnumSet flags, JsonNodeEqualsFunction equalsRefFunction) { this.flags = flags.clone(); + this.equalsRefFunction = equalsRefFunction; } + /** + * This method generates a JSON Patch from the source and target JsonNodes. + * It will return a JSON Patch that contains the differences between the two JsonNodes. + * @param source the source JsonNode + * @param target the target JsonNode + * @return a JsonNode representing the JSON Patch + */ public static JsonNode asJson(final JsonNode source, final JsonNode target) { - return asJson(source, target, DiffFlags.defaults()); + return asJson(source, target, DiffFlags.defaults(), DEFAULT_EQUALS_REF_FUNCTION); } + /** + * This method generates a JSON Patch from the source and target JsonNodes. + * It will return a JSON Patch that contains the differences between the two JsonNodes. + * @param source the source JsonNode + * @param target the target JsonNode + * @param flags the diff flags to customize the diff behavior + * @return a JsonNode representing the JSON Patch + */ public static JsonNode asJson(final JsonNode source, final JsonNode target, EnumSet flags) { - JsonDiff diff = new JsonDiff(flags); + return asJson(source, target, flags, DEFAULT_EQUALS_REF_FUNCTION); + } + + /** + * This method generates a JSON Patch from the source and target JsonNodes. + * It will return a JSON Patch that contains the differences between the two JsonNodes. + * It allows for a custom equality function to be used for comparing JsonNodes in arrays. + * @param source the source JsonNode + * @param target the target JsonNode + * @param flags the diff flags to customize the diff behavior + * @param equalsRefFunction the custom equality function + * @return a JsonNode representing the JSON Patch + */ + public static JsonNode asJson(final JsonNode source, final JsonNode target, EnumSet flags, + JsonNodeEqualsFunction equalsRefFunction) { + JsonDiff diff = new JsonDiff(flags, equalsRefFunction); if (source == null && target != null) { // return add node at root pointing to the target diff.diffs.add(Diff.generateDiff(Operation.ADD, JsonPointer.ROOT, target)); @@ -201,7 +246,7 @@ private void introduceMoveOperation() { for (int j = i + 1; j < diffs.size(); j++) { Diff diff2 = diffs.get(j); - if (!diff1.getValue().equals(diff2.getValue())) { + if (!equalsRefFunction.equals(diff1.getValue(), diff2.getValue())) { continue; } @@ -219,6 +264,11 @@ private void introduceMoveOperation() { if (moveDiff != null) { diffs.remove(j); diffs.set(i, moveDiff); + if (equalsRefFunction != DEFAULT_EQUALS_REF_FUNCTION) { // if a custom equals function is used, a further object comparison is wanted to reflect changes + Diff addedNode = diff1.getOperation() == Operation.ADD ? diff1 : diff2; + Diff removedNode = diff1.getOperation() == Operation.REMOVE ? diff1 : diff2; + compareObjects(moveDiff.getToPath(), removedNode.getValue(), addedNode.getValue()); + } break; } } @@ -384,22 +434,24 @@ private void compareArray(JsonPointer path, JsonNode source, JsonNode target) { JsonNode lcsNode = lcs.get(lcsIdx); JsonNode srcNode = source.get(srcIdx); JsonNode targetNode = target.get(targetIdx); - - - if (lcsNode.equals(srcNode) && lcsNode.equals(targetNode)) { // Both are same as lcs node, nothing to do here + if (equalsRefFunction.equals(lcsNode, srcNode) && equalsRefFunction.equals(lcsNode, targetNode)) { // Both are same as lcs node, nothing to do + if (equalsRefFunction != DEFAULT_EQUALS_REF_FUNCTION) { // if a custom equals function is used, a further object comparison is wanted to reflect changes + JsonPointer currPath = path.append(pos); + generateDiffs(currPath, srcNode, targetNode); + } srcIdx++; targetIdx++; lcsIdx++; pos++; } else { - if (lcsNode.equals(srcNode)) { // src node is same as lcs, but not targetNode - //addition + if (equalsRefFunction.equals(lcsNode, srcNode)) { // src node is same as lcs, but not targetNode + // addition JsonPointer currPath = path.append(pos); diffs.add(Diff.generateDiff(Operation.ADD, currPath, targetNode)); pos++; targetIdx++; - } else if (lcsNode.equals(targetNode)) { //targetNode node is same as lcs, but not src - //removal, + } else if (equalsRefFunction.equals(lcsNode, targetNode)) { // targetNode node is same as lcs, but not src + // removal, JsonPointer currPath = path.append(pos); if (flags.contains(DiffFlags.EMIT_TEST_OPERATIONS)) diffs.add(new Diff(Operation.TEST, currPath, srcNode)); @@ -407,7 +459,7 @@ private void compareArray(JsonPointer path, JsonNode source, JsonNode target) { srcIdx++; } else { JsonPointer currPath = path.append(pos); - //both are unequal to lcs node + // both are unequal to lcs node generateDiffs(currPath, srcNode, targetNode); srcIdx++; targetIdx++; @@ -476,7 +528,8 @@ private void compareObjects(JsonPointer path, JsonNode source, JsonNode target) } } - private static List getLCS(final JsonNode first, final JsonNode second) { - return ListUtils.longestCommonSubsequence(InternalUtils.toList((ArrayNode) first), InternalUtils.toList((ArrayNode) second)); + private List getLCS(final JsonNode first, final JsonNode second) { + return ListUtils.longestCommonSubsequence(InternalUtils.toList((ArrayNode) first), + InternalUtils.toList((ArrayNode) second), LCS_EQUATOR); } } diff --git a/src/main/java/com/flipkart/zjsonpatch/JsonNodeEqualsFunction.java b/src/main/java/com/flipkart/zjsonpatch/JsonNodeEqualsFunction.java new file mode 100644 index 0000000..beffc01 --- /dev/null +++ b/src/main/java/com/flipkart/zjsonpatch/JsonNodeEqualsFunction.java @@ -0,0 +1,10 @@ +package com.flipkart.zjsonpatch; + +import com.fasterxml.jackson.databind.JsonNode; + +/** Custom equality function for JsonNode objects */ +public interface JsonNodeEqualsFunction { + JsonNodeEqualsFunction REF_IDENTITY = (jsonNode1, jsonNode2) -> jsonNode1.equals(jsonNode2); + + boolean equals(JsonNode jsonNode1, JsonNode jsonNode2); +} diff --git a/src/test/java/com/flipkart/zjsonpatch/JsonSmartArrayDiffTest.java b/src/test/java/com/flipkart/zjsonpatch/JsonSmartArrayDiffTest.java new file mode 100644 index 0000000..4f6432c --- /dev/null +++ b/src/test/java/com/flipkart/zjsonpatch/JsonSmartArrayDiffTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2016 flipkart.com zjsonpatch. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +package com.flipkart.zjsonpatch; + +import java.io.IOException; +import java.io.InputStream; + +import org.apache.commons.io.IOUtils; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; + +/** + * @author ctranxuan (streamdata.io). + */ +public class JsonSmartArrayDiffTest { + + private static final int START_INDEX = 0; + private static ObjectMapper objectMapper = new ObjectMapper(); + private static ArrayNode jsonNode; + + @BeforeClass + public static void beforeClass() throws IOException { + String path = "/testdata/smart-array-move.json"; + InputStream resourceAsStream = JsonDiffTest.class.getResourceAsStream(path); + String testData = IOUtils.toString(resourceAsStream, "UTF-8"); + jsonNode = (ArrayNode) objectMapper.readTree(testData); + } + + @Test + public void testSampleJsonDiff() { + for (int i = START_INDEX; i < jsonNode.size(); i++) { + JsonNode first = jsonNode.get(i).get("first"); + JsonNode second = jsonNode.get(i).get("second"); + JsonNode patch = jsonNode.get(i).get("patch"); + JsonNodeEqualsFunction jsonNodeEqualFunction = new JsonNodeEqualsFunction() { + @Override + public boolean equals(JsonNode jsonNode1, JsonNode jsonNode2) { + if (jsonNode1 == null || jsonNode2 == null) { + return false; + } + if (jsonNode1.has("id") && jsonNode2.has("id")) { + return jsonNode1.get("id").asInt() == jsonNode2.get("id").asInt(); + } + return jsonNode1.equals(jsonNode2); + } + + }; + JsonNode actualPatch = JsonDiff.asJson(first, second, DiffFlags.defaults(), jsonNodeEqualFunction); + Assert.assertEquals("JSON Patch not equal [index=" + i + ", first=" + first + "]", patch, actualPatch); + JsonNode secondPrime = JsonPatch.apply(actualPatch, first); + Assert.assertEquals("JSON Patch applies not symmetrical [index=" + i + ", first=" + first + "]", second, + secondPrime); + } + } +} diff --git a/src/test/resources/testdata/smart-array-move.json b/src/test/resources/testdata/smart-array-move.json new file mode 100644 index 0000000..743d632 --- /dev/null +++ b/src/test/resources/testdata/smart-array-move.json @@ -0,0 +1,84 @@ + [{ + "message": "Move array element and modify its property", + "first": [ + {"id": 1, "val": "a"}, + {"id": 2, "val": "b"}, + {"id": 3, "val": "c"} + ], + "second": [ + {"id": 3, "val": "changed"}, + {"id": 1, "val": "a"}, + {"id": 2, "val": "b"} + ], + "patch": [ + {"op": "move", "from": "/2", "path": "/0"}, + {"op": "replace", "path": "/0/val", "value": "changed"} + ] + }, + { + "message": "Cross move multiple array element and modify its property", + "first": [ + {"id": 1, "val": "a"}, + {"id": 2, "val": "b"}, + {"id": 3, "val": "c"}, + {"id": 4, "val": "d"} + ], + "second": [ + {"id": 3, "val": "zz"}, + {"id": 1, "val": "a"}, + {"id": 4, "val": "d"}, + {"id": 2, "val": "xx"} + ], + "patch": [ + {"op":"move","from":"/2","path":"/0"}, + {"op":"move","from":"/3","path":"/2"}, + {"op":"replace","path":"/3/val","value":"xx"}, + {"op":"replace","path":"/0/val","value":"zz"} + ] + }, + { + "message": "Cross move multiple array element, modify its properties and add new inbetween", + "first": [ + {"id": 1, "val": "a"}, + {"id": 2, "val": "b"}, + {"id": 3, "val": "c"}, + {"id": 4, "val": "d"} + ], + "second": [ + {"id": 3, "val": "zz"}, + {"id": 5, "val": "new"}, + {"id": 1, "val": "a"}, + {"id": 6, "val": "new2"}, + {"id": 4, "val": "d"}, + {"id": 2, "val": "xx"} + ], + "patch": [ + {"op":"move","from":"/2","path":"/0"}, + {"op":"add","path":"/1","value":{"id":5,"val":"new"}}, + {"op":"add","path":"/3","value":{"id":6,"val":"new2"}}, + {"op":"move","from":"/5","path":"/4"}, + {"op":"replace","path":"/5/val","value":"xx"}, + {"op":"replace","path":"/0/val","value":"zz"} + ] + }, + { + "message": "Move array element and modify its property", + "first": { + "1": {"id": 1, "val": "a"}, + "2": {"id": 2, "val": "b"}, + "3": {"id": 3, "val": "c"} + }, + "second": { + "1": {"id": 1, "val": "a"}, + "2": {"id": 3, "val": "c"}, + "3": {"id": 2, "val": "zz"} + }, + "patch": [ + {"op":"replace","path":"/2/id","value":3}, + {"op":"replace","path":"/2/val","value":"c"}, + {"op":"replace","path":"/3/id","value":2}, + {"op":"replace","path":"/3/val","value":"zz"} + ] + + } +] \ No newline at end of file From 4e6db656771203095f09ac401f31428269dc299d Mon Sep 17 00:00:00 2001 From: HerrDerb Date: Tue, 15 Jul 2025 12:01:40 +0200 Subject: [PATCH 2/2] Apply code rabbit ai suggestions --- src/main/java/com/flipkart/zjsonpatch/JsonDiff.java | 2 ++ .../flipkart/zjsonpatch/JsonNodeEqualsFunction.java | 13 +++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java b/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java index 976781c..5450496 100644 --- a/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java +++ b/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java @@ -44,6 +44,7 @@ public boolean equate(JsonNode o1, JsonNode o2) { @Override public int hash(JsonNode o) { + // Note: Using default hashCode as the LCS algorithm doesn't rely on hashing return o.hashCode(); } }; @@ -84,6 +85,7 @@ public static JsonNode asJson(final JsonNode source, final JsonNode target, Enum * This method generates a JSON Patch from the source and target JsonNodes. * It will return a JSON Patch that contains the differences between the two JsonNodes. * It allows for a custom equality function to be used for comparing JsonNodes in arrays. + * Nodes that are equal according to the custom function will still get compared for differences to reflect nested changes. * @param source the source JsonNode * @param target the target JsonNode * @param flags the diff flags to customize the diff behavior diff --git a/src/main/java/com/flipkart/zjsonpatch/JsonNodeEqualsFunction.java b/src/main/java/com/flipkart/zjsonpatch/JsonNodeEqualsFunction.java index beffc01..b7e3817 100644 --- a/src/main/java/com/flipkart/zjsonpatch/JsonNodeEqualsFunction.java +++ b/src/main/java/com/flipkart/zjsonpatch/JsonNodeEqualsFunction.java @@ -2,9 +2,18 @@ import com.fasterxml.jackson.databind.JsonNode; -/** Custom equality function for JsonNode objects */ +/** + * Custom equality function for JsonNode objects. + * Allows clients to define custom equality semantics for JSON node comparison. + */ +@FunctionalInterface public interface JsonNodeEqualsFunction { JsonNodeEqualsFunction REF_IDENTITY = (jsonNode1, jsonNode2) -> jsonNode1.equals(jsonNode2); - + /** + * Compares two JsonNode objects for equality based on custom logic. + * @param jsonNode1 the first JsonNode to compare + * @param jsonNode2 the second JsonNode to compare + * @return true if the nodes are considered equal, false otherwise + */ boolean equals(JsonNode jsonNode1, JsonNode jsonNode2); }