com.cedarsoftware.util.DeepEquals Maven / Gradle / Ivy
package com.cedarsoftware.util;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import static com.cedarsoftware.util.Converter.convert2BigDecimal;
import static com.cedarsoftware.util.Converter.convert2boolean;
/**
* Test two objects for equivalence with a 'deep' comparison. This will traverse
* the Object graph and perform either a field-by-field comparison on each
* object (if no .equals() method has been overridden from Object), or it
* will call the customized .equals() method if it exists. This method will
* allow object graphs loaded at different times (with different object ids)
* to be reliably compared. Object.equals() / Object.hashCode() rely on the
* object's identity, which would not consider two equivalent objects necessarily
* equals. This allows graphs containing instances of Classes that did not
* overide .equals() / .hashCode() to be compared. For example, testing for
* existence in a cache. Relying on an object's identity will not locate an
* equivalent object in a cache.
*
* This method will handle cycles correctly, for example A->B->C->A. Suppose a and
* a' are two separate instances of A with the same values for all fields on
* A, B, and C. Then a.deepEquals(a') will return true. It uses cycle detection
* storing visited objects in a Set to prevent endless loops.
*
* Numbers will be compared for value. Meaning an int that has the same value
* as a long will match. Similarly, a double that has the same value as a long
* will match. If the flag "ALLOW_STRING_TO_MATCH_NUMBERS" is passed in the options
* are set to true, then Strings will be converted to BigDecimal and compared to
* the corresponding non-String Number. Two Strings will not be compared as numbers,
* however.
*
* @author John DeRegnaucourt ([email protected])
*
* Copyright (c) Cedar Software LLC
*
* 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
*
* License
*
* 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.
*/
@SuppressWarnings("unchecked")
public class DeepEquals {
private DeepEquals() {
}
public static final String IGNORE_CUSTOM_EQUALS = "ignoreCustomEquals";
public static final String ALLOW_STRINGS_TO_MATCH_NUMBERS = "stringsCanMatchNumbers";
private static final Map _customEquals = new ConcurrentHashMap<>();
private static final Map _customHash = new ConcurrentHashMap<>();
private static final double doubleEplison = 1e-15;
private static final double floatEplison = 1e-6;
private static final Set> prims = new HashSet<>();
static {
prims.add(Byte.class);
prims.add(Integer.class);
prims.add(Long.class);
prims.add(Double.class);
prims.add(Character.class);
prims.add(Float.class);
prims.add(Boolean.class);
prims.add(Short.class);
}
private final static class ItemsToCompare {
private final Object _key1;
private final Object _key2;
private ItemsToCompare(Object k1, Object k2) {
_key1 = k1;
_key2 = k2;
}
public boolean equals(Object other) {
if (!(other instanceof ItemsToCompare)) {
return false;
}
ItemsToCompare that = (ItemsToCompare) other;
return _key1 == that._key1 && _key2 == that._key2;
}
public int hashCode() {
int h1 = _key1 != null ? _key1.hashCode() : 0;
int h2 = _key2 != null ? _key2.hashCode() : 0;
return h1 + h2;
}
public String toString() {
if (_key1.getClass().isPrimitive() && _key2.getClass().isPrimitive()) {
return _key1 + " | " + _key2;
}
return _key1.getClass().getName() + " | " + _key2.getClass().getName();
}
}
/**
* Compare two objects with a 'deep' comparison. This will traverse the
* Object graph and perform either a field-by-field comparison on each
* object (if not .equals() method has been overridden from Object), or it
* will call the customized .equals() method if it exists. This method will
* allow object graphs loaded at different times (with different object ids)
* to be reliably compared. Object.equals() / Object.hashCode() rely on the
* object's identity, which would not consider to equivalent objects necessarily
* equals. This allows graphs containing instances of Classes that did no
* overide .equals() / .hashCode() to be compared. For example, testing for
* existence in a cache. Relying on an objects identity will not locate an
* object in cache, yet relying on it being equivalent will.
*
* This method will handle cycles correctly, for example A->B->C->A. Suppose a and
* a' are two separate instances of the A with the same values for all fields on
* A, B, and C. Then a.deepEquals(a') will return true. It uses cycle detection
* storing visited objects in a Set to prevent endless loops.
*
* @param a Object one to compare
* @param b Object two to compare
* @return true if a is equivalent to b, false otherwise. Equivalent means that
* all field values of both subgraphs are the same, either at the field level
* or via the respectively encountered overridden .equals() methods during
* traversal.
*/
public static boolean deepEquals(Object a, Object b) {
return deepEquals(a, b, new HashMap<>());
}
/**
* Compare two objects with a 'deep' comparison. This will traverse the
* Object graph and perform either a field-by-field comparison on each
* object (if not .equals() method has been overridden from Object), or it
* will call the customized .equals() method if it exists. This method will
* allow object graphs loaded at different times (with different object ids)
* to be reliably compared. Object.equals() / Object.hashCode() rely on the
* object's identity, which would not consider to equivalent objects necessarily
* equals. This allows graphs containing instances of Classes that did no
* overide .equals() / .hashCode() to be compared. For example, testing for
* existence in a cache. Relying on an objects identity will not locate an
* object in cache, yet relying on it being equivalent will.
*
* This method will handle cycles correctly, for example A->B->C->A. Suppose a and
* a' are two separate instances of the A with the same values for all fields on
* A, B, and C. Then a.deepEquals(a') will return true. It uses cycle detection
* storing visited objects in a Set to prevent endless loops.
*
* @param a Object one to compare
* @param b Object two to compare
* @param options Map options for compare. With no option, if a custom equals()
* method is present, it will be used. If IGNORE_CUSTOM_EQUALS is
* present, it will be expected to be a Set of classes to ignore.
* It is a black-list of classes that will not be compared
* using .equals() even if the classes have a custom .equals() method
* present. If it is and empty set, then no custom .equals() methods
* will be called.
* @return true if a is equivalent to b, false otherwise. Equivalent means that
* all field values of both subgraphs are the same, either at the field level
* or via the respectively encountered overridden .equals() methods during
* traversal.
*/
public static boolean deepEquals(Object a, Object b, Map options) {
Set visited = new HashSet<>();
return deepEquals(a, b, options, visited);
}
private static boolean deepEquals(Object a, Object b, Map options, Set visited) {
Deque stack = new LinkedList<>();
Set> ignoreCustomEquals = (Set>) options.get(IGNORE_CUSTOM_EQUALS);
final boolean allowStringsToMatchNumbers = convert2boolean(options.get(ALLOW_STRINGS_TO_MATCH_NUMBERS));
stack.addFirst(new ItemsToCompare(a, b));
while (!stack.isEmpty()) {
ItemsToCompare itemsToCompare = stack.removeFirst();
visited.add(itemsToCompare);
final Object key1 = itemsToCompare._key1;
final Object key2 = itemsToCompare._key2;
if (key1 == key2) { // Same instance is always equal to itself.
continue;
}
if (key1 == null || key2 == null) { // If either one is null, they are not equal (both can't be null, due to above comparison).
return false;
}
if (key1 instanceof Number && key2 instanceof Number && compareNumbers((Number) key1, (Number) key2)) {
continue;
}
if (key1 instanceof AtomicBoolean && key2 instanceof AtomicBoolean) {
if (!compareAtomicBoolean((AtomicBoolean) key1, (AtomicBoolean) key2)) {
return false;
} else {
continue;
}
}
if (key1 instanceof Number || key2 instanceof Number) { // If one is a Number and the other one is not, then optionally compare them as strings, otherwise return false
if (allowStringsToMatchNumbers) {
try {
if (key1 instanceof String && compareNumbers(convert2BigDecimal(key1), (Number) key2)) {
continue;
} else if (key2 instanceof String && compareNumbers((Number) key1, convert2BigDecimal(key2))) {
continue;
}
} catch (Exception ignore) {
}
}
return false;
}
Class> key1Class = key1.getClass();
if (key1Class.isPrimitive() || prims.contains(key1Class) || key1 instanceof String || key1 instanceof Date || key1 instanceof Class) {
if (!key1.equals(key2)) {
return false;
}
continue; // Nothing further to push on the stack
}
if (key1 instanceof Set) {
if (!(key2 instanceof Set)) {
return false;
}
} else if (key2 instanceof Set) {
return false;
}
if (key1 instanceof Collection) { // If Collections, they both must be Collection
if (!(key2 instanceof Collection)) {
return false;
}
} else if (key2 instanceof Collection) {
return false;
}
if (key1 instanceof Map) {
if (!(key2 instanceof Map)) {
return false;
}
} else if (key2 instanceof Map) {
return false;
}
Class> key2Class = key2.getClass();
if (key1Class.isArray()) {
if (!key2Class.isArray()) {
return false;
}
} else if (key2Class.isArray()) {
return false;
}
if (!isContainerType(key1) && !isContainerType(key2) && !key1Class.equals(key2.getClass())) { // Must be same class
return false;
}
// Special handle Sets - items matter but order does not for equality.
if (key1 instanceof Set>) {
if (!compareUnorderedCollection((Collection>) key1, (Collection>) key2, stack, visited, options)) {
return false;
}
continue;
}
// Collections must match in items and order for equality.
if (key1 instanceof Collection>) {
if (!compareOrderedCollection((Collection>) key1, (Collection>) key2, stack, visited)) {
return false;
}
continue;
}
// Compare two Maps. This is a slightly more expensive comparison because
// order cannot be assumed, therefore a temporary Map must be created, however the
// comparison still runs in O(N) time.
if (key1 instanceof Map) {
if (!compareMap((Map, ?>) key1, (Map, ?>) key2, stack, visited, options)) {
return false;
}
continue;
}
// Handle all [] types. In order to be equal, the arrays must be the same
// length, be of the same type, be in the same order, and all elements within
// the array must be deeply equivalent.
if (key1Class.isArray()) {
if (!compareArrays(key1, key2, stack, visited)) {
return false;
}
continue;
}
// If there is a custom equals ... AND
// the caller has not specified any classes to skip ... OR
// the caller has specified come classes to ignore and this one is not in the list ... THEN
// compare using the custom equals.
if (hasCustomEquals(key1Class)) {
if (ignoreCustomEquals == null || (ignoreCustomEquals.size() > 0 && !ignoreCustomEquals.contains(key1Class))) {
if (!key1.equals(key2)) {
return false;
}
continue;
}
}
Collection fields = ReflectionUtils.getDeepDeclaredFields(key1Class);
for (Field field : fields) {
try {
ItemsToCompare dk = new ItemsToCompare(field.get(key1), field.get(key2));
if (!visited.contains(dk)) {
stack.addFirst(dk);
}
} catch (Exception ignored) {
}
}
}
return true;
}
public static boolean isContainerType(Object o) {
return o instanceof Collection || o instanceof Map;
}
/**
* Deeply compare to Arrays []. Both arrays must be of the same type, same length, and all
* elements within the arrays must be deeply equal in order to return true.
*
* @param array1 [] type (Object[], String[], etc.)
* @param array2 [] type (Object[], String[], etc.)
* @param stack add items to compare to the Stack (Stack versus recursion)
* @param visited Set of objects already compared (prevents cycles)
* @return true if the two arrays are the same length and contain deeply equivalent items.
*/
private static boolean compareArrays(Object array1, Object array2, Deque stack, Set visited) {
// Same instance check already performed...
final int len = Array.getLength(array1);
if (len != Array.getLength(array2)) {
return false;
}
for (int i = 0; i < len; i++) {
ItemsToCompare dk = new ItemsToCompare(Array.get(array1, i), Array.get(array2, i));
if (!visited.contains(dk)) { // push contents for further comparison
stack.addFirst(dk);
}
}
return true;
}
/**
* Deeply compare two Collections that must be same length and in same order.
*
* @param col1 First collection of items to compare
* @param col2 Second collection of items to compare
* @param stack add items to compare to the Stack (Stack versus recursion)
* @param visited Set of objects already compared (prevents cycles)
* value of 'true' indicates that the Collections may be equal, and the sets
* items will be added to the Stack for further comparison.
*/
private static boolean compareOrderedCollection(Collection> col1, Collection> col2, Deque stack, Set visited) {
// Same instance check already performed...
if (col1.size() != col2.size()) {
return false;
}
Iterator> i1 = col1.iterator();
Iterator> i2 = col2.iterator();
while (i1.hasNext()) {
ItemsToCompare dk = new ItemsToCompare(i1.next(), i2.next());
if (!visited.contains(dk)) { // push contents for further comparison
stack.addFirst(dk);
}
}
return true;
}
/**
* Deeply compare the two sets referenced by ItemsToCompare. This method attempts
* to quickly determine inequality by length, then if lengths match, it
* places one collection into a temporary Map by deepHashCode(), so that it
* can walk the other collection and look for each item in the map, which
* runs in O(N) time, rather than an O(N^2) lookup that would occur if each
* item from collection one was scanned for in collection two.
*
* @param col1 First collection of items to compare
* @param col2 Second collection of items to compare
* @param stack add items to compare to the Stack (Stack versus recursion)
* @param visited Set containing items that have already been compared, to prevent cycles.
* @param options the options for comparison (see {@link #deepEquals(Object, Object, Map)}
* @return boolean false if the Collections are for certain not equals. A
* value of 'true' indicates that the Collections may be equal, and the sets
* items will be added to the Stack for further comparison.
*/
private static boolean compareUnorderedCollection(Collection> col1, Collection> col2, Deque stack, Set visited, Map options) {
// Same instance check already performed...
if (col1.size() != col2.size()) {
return false;
}
Map> fastLookup = new HashMap<>();
for (Object o : col2) {
int hash = deepHashCode(o);
fastLookup.computeIfAbsent(hash, k -> new ArrayList<>()).add(o);
}
for (Object o : col1) {
Collection> other = fastLookup.get(deepHashCode(o));
if (other == null || other.isEmpty()) { // fail fast: item not even found in other Collection, no need to continue.
return false;
}
if (other.size() == 1) { // no hash collision, items must be equivalent or deepEquals is false
ItemsToCompare dk = new ItemsToCompare(o, other.iterator().next());
if (!visited.contains(dk)) { // Place items on 'stack' for future equality comparison.
stack.addFirst(dk);
}
} else { // hash collision: try all collided items against the current item (if 1 equals, we are good - remove it
// from collision list, making further comparisons faster)
if (!isContained(o, other, visited, options)) {
return false;
}
}
}
return true;
}
/**
* Deeply compare two Map instances. After quick short-circuit tests, this method
* uses a temporary Map so that this method can run in O(N) time.
*
* @param map1 Map one
* @param map2 Map two
* @param stack add items to compare to the Stack (Stack versus recursion)
* @param visited Set containing items that have already been compared, to prevent cycles.
* @param options the options for comparison (see {@link #deepEquals(Object, Object, Map)}
* @return false if the Maps are for certain not equals. 'true' indicates that 'on the surface' the maps
* are equal, however, it will place the contents of the Maps on the stack for further comparisons.
*/
private static boolean compareMap(Map, ?> map1, Map, ?> map2, Deque stack, Set visited, Map options) {
// Same instance check already performed...
if (map1.size() != map2.size()) {
return false;
}
Map> fastLookup = new HashMap<>();
for (Map.Entry, ?> entry : map2.entrySet()) {
int hash = deepHashCode(entry.getKey());
Collection