All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.cedarsoftware.util.DeepEquals Maven / Gradle / Ivy

The newest version!
package com.cedarsoftware.util;

import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
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.ListIterator;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TimeZone;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

import static com.cedarsoftware.util.Converter.convert2BigDecimal;
import static com.cedarsoftware.util.Converter.convert2boolean;

/**
 * Performs a deep comparison of two objects, going beyond simple {@code equals()} checks.
 * Handles nested objects, collections, arrays, and maps while detecting circular references.
 *
 * 

Key features:

*
    *
  • Compares entire object graphs including nested structures
  • *
  • Handles circular references safely
  • *
  • Provides detailed difference descriptions for troubleshooting
  • *
  • Supports numeric comparisons with configurable precision
  • *
  • Supports selective ignoring of custom {@code equals()} implementations
  • *
  • Supports string-to-number equality comparisons
  • *
* *

Options:

*
    *
  • * IGNORE_CUSTOM_EQUALS (a {@code Set>}): *
      *
    • {@code null} — Use all custom {@code equals()} methods (ignore none).
    • *
    • Empty set — Ignore all custom {@code equals()} methods.
    • *
    • Non-empty set — Ignore only those classes’ custom {@code equals()} implementations.
    • *
    *
  • *
  • * ALLOW_STRINGS_TO_MATCH_NUMBERS (a {@code Boolean}): * If set to {@code true}, allows strings like {@code "10"} to match the numeric value {@code 10}. *
  • *
* *

The options {@code Map} acts as both input and output. When objects differ, the difference * description is placed in the options {@code Map} under the "diff" key * (see {@link DeepEquals#deepEquals(Object, Object, Map) deepEquals}).

*

"diff" output notes:

*
    *
  • Empty lists, maps, and arrays are shown with (∅) or [∅]
  • *
  • A Map of size 1 is shown as Map(0..0), an int[] of size 2 is shown as int[0..1], an empty list is List(∅)
  • *
  • Sub-object fields on non-difference path shown as {..}
  • *
  • Map entry shown with 《key ⇨ value》 and may be nested
  • *
  • General pattern is [difference type] ▶ root context ▶ shorthand path starting at a root context element (Object field, array/collection element, Map key-value)
  • *
  • If the root is not a container (Collection, Map, Array, or Object), no shorthand description is displayed
  • *
*

Example usage:

*

 * Map<String, Object> options = new HashMap<>();
 * options.put(IGNORE_CUSTOM_EQUALS, Set.of(MyClass.class, OtherClass.class));
 * options.put(ALLOW_STRINGS_TO_MATCH_NUMBERS, true);
 *
 * if (!DeepEquals.deepEquals(obj1, obj2, options)) {
 *     String diff = (String) options.get(DeepEquals.DIFF);  // Get difference description
 *     // Handle or log 'diff'
 *
 * Example output:
 * // Simple object difference
 * [field value mismatch] ▶ Person {name: "Jim Bob", age: 27} ▶ .age
 *   Expected: 27
 *   Found: 34
 *   
 * // Array element mismatch within an object that has an array
 * [array element mismatch] ▶ Person {id: 173679590720000287, first: "John", last: "Smith", favoritePet: {..}, pets: Pet[0..1]} ▶ .pets[0].nickNames[0]
 *   Expected: "Edward"
 *   Found: "Eddie"
 *
 * // Map with a different value associated to a key (Map size = 1 noted as 0..0)
 * [map value mismatch] ▶ LinkedHashMap(0..0) ▶ 《"key" ⇨ "value1"》
 *   Expected: "value1"
 *   Found: "value2"
 *   
 * 
* * @see #deepEquals(Object, Object) * @see #deepEquals(Object, Object, Map) * * @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 { // Option keys public static final String IGNORE_CUSTOM_EQUALS = "ignoreCustomEquals"; public static final String ALLOW_STRINGS_TO_MATCH_NUMBERS = "stringsCanMatchNumbers"; public static final String DIFF = "diff"; private static final String EMPTY = "∅"; private static final String TRIANGLE_ARROW = "▶"; private static final String ARROW = "⇨"; private static final String ANGLE_LEFT = "《"; private static final String ANGLE_RIGHT = "》"; private static final double SCALE_DOUBLE = Math.pow(10, 10); // Scale according to epsilon for double private static final float SCALE_FLOAT = (float) Math.pow(10, 5); // Scale according to epsilon for float private static final ThreadLocal> formattingStack = ThreadLocal.withInitial(() -> Collections.newSetFromMap(new IdentityHashMap<>())); // Epsilon values for floating-point comparisons private static final double doubleEpsilon = 1e-15; // Class to hold information about items being compared private final static class ItemsToCompare { private final Object _key1; private final Object _key2; private final ItemsToCompare parent; private final String fieldName; private final int[] arrayIndices; private final Object mapKey; private final Difference difference; // New field // Modified constructors to include Difference // Constructor for root private ItemsToCompare(Object k1, Object k2) { this(k1, k2, null, null, null, null, null); } // Constructor for differences where the Difference does not need additional information private ItemsToCompare(Object k1, Object k2, ItemsToCompare parent, Difference difference) { this(k1, k2, parent, null, null, null, difference); } // Constructor for field access with difference private ItemsToCompare(Object k1, Object k2, String fieldName, ItemsToCompare parent, Difference difference) { this(k1, k2, parent, fieldName, null, null, difference); } // Constructor for array access with difference private ItemsToCompare(Object k1, Object k2, int[] indices, ItemsToCompare parent, Difference difference) { this(k1, k2, parent, null, indices, null, difference); } // Constructor for map access with difference private ItemsToCompare(Object k1, Object k2, Object mapKey, ItemsToCompare parent, boolean isMapKey, Difference difference) { this(k1, k2, parent, null, null, mapKey, difference); } // Base constructor private ItemsToCompare(Object k1, Object k2, ItemsToCompare parent, String fieldName, int[] arrayIndices, Object mapKey, Difference difference) { this._key1 = k1; this._key2 = k2; this.parent = parent; this.fieldName = fieldName; this.arrayIndices = arrayIndices; this.mapKey = mapKey; this.difference = difference; } @Override public boolean equals(Object other) { if (!(other instanceof ItemsToCompare)) { return false; } ItemsToCompare that = (ItemsToCompare) other; // Only compare the actual objects being compared (by identity) return _key1 == that._key1 && _key2 == that._key2; } @Override public int hashCode() { return System.identityHashCode(_key1) * 31 + System.identityHashCode(_key2); } } /** * Performs a deep comparison between two objects, going beyond a simple {@code equals()} check. *

* This method is functionally equivalent to calling * {@link #deepEquals(Object, Object, Map) deepEquals(a, b, new HashMap<>())}, * which means it uses no additional comparison options. In other words: *

    *
  • {@code IGNORE_CUSTOM_EQUALS} is not set (all custom {@code equals()} methods are used)
  • *
  • {@code ALLOW_STRINGS_TO_MATCH_NUMBERS} defaults to {@code false}
  • *
*

* * @param a the first object to compare, may be {@code null} * @param b the second object to compare, may be {@code null} * @return {@code true} if the two objects are deeply equal, {@code false} otherwise * @see #deepEquals(Object, Object, Map) */ public static boolean deepEquals(Object a, Object b) { return deepEquals(a, b, new HashMap<>()); } /** * Performs a deep comparison between two objects with optional comparison settings. *

* In addition to comparing objects, collections, maps, and arrays for equality of nested * elements, this method can also: *

    *
  • Ignore certain classes' custom {@code equals()} methods according to user-defined rules
  • *
  • Allow string-to-number comparisons (e.g., {@code "10"} equals {@code 10})
  • *
  • Handle floating-point comparisons with tolerance for precision
  • *
  • Detect and handle circular references to avoid infinite loops
  • *
* *

Options:

*
    *
  • * {@code DeepEquals.IGNORE_CUSTOM_EQUALS} (a {@code Collection>}): *
      *
    • {@code null} — Use all custom {@code equals()} methods (ignore none). Default.
    • *
    • Empty set — Ignore all custom {@code equals()} methods.
    • *
    • Non-empty set — Ignore only those classes’ custom {@code equals()} implementations.
    • *
    *
  • *
  • * {@code DeepEquals.ALLOW_STRINGS_TO_MATCH_NUMBERS} (a {@code Boolean}): * If set to {@code true}, allows strings like {@code "10"} to match the numeric value {@code 10}. Default false. *
  • *
* *

If the objects differ, a difference description string is stored in {@code options} * under the key {@code "diff"}. The key {@code "diff_item"} can provide additional context * regarding the specific location of the mismatch.

* * @param a the first object to compare, may be {@code null} * @param b the second object to compare, may be {@code null} * @param options a map of comparison options and, on return, possibly difference details * @return {@code true} if the two objects are deeply equal, {@code false} otherwise * @see #deepEquals(Object, Object) */ public static boolean deepEquals(Object a, Object b, Map options) { try { Set visited = new HashSet<>(); return deepEquals(a, b, options, visited); } finally { formattingStack.remove(); // Always remove. When needed next time, initialValue() will be called. } } private static boolean deepEquals(Object a, Object b, Map options, Set visited) { Deque stack = new LinkedList<>(); boolean result = deepEquals(a, b, stack, options, visited); boolean isRecurive = Objects.equals(true, options.get("recursive_call")); if (!result && !stack.isEmpty()) { // Store both the breadcrumb and the difference ItemsToCompare ItemsToCompare top = stack.peek(); String breadcrumb = generateBreadcrumb(stack); ((Map) options).put(DIFF, breadcrumb); ((Map) options).put("diff_item", top); // if (!isRecurive) { // System.out.println(breadcrumb); // System.out.println("--------------------"); // System.out.flush(); // } } return result; } // Recursive deepEquals implementation private static boolean deepEquals(Object a, Object b, Deque stack, Map options, Set visited) { Collection> ignoreCustomEquals = (Collection>) options.get(IGNORE_CUSTOM_EQUALS); boolean allowAllCustomEquals = ignoreCustomEquals == null; boolean hasNonEmptyIgnoreSet = (ignoreCustomEquals != null && !ignoreCustomEquals.isEmpty()); final boolean allowStringsToMatchNumbers = convert2boolean(options.get(ALLOW_STRINGS_TO_MATCH_NUMBERS)); stack.addFirst(new ItemsToCompare(a, b)); while (!stack.isEmpty()) { ItemsToCompare itemsToCompare = stack.peek(); if (visited.contains(itemsToCompare)) { stack.removeFirst(); continue; } visited.add(itemsToCompare); final Object key1 = itemsToCompare._key1; final Object key2 = itemsToCompare._key2; // Same instance is always equal to itself, null or otherwise. if (key1 == key2) { continue; } // If either one is null, they are not equal (key1 == key2 already checked) if (key1 == null || key2 == null) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.VALUE_MISMATCH)); return false; } // Handle all numeric comparisons first if (key1 instanceof Number && key2 instanceof Number) { if (!compareNumbers((Number) key1, (Number) key2)) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.VALUE_MISMATCH)); return false; } continue; } // Handle String-to-Number comparison if option is enabled if (allowStringsToMatchNumbers && ((key1 instanceof String && key2 instanceof Number) || (key1 instanceof Number && key2 instanceof String))) { try { if (key1 instanceof String) { if (compareNumbers(convert2BigDecimal(key1), (Number) key2)) { continue; } } else { if (compareNumbers((Number) key1, convert2BigDecimal(key2))) { continue; } } } catch (Exception ignore) { } stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.VALUE_MISMATCH)); return false; } if (key1 instanceof AtomicBoolean && key2 instanceof AtomicBoolean) { if (!compareAtomicBoolean((AtomicBoolean) key1, (AtomicBoolean) key2)) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.VALUE_MISMATCH)); return false; } else { continue; } } Class key1Class = key1.getClass(); Class key2Class = key2.getClass(); // Handle primitive wrappers, String, Date, Class, UUID, URL, URI, Temporal classes, etc. if (Converter.isSimpleTypeConversionSupported(key1Class)) { if (key1 instanceof Comparable && key2 instanceof Comparable) { try { if (((Comparable)key1).compareTo(key2) != 0) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.VALUE_MISMATCH)); return false; } continue; } catch (Exception ignored) { } // Fall back to equals() if compareTo() fails } if (!key1.equals(key2)) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.VALUE_MISMATCH)); return false; } continue; } // List interface defines order as required as part of equality if (key1 instanceof List) { // If List, the order must match if (!(key2 instanceof Collection)) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.TYPE_MISMATCH)); return false; } if (!decomposeOrderedCollection((Collection) key1, (Collection) key2, stack)) { // Push VALUE_MISMATCH so parent's container-level description (e.g. "collection size mismatch") // takes precedence over element-level differences ItemsToCompare prior = stack.peek(); stack.addFirst(new ItemsToCompare(prior._key1, prior._key2, prior, Difference.VALUE_MISMATCH)); return false; } continue; } // Unordered Collection comparison if (key1 instanceof Collection) { if (!(key2 instanceof Collection)) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.COLLECTION_TYPE_MISMATCH)); return false; } if (!decomposeUnorderedCollection((Collection) key1, (Collection) key2, stack, options, visited)) { // Push VALUE_MISMATCH so parent's container-level description (e.g. "collection size mismatch") // takes precedence over element-level differences ItemsToCompare prior = stack.peek(); stack.addFirst(new ItemsToCompare(prior._key1, prior._key2, prior, Difference.VALUE_MISMATCH)); return false; } continue; } else if (key2 instanceof Collection) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.COLLECTION_TYPE_MISMATCH)); return false; } // Map comparison if (key1 instanceof Map) { if (!(key2 instanceof Map)) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.TYPE_MISMATCH)); return false; } if (!decomposeMap((Map) key1, (Map) key2, stack, options, visited)) { // Push VALUE_MISMATCH so parent's container-level description (e.g. "map value mismatch") // takes precedence over element-level differences ItemsToCompare prior = stack.peek(); stack.addFirst(new ItemsToCompare(prior._key1, prior._key2, prior, Difference.VALUE_MISMATCH)); return false; } continue; } else if (key2 instanceof Map) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.TYPE_MISMATCH)); return false; } // Array comparison if (key1Class.isArray()) { if (!key2Class.isArray()) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.TYPE_MISMATCH)); return false; } if (!decomposeArray(key1, key2, stack)) { // Push VALUE_MISMATCH so parent's container-level description (e.g. "array element mismatch") // takes precedence over element-level differences ItemsToCompare prior = stack.peek(); stack.addFirst(new ItemsToCompare(prior._key1, prior._key2, prior, Difference.VALUE_MISMATCH)); return false; } continue; } else if (key2Class.isArray()) { stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.TYPE_MISMATCH)); return false; } // Must be same class if not a container type if (!key1Class.equals(key2Class)) { // Must be same class stack.addFirst(new ItemsToCompare(key1, key2, stack.peek(), Difference.TYPE_MISMATCH)); return false; } // If there is a custom equals and not ignored, compare using custom equals if (hasCustomEquals(key1Class)) { boolean useCustomEqualsForThisClass = hasNonEmptyIgnoreSet && !ignoreCustomEquals.contains(key1Class); if (allowAllCustomEquals || useCustomEqualsForThisClass) { // No Field-by-field break down if (!key1.equals(key2)) { // Custom equals failed. Call "deepEquals()" below on failure of custom equals() above. // This gets us the "detail" on WHY the custom equals failed (first issue). Map newOptions = new HashMap<>(options); newOptions.put("recursive_call", true); // Create new ignore set preserving existing ignored classes Set> ignoreSet = new HashSet<>(); if (ignoreCustomEquals != null) { ignoreSet.addAll(ignoreCustomEquals); } ignoreSet.add(key1Class); newOptions.put(IGNORE_CUSTOM_EQUALS, ignoreSet); // Make recursive call to find the actual difference deepEquals(key1, key2, newOptions); // Get the difference and add it to our stack ItemsToCompare diff = (ItemsToCompare) newOptions.get("diff_item"); if (diff != null) { stack.addFirst(diff); } return false; } continue; } } // Decompose object into its fields (not using custom equals) decomposeObject(key1, key2, stack); } return true; } /** * Compares two unordered collections (e.g., Sets) deeply. * * @param col1 First collection. * @param col2 Second collection. * @param stack Comparison stack. * @param options Comparison options. * @param visited Visited set used for cycle detection. * @return true if collections are equal, false otherwise. */ private static boolean decomposeUnorderedCollection(Collection col1, Collection col2, Deque stack, Map options, Set visited) { ItemsToCompare currentItem = stack.peek(); // Check sizes first if (col1.size() != col2.size()) { stack.addFirst(new ItemsToCompare(col1, col2, currentItem, Difference.COLLECTION_SIZE_MISMATCH)); return false; } // Group col2 items by hash for efficient lookup Map> hashGroups = new HashMap<>(); for (Object o : col2) { int hash = deepHashCode(o); hashGroups.computeIfAbsent(hash, k -> new ArrayList<>()).add(o); } // Find first item in col1 not found in col2 for (Object item1 : col1) { int hash1 = deepHashCode(item1); List candidates = hashGroups.get(hash1); if (candidates == null || candidates.isEmpty()) { // No hash matches - first difference found stack.addFirst(new ItemsToCompare(item1, null, currentItem, Difference.COLLECTION_MISSING_ELEMENT)); return false; } // Check candidates with matching hash boolean foundMatch = false; for (Object item2 : candidates) { if (deepEquals(item1, item2, options, visited)) { foundMatch = true; candidates.remove(item2); if (candidates.isEmpty()) { hashGroups.remove(hash1); } break; } } if (!foundMatch) { // No matching element found - first difference found stack.addFirst(new ItemsToCompare(item1, null, currentItem, Difference.COLLECTION_MISSING_ELEMENT)); return false; } } return true; } private static boolean decomposeOrderedCollection(Collection col1, Collection col2, Deque stack) { ItemsToCompare currentItem = stack.peek(); // Check sizes first if (col1.size() != col2.size()) { stack.addFirst(new ItemsToCompare(col1, col2, currentItem, Difference.COLLECTION_SIZE_MISMATCH)); return false; } // Push elements in order Iterator i1 = col1.iterator(); Iterator i2 = col2.iterator(); int index = 0; while (i1.hasNext()) { Object item1 = i1.next(); Object item2 = i2.next(); stack.addFirst(new ItemsToCompare(item1, item2, new int[]{index++}, currentItem, Difference.COLLECTION_ELEMENT_MISMATCH)); } return true; } private static boolean decomposeMap(Map map1, Map map2, Deque stack, Map options, Set visited) { ItemsToCompare currentItem = stack.peek(); // Check sizes first if (map1.size() != map2.size()) { stack.addFirst(new ItemsToCompare(map1, map2, currentItem, Difference.MAP_SIZE_MISMATCH)); return false; } // Build lookup of map2 entries for efficient matching Map>> fastLookup = new HashMap<>(); for (Map.Entry entry : map2.entrySet()) { int hash = deepHashCode(entry.getKey()); fastLookup.computeIfAbsent(hash, k -> new ArrayList<>()) .add(new AbstractMap.SimpleEntry<>(entry.getKey(), entry.getValue())); } // Process map1 entries for (Map.Entry entry : map1.entrySet()) { Collection> otherEntries = fastLookup.get(deepHashCode(entry.getKey())); // Key not found in map2 if (otherEntries == null || otherEntries.isEmpty()) { stack.addFirst(new ItemsToCompare(entry.getKey(), null, currentItem, Difference.MAP_MISSING_KEY)); return false; } // Find matching key in otherEntries boolean foundMatch = false; Iterator> iterator = otherEntries.iterator(); while (iterator.hasNext()) { Map.Entry otherEntry = iterator.next(); // Check if keys are equal if (deepEquals(entry.getKey(), otherEntry.getKey(), options, visited)) { // Push value comparison only - keys are known to be equal stack.addFirst(new ItemsToCompare( entry.getValue(), // map1 value otherEntry.getValue(), // map2 value entry.getKey(), // pass the key as 'mapKey' currentItem, // parent true, // isMapKey = true Difference.MAP_VALUE_MISMATCH)); iterator.remove(); if (otherEntries.isEmpty()) { fastLookup.remove(deepHashCode(entry.getKey())); } foundMatch = true; break; } } if (!foundMatch) { stack.addFirst(new ItemsToCompare(entry.getKey(), null, currentItem, Difference.MAP_MISSING_KEY)); return false; } } return true; } /** * Breaks an array into comparable pieces. * * @param array1 First array. * @param array2 Second array. * @param stack Comparison stack. * @return true if arrays are equal, false otherwise. */ private static boolean decomposeArray(Object array1, Object array2, Deque stack) { ItemsToCompare currentItem = stack.peek(); // This will be the parent // 1. Check dimensionality Class type1 = array1.getClass(); Class type2 = array2.getClass(); int dim1 = 0, dim2 = 0; while (type1.isArray()) { dim1++; type1 = type1.getComponentType(); } while (type2.isArray()) { dim2++; type2 = type2.getComponentType(); } if (dim1 != dim2) { stack.addFirst(new ItemsToCompare(array1, array2, currentItem, Difference.ARRAY_DIMENSION_MISMATCH)); return false; } // 2. Check component types if (!array1.getClass().getComponentType().equals(array2.getClass().getComponentType())) { stack.addFirst(new ItemsToCompare(array1, array2, currentItem, Difference.ARRAY_COMPONENT_TYPE_MISMATCH)); return false; } // 3. Check lengths int len1 = Array.getLength(array1); int len2 = Array.getLength(array2); if (len1 != len2) { stack.addFirst(new ItemsToCompare(array1, array2, currentItem, Difference.ARRAY_LENGTH_MISMATCH)); return false; } // 4. Push all elements onto stack (with their full dimensional indices) for (int i = len1 - 1; i >= 0; i--) { stack.addFirst(new ItemsToCompare(Array.get(array1, i), Array.get(array2, i), new int[]{i}, // For multidimensional arrays, this gets built up currentItem, Difference.ARRAY_ELEMENT_MISMATCH)); } return true; } private static boolean decomposeObject(Object obj1, Object obj2, Deque stack) { ItemsToCompare currentItem = stack.peek(); // Get all fields from the object Collection fields = ReflectionUtils.getAllDeclaredFields(obj1.getClass()); // Push each field for comparison for (Field field : fields) { try { if (field.isSynthetic()) { continue; } Object value1 = field.get(obj1); Object value2 = field.get(obj2); stack.addFirst(new ItemsToCompare(value1, value2, field.getName(), currentItem, Difference.FIELD_VALUE_MISMATCH)); } catch (Exception ignored) { } } return true; } /** * Compares two numbers deeply, handling floating point precision. * * @param a First number. * @param b Second number. * @return true if numbers are equal within the defined precision, false otherwise. */ private static boolean compareNumbers(Number a, Number b) { // Handle floating point comparisons if (a instanceof Float || a instanceof Double || b instanceof Float || b instanceof Double) { // Check for overflow/underflow when comparing with BigDecimal if (a instanceof BigDecimal || b instanceof BigDecimal) { try { BigDecimal bd; if (a instanceof BigDecimal) { bd = (BigDecimal) a; } else { bd = (BigDecimal) b; } // If BigDecimal is outside Double's range, they can't be equal if (bd.compareTo(BigDecimal.valueOf(Double.MAX_VALUE)) > 0 || bd.compareTo(BigDecimal.valueOf(-Double.MAX_VALUE)) < 0) { return false; } } catch (Exception e) { return false; } } // Normal floating point comparison double d1 = a.doubleValue(); double d2 = b.doubleValue(); return nearlyEqual(d1, d2, doubleEpsilon); } // For non-floating point numbers, use exact comparison try { BigDecimal x = convert2BigDecimal(a); BigDecimal y = convert2BigDecimal(b); return x.compareTo(y) == 0; } catch (Exception e) { return false; } } /** * Correctly handles floating point comparisons. * * @param a First number. * @param b Second number. * @param epsilon Tolerance value. * @return true if numbers are nearly equal within the tolerance, false otherwise. */ private static boolean nearlyEqual(double a, double b, double epsilon) { final double absA = Math.abs(a); final double absB = Math.abs(b); final double diff = Math.abs(a - b); if (a == b) { // shortcut, handles infinities return true; } else if (a == 0 || b == 0 || diff < Double.MIN_NORMAL) { // a or b is zero or both are extremely close to it // relative error is less meaningful here return diff < (epsilon * Double.MIN_NORMAL); } else { // use relative error return diff / (absA + absB) < epsilon; } } /** * Compares two AtomicBoolean instances. * * @param a First AtomicBoolean. * @param b Second AtomicBoolean. * @return true if both have the same value, false otherwise. */ private static boolean compareAtomicBoolean(AtomicBoolean a, AtomicBoolean b) { return a.get() == b.get(); } /** * Determines whether the given class has a custom {@code equals(Object)} method * distinct from {@code Object.equals(Object)}. *

* Useful for detecting when a class relies on a specialized equality definition, * which can be selectively ignored by deep-comparison if desired. *

* * @param c the class to inspect, must not be {@code null} * @return {@code true} if {@code c} declares its own {@code equals(Object)} method, * {@code false} otherwise */ public static boolean hasCustomEquals(Class c) { Method equals = ReflectionUtils.getMethod(c, "equals", Object.class); // cached return equals.getDeclaringClass() != Object.class; } /** * Determines whether the given class has a custom {@code hashCode()} method * distinct from {@code Object.hashCode()}. *

* This method helps identify classes that rely on a specialized hashing algorithm, * which can be relevant for certain comparison or hashing scenarios. *

* *

* Usage Example: *

*
{@code
     * Class clazz = MyCustomClass.class;
     * boolean hasCustomHashCode = hasCustomHashCodeMethod(clazz);
     * System.out.println("Has custom hashCode(): " + hasCustomHashCode);
     * }
* *

* Notes: *

*
    *
  • * A class is considered to have a custom {@code hashCode()} method if it declares * its own {@code hashCode()} method that is not inherited directly from {@code Object}. *
  • *
  • * This method does not consider interfaces or abstract classes unless they declare * a {@code hashCode()} method. *
  • *
* * @param c the class to inspect, must not be {@code null} * @return {@code true} if {@code c} declares its own {@code hashCode()} method, * {@code false} otherwise * @throws IllegalArgumentException if the provided class {@code c} is {@code null} * @see Object#hashCode() */ public static boolean hasCustomHashCode(Class c) { Method hashCode = ReflectionUtils.getMethod(c, "hashCode"); // cached return hashCode.getDeclaringClass() != Object.class; } /** * Computes a deep hash code for the given object by traversing its entire graph. *

* This method considers the hash codes of nested objects, arrays, maps, and collections, * and uses cyclic reference detection to avoid infinite loops. *

*

* While deepHashCode() enables O(n) comparison performance in DeepEquals() when comparing * unordered collections and maps, it does not guarantee that objects which are deepEquals() * will have matching deepHashCode() values. This design choice allows for optimized * performance while maintaining correctness of equality comparisons. *

*

* You can use it for generating your own hashCodes() on complex items, but understand that * it *always* calls an instants hashCode() method if it has one that override's the * hashCode() method defined on Object.class. *

* @param obj the object to hash, may be {@code null} * @return an integer representing the object's deep hash code */ public static int deepHashCode(Object obj) { Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); return deepHashCode(obj, visited); } private static int deepHashCode(Object obj, Set visited) { Deque stack = new LinkedList<>(); stack.addFirst(obj); int hash = 0; while (!stack.isEmpty()) { obj = stack.removeFirst(); if (obj == null || visited.contains(obj)) { continue; } visited.add(obj); // Ensure array order matters to hash if (obj.getClass().isArray()) { final int len = Array.getLength(obj); long result = 1; for (int i = 0; i < len; i++) { Object element = Array.get(obj, i); result = 31 * result + hashElement(visited, element); } hash += (int) result; continue; } // Order matters for List - it is defined as part of equality if (obj instanceof List) { List col = (List) obj; long result = 1; for (Object element : col) { result = 31 * result + hashElement(visited, element); } hash += (int) result; continue; } // Ignore order for non-List Collections (not part of definition of equality) if (obj instanceof Collection) { addCollectionToStack(stack, (Collection) obj); continue; } if (obj instanceof Map) { addCollectionToStack(stack, ((Map) obj).keySet()); addCollectionToStack(stack, ((Map) obj).values()); continue; } 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.getAllDeclaredFields(obj.getClass()); for (Field field : fields) { try { if (field.isSynthetic()) { continue; } stack.addFirst(field.get(obj)); } catch (Exception ignored) { } } } return hash; } private static int hashElement(Set visited, Object element) { if (element == null) { return 0; } else if (element instanceof Double) { return hashDouble((Double) element); } else if (element instanceof Float) { return hashFloat((Float) element); } else if (Converter.isSimpleTypeConversionSupported(element.getClass())) { return element.hashCode(); } else { return deepHashCode(element, visited); } } private static int hashDouble(double value) { double normalizedValue = Math.round(value * SCALE_DOUBLE) / SCALE_DOUBLE; long bits = Double.doubleToLongBits(normalizedValue); return (int) (bits ^ (bits >>> 32)); } private static int hashFloat(float value) { float normalizedValue = Math.round(value * SCALE_FLOAT) / SCALE_FLOAT; return Float.floatToIntBits(normalizedValue); } private static void addCollectionToStack(Deque stack, Collection collection) { List items = (collection instanceof List) ? (List) collection : new ArrayList<>(collection); for (int i = items.size() - 1; i >= 0; i--) { stack.addFirst(items.get(i)); } } private enum DiffCategory { VALUE, TYPE, SIZE, LENGTH, DIMENSION } private enum Difference { // Basic value difference (includes numbers, atomic values, field values) VALUE_MISMATCH("value mismatch", DiffCategory.VALUE), FIELD_VALUE_MISMATCH("field value mismatch", DiffCategory.VALUE), // Collection-specific COLLECTION_SIZE_MISMATCH("collection size mismatch", DiffCategory.SIZE), COLLECTION_MISSING_ELEMENT("missing collection element", DiffCategory.VALUE), COLLECTION_TYPE_MISMATCH("collection type mismatch", DiffCategory.TYPE), COLLECTION_ELEMENT_MISMATCH("collection element mismatch", DiffCategory.VALUE), // Map-specific MAP_SIZE_MISMATCH("map size mismatch", DiffCategory.SIZE), MAP_MISSING_KEY("missing map key", DiffCategory.VALUE), MAP_VALUE_MISMATCH("map value mismatch", DiffCategory.VALUE), // Array-specific ARRAY_DIMENSION_MISMATCH("array dimensionality mismatch", DiffCategory.DIMENSION), ARRAY_COMPONENT_TYPE_MISMATCH("array component type mismatch", DiffCategory.TYPE), ARRAY_LENGTH_MISMATCH("array length mismatch", DiffCategory.LENGTH), ARRAY_ELEMENT_MISMATCH("array element mismatch", DiffCategory.VALUE), // General type mismatch (when classes don't match) TYPE_MISMATCH("type mismatch", DiffCategory.TYPE); private final String description; private final DiffCategory category; Difference(String description, DiffCategory category) { this.description = description; this.category = category; } String getDescription() { return description; } DiffCategory getCategory() { return category; } } private static String generateBreadcrumb(Deque stack) { ItemsToCompare diffItem = stack.peek(); StringBuilder result = new StringBuilder(); // Build the path AND get the mismatch phrase PathResult pr = buildPathContextAndPhrase(diffItem); String pathStr = pr.path; result.append("["); result.append(pr.mismatchPhrase); result.append("] "); result.append(TRIANGLE_ARROW); result.append(" "); result.append(pathStr); result.append("\n"); // Format the difference details formatDifference(result, diffItem); return result.toString(); } private static PathResult buildPathContextAndPhrase(ItemsToCompare diffItem) { List path = getPath(diffItem); // path.size is >= 2 always. Even with a root only diff like this deepEquals(4, 5) // because there is an initial root stack push, and then all 'false' paths push a // descriptive ItemsToCompare() on the stack before returning. // 1) Format root StringBuilder sb = new StringBuilder(); ItemsToCompare rootItem = path.get(0); sb.append(formatRootObject(rootItem._key1)); // "Dictionary {...}" // 2) Build up child path StringBuilder sb2 = new StringBuilder(); for (int i = 1; i < path.size(); i++) { ItemsToCompare cur = path.get(i); // If it's a mapKey, we do the " 《 key ⇨ value 》 if (cur.mapKey != null) { appendSpaceIfNeeded(sb2); sb2.append(ANGLE_LEFT) .append(formatMapKey(cur.mapKey)) .append(" ") .append(ARROW) .append(" ") .append(formatValueConcise(cur._key1)) .append(ANGLE_RIGHT); } // If it's a normal field name else if (cur.fieldName != null) { sb2.append(".").append(cur.fieldName); } // If it’s array indices else if (cur.arrayIndices != null) { for (int idx : cur.arrayIndices) { boolean isArray = cur.difference.name().contains("ARRAY"); sb2.append(isArray ? "[" : "("); sb2.append(idx); sb2.append(isArray ? "]" : ")"); } } } // If we built child path text, attach it after " ▶ " if (sb2.length() > 0) { sb.append(" "); sb.append(TRIANGLE_ARROW); sb.append(" "); sb.append(sb2); } // 3) Find the correct mismatch phrase (it will be from the "container" of the difference's pov) String mismatchPhrase = getContainingDescription(path); return new PathResult(sb.toString(), mismatchPhrase); } /** * Gets the most appropriate difference description from the comparison path. *

* For container types (Arrays, Collections, Maps), the parent node's description * often provides better context than the leaf node. For example, an array length * mismatch is more informative than a simple value mismatch of its elements. *

* The method looks at the last two nodes in the path: * - If only one node exists, uses its description * - If two or more nodes exist, prefers the second-to-last node's description * - Falls back to the last node's description if the parent's is null * * @param path The list of ItemsToCompare representing the traversal path to the difference * @return The most appropriate difference description, or null if path is empty */ private static String getContainingDescription(List path) { ListIterator it = path.listIterator(path.size()); String a = it.previous().difference.getDescription(); if (it.hasPrevious()) { Difference diff = it.previous().difference; if (diff != null) { String b = diff.getDescription(); if (b != null) { return b; } } } return a; } /** * Tiny struct-like class to hold both the path & the mismatch phrase. */ private static class PathResult { final String path; final String mismatchPhrase; PathResult(String path, String mismatchPhrase) { this.path = path; this.mismatchPhrase = mismatchPhrase; } } private static void appendSpaceIfNeeded(StringBuilder sb) { if (sb.length() > 0) { char last = sb.charAt(sb.length() - 1); if (last != ' ' && last != '.' && last != '[') { sb.append(' '); } } } private static Class getCollectionElementType(Collection col) { if (col == null || col.isEmpty()) { return null; } for (Object item : col) { if (item != null) { return item.getClass(); } } return null; } private static List getPath(ItemsToCompare diffItem) { List path = new ArrayList<>(); ItemsToCompare current = diffItem; while (current != null) { path.add(0, current); // Add to front to maintain root→diff order current = current.parent; } return path; } private static void formatDifference(StringBuilder result, ItemsToCompare item) { if (item.difference == null) { return; } DiffCategory category = item.difference.getCategory(); if (item.parent.difference != null) { category = item.parent.difference.category; } switch (category) { case SIZE: result.append(String.format(" Expected size: %d%n Found size: %d", getContainerSize(item._key1), getContainerSize(item._key2))); break; case TYPE: result.append(String.format(" Expected type: %s%n Found type: %s", getTypeDescription(item._key1 != null ? item._key1.getClass() : null), getTypeDescription(item._key2 != null ? item._key2.getClass() : null))); break; case LENGTH: result.append(String.format(" Expected length: %d%n Found length: %d", Array.getLength(item._key1), Array.getLength(item._key2))); break; case DIMENSION: result.append(String.format(" Expected dimensions: %d%n Found dimensions: %d", getDimensions(item._key1), getDimensions(item._key2))); break; case VALUE: default: result.append(String.format(" Expected: %s%n Found: %s", formatDifferenceValue(item._key1), formatDifferenceValue(item._key2))); break; } } private static String formatDifferenceValue(Object value) { if (value == null) { return "null"; } // For simple types, show just the value (type is shown in context) if (Converter.isSimpleTypeConversionSupported(value.getClass())) { return formatSimpleValue(value); } // For arrays, collections, maps, and complex objects, use concise format return formatValueConcise(value); } private static int getDimensions(Object array) { if (array == null) return 0; int dimensions = 0; Class type = array.getClass(); while (type.isArray()) { dimensions++; type = type.getComponentType(); } return dimensions; } private static String formatValueConcise(Object value) { if (value == null) { return "null"; } try { // Handle collections if (value instanceof Collection) { Collection col = (Collection) value; String typeName = value.getClass().getSimpleName(); return String.format("%s(%s)", typeName, col.isEmpty() ? EMPTY : "0.." + (col.size() - 1)); } // Handle maps if (value instanceof Map) { Map map = (Map) value; String typeName = value.getClass().getSimpleName(); return String.format("%s(%s)", typeName, map.isEmpty() ? EMPTY : "0.." + (map.size() - 1)); } // Handle arrays if (value.getClass().isArray()) { int length = Array.getLength(value); String typeName = getTypeDescription(value.getClass().getComponentType()); return String.format("%s[%s]", typeName, length == 0 ? EMPTY : "0.." + (length - 1)); } // Handle simple types if (Converter.isSimpleTypeConversionSupported(value.getClass())) { return formatSimpleValue(value); } // For objects, include basic fields Collection fields = ReflectionUtils.getAllDeclaredFields(value.getClass()); StringBuilder sb = new StringBuilder(value.getClass().getSimpleName()); sb.append(" {"); boolean first = true; for (Field field : fields) { if (field.isSynthetic()) { continue; } if (!first) sb.append(", "); first = false; Object fieldValue = field.get(value); sb.append(field.getName()).append(": "); if (fieldValue == null) { sb.append("null"); continue; } Class fieldType = field.getType(); if (Converter.isSimpleTypeConversionSupported(fieldType)) { // Simple type - show value sb.append(formatSimpleValue(fieldValue)); } else if (fieldType.isArray()) { // Array - show type and size int length = Array.getLength(fieldValue); String typeName = getTypeDescription(fieldType.getComponentType()); sb.append(String.format("%s[%s]", typeName, length == 0 ? EMPTY : "0.." + (length - 1))); } else if (Collection.class.isAssignableFrom(fieldType)) { // Collection - show type and size Collection col = (Collection) fieldValue; sb.append(String.format("%s(%s)", fieldType.getSimpleName(), col.isEmpty() ? EMPTY : "0.." + (col.size() - 1))); } else if (Map.class.isAssignableFrom(fieldType)) { // Map - show type and size Map map = (Map) fieldValue; sb.append(String.format("%s(%s)", fieldType.getSimpleName(), map.isEmpty() ? EMPTY : "0.." + (map.size() - 1))); } else { // Non-simple object - show {..} sb.append("{..}"); } } sb.append("}"); return sb.toString(); } catch (Exception e) { return value.getClass().getSimpleName(); } } private static String formatSimpleValue(Object value) { if (value == null) return "null"; if (value instanceof AtomicBoolean) { return String.valueOf(((AtomicBoolean) value).get()); } if (value instanceof AtomicInteger) { return String.valueOf(((AtomicInteger) value).get()); } if (value instanceof AtomicLong) { return String.valueOf(((AtomicLong) value).get()); } if (value instanceof String) return "\"" + value + "\""; if (value instanceof Character) return "'" + value + "'"; if (value instanceof Number) { return formatNumber((Number) value); } if (value instanceof Boolean) return value.toString(); if (value instanceof Date) { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format((Date)value); } if (value instanceof TimeZone) { TimeZone timeZone = (TimeZone) value; return "TimeZone: " + timeZone.getID(); } if (value instanceof URI) { return value.toString(); // Just the URI string } if (value instanceof URL) { return value.toString(); // Just the URL string } if (value instanceof UUID) { return value.toString(); // Just the UUID string } // For other types, just show type and toString return value.getClass().getSimpleName() + ":" + value; } private static String formatValue(Object value) { if (value == null) return "null"; // Check if we're already formatting this object Set stack = formattingStack.get(); if (!stack.add(value)) { return ""; } try { if (value instanceof Number) { return formatNumber((Number) value); } if (value instanceof String) return "\"" + value + "\""; if (value instanceof Character) return "'" + value + "'"; if (value instanceof Date) { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format((Date)value); } // If it's a simple type, use toString() if (Converter.isSimpleTypeConversionSupported(value.getClass())) { return String.valueOf(value); } if (value instanceof Collection) { return formatCollectionContents((Collection) value); } if (value instanceof Map) { return formatMapContents((Map) value); } if (value.getClass().isArray()) { return formatArrayContents(value); } return formatComplexObject(value); } finally { stack.remove(value); } } private static String formatArrayContents(Object array) { final int limit = 3; // Get base type Class type = array.getClass(); Class componentType = type; while (componentType.getComponentType() != null) { componentType = componentType.getComponentType(); } StringBuilder sb = new StringBuilder(); sb.append(componentType.getSimpleName()); // Base type (int, String, etc) // Only show outer dimensions int outerLength = Array.getLength(array); sb.append("[").append(outerLength).append("]"); Class current = type.getComponentType(); while (current != null && current.isArray()) { sb.append("[]"); current = current.getComponentType(); } // Add contents sb.append("{"); int length = Array.getLength(array); // Using original array here if (length > 0) { int showItems = Math.min(length, limit); for (int i = 0; i < showItems; i++) { if (i > 0) sb.append(", "); Object item = Array.get(array, i); if (item == null) { sb.append("null"); } else if (item.getClass().isArray()) { // For sub-arrays, just show their contents in brackets int subLength = Array.getLength(item); sb.append('['); for (int j = 0; j < Math.min(subLength, limit); j++) { if (j > 0) sb.append(", "); sb.append(formatValue(Array.get(item, j))); } if (subLength > 3) sb.append(", ..."); sb.append(']'); } else { sb.append(formatValue(item)); } } if (length > 3) sb.append(", ..."); } sb.append("}"); return sb.toString(); } private static String formatCollectionContents(Collection collection) { final int limit = 3; StringBuilder sb = new StringBuilder(); // Get collection type and element type Class type = collection.getClass(); Type elementType = getCollectionElementType(collection); sb.append(type.getSimpleName()); if (elementType != null) { sb.append("<").append(getTypeSimpleName(elementType)).append(">"); } // Add size sb.append("(").append(collection.size()).append(")"); // Add contents sb.append("{"); if (!collection.isEmpty()) { Iterator it = collection.iterator(); int count = 0; while (count < limit && it.hasNext()) { if (count > 0) sb.append(", "); Object item = it.next(); if (item == null) { sb.append("null"); } else if (item instanceof Collection) { Collection subCollection = (Collection) item; sb.append("("); Iterator subIt = subCollection.iterator(); for (int j = 0; j < Math.min(subCollection.size(), limit); j++) { if (j > 0) sb.append(", "); sb.append(formatValue(subIt.next())); } if (subCollection.size() > limit) sb.append(", ..."); sb.append(")"); } else { sb.append(formatValue(item)); } count++; } if (collection.size() > limit) sb.append(", ..."); } sb.append("}"); return sb.toString(); } private static String formatMapContents(Map map) { final int limit = 3; StringBuilder sb = new StringBuilder(); // Get map type and key/value types Class type = map.getClass(); Type[] typeArgs = getMapTypes(map); sb.append(type.getSimpleName()); if (typeArgs != null && typeArgs.length == 2) { sb.append("<") .append(getTypeSimpleName(typeArgs[0])) .append(", ") .append(getTypeSimpleName(typeArgs[1])) .append(">"); } // Add size in parentheses sb.append("(").append(map.size()).append(")"); // Add contents if (!map.isEmpty()) { Iterator> it = map.entrySet().iterator(); int count = 0; while (count < limit && it.hasNext()) { if (count > 0) sb.append(", "); Map.Entry entry = it.next(); sb.append(ANGLE_LEFT) .append(formatValue(entry.getKey())) .append(" ") .append(ARROW) .append(" ") .append(formatValue(entry.getValue())) .append(ANGLE_RIGHT); count++; } if (map.size() > limit) sb.append(", ..."); } return sb.toString(); } private static String getTypeSimpleName(Type type) { if (type instanceof Class) { return ((Class) type).getSimpleName(); } return type.getTypeName(); } private static String formatComplexObject(Object obj) { StringBuilder sb = new StringBuilder(); sb.append(obj.getClass().getSimpleName()); sb.append(" {"); Collection fields = ReflectionUtils.getAllDeclaredFields(obj.getClass()); boolean first = true; for (Field field : fields) { try { if (field.isSynthetic()) { continue; } if (!first) { sb.append(", "); } first = false; sb.append(field.getName()).append(": "); Object value = field.get(obj); if (value == obj) { sb.append("(this ").append(obj.getClass().getSimpleName()).append(")"); } else { sb.append(formatValue(value)); // Recursive call with cycle detection } } catch (Exception ignored) { // If we can't access a field, skip it } } sb.append("}"); return sb.toString(); } private static String formatArrayNotation(Object array) { if (array == null) return "null"; int length = Array.getLength(array); String typeName = getTypeDescription(array.getClass().getComponentType()); return String.format("%s[%s]", typeName, length == 0 ? EMPTY : "0.." + (length - 1)); } private static String formatCollectionNotation(Collection col) { StringBuilder sb = new StringBuilder(); sb.append(col.getClass().getSimpleName()); // Only add type parameter if it's more specific than Object Class elementType = getCollectionElementType(col); if (elementType != null && elementType != Object.class) { sb.append("<").append(getTypeDescription(elementType)).append(">"); } sb.append("("); if (col.isEmpty()) { sb.append(EMPTY); } else { sb.append("0..").append(col.size() - 1); } sb.append(")"); return sb.toString(); } private static String formatMapNotation(Map map) { if (map == null) return "null"; StringBuilder sb = new StringBuilder(); sb.append(map.getClass().getSimpleName()); sb.append("("); if (map.isEmpty()) { sb.append(EMPTY); } else { sb.append("0..").append(map.size() - 1); } sb.append(")"); return sb.toString(); } private static String formatMapKey(Object key) { if (key == null) return "null"; // If the key is truly a String, keep quotes if (key instanceof String) { return "\"" + key + "\""; } // Otherwise, format the key in a "concise" way, // but remove any leading/trailing quotes that come // from 'formatValueConcise()' if it decides it's a String. String text = formatValue(key); return StringUtilities.removeLeadingAndTrailingQuotes(text); } private static String formatNumber(Number value) { if (value == null) return "null"; if (value instanceof BigDecimal) { BigDecimal bd = (BigDecimal) value; double doubleValue = bd.doubleValue(); // Use scientific notation only for very large or very small values if (Math.abs(doubleValue) >= 1e16 || (Math.abs(doubleValue) < 1e-6 && doubleValue != 0)) { return String.format("%.6e", doubleValue); } // For values between -1 and 1, ensure we don't use scientific notation if (Math.abs(doubleValue) <= 1) { return bd.stripTrailingZeros().toPlainString(); } // For other values, use regular decimal notation return bd.stripTrailingZeros().toPlainString(); } if (value instanceof Double || value instanceof Float) { double d = value.doubleValue(); if (Math.abs(d) >= 1e16 || (Math.abs(d) < 1e-6 && d != 0)) { return String.format("%.6e", d); } // For doubles, up to 15 decimal places if (value instanceof Double) { return String.format("%.15g", d).replaceAll("\\.?0+$", ""); } // For floats, up to 7 decimal places return String.format("%.7g", d).replaceAll("\\.?0+$", ""); } // For other number types (Integer, Long, etc.), use toString return value.toString(); } private static String formatRootObject(Object obj) { if (obj == null) { return "null"; } // For collections and maps, just show the container notation if (obj instanceof Collection) { return formatCollectionNotation((Collection)obj); } if (obj instanceof Map) { return formatMapNotation((Map)obj); } if (obj.getClass().isArray()) { return formatArrayNotation(obj); } // For simple types, show type: value if (Converter.isSimpleTypeConversionSupported(obj.getClass())) { return String.format("%s: %s", getTypeDescription(obj.getClass()), formatSimpleValue(obj)); } // For objects, use the concise format return formatValueConcise(obj); } private static String getTypeDescription(Class type) { if (type == null) return "Object"; // Default to Object for null types if (type.isArray()) { Class componentType = type.getComponentType(); return getTypeDescription(componentType) + "[]"; } return type.getSimpleName(); } private static Type[] getMapTypes(Map map) { // Try to get generic types from superclass Type type = map.getClass().getGenericSuperclass(); if (type instanceof ParameterizedType) { return ((ParameterizedType) type).getActualTypeArguments(); } return null; } private static int getContainerSize(Object container) { if (container == null) return 0; if (container instanceof Collection) return ((Collection) container).size(); if (container instanceof Map) return ((Map) container).size(); if (container.getClass().isArray()) return Array.getLength(container); return 0; } }