CustomValueComparator for Object.class not used for Map values

Summary

The issue is a type resolution mismatch in JaVers. While a CustomValueComparator for Object.class is registered, JaVers internally uses generic type arguments (Map<String, Object>) to select comparators. It treats Object as a container type (specifically Map/Collection) rather than a value type, bypassing the custom comparator when comparing map values. This results in standard Object.equals() behavior instead of the numeric tolerance logic.

Root Cause

JaVers’ internal type registry has two conflicting behaviors for the Object type:

  1. Value Type Registration: When you call registerValue(Object.class, ...), JaVers marks Object as a value type globally.
  2. Container Type Resolution: When scanning Map<String, Object>, JaVers attempts to determine the concrete type of the map values. It sees the generic parameter Object and interprets it as a container (map or collection) because Object is the root of the Java type hierarchy and does not inherently carry value semantics.

Consequently, when JaVers reaches the map value 1.0f, the internal comparator logic ignores the registered Object value comparator and falls back to the default equality check (or a specific primitive check for Float), which does not apply your tolerance logic.

Why This Happens in Real Systems

This behavior stems from Java Generics Type Erasure and Type Hierarchy Ambiguity:

  • Type Erasure: At runtime, Map<String, Object> loses its generic signature, becoming a raw Map. JaVers relies on its internal Type mapping to reconstruct the generic context.
  • Ambiguity of Object: The Object class represents two distinct concepts:
    • Value: An opaque data blob (e.g., a string).
    • Container: A structure holding other types (e.g., a list or map).
    • Because JaVers must be defensive, when it encounters Object as a generic parameter in a collection context, it defaults to treating it as a container to avoid casting errors, skipping value-type customizations.

Real-World Impact

  • Silent Logic Failure: The Diff produced is mathematically incorrect based on the business requirements (e.g., 1.0f is considered not equal to 1L).
  • Data Consistency Issues: If this comparator is used for database state snapshotting or change tracking, JaVers will generate false positives for “changes,” leading to unnecessary audit logs or failed synchronization jobs.
  • Hard to Debug: The comparator is registered successfully, but the execution path is never triggered. This requires digging into the JaVers source code (specifically PrimitiveOrValueType and MapChangeAppender) to understand why the valueComparator field is null.

Example or Code

The following code demonstrates the failure. Even though NumericComparator is registered for Object.class, the Diff will show a change because the internal comparison uses Float.equals(Long) (which returns false) rather than the custom numeric logic.

import java.util.HashMap;
import java.util.Map;
import javers.core.Javers;
import javers.core.JaversBuilder;
import javers.core.diff.Diff;
import javers.core.valuetype.CustomValueComparator;

public class JaVersMapObjectIssue {

    public static void main(String[] args) {
        // 1. Register the comparator for Object.class
        Javers javers = JaversBuilder.javers()
                .registerValue(Object.class, new NumericComparator())
                .build();

        // 2. Create Maps with different number types in the values
        Map map1 = new HashMap();
        map1.put("a", 1.0f); // Float

        Map map2 = new HashMap();
        map2.put("a", 1L);   // Long

        // 3. Compare
        Diff diff = javers.compare(map1, map2);

        // 4. Result
        // Expected: No changes (because 1.0f == 1L numerically)
        // Actual: Change detected (because Object.equals is not used, 
        //         and specific number comparison bypasses custom comparator)
        System.out.println("Changes found: " + diff.hasChanges());
        System.out.println(diff);
    }
}

How Senior Engineers Fix It

To fix this, you must bypass the generic Object resolution and force JaVers to treat the content as a specific value type.

  1. Use a Specific Interface (Recommended): Since the map is dynamic, you cannot type the generic signature easily. However, if you have control over the data, wrap the numeric value in a class that implements a marker interface (e.g., NumericValue). Register the comparator for that interface.
  2. Global Object Comparator: If the map is truly dynamic (containing mixed types), you cannot rely on Map<String, Object> generic hints. You must ensure the comparator is applied globally for all value types that happen to be Object.
    • Fix: Register the comparator not just for Object.class, but register it as the fallback for all primitives if possible, or explicitly register for all number types (Float.class, Long.class, etc.).
  3. Wrapper Class Strategy: Create a generic wrapper class JaversNumericWrapper<T extends Number>. Map your data to this wrapper. Register the comparator for JaversNumericWrapper.class. This removes the Object ambiguity entirely.

Senior Takeaway: Never rely on Object generic parameters in collection types for specific value logic. Concrete types (even if custom) are required for JaVers to select the correct ValueComparator.

Why Juniors Miss It

  • “Object is a Class” Misconception: Juniors often think Object.class acts as a catch-all wildcard for every object in the system. They fail to realize that in JaVers’ internal type graph, Object is treated specially as a potential Collection or Map container.
  • Generic Signature Blindness: They check that the comparator is “registered,” but they don’t check how it is retrieved. They miss that retrieval depends on the generic type argument found during graph traversal, not just the raw class of the value instance.
  • Expectation of Recursive Application: They expect the comparator to apply recursively to any Object found anywhere. In reality, comparators are selected at the node level based on the declared type at that specific node in the object graph.

Key Takeaway: When dealing with Map<String, Object>, the Object type parameter is often interpreted as a Container Type, not a Value Type, causing custom comparators registered for Object.class to be ignored.