package jiff; import java.util.List; import java.util.ArrayList; import java.util.Map; import java.util.HashMap; import java.util.Iterator; import java.io.IOException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ValueNode; /** * Compares two json documents and reports differences * * @author bserdar */ public class JsonDiff { /** * Comparison options */ public static enum Option { /** * If set, array ordering is significant. That is, [1,2,3] != [1,3,2]. * This is the default. */ ARRAY_ORDER_SIGNIFICANT, /** * If set, array ordering is not significant. That is, [1,2,3] == * [1,3,2] */ ARRAY_ORDER_INSIGNIFICANT, /** * If set, a difference in a field will also be recorded as a difference * in the object containing it. That is: * <pre> * { a:{ x:1, y:2} } * { a:{ x:2, y:2} } * </pre> will report two differences, one for "a", and one for "a.x" */ RETURN_PARENT_DIFFS, /** * If set, a difference in a field will also be recorded for that field, * but not its parent. That is: * <pre> * { a:{ x:1, y:2} } * { a:{ x:2, y:2} } * </pre> will report one difference, "a". In comparing arrays, if array * sizes differ, the array field will be recorded as a difference. */ RETURN_LEAVES_ONLY } private JsonComparator objectComparator = new DefaultObjectNodeComparator(); private JsonComparator arrayComparator = new DefaultArrayNodeComparator(); private JsonComparator valueComparator = new DefaultValueNodeComparator(); private boolean returnParentDiffs = false; private Filter filter = INCLUDE_ALL; private static final Filter INCLUDE_ALL = new Filter() { @Override public boolean includeField(List<String> field) { return true; } }; private static final JsonComparator NODIFF_CMP = new JsonComparator() { @Override public boolean compare(List<JsonDelta> delta, List<String> context, JsonNode node1, JsonNode node2) { return false; } }; /** * Recursively compares the fields in two objects */ public class DefaultObjectNodeComparator implements JsonComparator { @Override public boolean compare(List<JsonDelta> delta, List<String> context, JsonNode node1, JsonNode node2) { boolean ret = false; int ctxn = context.size(); context.add(""); for (Iterator<Map.Entry<String, JsonNode>> itr = node1.fields(); itr.hasNext();) { Map.Entry<String, JsonNode> entry = itr.next(); String field = entry.getKey(); context.set(ctxn, field); JsonNode node1Value = entry.getValue(); JsonNode node2Value = node2.get(field); if (computeDiff(delta, context, node1Value, node2Value)) { ret = true; } } // Are there any nodes that are in node2, but not in node1? for (Iterator<String> node2Names = node2.fieldNames(); node2Names.hasNext();) { String field = node2Names.next(); context.set(ctxn, field); if (filter.includeField(context)) { if (!node1.has(field)) { delta.add(new JsonDelta(JsonDiff.toString(context), null, node2.get(field))); ret = true; } } } if (ret && returnParentDiffs) { delta.add(new JsonDelta(JsonDiff.toString(context), node1, node2)); } context.remove(ctxn); return ret; } } /** * Recursively compares two arrays. Expects to see the elements in the same * order */ public class DefaultArrayNodeComparator implements JsonComparator { @Override public boolean compare(List<JsonDelta> delta, List<String> context, JsonNode node1, JsonNode node2) { boolean ret = false; int n = Math.min(node1.size(), node2.size()); int ctxn = context.size(); context.add(""); for (int i = 0; i < n; i++) { context.set(ctxn, Integer.toString(i)); JsonNode node1Value = node1.get(i); JsonNode node2Value = node2.get(i); if (computeDiff(delta, context, node1Value, node2Value)) { ret = true; } } context.remove(ctxn); if ((ret && returnParentDiffs) || node1.size() != node2.size()) { delta.add(new JsonDelta(JsonDiff.toString(context), node1, node2)); ret = true; } return ret; } } /** * Creates a hash for a json node. The hash is a weak hash, but insensitive * to element order. */ private static class HashedNode { private final JsonNode node; private final int index; public HashedNode(JsonNode node, int index) { this.node = node; this.index = index; } public JsonNode getNode() { return node; } public int getIndex() { return index; } } private class ArrayNodes { private final List<HashedNode> node1Elements = new ArrayList<>(); private final List<HashedNode> node2Elements = new ArrayList<>(); private void findAndRemove(List<String> context) { List<JsonDelta> delta = new ArrayList<>(); int nctx = context.size(); context.add(""); for (int ix1 = 0; ix1 < node1Elements.size(); ix1++) { HashedNode node1 = node1Elements.get(ix1); for (int ix2 = 0; ix2 < node2Elements.size(); ix2++) { HashedNode node2 = node2Elements.get(ix2); context.set(nctx, Integer.toString(node1.index)); if (!computeDiff(delta, context, node1.getNode(), node2.getNode())) { node1Elements.remove(ix1); ix1--; node2Elements.remove(ix2); break; } } } context.remove(nctx); } } /** * Recursively compares two arrays, treating then as sets (no element order * requirement) */ public class SetArrayNodeComparator implements JsonComparator { @Override public boolean compare(List<JsonDelta> delta, List<String> context, JsonNode node1, JsonNode node2) { boolean ret = false; int index = 0; Map<Long, ArrayNodes> map = new HashMap<>(); for (Iterator<JsonNode> itr = node1.elements(); itr.hasNext(); index++) { HashedNode hnode = new HashedNode(itr.next(), index); put(map, hnode, context).node1Elements.add(hnode); } index = 0; Map<Long, List<HashedNode>> node2Elements = new HashMap<>(); for (Iterator<JsonNode> itr = node2.elements(); itr.hasNext(); index++) { HashedNode hnode = new HashedNode(itr.next(), index); put(map, hnode, context).node2Elements.add(hnode); } for (ArrayNodes entry : map.values()) { entry.findAndRemove(context); for (HashedNode node : entry.node1Elements) { delta.add(new JsonDelta(JsonDiff.toString(context, Integer.toString(node.getIndex())), node.getNode(), null)); ret = true; } for (HashedNode node : entry.node2Elements) { delta.add(new JsonDelta(JsonDiff.toString(context, Integer.toString(node.getIndex())), null, node.getNode())); ret = true; } } if (ret || node1.size() != node2.size()) { if (returnParentDiffs) { delta.add(new JsonDelta(JsonDiff.toString(context), node1, node2)); } ret = true; } return ret; } private long computeHash(JsonNode node, List<String> context) { long hash = 0; if (filter.includeField(context)) { if (node instanceof ValueNode) { return node.hashCode(); } else if (node == null) { hash = 0; } else if (node instanceof NullNode) { hash = 1; } else if (node instanceof ObjectNode) { hash = 0; int ctxn = context.size(); context.add(""); for (Iterator<Map.Entry<String, JsonNode>> itr = node.fields(); itr.hasNext();) { Map.Entry<String, JsonNode> entry = itr.next(); context.set(ctxn, entry.getKey()); if (filter.includeField(context)) { hash += entry.getKey().hashCode(); hash += computeHash(entry.getValue(), context); } } context.remove(ctxn); } else if (node instanceof ArrayNode) { hash = 0; int i = 0; int ctxn = context.size(); context.add(""); for (Iterator<JsonNode> itr = node.elements(); itr.hasNext(); i++) { context.set(ctxn, Integer.toString(i)); hash += computeHash(itr.next(), context); } context.remove(ctxn); } else { hash = node.hashCode(); } } return hash; } private ArrayNodes put(Map<Long, ArrayNodes> map, HashedNode node, List<String> context) { int ctxn = context.size(); context.add(Integer.toString(node.index)); Long hash = computeHash(node.getNode(), context); context.remove(ctxn); ArrayNodes an = map.get(hash); if (an == null) { map.put(hash, an = new ArrayNodes()); } return an; } } /** * Compares two value nodes */ public class DefaultValueNodeComparator implements JsonComparator { @Override public boolean compare(List<JsonDelta> delta, List<String> context, JsonNode node1, JsonNode node2) { if (node1.isValueNode() && node2.isValueNode()) { if (node1.isNumber() && node2.isNumber()) { if (!node1.asText().equals(node2.asText())) { delta.add(new JsonDelta(JsonDiff.toString(context), node1, node2)); return true; } } else if (!node1.equals(node2)) { delta.add(new JsonDelta(JsonDiff.toString(context), node1, node2)); return true; } } else if (!node1.equals(node2)) { delta.add(new JsonDelta(JsonDiff.toString(context), node1, node2)); return true; } return false; } } public JsonDiff() { } public JsonDiff(Option... options) { for (Option x : options) { setOption(x); } } /** * Sets an option */ public void setOption(Option option) { switch (option) { case ARRAY_ORDER_SIGNIFICANT: arrayComparator = new DefaultArrayNodeComparator(); break; case ARRAY_ORDER_INSIGNIFICANT: arrayComparator = new SetArrayNodeComparator(); break; case RETURN_PARENT_DIFFS: returnParentDiffs = true; break; case RETURN_LEAVES_ONLY: returnParentDiffs = false; break; } } /** * Sets a filter that determines whether to include fields in comparison */ public void setFilter(Filter f) { this.filter = f; } /** * Computes the difference of two JSON strings, and returns the differences */ public List<JsonDelta> computeDiff(String node1, String node2) throws IOException { ObjectMapper mapper = new ObjectMapper(); return computeDiff(mapper.readTree(node1), mapper.readTree(node2)); } /** * Computes the difference of two JSON nodes and returns the differences */ public List<JsonDelta> computeDiff(JsonNode node1, JsonNode node2) { List<JsonDelta> list = new ArrayList<>(); computeDiff(list, new ArrayList<String>(), node1, node2); return list; } /** * Returns true if there is a difference */ public boolean computeDiff(List<JsonDelta> delta, List<String> context, JsonNode node1, JsonNode node2) { boolean ret = false; if (context.size() == 0 || filter.includeField(context)) { JsonComparator cmp = getComparator(context, node1, node2); if (cmp != null) { ret = cmp.compare(delta, context, node1, node2); } else { delta.add(new JsonDelta(toString(context), node1, node2)); ret = true; } } return ret; } /** * Returns the comparator for the give field, and nodes. This method can be * overriden to customize comparison logic. */ public JsonComparator getComparator(List<String> context, JsonNode node1, JsonNode node2) { if (node1 == null) { if (node2 == null) { return NODIFF_CMP; } else { return null; } } else if (node2 == null) { return null; } else { if (node1 instanceof NullNode) { if (node2 instanceof NullNode) { return NODIFF_CMP; } else { return null; } } else if (node2 instanceof NullNode) { return null; } // Nodes are not null, and they are not null node if (node1.isContainerNode() && node2.isContainerNode()) { if (node1 instanceof ObjectNode) { return objectComparator; } else if (node1 instanceof ArrayNode) { return arrayComparator; } } else if (node1.isValueNode() && node2.isValueNode()) { return valueComparator; } } return null; } public static String toString(List<String> list) { return toString(list, null); } public static String toString(List<String> list, String addtn) { StringBuilder bld = new StringBuilder(); boolean first = true; for (String s : list) { if (first) { first = false; } else { bld.append('.'); } bld.append(s); } if (addtn != null) { if (!first) { bld.append('.'); } bld.append(addtn); } return bld.toString(); } }