diff --git a/pom.xml b/pom.xml index 633aca8..bb53744 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.flipkart.zjsonpatch zjsonpatch - 0.4.10 + 0.4.11 jar zjsonpatch diff --git a/src/main/java/com/flipkart/zjsonpatch/DiffFlags.java b/src/main/java/com/flipkart/zjsonpatch/DiffFlags.java index f0d5232..7d40ac0 100644 --- a/src/main/java/com/flipkart/zjsonpatch/DiffFlags.java +++ b/src/main/java/com/flipkart/zjsonpatch/DiffFlags.java @@ -3,6 +3,7 @@ import java.util.EnumSet; public enum DiffFlags { + /** * This flag omits the value field on remove operations. * This is a default flag. @@ -32,7 +33,6 @@ public enum DiffFlags { * fromValue represents the the value replaced by a {@link Operation#REPLACE} * operation, in other words, the original value. This can be useful for debugging * output or custom processing of the diffs by downstream systems. - * * Please note that this is a non-standard extension to RFC 6902 and will not affect * how patches produced by this library are processed by this or other libraries. * @@ -40,11 +40,29 @@ public enum DiffFlags { */ ADD_ORIGINAL_VALUE_ON_REPLACE, + /** + * This flag normalizes a {@link Operation#REPLACE} operation into its respective + * {@link Operation#REMOVE} and {@link Operation#ADD} operations. Although it adds + * a redundant step, this can be useful for auditing systems in which immutability + * is a requirement. + *

+ * For the flag to work, {@link DiffFlags#ADD_ORIGINAL_VALUE_ON_REPLACE} has to be + * enabled as the new instructions in the patch need to grab the old fromValue + * {@code "op": "replace", "fromValue": "F1", "value": "F2" } + * The above instruction will be split into + * {@code "op":"remove", "value":"F1" } and {@code "op":"add", "value":"F2"} respectively. + *

+ * Please note that this is a non-standard extension to RFC 6902 and will not affect + * how patches produced by this library are processed by this or other libraries. + * + * @since 0.4.11 + */ + ADD_EXPLICIT_REMOVE_ADD_ON_REPLACE, + /** * This flag instructs the diff generator to emit {@link Operation#TEST} operations * that validate the state of the source document before each mutation. This can be * useful if you want to ensure data integrity prior to applying the patch. - * * The resulting patches are standard per RFC 6902 and should be processed correctly * by any compliant library; due to the associated space and performance costs, * however, this isn't default behavior. diff --git a/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java b/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java index fa036ad..effc963 100644 --- a/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java +++ b/src/main/java/com/flipkart/zjsonpatch/JsonDiff.java @@ -22,13 +22,17 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.commons.collections4.ListUtils; -import java.util.*; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; /** * User: gopi.vishwakarma * Date: 30/07/14 */ - public final class JsonDiff { private final List diffs = new ArrayList(); @@ -44,18 +48,29 @@ public static JsonNode asJson(final JsonNode source, final JsonNode target) { public static JsonNode asJson(final JsonNode source, final JsonNode target, EnumSet flags) { JsonDiff diff = new JsonDiff(flags); + if (source == null && target != null) { + // return add node at root pointing to the target + diff.diffs.add(Diff.generateDiff(Operation.ADD, JsonPointer.ROOT, target)); + } + if (source != null && target == null) { + // return remove node at root pointing to the source + diff.diffs.add(Diff.generateDiff(Operation.REMOVE, JsonPointer.ROOT, source)); + } + if (source != null && target != null) { + diff.generateDiffs(JsonPointer.ROOT, source, target); - // generating diffs in the order of their occurrence - diff.generateDiffs(JsonPointer.ROOT, source, target); - - if (!flags.contains(DiffFlags.OMIT_MOVE_OPERATION)) - // Merging remove & add to move operation - diff.introduceMoveOperation(); + if (!flags.contains(DiffFlags.OMIT_MOVE_OPERATION)) + // Merging remove & add to move operation + diff.introduceMoveOperation(); - if (!flags.contains(DiffFlags.OMIT_COPY_OPERATION)) - // Introduce copy operation - diff.introduceCopyOperation(source, target); + if (!flags.contains(DiffFlags.OMIT_COPY_OPERATION)) + // Introduce copy operation + diff.introduceCopyOperation(source, target); + if (flags.contains(DiffFlags.ADD_EXPLICIT_REMOVE_ADD_ON_REPLACE)) + // Split replace into remove and add instructions + diff.introduceExplicitRemoveAndAddOperation(); + } return diff.getJsonNodes(); } @@ -210,6 +225,26 @@ private void introduceMoveOperation() { } } + /** + * This method splits a {@link Operation#REPLACE} operation within a diff into a {@link Operation#REMOVE} + * and {@link Operation#ADD} in order, respectively. + * Does nothing if {@link Operation#REPLACE} op does not contain a from value + */ + private void introduceExplicitRemoveAndAddOperation() { + List updatedDiffs = new ArrayList(); + for (Diff diff : diffs) { + if (!diff.getOperation().equals(Operation.REPLACE) || diff.getSrcValue() == null) { + updatedDiffs.add(diff); + continue; + } + //Split into two #REMOVE and #ADD + updatedDiffs.add(new Diff(Operation.REMOVE, diff.getPath(), diff.getSrcValue())); + updatedDiffs.add(new Diff(Operation.ADD, diff.getPath(), diff.getValue())); + } + diffs.clear(); + diffs.addAll(updatedDiffs); + } + //Note : only to be used for arrays //Finds the longest common Ancestor ending at Array private static JsonPointer computeRelativePath(JsonPointer path, int startIdx, int endIdx, List diffs) { diff --git a/src/test/java/com/flipkart/zjsonpatch/JsonDiffTest.java b/src/test/java/com/flipkart/zjsonpatch/JsonDiffTest.java index f1a955d..41e7b6b 100644 --- a/src/test/java/com/flipkart/zjsonpatch/JsonDiffTest.java +++ b/src/test/java/com/flipkart/zjsonpatch/JsonDiffTest.java @@ -12,10 +12,11 @@ * 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 com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -30,6 +31,8 @@ import java.util.EnumSet; import java.util.Random; +import static org.junit.Assert.assertEquals; + /** * Unit test */ @@ -123,4 +126,34 @@ public void testPath() throws Exception { JsonNode expected = objectMapper.readTree("{\"profiles\":{\"abc\":[],\"def\":[{\"hello\":\"world2\"},{\"hello\":\"world\"}]}}"); Assert.assertEquals(target, expected); } + + @Test + public void testJsonDiffReturnsEmptyNodeExceptionWhenBothSourceAndTargetNodeIsNull() { + JsonNode diff = JsonDiff.asJson(null, null); + assertEquals(0, diff.size()); + } + + @Test + public void testJsonDiffShowsDiffWhenSourceNodeIsNull() throws JsonProcessingException { + String target = "{ \"K1\": {\"K2\": \"V1\"} }"; + JsonNode diff = JsonDiff.asJson(null, objectMapper.reader().readTree(target)); + assertEquals(1, diff.size()); + + System.out.println(diff); + assertEquals(Operation.ADD.rfcName(), diff.get(0).get("op").textValue()); + assertEquals(JsonPointer.ROOT.toString(), diff.get(0).get("path").textValue()); + assertEquals("V1", diff.get(0).get("value").get("K1").get("K2").textValue()); + } + + @Test + public void testJsonDiffShowsDiffWhenTargetNodeIsNullWithFlags() throws JsonProcessingException { + String source = "{ \"K1\": \"V1\" }"; + JsonNode sourceNode = objectMapper.reader().readTree(source); + JsonNode diff = JsonDiff.asJson(sourceNode, null, EnumSet.of(DiffFlags.ADD_ORIGINAL_VALUE_ON_REPLACE)); + + assertEquals(1, diff.size()); + assertEquals(Operation.REMOVE.rfcName(), diff.get(0).get("op").textValue()); + assertEquals(JsonPointer.ROOT.toString(), diff.get(0).get("path").textValue()); + assertEquals("V1", diff.get(0).get("value").get("K1").textValue()); + } } diff --git a/src/test/java/com/flipkart/zjsonpatch/JsonSplitReplaceOpTest.java b/src/test/java/com/flipkart/zjsonpatch/JsonSplitReplaceOpTest.java new file mode 100644 index 0000000..677f863 --- /dev/null +++ b/src/test/java/com/flipkart/zjsonpatch/JsonSplitReplaceOpTest.java @@ -0,0 +1,95 @@ +package com.flipkart.zjsonpatch; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import java.util.EnumSet; + +import static org.junit.Assert.assertEquals; + +/** + * @author isopropylcyanide + */ +public class JsonSplitReplaceOpTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + public void testJsonDiffSplitsReplaceIntoAddAndRemoveOperationWhenFlagIsAdded() throws JsonProcessingException { + String source = "{ \"ids\": [ \"F1\", \"F3\" ] }"; + String target = "{ \"ids\": [ \"F1\", \"F6\", \"F4\" ] }"; + JsonNode sourceNode = OBJECT_MAPPER.reader().readTree(source); + JsonNode targetNode = OBJECT_MAPPER.reader().readTree(target); + + JsonNode diff = JsonDiff.asJson(sourceNode, targetNode, EnumSet.of( + DiffFlags.ADD_EXPLICIT_REMOVE_ADD_ON_REPLACE + )); + assertEquals(3, diff.size()); + assertEquals(Operation.REMOVE.rfcName(), diff.get(0).get("op").textValue()); + assertEquals("/ids/1", diff.get(0).get("path").textValue()); + assertEquals("F3", diff.get(0).get("value").textValue()); + + assertEquals(Operation.ADD.rfcName(), diff.get(1).get("op").textValue()); + assertEquals("/ids/1", diff.get(1).get("path").textValue()); + assertEquals("F6", diff.get(1).get("value").textValue()); + + assertEquals(Operation.ADD.rfcName(), diff.get(2).get("op").textValue()); + assertEquals("/ids/2", diff.get(2).get("path").textValue()); + assertEquals("F4", diff.get(2).get("value").textValue()); + } + + @Test + public void testJsonDiffDoesNotSplitReplaceIntoAddAndRemoveOperationWhenFlagIsNotAdded() throws JsonProcessingException { + String source = "{ \"ids\": [ \"F1\", \"F3\" ] }"; + String target = "{ \"ids\": [ \"F1\", \"F6\", \"F4\" ] }"; + JsonNode sourceNode = OBJECT_MAPPER.reader().readTree(source); + JsonNode targetNode = OBJECT_MAPPER.reader().readTree(target); + + JsonNode diff = JsonDiff.asJson(sourceNode, targetNode); + System.out.println(diff); + assertEquals(2, diff.size()); + assertEquals(Operation.REPLACE.rfcName(), diff.get(0).get("op").textValue()); + assertEquals("/ids/1", diff.get(0).get("path").textValue()); + assertEquals("F6", diff.get(0).get("value").textValue()); + + assertEquals(Operation.ADD.rfcName(), diff.get(1).get("op").textValue()); + assertEquals("/ids/2", diff.get(1).get("path").textValue()); + assertEquals("F4", diff.get(1).get("value").textValue()); + } + + @Test + public void testJsonDiffDoesNotSplitsWhenThereIsNoReplaceOperationButOnlyRemove() throws JsonProcessingException { + String source = "{ \"ids\": [ \"F1\", \"F3\" ] }"; + String target = "{ \"ids\": [ \"F3\"] }"; + + JsonNode sourceNode = OBJECT_MAPPER.reader().readTree(source); + JsonNode targetNode = OBJECT_MAPPER.reader().readTree(target); + + JsonNode diff = JsonDiff.asJson(sourceNode, targetNode, EnumSet.of( + DiffFlags.ADD_EXPLICIT_REMOVE_ADD_ON_REPLACE + )); + assertEquals(1, diff.size()); + assertEquals(Operation.REMOVE.rfcName(), diff.get(0).get("op").textValue()); + assertEquals("/ids/0", diff.get(0).get("path").textValue()); + assertEquals("F1", diff.get(0).get("value").textValue()); + } + + @Test + public void testJsonDiffDoesNotSplitsWhenThereIsNoReplaceOperationButOnlyAdd() throws JsonProcessingException { + String source = "{ \"ids\": [ \"F1\" ] }"; + String target = "{ \"ids\": [ \"F1\", \"F6\"] }"; + + JsonNode sourceNode = OBJECT_MAPPER.reader().readTree(source); + JsonNode targetNode = OBJECT_MAPPER.reader().readTree(target); + + JsonNode diff = JsonDiff.asJson(sourceNode, targetNode, EnumSet.of( + DiffFlags.ADD_EXPLICIT_REMOVE_ADD_ON_REPLACE + )); + assertEquals(1, diff.size()); + assertEquals(Operation.ADD.rfcName(), diff.get(0).get("op").textValue()); + assertEquals("/ids/1", diff.get(0).get("path").textValue()); + assertEquals("F6", diff.get(0).get("value").textValue()); + } +}