diff --git a/src/main/java/com/cedarsoftware/util/DeepEquals.java b/src/main/java/com/cedarsoftware/util/DeepEquals.java index 9986c159..17c84aa4 100644 --- a/src/main/java/com/cedarsoftware/util/DeepEquals.java +++ b/src/main/java/com/cedarsoftware/util/DeepEquals.java @@ -12,6 +12,7 @@ import java.util.HashSet; 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; @@ -652,7 +653,7 @@ private static boolean compareFloatingPointNumbers(Object a, Object b, double ep } /** - * Correctly handles floating point comparisions.
+ * Correctly handles floating point comparisons.
* source: http://floating-point-gui.de/errors/comparison/ * * @param a first number @@ -728,79 +729,118 @@ public static boolean hasCustomEquals(Class c) * * This method will handle cycles correctly (A->B->C->A). In this case, * Starting with object A, B, or C would yield the same hashCode. If an - * object encountered (root, suboject, etc.) has a hashCode() method on it + * object encountered (root, sub-object, etc.) has a hashCode() method on it * (that is not Object.hashCode()), that hashCode() method will be called * and it will stop traversal on that branch. * @param obj Object who hashCode is desired. * @return the 'deep' hashCode value for the passed in object. */ - public static int deepHashCode(Object obj) - { + public static int deepHashCode(Object obj) { Set visited = new HashSet<>(); + return deepHashCode(obj, visited); + } + + private static int deepHashCode(Object obj, Set visited) { LinkedList stack = new LinkedList<>(); stack.addFirst(obj); int hash = 0; - while (!stack.isEmpty()) - { + while (!stack.isEmpty()) { obj = stack.removeFirst(); - if (obj == null || visited.contains(obj)) - { + if (obj == null || visited.contains(obj)) { continue; } visited.add(obj); - if (obj.getClass().isArray()) - { + // Ensure array order matters to hash + if (obj.getClass().isArray()) { final int len = Array.getLength(obj); - for (int i = 0; i < len; i++) - { - stack.addFirst(Array.get(obj, i)); + long result = 1; + + for (int i = 0; i < len; i++) { + Object element = Array.get(obj, i); + result = 31 * result + deepHashCode(element, visited); // recursive } + hash += (int) result; continue; } - if (obj instanceof Collection) - { - stack.addAll(0, (Collection)obj); + // Ensure list order matters to hash + if (obj instanceof List) { + List list = (List) obj; + long result = 1; + + for (Object element : list) { + result = 31 * result + deepHashCode(element, visited); // recursive + } + hash += (int) result; continue; } - if (obj instanceof Map) - { - stack.addAll(0, ((Map)obj).keySet()); - stack.addAll(0, ((Map)obj).values()); + if (obj instanceof Collection) { + stack.addAll(0, (Collection) obj); continue; } - if (obj instanceof Double || obj instanceof Float) - { - // just take the integral value for hashcode - // equality tests things more comprehensively - stack.add(Math.round(((Number) obj).doubleValue())); + if (obj instanceof Map) { + stack.addAll(0, ((Map) obj).keySet()); + stack.addAll(0, ((Map) obj).values()); continue; } - if (hasCustomHashCode(obj.getClass())) - { // A real hashCode() method exists, call it. + // Protects Floats and Doubles from causing inequality, even if there are within an epsilon distance + // of one another. It does this by marshalling values of IEEE 754 numbers to coarser grained resolution, + // allowing for dynamic range on obviously different values, but identical values for IEEE 754 values + // that are near each other. Since hashes do not have to be unique, this upholds the hashCode() + // contract...two hash values that are not the same guarantee the objects are not equal, however, two + // values that are the same mean the two objects COULD be equals. + if (obj instanceof Float) { + hash += hashFloat((Float) obj); + continue; + } else if (obj instanceof Double) { + hash += hashDouble((Double) obj); + continue; + } + + if (hasCustomHashCode(obj.getClass())) { // A real hashCode() method exists, call it. hash += obj.hashCode(); continue; } Collection fields = ReflectionUtils.getDeepDeclaredFields(obj.getClass()); - for (Field field : fields) - { - try - { + for (Field field : fields) { + try { stack.addFirst(field.get(obj)); + } catch (Exception ignored) { } - catch (Exception ignored) { } } } return hash; } + private static final double SCALE_DOUBLE = Math.pow(10, 10); + + private static int hashDouble(double value) { + // Normalize the value to a fixed precision + double normalizedValue = Math.round(value * SCALE_DOUBLE) / SCALE_DOUBLE; + // Convert to long for hashing + long bits = Double.doubleToLongBits(normalizedValue); + // Standard way to hash a long in Java + return (int)(bits ^ (bits >>> 32)); + } + + private static final float SCALE_FLOAT = (float)Math.pow(10, 5); // Scale according to epsilon for float + + private static int hashFloat(float value) { + // Normalize the value to a fixed precision + float normalizedValue = Math.round(value * SCALE_FLOAT) / SCALE_FLOAT; + // Convert to int for hashing, as float bits can be directly converted + int bits = Float.floatToIntBits(normalizedValue); + // Return the hash + return bits; + } + /** * Determine if the passed in class has a non-Object.hashCode() method. This * method caches its results in static ConcurrentHashMap to benefit diff --git a/src/test/java/com/cedarsoftware/util/TestDeepEquals.java b/src/test/java/com/cedarsoftware/util/TestDeepEquals.java index 1c7f1043..40c7e3a2 100644 --- a/src/test/java/com/cedarsoftware/util/TestDeepEquals.java +++ b/src/test/java/com/cedarsoftware/util/TestDeepEquals.java @@ -283,6 +283,19 @@ public void testPrimitiveArrays() assertFalse(DeepEquals.deepEquals(array1, array4)); } + @Test + public void testArrayOrder() + { + int array1[] = { 3, 4, 7 }; + int array2[] = { 7, 3, 4 }; + + int x = DeepEquals.deepHashCode(array1); + int y = DeepEquals.deepHashCode(array2); + assertNotEquals(x, y); + + assertFalse(DeepEquals.deepEquals(array1, array2)); + } + @Test public void testOrderedCollection() { @@ -304,7 +317,27 @@ public void testOrderedCollection() assertTrue(DeepEquals.deepEquals(x1, x2)); } - @Test + @Test + public void testOrderedDoubleCollection() { + List aa = asList(log(pow(E, 2)), tan(PI / 4)); + List bb = asList(2.0, 1.0); + List cc = asList(1.0, 2.0); + assertEquals(DeepEquals.deepHashCode(aa), DeepEquals.deepHashCode(bb)); + assertNotEquals(DeepEquals.deepHashCode(aa), DeepEquals.deepHashCode(cc)); + assertNotEquals(DeepEquals.deepHashCode(bb), DeepEquals.deepHashCode(cc)); + } + + @Test + public void testOrderedFloatCollection() { + List aa = asList((float)log(pow(E, 2)), (float)tan(PI / 4)); + List bb = asList(2.0f, 1.0f); + List cc = asList(1.0f, 2.0f); + assertEquals(DeepEquals.deepHashCode(aa), DeepEquals.deepHashCode(bb)); + assertNotEquals(DeepEquals.deepHashCode(aa), DeepEquals.deepHashCode(cc)); + assertNotEquals(DeepEquals.deepHashCode(bb), DeepEquals.deepHashCode(cc)); + } + + @Test public void testUnorderedCollection() { Set a = new HashSet<>(asList("one", "two", "three", "four", "five")); @@ -317,10 +350,20 @@ public void testUnorderedCollection() Set d = new HashSet<>(asList(4, 2, 6)); assertFalse(DeepEquals.deepEquals(c, d)); - Set x1 = new HashSet<>(asList(new Class1(true, log(pow(E, 2)), 6), new Class1(true, tan(PI / 4), 1))); - Set x2 = new HashSet<>(asList(new Class1(true, 1, 1), new Class1(true, 2, 6))); - assertTrue(DeepEquals.deepEquals(x1, x2)); + Set x1 = new LinkedHashSet<>(); + x1.add(new Class1(true, log(pow(E, 2)), 6)); + x1.add(new Class1(true, tan(PI / 4), 1)); + + Set x2 = new HashSet<>(); + x2.add(new Class1(true, 1, 1)); + x2.add(new Class1(true, 2, 6)); + int x = DeepEquals.deepHashCode(x1); + int y = DeepEquals.deepHashCode(x2); + + assertEquals(x, y); + assertTrue(DeepEquals.deepEquals(x1, x2)); + // Proves that objects are being compared against the correct objects in each collection (all objects have same // hash code, so the unordered compare must handle checking item by item for hash-collided items) Set d1 = new LinkedHashSet<>(); @@ -341,6 +384,21 @@ public void testUnorderedCollection() assert !DeepEquals.deepEquals(d2, d1); } + @Test + public void testSetOrder() { + Set a = new LinkedHashSet<>(); + Set b = new LinkedHashSet<>(); + a.add("a"); + a.add("b"); + a.add("c"); + + b.add("c"); + b.add("a"); + b.add("b"); + assertEquals(DeepEquals.deepHashCode(a), DeepEquals.deepHashCode(b)); + assertTrue(DeepEquals.deepEquals(a, b)); + } + @SuppressWarnings("unchecked") @Test public void testEquivalentMaps() diff --git a/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java b/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java index 74ccc2db..eefe3f6f 100644 --- a/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java +++ b/src/test/java/com/cedarsoftware/util/convert/ConverterEverythingTest.java @@ -192,6 +192,11 @@ private static void loadMapTests() { {(byte)1, mapOf(VALUE, (byte)1)}, {(byte)2, mapOf(VALUE, (byte)2)} }); + TEST_DB.put(pair(Integer.class, Map.class), new Object[][]{ + {-1, mapOf(VALUE, -1)}, + {0, mapOf(VALUE, 0)}, + {1, mapOf(VALUE, 1)} + }); TEST_DB.put(pair(Float.class, Map.class), new Object[][]{ {1.0f, mapOf(VALUE, 1.0f)}, {2.0f, mapOf(VALUE, 2.0f)} @@ -261,6 +266,23 @@ private static void loadAtomicBooleanTests() { TEST_DB.put(pair(Void.class, AtomicBoolean.class), new Object[][]{ {null, null} }); + TEST_DB.put(pair(Integer.class, AtomicBoolean.class), new Object[][]{ + {-1, new AtomicBoolean(true)}, + {0, new AtomicBoolean(false), true}, + {1, new AtomicBoolean(true), true}, + }); + TEST_DB.put(pair(Float.class, AtomicBoolean.class), new Object[][]{ + {1.9f, new AtomicBoolean(true)}, + {1.0f, new AtomicBoolean(true), true}, + {-1.0f, new AtomicBoolean(true)}, + {0.0f, new AtomicBoolean(false), true}, + }); + TEST_DB.put(pair(Double.class, AtomicBoolean.class), new Object[][]{ + {1.1, new AtomicBoolean(true)}, + {1.0, new AtomicBoolean(true), true}, + {-1.0, new AtomicBoolean(true)}, + {0.0, new AtomicBoolean(false), true}, + }); TEST_DB.put(pair(AtomicBoolean.class, AtomicBoolean.class), new Object[][] { { new AtomicBoolean(false), new AtomicBoolean(false)}, { new AtomicBoolean(true), new AtomicBoolean(true)}, @@ -275,18 +297,6 @@ private static void loadAtomicBooleanTests() { { new AtomicLong((byte)0), new AtomicBoolean(false), true}, { new AtomicLong((byte)1), new AtomicBoolean(true), true}, }); - TEST_DB.put(pair(Float.class, AtomicBoolean.class), new Object[][]{ - {1.9f, new AtomicBoolean(true)}, - {1.0f, new AtomicBoolean(true), true}, - {-1.0f, new AtomicBoolean(true)}, - {0.0f, new AtomicBoolean(false), true}, - }); - TEST_DB.put(pair(Double.class, AtomicBoolean.class), new Object[][]{ - {1.1, new AtomicBoolean(true)}, - {1.0, new AtomicBoolean(true), true}, - {-1.0, new AtomicBoolean(true)}, - {0.0, new AtomicBoolean(false), true}, - }); TEST_DB.put(pair(BigInteger.class, AtomicBoolean.class), new Object[][] { { BigInteger.valueOf(-1), new AtomicBoolean(true)}, { BigInteger.ZERO, new AtomicBoolean(false), true}, @@ -326,6 +336,13 @@ private static void loadAtomicIntegerTests() { TEST_DB.put(pair(Void.class, AtomicInteger.class), new Object[][]{ {null, null} }); + TEST_DB.put(pair(Integer.class, AtomicInteger.class), new Object[][]{ + {-1, new AtomicInteger(-1)}, + {0, new AtomicInteger(0), true}, + {1, new AtomicInteger(1), true}, + {Integer.MIN_VALUE, new AtomicInteger(-2147483648)}, + {Integer.MAX_VALUE, new AtomicInteger(2147483647)}, + }); TEST_DB.put(pair(AtomicInteger.class, AtomicInteger.class), new Object[][] { { new AtomicInteger(1), new AtomicInteger((byte)1), true} }); @@ -712,7 +729,16 @@ private static void loadLocalDateTimeTests() { cal.set(2024, Calendar.MARCH, 2, 22, 54, 17); cal.set(Calendar.MILLISECOND, 0); return cal; - }, LocalDateTime.of(2024, Month.MARCH, 2, 22, 54, 17), true } + }, LocalDateTime.of(2024, Month.MARCH, 2, 22, 54, 17), true} + }); + TEST_DB.put(pair(java.sql.Date.class, LocalDateTime.class), new Object[][]{ + {new java.sql.Date(-62167219200000L), ZonedDateTime.parse("0000-01-01T00:00:00Z").withZoneSameInstant(TOKYO_Z).toLocalDateTime(), true}, + {new java.sql.Date(-62167219199999L), ZonedDateTime.parse("0000-01-01T00:00:00.001Z").withZoneSameInstant(TOKYO_Z).toLocalDateTime(), true}, + {new java.sql.Date(-1000L), ZonedDateTime.parse("1969-12-31T23:59:59Z").withZoneSameInstant(TOKYO_Z).toLocalDateTime(), true}, + {new java.sql.Date(-1L), ZonedDateTime.parse("1969-12-31T23:59:59.999Z").withZoneSameInstant(TOKYO_Z).toLocalDateTime(), true}, + {new java.sql.Date(0L), ZonedDateTime.parse("1970-01-01T00:00:00Z").withZoneSameInstant(TOKYO_Z).toLocalDateTime(), true}, + {new java.sql.Date(1L), ZonedDateTime.parse("1970-01-01T00:00:00.001Z").withZoneSameInstant(TOKYO_Z).toLocalDateTime(), true}, + {new java.sql.Date(999L), ZonedDateTime.parse("1970-01-01T00:00:00.999Z").withZoneSameInstant(TOKYO_Z).toLocalDateTime(), true}, }); TEST_DB.put(pair(Instant.class, LocalDateTime.class), new Object[][] { {Instant.parse("0000-01-01T00:00:00Z"), ZonedDateTime.parse("0000-01-01T00:00:00Z").withZoneSameInstant(TOKYO_Z).toLocalDateTime(), true}, @@ -1240,6 +1266,17 @@ private static void loadSqlDateTests() { {new Date(1), new java.sql.Date(1), true }, {new Date(Long.MAX_VALUE), new java.sql.Date(Long.MAX_VALUE), true }, }); + TEST_DB.put(pair(LocalDate.class, java.sql.Date.class), new Object[][] { + {ZonedDateTime.parse("0000-01-01T00:00:00Z").withZoneSameInstant(TOKYO_Z).toLocalDate(), new java.sql.Date(-62167252739000L), true}, + {ZonedDateTime.parse("0000-01-01T00:00:00.001Z").withZoneSameInstant(TOKYO_Z).toLocalDate(), new java.sql.Date(-62167252739000L), true}, + {ZonedDateTime.parse("1969-12-31T14:59:59.999Z").withZoneSameInstant(TOKYO_Z).toLocalDate(), new java.sql.Date(-118800000L), true}, + {ZonedDateTime.parse("1969-12-31T15:00:00Z").withZoneSameInstant(TOKYO_Z).toLocalDate(), new java.sql.Date(-32400000L), true}, + {ZonedDateTime.parse("1969-12-31T23:59:59.999Z").withZoneSameInstant(TOKYO_Z).toLocalDate(), new java.sql.Date(-32400000L), true}, + {ZonedDateTime.parse("1970-01-01T00:00:00Z").withZoneSameInstant(TOKYO_Z).toLocalDate(), new java.sql.Date(-32400000L), true}, + {ZonedDateTime.parse("1970-01-01T00:00:00.001Z").withZoneSameInstant(TOKYO_Z).toLocalDate(), new java.sql.Date(-32400000L), true}, + {ZonedDateTime.parse("1970-01-01T00:00:00.999Z").withZoneSameInstant(TOKYO_Z).toLocalDate(), new java.sql.Date(-32400000L), true}, + {ZonedDateTime.parse("1970-01-01T00:00:00.999Z").withZoneSameInstant(TOKYO_Z).toLocalDate(), new java.sql.Date(-32400000L), true}, + }); TEST_DB.put(pair(Calendar.class, java.sql.Date.class), new Object[][] { {(Supplier) () -> { Calendar cal = Calendar.getInstance(TOKYO_TZ); @@ -2600,17 +2637,6 @@ private static void loadIntegerTests() { {-2147483648.0, Integer.MIN_VALUE}, {2147483647.0, Integer.MAX_VALUE}, }); - TEST_DB.put(pair(AtomicBoolean.class, Integer.class), new Object[][]{ - {new AtomicBoolean(true), 1}, - {new AtomicBoolean(false), 0}, - }); - TEST_DB.put(pair(AtomicInteger.class, Integer.class), new Object[][]{ - {new AtomicInteger(-1), -1}, - {new AtomicInteger(0), 0}, - {new AtomicInteger(1), 1}, - {new AtomicInteger(-2147483648), Integer.MIN_VALUE}, - {new AtomicInteger(2147483647), Integer.MAX_VALUE}, - }); TEST_DB.put(pair(AtomicLong.class, Integer.class), new Object[][]{ {new AtomicLong(-1), -1, true}, {new AtomicLong(0), 0, true}, @@ -2628,17 +2654,17 @@ private static void loadIntegerTests() { {new BigInteger("2147483648"), Integer.MIN_VALUE}, }); TEST_DB.put(pair(BigDecimal.class, Integer.class), new Object[][]{ - {new BigDecimal("-1"), -1}, + {new BigDecimal("-1"), -1, true}, {new BigDecimal("-1.1"), -1}, {new BigDecimal("-1.9"), -1}, - {BigDecimal.ZERO, 0}, - {new BigDecimal("1"), 1}, + {BigDecimal.ZERO, 0, true}, + {new BigDecimal("1"), 1, true}, {new BigDecimal("1.1"), 1}, {new BigDecimal("1.9"), 1}, - {new BigDecimal("-2147483648"), Integer.MIN_VALUE}, - {new BigDecimal("2147483647"), Integer.MAX_VALUE}, - {new BigDecimal("-2147483649"), Integer.MAX_VALUE}, - {new BigDecimal("2147483648"), Integer.MIN_VALUE}, + {new BigDecimal("-2147483648"), Integer.MIN_VALUE, true}, + {new BigDecimal("2147483647"), Integer.MAX_VALUE, true}, + {new BigDecimal("-2147483649"), Integer.MAX_VALUE}, // wrap around test + {new BigDecimal("2147483648"), Integer.MIN_VALUE}, // wrap around test }); TEST_DB.put(pair(Number.class, Integer.class), new Object[][]{ {-2L, -2},