Skip to content

Add smart equals for arrays #214

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 69 additions & 14 deletions src/main/java/com/flipkart/zjsonpatch/JsonDiff.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,20 +35,66 @@
* Date: 30/07/14
*/
public final class JsonDiff {
private static final JsonNodeEqualsFunction DEFAULT_EQUALS_REF_FUNCTION = JsonNodeEqualsFunction.REF_IDENTITY;
private final Equator<JsonNode> LCS_EQUATOR = new Equator<JsonNode>() {
@Override
public boolean equate(JsonNode o1, JsonNode o2) {
return equalsRefFunction.equals(o1, o2);
}

@Override
public int hash(JsonNode o) {
// Note: Using default hashCode as the LCS algorithm doesn't rely on hashing
return o.hashCode();
}
};

private final JsonNodeEqualsFunction equalsRefFunction;
private final List<Diff> diffs = new ArrayList<Diff>();
private final EnumSet<DiffFlags> flags;

private JsonDiff(EnumSet<DiffFlags> flags) {
private JsonDiff(EnumSet<DiffFlags> 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<DiffFlags> 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.
* 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
* @param equalsRefFunction the custom equality function
* @return a JsonNode representing the JSON Patch
*/
public static JsonNode asJson(final JsonNode source, final JsonNode target, EnumSet<DiffFlags> 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));
Expand Down Expand Up @@ -201,7 +248,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;
}

Expand All @@ -219,6 +266,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;
}
}
Expand Down Expand Up @@ -384,30 +436,32 @@ 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));
diffs.add(Diff.generateDiff(Operation.REMOVE, currPath, srcNode));
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++;
Expand Down Expand Up @@ -476,7 +530,8 @@ private void compareObjects(JsonPointer path, JsonNode source, JsonNode target)
}
}

private static List<JsonNode> getLCS(final JsonNode first, final JsonNode second) {
return ListUtils.longestCommonSubsequence(InternalUtils.toList((ArrayNode) first), InternalUtils.toList((ArrayNode) second));
private List<JsonNode> getLCS(final JsonNode first, final JsonNode second) {
return ListUtils.longestCommonSubsequence(InternalUtils.toList((ArrayNode) first),
InternalUtils.toList((ArrayNode) second), LCS_EQUATOR);
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/flipkart/zjsonpatch/JsonNodeEqualsFunction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.flipkart.zjsonpatch;

import com.fasterxml.jackson.databind.JsonNode;

/**
* 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);
}
74 changes: 74 additions & 0 deletions src/test/java/com/flipkart/zjsonpatch/JsonSmartArrayDiffTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading