com.unboundid.scim2.common.utils.JsonDiff.java Source code

Java tutorial

Introduction

Here is the source code for com.unboundid.scim2.common.utils.JsonDiff.java

Source

/*
 * Copyright 2016-2017 UnboundID Corp.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License (GPLv2 only)
 * or the terms of the GNU Lesser General Public License (LGPLv2.1 only)
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, see <http://www.gnu.org/licenses>.
 */

package com.unboundid.scim2.common.utils;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ValueNode;
import com.unboundid.scim2.common.Path;
import com.unboundid.scim2.common.filters.Filter;
import com.unboundid.scim2.common.messages.PatchOperation;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.logging.Level;

/**
 * This class can be used to calculate the diffs between two SCIM/JSON
 * resources for the purpose of building a set of patch operations.
 */
public class JsonDiff {
    /**
     * Generates a list of patch operations that can be applied to the source
     * node in order to make it match the target node.
     *
     * @param source The source node for which the set of modifications should
     *               be generated.
     * @param target The target node, which is what the source node should
     *               look like if the returned modifications are applied.
     * @param removeMissing Whether to remove fields that are missing in the
     *                      target node.
     * @return A diff with modifications that can be applied to the source
     *         resource in order to make it match the target resource.
     */
    public List<PatchOperation> diff(final ObjectNode source, final ObjectNode target,
            final boolean removeMissing) {
        List<PatchOperation> ops = new LinkedList<PatchOperation>();
        ObjectNode targetToAdd = target.deepCopy();
        ObjectNode targetToReplace = target.deepCopy();
        diff(Path.root(), source, targetToAdd, targetToReplace, ops, removeMissing);
        if (targetToReplace.size() > 0) {
            ops.add(PatchOperation.replace(targetToReplace));
        }
        if (targetToAdd.size() > 0) {
            ops.add(PatchOperation.add(targetToAdd));
        }
        return ops;
    }

    /**
     * Internal diff that is used to recursively diff source and target object
     * nodes.
     *
     * @param parentPath The path to the source object node.
     * @param source The source node.
     * @param targetToAdd The target node that will be modified to only contain
     *                    the fields to add.
     * @param targetToReplace The target node that will be modified to only
     *                        contain the fields to replace.
     * @param operations The list of operations to append.
     * @param removeMissing Whether to remove fields that are missing in the
     *                      target node.
     */
    private void diff(final Path parentPath, final ObjectNode source, final ObjectNode targetToAdd,
            final ObjectNode targetToReplace, final List<PatchOperation> operations, final boolean removeMissing) {
        // First iterate through the source fields and compare it to the target
        Iterator<Map.Entry<String, JsonNode>> si = source.fields();
        while (si.hasNext()) {
            processEntry(parentPath, targetToAdd, targetToReplace, operations, removeMissing, si.next());
        }

        if (targetToAdd != targetToReplace) {
            // Now iterate through the fields in targetToAdd and remove any that
            // are not in the source. These new fields should only be in
            // targetToReplace.
            Iterator<String> ai = targetToAdd.fieldNames();
            while (ai.hasNext()) {
                final String f = ai.next();
                if (!source.has(f)) {
                    ai.remove();
                }
            }
        }

        removeNullAndEmptyValues(targetToAdd);
        removeNullAndEmptyValues(targetToReplace);
    }

    private void processEntry(final Path parentPath, final ObjectNode targetToAdd, final ObjectNode targetToReplace,
            final List<PatchOperation> operations, final boolean removeMissing,
            final Map.Entry<String, JsonNode> sourceEntry) {
        String sourceKey = sourceEntry.getKey();
        JsonNode sourceNode = sourceEntry.getValue();

        Path path = computeDiffPath(parentPath, sourceKey, sourceNode);
        JsonNode targetValueToAdd = targetToAdd.remove(sourceKey);
        JsonNode targetValueToReplace = targetToReplace == targetToAdd ? targetValueToAdd
                : targetToReplace.remove(sourceKey);

        if (targetValueToAdd == null) {
            if (removeMissing) {
                operations.add(PatchOperation.remove(path));
            }
            return;
        }

        if (isSameType(sourceNode, targetValueToAdd)) {
            replaceNode(parentPath, path, targetToAdd, targetToReplace, operations, removeMissing, sourceNode,
                    targetValueToAdd, targetValueToReplace, sourceKey);
        } else {
            // Value present in both but they are of different types.
            if (targetValueToAdd.isNull() || (targetValueToAdd.isArray() && targetValueToAdd.size() == 0)) {
                // Explicitly clear attribute value.
                operations.add(PatchOperation.remove(path));
            } else {
                // Just replace with the target value.
                targetToReplace.set(sourceKey, targetValueToReplace);
            }
        }
    }

    private void replaceNode(final Path parentPath, final Path path, final ObjectNode targetToAdd,
            final ObjectNode targetToReplace, final List<PatchOperation> operations, final boolean removeMissing,
            final JsonNode sourceNode, final JsonNode targetValueToAdd, final JsonNode targetValueToReplace,
            final String sourceKey) {
        // Value present in both and they are of the same type.
        if (sourceNode.isObject()) {
            computeObjectNodeDiffs(path, sourceNode, targetValueToAdd, targetValueToReplace, operations,
                    removeMissing, targetToAdd, targetToReplace, sourceKey);
        } else if (sourceNode.isArray()) {
            computeArrayNodeDiffs(parentPath, path, targetToAdd, targetToReplace, operations, removeMissing,
                    sourceNode, targetValueToAdd, targetValueToReplace, sourceKey);
        } else {
            // They are value nodes.
            if (compareTo(path.withoutFilters(), sourceNode, targetValueToAdd) != 0) {
                // Just replace with the target value.
                targetToReplace.set(sourceKey, targetValueToReplace);
            }
        }
    }

    /**
     * Compare the JSON value nodes at the specified path.
     * @param path path
     * @param sourceNode source node
     * @param targetNode target node
     * @return a negative integer, zero, or a positive integer as the
     *         first argument is less than, equal to, or greater than the second.
     */
    protected int compareTo(final Path path, final JsonNode sourceNode, final JsonNode targetNode) {
        return JsonUtils.compareTo(sourceNode, targetNode, null);
    }

    private void computeArrayNodeDiffs(final Path parentPath, final Path path, final ObjectNode targetToAdd,
            final ObjectNode targetToReplace, final List<PatchOperation> operations, final boolean removeMissing,
            final JsonNode sourceNode, final JsonNode targetValueToAdd, final JsonNode targetValueToReplace,
            final String sourceKey) {
        if (targetValueToAdd.size() == 0) {
            if ((sourceNode != null) && (sourceNode.isArray()) && (sourceNode.size() == 0)) {
                return;
            }

            // Explicitly clear all attribute values.
            operations.add(PatchOperation.remove(path));
        } else {
            // Go through each value and try to individually patch them first
            // instead of replacing all values.
            List<PatchOperation> targetOpToRemoveOrReplace = new LinkedList<PatchOperation>();
            boolean replaceAllValues = false;
            for (JsonNode sv : sourceNode) {
                JsonNode tv = removeMatchingValue(sv, (ArrayNode) targetValueToAdd);
                Filter valueFilter = generateValueFilter(sv);
                if (valueFilter == null) {
                    replaceAllValues = true;
                    Debug.debug(Level.WARNING, DebugType.OTHER,
                            "Performing full replace of target " + "array node " + path + " since the it is not "
                                    + "possible to generate a value filter to uniquely " + "identify the value "
                                    + sv.toString());
                    break;
                }
                Path valuePath = parentPath.attribute(sourceKey, valueFilter);
                if (tv != null) {
                    // The value is in both source and target arrays.
                    if (sv.isObject() && tv.isObject()) {
                        // Recursively diff the object node.
                        diff(valuePath, (ObjectNode) sv, (ObjectNode) tv, (ObjectNode) tv, operations,
                                removeMissing);
                        if (tv.size() > 0) {
                            targetOpToRemoveOrReplace.add(PatchOperation.replace(valuePath, tv));
                        }
                    }
                } else {
                    targetOpToRemoveOrReplace.add(PatchOperation.remove(valuePath));
                }
            }
            if (!replaceAllValues
                    && targetValueToReplace.size() <= targetValueToAdd.size() + targetOpToRemoveOrReplace.size()) {
                // We are better off replacing the entire array.
                Debug.debug(Level.INFO, DebugType.OTHER,
                        "Performing full replace of target " + "array node " + path + " since the " + "array ("
                                + targetValueToReplace.size() + ") " + "is smaller than removing and "
                                + "replacing (" + targetOpToRemoveOrReplace.size() + ") " + "then adding ("
                                + targetValueToAdd.size() + ")  " + "the values individually");
                replaceAllValues = true;
                targetToReplace.set(sourceKey, targetValueToReplace);

            }
            if (replaceAllValues) {
                targetToReplace.set(sourceKey, targetValueToReplace);
            } else {
                if (!targetOpToRemoveOrReplace.isEmpty()) {
                    operations.addAll(targetOpToRemoveOrReplace);
                }
                if (targetValueToAdd.size() > 0) {
                    targetToAdd.set(sourceKey, targetValueToAdd);
                }
            }
        }
    }

    private void computeObjectNodeDiffs(final Path path, final JsonNode sourceNode, final JsonNode targetValueToAdd,
            final JsonNode targetValueToReplace, final List<PatchOperation> operations, final boolean removeMissing,
            final ObjectNode targetToAdd, final ObjectNode targetToReplace, final String sourceKey) {
        // Recursively diff the object node.
        diff(path, (ObjectNode) sourceNode, (ObjectNode) targetValueToAdd, (ObjectNode) targetValueToReplace,
                operations, removeMissing);
        // Include the object node if there are fields to add or replace.
        if (targetValueToAdd.size() > 0) {
            targetToAdd.set(sourceKey, targetValueToAdd);
        }
        if (targetValueToReplace.size() > 0) {
            targetToReplace.set(sourceKey, targetValueToReplace);
        }
    }

    private Path computeDiffPath(final Path parentPath, final String sourceKey, final JsonNode sourceNode) {
        return parentPath.isRoot() && SchemaUtils.isUrn(sourceKey) ? Path.root(sourceKey)
                : parentPath.attribute(sourceKey);
    }

    /**
     * Removes the value from an ArrayNode that matches the provided node.
     *
     * @param sourceValue The sourceValue node to match.
     * @param targetValues The ArrayNode containing the values to remove from.
     * @return The matching value that was removed or {@code null} if no matching
     *         value was found.
     */
    private JsonNode removeMatchingValue(final JsonNode sourceValue, final ArrayNode targetValues) {
        if (sourceValue.isObject()) {
            // Find a target value that has the most fields in common with the source
            // and have identical values. Common fields that are also one of the
            // SCIM standard multi-value sub-attributes (ie. type, value, etc...) have
            // a higher weight when determining the best matching value.
            TreeMap<Integer, Integer> matchScoreToIndex = new TreeMap<Integer, Integer>();
            for (int i = 0; i < targetValues.size(); i++) {
                JsonNode targetValue = targetValues.get(i);
                if (targetValue.isObject()) {
                    int matchScore = 0;
                    Iterator<String> si = sourceValue.fieldNames();
                    while (si.hasNext()) {
                        String field = si.next();
                        if (sourceValue.get(field).equals(targetValue.path(field))) {
                            if (field.equals("value") || field.equals("$ref")) {
                                // These fields have the highest chance of having unique values.
                                matchScore += 3;
                            } else if (field.equals("type") || field.equals("display")) {
                                // These fields should mostly be unique.
                                matchScore += 2;
                            } else if (field.equals("primary")) {
                                // This field will definitely not be unique.
                                matchScore += 0;
                            } else {
                                // Not one of the normative fields. Use the default weight.
                                matchScore += 1;
                            }
                        }
                    }
                    // Only consider the match if there is not already match with the same
                    // score. This will prefer matches at the same index in the array.
                    if (matchScore > 0 && !matchScoreToIndex.containsKey(matchScore)) {
                        matchScoreToIndex.put(matchScore, i);
                    }
                }
            }
            if (!matchScoreToIndex.isEmpty()) {
                return targetValues.remove(matchScoreToIndex.lastEntry().getValue());
            }
        } else {
            // Find an exact match
            for (int i = 0; i < targetValues.size(); i++) {
                if (JsonUtils.compareTo(sourceValue, targetValues.get(i), null) == 0) {
                    return targetValues.remove(i);
                }
            }
        }

        // Can't find a match at all.
        return null;
    }

    /**
     * Generate a value filter that may be used to uniquely identify this value
     * in an array node.
     *
     * @param value The value to generate a filter from.
     * @return The value filter or {@code null} if a value filter can not be used
     *         to uniquely identify the node.
     */
    private Filter generateValueFilter(final JsonNode value) {
        if (value.isValueNode()) {
            // Use the implicit "value" sub-attribute to reference this value.
            return Filter.eq(Path.root().attribute("value"), (ValueNode) value);
        }
        if (value.isObject()) {
            List<Filter> filters = new ArrayList<Filter>(value.size());
            Iterator<Map.Entry<String, JsonNode>> fieldsIterator = value.fields();
            while (fieldsIterator.hasNext()) {
                Map.Entry<String, JsonNode> field = fieldsIterator.next();
                if (!field.getValue().isValueNode()) {
                    // We can't nest value filters.
                    return null;
                }
                filters.add(Filter.eq(Path.root().attribute(field.getKey()), (ValueNode) field.getValue()));
            }

            if (filters.size() == 0) {
                return null;
            } else if (filters.size() == 1) {
                return filters.get(0);
            } else {
                return Filter.and(filters);
            }
        }

        // We can't uniquely identify this value with a filter.
        return null;
    }

    /**
     * Removes any fields with the {@code null} value or an empty array.
     *
     * @param node The node with {@code null} and empty array values removed.
     */
    private void removeNullAndEmptyValues(final JsonNode node) {
        Iterator<JsonNode> si = node.elements();
        while (si.hasNext()) {
            JsonNode field = si.next();
            if (field.isNull() || field.isArray() && field.size() == 0) {
                si.remove();
            } else if (field.isContainerNode()) {
                removeNullAndEmptyValues(field);
            }
        }
    }

    /**
     * Determines whether the provided JSON nodes have the same JSON data type.
     * @param n1  The first node.
     * @param n2  The second node.
     * @return  {@code true} iff the nodes have the same JSON data type.
     */
    public boolean isSameType(final JsonNode n1, final JsonNode n2) {
        return (n1.getNodeType() == n2.getNodeType()
                || ((n1.isTextual() || n1.isBinary()) && (n2.isTextual() || n2.isBinary())));
    }
}