JaVers CustomValueComparator Not Working for Map Values
Discover why JaVers CustomValueComparator registered for Object.class fails on Map<String, Object> numeric values. Learn solutions like registering for Number types to enable tolerance-based comparison without changing map structure.
JaVers CustomValueComparator for Object.class not applied to Map<String, Object> values
I am using JaVers and trying to register a CustomValueComparator for Object.class to handle values inside a Map<String, Object>. The goal is to consider numbers equal if they are numerically similar (within a tolerance of 0.01), and fall back to Object.equals() for non-Number types.
The comparator is registered successfully, but its equals method is not invoked during comparison. Debugging shows no value comparator present for ValueType of Object.class in PrimitiveOrValueType.equals.
I cannot change the map to Map<String, Number> because it may contain other data types.
Here is the NumericComparator implementation:
public class NumericComparator implements CustomValueComparator<Object> {
private static final double TOLERANCE = 0.01;
@Override
public boolean equals(Object a, Object b) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
if (a instanceof Number && b instanceof Number) {
return compareNumbersWithTolerance(a, b);
}
return a.equals(b);
}
public String toString(Object value) {
return value == null ? "null" : value.toString();
}
private boolean compareNumbersWithTolerance(Object a, Object b) {
Number numA = (Number) a;
Number numB = (Number) b;
if (isSpecialFloatingPoint(numA) || isSpecialFloatingPoint(numB)) {
return handleSpecialFloatingPoint(numA, numB);
}
try {
BigDecimal bdA = toBigDecimal(numA);
BigDecimal bdB = toBigDecimal(numB);
BigDecimal difference = bdA.subtract(bdB).abs();
BigDecimal toleranceBD = BigDecimal.valueOf(TOLERANCE);
return difference.compareTo(toleranceBD) <= 0;
} catch (NumberFormatException e) {
double diff = Math.abs(numA.doubleValue() - numB.doubleValue());
return diff <= TOLERANCE;
}
}
private boolean isSpecialFloatingPoint(Number n) {
if (n instanceof Double) {
double d = n.doubleValue();
return Double.isNaN(d) || Double.isInfinite(d);
}
if (n instanceof Float) {
float f = n.floatValue();
return Float.isNaN(f) || Float.isInfinite(f);
}
return false;
}
private boolean handleSpecialFloatingPoint(Number a, Number b) {
double aDouble = a.doubleValue();
double bDouble = b.doubleValue();
if (Double.isNaN(aDouble) && Double.isNaN(bDouble)) {
return true;
}
if (Double.isInfinite(aDouble) && Double.isInfinite(bDouble)) {
return aDouble == bDouble;
}
return false;
}
private BigDecimal toBigDecimal(Number number) {
if (number instanceof BigDecimal) {
return (BigDecimal) number;
} else if (number instanceof BigInteger) {
return new BigDecimal((BigInteger) number);
} else if (number instanceof Integer || number instanceof Long ||
number instanceof Short || number instanceof Byte) {
return BigDecimal.valueOf(number.longValue());
} else if (number instanceof Double || number instanceof Float) {
return BigDecimal.valueOf(number.doubleValue());
} else {
return new BigDecimal(number.toString());
}
}
}
Registration:
private static final Javers javers = JaversBuilder.javers()
.registerValue(Object.class, new NumericComparator())
.build();
Test comparison:
Map<String, Object> map = new HashMap<>();
map.put("a", 1.0f);
Map<String, Object> map2 = new HashMap<>();
map2.put("a", 1L);
Diff diff = javers.compare(map, map2); // Expects no diff, but comparator not used
Why isn’t the custom comparator applied to Map values? How can I make JaVers use it for numeric values in Map<String, Object>?
JaVers CustomValueComparator for Object.class is not being applied to Map<String, Object> values because JaVers determines specific ValueTypes for each value in the map rather than treating them as generic Object.class. When you store 1.0f and 1L in a map, JaVers identifies them as FloatValueType and LongValueType respectively, not as Object.class. This means your comparator registered for Object.class won’t be invoked during comparison.
Contents
- Understanding JaVers CustomValueComparator and Its Limitations
- Why Your Object.class Comparator Isn’t Working for Map Values
- Solutions for Implementing Numeric Comparison in Map<String, Object>
- Alternative Approaches and Best Practices
- Testing and Validating Your Custom Comparator Implementation
Understanding JaVers CustomValueComparator and Its Limitations
JaVers provides a flexible type system for comparing objects, and CustomValueComparator is a powerful feature for customizing how values are compared. When you register a comparator for Object.class, you’re essentially telling JaVers to use this comparator for any value that doesn’t have a more specific comparator registered.
However, there’s a crucial limitation in how JaVers handles values within collections like Maps. According to the official JaVers documentation, when JaVers processes a Map, it determines the specific ValueType for each value based on its actual Java type, not based on the declared Map type. This means that even though your map is declared as Map<String, Object>, JaVers sees 1.0f as a FloatValueType and 1L as a LongValueType.
The Javers ValueType implementation shows that each concrete type has its own ValueType instance, and JaVers uses these specific types to determine which comparator to use. Only if a value doesn’t match any specific ValueType would it fall back to the Object.class comparator.
Why Your Object.class Comparator Isn’t Working for Map Values
The root cause of your issue lies in how JaVers resolves types during comparison. When you run javers.compare(map, map2), here’s what happens internally:
- JaVers processes the Map<String, Object> by examining its values
- For the value
1.0f, it determines this is aFloatValueType - For the value
1L, it determines this is aLongValueType - JaVers then looks for a registered comparator for
FloatValueTypeandLongValueTypespecifically - Since you only registered a comparator for
Object.class, and these specific types don’t match, the custom comparator isn’t used
This behavior is confirmed in a GitHub issue where users reported similar problems with custom comparators not being invoked for Map values. The issue explains that “JaVers has specific ValueTypes for primitive types and their wrapper classes, and these take precedence over any Object.class comparator.”
The debugging information you mentioned about “no value comparator present for ValueType of Object.class in PrimitiveOrValueType.equals” aligns with this explanation. JaVers doesn’t see the values as Object.class instances, so it doesn’t look for an Object.class comparator.
Solutions for Implementing Numeric Comparison in Map<String, Object>
Solution 1: Register Comparators for All Number Types
Instead of registering for Object.class, register your comparator for all specific Number types that might appear in your maps:
private static final Javers javers = JaversBuilder.javers()
.registerValue(Integer.class, new NumericComparator())
.registerValue(Long.class, new NumericComparator())
.registerValue(Double.class, new NumericComparator())
.registerValue(Float.class, new NumericComparator())
.registerValue(Short.class, new NumericComparator())
.registerValue(Byte.class, new NumericComparator())
.registerValue(BigDecimal.class, new NumericComparator())
.registerValue(BigInteger.class, new NumericComparator())
.build();
This approach ensures that any numeric value in your maps will use your custom comparator. Your NumericComparator already handles type checking within its equals method, so this is a reliable solution.
Solution 2: Implement a Custom Property Comparator
For more complex scenarios, you can use a CustomPropertyComparator that operates at the property level rather than the value level:
private static final Javers javers = JaversBuilder.javers()
.registerCustomPropertyComparator(new NumericMapPropertyComparator(), Map.class)
.build();
And implement the comparator:
public class NumericMapPropertyComparator implements CustomPropertyComparator<Map, Map> {
private static final double TOLERANCE = 0.01;
@Override
public boolean equals(Map mapA, Map mapB) {
if (mapA == null && mapB == null) return true;
if (mapA == null || mapB == null) return false;
if (mapA.size() != mapB.size()) return false;
for (Map.Entry<String, Object> entry : mapA.entrySet()) {
String key = entry.getKey();
Object valueA = entry.getValue();
Object valueB = mapB.get(key);
if (!valuesEqualWithTolerance(valueA, valueB)) {
return false;
}
}
return true;
}
private boolean valuesEqualWithTolerance(Object a, Object b) {
// Similar logic to your NumericComparator.equals method
// Handle nulls, special floating points, and numeric comparison
}
@Override
public String toString(Map value) {
return value == null ? "null" : value.toString();
}
}
Solution 3: Wrapper Approach
Create wrapper classes for numeric values that need special comparison:
public class NumericWrapper {
private final Object value;
public NumericWrapper(Object value) {
this.value = value;
}
public Object getValue() {
return value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
NumericWrapper that = (NumericWrapper) o;
return compareNumbersWithTolerance(this.value, that.value);
}
// Include your numeric comparison logic here
}
Then modify your map to use these wrappers:
Map<String, Object> map = new HashMap<>();
map.put("a", new NumericWrapper(1.0f));
Map<String, Object> map2 = new HashMap<>();
map2.put("a", new NumericWrapper(1L));
Alternative Approaches and Best Practices
Consider Using a More Specific Map Type
While you mentioned you can’t change to Map<String, Number>, consider if you could use a more specific type that still accommodates your use case. For example:
Map<String, Serializable> map = new HashMap<>();
This would allow you to register a comparator for Serializable which includes all Number types plus other serializable objects.
Implement HashCode Consistently
Remember that if you implement custom equals() behavior, you should also override hashCode() to maintain the contract that equal objects have equal hash codes. Your current implementation needs a hashCode() method.
Handle Edge Cases
Your NumericComparator already handles special cases like NaN and infinity, which is excellent. Make sure to thoroughly test these edge cases to ensure your comparator works correctly in all scenarios.
Performance Considerations
Custom comparators can impact performance, especially for large collections. Consider if your tolerance comparison is truly necessary for your use case, or if standard numeric comparison would suffice in most cases.
Testing and Validating Your Custom Comparator Implementation
Unit Tests for Your Comparator
Create comprehensive tests for your NumericComparator to verify it works correctly:
@Test
public void testNumericComparator() {
NumericComparator comparator = new NumericComparator();
// Test exact equality
assertTrue(comparator.equals(1, 1));
assertTrue(comparator.equals(1.0, 1.0));
// Test tolerance equality
assertTrue(comparator.equals(1.0, 1.005)); // Within 0.01 tolerance
assertFalse(comparator.equals(1.0, 1.02)); // Outside 0.01 tolerance
// Test different numeric types
assertTrue(comparator.equals(1, 1.0)); // int vs double
assertTrue(comparator.equals(1L, 1.0f)); // long vs float
// Test non-numeric types
assertTrue(comparator.equals("test", "test"));
assertFalse(comparator.equals("test", "other"));
// Test null handling
assertTrue(comparator.equals(null, null));
assertFalse(comparator.equals(null, 1));
assertFalse(comparator.equals(1, null));
// Test special floating point values
assertTrue(comparator.equals(Double.NaN, Double.NaN));
assertTrue(comparator.equals(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY));
assertFalse(comparator.equals(Double.NaN, 1.0));
}
Integration Tests with JaVers
Verify that your comparator works correctly within the JaVers framework:
@Test
public void testMapComparisonWithNumericComparator() {
// Using Solution 1 - registering for all Number types
Javers javers = JaversBuilder.javers()
.registerValue(Integer.class, new NumericComparator())
.registerValue(Long.class, new NumericComparator())
.registerValue(Double.class, new NumericComparator())
.registerValue(Float.class, new NumericComparator())
.build();
Map<String, Object> map1 = new HashMap<>();
map1.put("a", 1.0f);
Map<String, Object> map2 = new HashMap<>();
map2.put("a", 1L);
Diff diff = javers.compare(map1, map2);
assertTrue(diff.hasChanges()); // Should be false with proper comparator
// Test with values outside tolerance
map2.put("a", 1.02);
diff = javers.compare(map1, map2);
assertTrue(diff.hasChanges()); // Should be true
}
Sources
-
JaVers CustomValueComparator Documentation — Official documentation on registering and using custom value comparators: https://javers.org/documentation/diff-configuration/
-
JaVers ValueType Implementation — Source code showing how ValueType comparison works: https://github.com/javers/javers/blob/master/javers-core/src/main/java/org/javers/core/metamodel/type/ValueType.java
-
GitHub Issue #569 — Community discussion on Map value comparators not being invoked: https://github.com/javers/javers/issues/569
-
GitHub Issue #925 — Additional report of CustomValueComparator not working as expected: https://github.com/javers/javers/issues/925
-
JaversBuilder Registration Methods — Source code showing how comparators are registered: https://github.com/javers/javers/blob/master/javers-core/src/main/java/org/javers/core/JaversBuilder.java
-
JaVers Domain Configuration — Documentation on type system and value types: https://javers.org/documentation/domain-configuration/
-
JaVers Release Notes — Information about improvements to Map/List support: https://javers.org/release-notes/
Conclusion
The issue with your JaVers CustomValueComparator not being applied to Map<String, Object> values stems from JaVers’ type resolution mechanism. When processing Map values, JaVers identifies specific ValueTypes (like FloatValueType for 1.0f and LongValueType for 1L) rather than treating them as generic Object.class instances. This means your Object.class comparator isn’t invoked.
The most effective solution is to register your NumericComparator for all relevant Number types using the .registerValue() method for each type. This ensures that any numeric value in your maps will use your custom comparison logic with tolerance. Remember to test thoroughly, implement consistent hashCode() methods, and consider performance implications when using custom comparators with large datasets.