Skip to content

Commit bbc98f5

Browse files
jderegclaude
andcommitted
DeepEquals: Fix overly strict collection type comparison
- Fixed issue where Collections.unmodifiableCollection() results could not be compared with Lists - Relaxed comparison logic to allow plain Collection vs List comparison as unordered collections - Preserved important Set vs List distinction (still reports type mismatch) - Added comprehensive test coverage for various unmodifiable collection types - Fixes compatibility with json-io which converts unmodifiable collections to different types This change allows DeepEquals to properly compare collections that have been serialized/deserialized to different but compatible implementation classes, while still maintaining proper equality semantics between incompatible collection types like Set and List. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d42d4bf commit bbc98f5

File tree

4 files changed

+187
-41
lines changed

4 files changed

+187
-41
lines changed

changelog.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
### Revision History
22
#### 4.0.1 (unreleased)
3+
> * **FIXED**: `DeepEquals` collection comparison was too strict when comparing different Collection implementations:
4+
> * **Fixed UnmodifiableCollection comparison**: Collections.unmodifiableCollection() results can now be compared with Lists/ArrayLists based on content
5+
> * **Relaxed plain Collection vs List comparison**: Plain Collection implementations (not Set) are now compared as unordered collections with Lists
6+
> * **Preserved Set vs List distinction**: Sets and Lists still correctly report type mismatch due to incompatible equality semantics
7+
> * **Added comprehensive test coverage**: New test ensures various unmodifiable collection types compare correctly with their mutable counterparts
38
> * **FIXED**: `SafeSimpleDateFormat` thread-safety and lenient mode issues:
49
> * **Fixed NPE in setters**: Initialize parent DateFormat fields (calendar, numberFormat) in constructor to prevent NPEs when setters are called
510
> * **Fixed lenient propagation**: Ensure lenient setting is properly applied to both Calendar and SimpleDateFormat in State.build()
@@ -91,6 +96,11 @@
9196
> * **Added special case handling for AtomicInteger and AtomicLong**: Use get() methods directly like AtomicBoolean, avoiding reflective field access for better performance and consistency
9297
> * **Precompiled sensitive data regex patterns**: Avoid regex compilation overhead on every call to looksLikeSensitiveData() by using precompiled Pattern objects
9398
> * **Added Enum handling as simple type**: Use reference equality (==) for enum comparisons and format as EnumType.NAME, avoiding unnecessary reflective field walking
99+
> * **IMPROVED**: `ReflectionUtils` enhancements based on GPT-5 review:
100+
> * **Fixed getMethod interface search**: Now properly searches entire interface hierarchy using BFS traversal to find default methods
101+
> * **Removed pre-emptive SecurityManager checks**: Removed unnecessary SecurityManager checks from call() methods since setAccessible is already wrapped
102+
> * **Documented null-caching requirement**: Added clear documentation to all cache setter methods that custom Map implementations must support null values
103+
> * **Fixed getClassAnnotation javadoc**: Corrected @throws documentation to accurately reflect that only annoClass=null throws, classToCheck=null returns null
94104
#### 4.0.0
95105
> * **FEATURE**: Added `deepCopyContainers()` method to `CollectionUtilities` and `ArrayUtilities`:
96106
> * **Deep Container Copy**: Iteratively copies all arrays and collections to any depth while preserving references to non-container objects ("berries")

src/main/java/com/cedarsoftware/util/DeepEquals.java

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -554,12 +554,7 @@ private static boolean deepEquals(Object a, Object b, Deque<ItemsToCompare> stac
554554
boolean key1Ordered = key1 instanceof List || key1 instanceof Deque;
555555
boolean key2Ordered = key2 instanceof List || key2 instanceof Deque;
556556

557-
if (key1Ordered || key2Ordered) {
558-
if (!(key1Ordered && key2Ordered)) {
559-
// One is ordered, the other is not (or not a collection)
560-
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
561-
return false;
562-
}
557+
if (key1Ordered && key2Ordered) {
563558
// Both are ordered collections - compare with order
564559
if (!decomposeOrderedCollection((Collection<?>) key1, (Collection<?>) key2, stack, itemsToCompare, maxCollectionSize)) {
565560
// Push VALUE_MISMATCH so parent's container-level description (e.g. "collection size mismatch")
@@ -571,6 +566,28 @@ private static boolean deepEquals(Object a, Object b, Deque<ItemsToCompare> stac
571566
return false;
572567
}
573568
continue;
569+
} else if (key1Ordered || key2Ordered) {
570+
// One is ordered (List/Deque), the other is not
571+
// Check if the non-ordered one is still a Collection
572+
if (key1 instanceof Collection && key2 instanceof Collection) {
573+
// Both are collections but different categories
574+
// Check if the non-ordered one is a Set (which has specific equality semantics)
575+
boolean key1IsSet = key1 instanceof Set;
576+
boolean key2IsSet = key2 instanceof Set;
577+
578+
if (key1IsSet || key2IsSet) {
579+
// Set vs List/Deque is a type mismatch - they have incompatible equality semantics
580+
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
581+
return false;
582+
}
583+
// Neither is a Set, so we have a plain Collection vs List/Deque
584+
// This can happen with Collections.unmodifiableCollection() wrapping a List
585+
// Compare as unordered collections (fall through)
586+
} else {
587+
// One is an ordered collection, the other is not a collection at all
588+
stack.addFirst(new ItemsToCompare(key1, key2, itemsToCompare, Difference.TYPE_MISMATCH));
589+
return false;
590+
}
574591
}
575592

576593
// Unordered Collection comparison

src/main/java/com/cedarsoftware/util/ReflectionUtils.java

Lines changed: 97 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,15 @@ private static <T> void swap(AtomicReference<T> ref, T newValue) {
187187
* cache implementation. The provided cache must be thread-safe and should implement
188188
* the Map interface. This method is typically called once during application initialization.
189189
* </p>
190+
* <p>
191+
* <b>Important:</b> The provided cache implementation must support storing null values,
192+
* as the caching logic uses null to represent "not found" results to avoid repeated
193+
* expensive lookups. If using a standard ConcurrentHashMap, consider using
194+
* ConcurrentHashMapNullSafe from java-util or another implementation that supports null values.
195+
* </p>
190196
*
191197
* @param cache The custom cache implementation to use for storing method lookups.
192-
* Must be thread-safe and implement Map interface.
198+
* Must be thread-safe, implement Map interface, and support null values.
193199
*/
194200
public static void setMethodCache(Map<Object, Method> cache) {
195201
swap(METHOD_CACHE, ensureThreadSafe(cache));
@@ -202,9 +208,15 @@ public static void setMethodCache(Map<Object, Method> cache) {
202208
* cache implementation. The provided cache must be thread-safe and should implement
203209
* the Map interface. This method is typically called once during application initialization.
204210
* </p>
211+
* <p>
212+
* <b>Important:</b> The provided cache implementation must support storing null values,
213+
* as the caching logic uses null to represent "not found" results to avoid repeated
214+
* expensive lookups. If using a standard ConcurrentHashMap, consider using
215+
* ConcurrentHashMapNullSafe from java-util or another implementation that supports null values.
216+
* </p>
205217
*
206218
* @param cache The custom cache implementation to use for storing field lookups.
207-
* Must be thread-safe and implement Map interface.
219+
* Must be thread-safe, implement Map interface, and support null values.
208220
*/
209221
public static void setClassFieldsCache(Map<Object, Collection<Field>> cache) {
210222
swap(FIELDS_CACHE, ensureThreadSafe(cache));
@@ -217,9 +229,15 @@ public static void setClassFieldsCache(Map<Object, Collection<Field>> cache) {
217229
* cache implementation. The provided cache must be thread-safe and should implement
218230
* the Map interface. This method is typically called once during application initialization.
219231
* </p>
232+
* <p>
233+
* <b>Important:</b> The provided cache implementation must support storing null values,
234+
* as the caching logic uses null to represent "not found" results to avoid repeated
235+
* expensive lookups. If using a standard ConcurrentHashMap, consider using
236+
* ConcurrentHashMapNullSafe from java-util or another implementation that supports null values.
237+
* </p>
220238
*
221239
* @param cache The custom cache implementation to use for storing field lookups.
222-
* Must be thread-safe and implement Map interface.
240+
* Must be thread-safe, implement Map interface, and support null values.
223241
*/
224242
public static void setFieldCache(Map<Object, Field> cache) {
225243
swap(FIELD_NAME_CACHE, ensureThreadSafe(cache));
@@ -232,9 +250,15 @@ public static void setFieldCache(Map<Object, Field> cache) {
232250
* cache implementation. The provided cache must be thread-safe and should implement
233251
* the Map interface. This method is typically called once during application initialization.
234252
* </p>
253+
* <p>
254+
* <b>Important:</b> The provided cache implementation must support storing null values,
255+
* as the caching logic uses null to represent "not found" results to avoid repeated
256+
* expensive lookups. If using a standard ConcurrentHashMap, consider using
257+
* ConcurrentHashMapNullSafe from java-util or another implementation that supports null values.
258+
* </p>
235259
*
236260
* @param cache The custom cache implementation to use for storing class annotation lookups.
237-
* Must be thread-safe and implement Map interface.
261+
* Must be thread-safe, implement Map interface, and support null values.
238262
*/
239263
public static void setClassAnnotationCache(Map<Object, Annotation> cache) {
240264
swap(CLASS_ANNOTATION_CACHE, ensureThreadSafe(cache));
@@ -247,9 +271,15 @@ public static void setClassAnnotationCache(Map<Object, Annotation> cache) {
247271
* cache implementation. The provided cache must be thread-safe and should implement
248272
* the Map interface. This method is typically called once during application initialization.
249273
* </p>
274+
* <p>
275+
* <b>Important:</b> The provided cache implementation must support storing null values,
276+
* as the caching logic uses null to represent "not found" results to avoid repeated
277+
* expensive lookups. If using a standard ConcurrentHashMap, consider using
278+
* ConcurrentHashMapNullSafe from java-util or another implementation that supports null values.
279+
* </p>
250280
*
251281
* @param cache The custom cache implementation to use for storing method annotation lookups.
252-
* Must be thread-safe and implement Map interface.
282+
* Must be thread-safe, implement Map interface, and support null values.
253283
*/
254284
public static void setMethodAnnotationCache(Map<Object, Annotation> cache) {
255285
swap(METHOD_ANNOTATION_CACHE, ensureThreadSafe(cache));
@@ -262,9 +292,15 @@ public static void setMethodAnnotationCache(Map<Object, Annotation> cache) {
262292
* cache implementation. The provided cache must be thread-safe and should implement
263293
* the Map interface. This method is typically called once during application initialization.
264294
* </p>
295+
* <p>
296+
* <b>Important:</b> The provided cache implementation must support storing null values,
297+
* as the caching logic uses null to represent "not found" results to avoid repeated
298+
* expensive lookups. If using a standard ConcurrentHashMap, consider using
299+
* ConcurrentHashMapNullSafe from java-util or another implementation that supports null values.
300+
* </p>
265301
*
266302
* @param cache The custom cache implementation to use for storing constructor lookups.
267-
* Must be thread-safe and implement Map interface.
303+
* Must be thread-safe, implement Map interface, and support null values.
268304
*/
269305
public static void setConstructorCache(Map<Object, Constructor<?>> cache) {
270306
swap(CONSTRUCTOR_CACHE, ensureThreadSafe(cache));
@@ -277,9 +313,15 @@ public static void setConstructorCache(Map<Object, Constructor<?>> cache) {
277313
* cache implementation. The provided cache must be thread-safe and should implement
278314
* the Map interface. This method is typically called once during application initialization.
279315
* </p>
316+
* <p>
317+
* <b>Important:</b> The provided cache implementation must support storing null values,
318+
* as the caching logic uses null to represent "not found" results to avoid repeated
319+
* expensive lookups. If using a standard ConcurrentHashMap, consider using
320+
* ConcurrentHashMapNullSafe from java-util or another implementation that supports null values.
321+
* </p>
280322
*
281323
* @param cache The custom cache implementation to use for storing constructor lookups.
282-
* Must be thread-safe and implement Map interface.
324+
* Must be thread-safe, implement Map interface, and support null values.
283325
*/
284326
public static void setSortedConstructorsCache(Map<Object, Constructor<?>[]> cache) {
285327
swap(SORTED_CONSTRUCTORS_CACHE, ensureThreadSafe(cache));
@@ -724,11 +766,11 @@ public int hashCode() {
724766
* }
725767
* </pre>
726768
*
727-
* @param classToCheck The class to search for the annotation
769+
* @param classToCheck The class to search for the annotation (may be null)
728770
* @param annoClass The annotation class to search for
729771
* @param <T> The type of the annotation
730-
* @return The annotation if found, null otherwise
731-
* @throws IllegalArgumentException if either classToCheck or annoClass is null
772+
* @return The annotation if found, null if not found or if classToCheck is null
773+
* @throws IllegalArgumentException if annoClass is null
732774
*/
733775
public static <T extends Annotation> T getClassAnnotation(final Class<?> classToCheck, final Class<T> annoClass) {
734776
if (classToCheck == null) {
@@ -1308,15 +1350,6 @@ public static Object call(Object instance, Method method, Object... args) {
13081350
}
13091351

13101352
// Security check: Verify permission for reflection access
1311-
SecurityManager sm = System.getSecurityManager();
1312-
if (sm != null) {
1313-
try {
1314-
sm.checkPermission(new ReflectPermission("suppressAccessChecks"));
1315-
} catch (SecurityException e) {
1316-
throw new SecurityException("Access denied: ReflectionUtils.call() requires suppressAccessChecks permission for method: " + method.getName(), e);
1317-
}
1318-
}
1319-
13201353
try {
13211354
return method.invoke(instance, args);
13221355
} catch (IllegalAccessException | InvocationTargetException e) {
@@ -1376,15 +1409,6 @@ public static Object call(Object instance, Method method, Object... args) {
13761409
*/
13771410
public static Object call(Object instance, String methodName, Object... args) {
13781411
// Security check: Verify permission for reflection access
1379-
SecurityManager sm = System.getSecurityManager();
1380-
if (sm != null) {
1381-
try {
1382-
sm.checkPermission(new ReflectPermission("suppressAccessChecks"));
1383-
} catch (SecurityException e) {
1384-
throw new SecurityException("Access denied: ReflectionUtils.call() requires suppressAccessChecks permission for method: " + methodName, e);
1385-
}
1386-
}
1387-
13881412
Method method = getMethod(instance, methodName, args.length);
13891413
try {
13901414
return method.invoke(instance, args);
@@ -1431,20 +1455,58 @@ public static Method getMethod(Class<?> c, String methodName, Class<?>... types)
14311455

14321456
// Atomically retrieve (or compute) the method
14331457
return METHOD_CACHE.get().computeIfAbsent(key, k -> {
1434-
Method method = null;
1458+
// 1) Walk class chain first
14351459
Class<?> current = c;
1436-
1437-
while (current != null && method == null) {
1460+
while (current != null) {
14381461
try {
1439-
method = current.getDeclaredMethod(methodName, types);
1462+
Method method = current.getDeclaredMethod(methodName, types);
14401463
secureSetAccessible(method);
1441-
} catch (Exception ignored) {
1442-
// Move on up the superclass chain
1464+
return method;
1465+
} catch (NoSuchMethodException ignored) {
1466+
// Not in this class, try superclass
14431467
}
14441468
current = current.getSuperclass();
14451469
}
1470+
1471+
// 2) Walk interface graph (BFS) for default methods
1472+
Set<Class<?>> seen = new HashSet<>();
1473+
Deque<Class<?>> toVisit = new ArrayDeque<>();
1474+
toVisit.add(c);
1475+
1476+
while (!toVisit.isEmpty()) {
1477+
Class<?> x = toVisit.poll();
1478+
if (!seen.add(x)) continue;
1479+
1480+
for (Class<?> iface : x.getInterfaces()) {
1481+
try {
1482+
Method method = iface.getDeclaredMethod(methodName, types);
1483+
// Default/public interface methods don't always need elevation, but safe to call
1484+
secureSetAccessible(method);
1485+
return method;
1486+
} catch (NoSuchMethodException ignored) {
1487+
// Not in this interface
1488+
}
1489+
toVisit.add(iface);
1490+
}
1491+
1492+
// Also check superclass interfaces
1493+
Class<?> superclass = x.getSuperclass();
1494+
if (superclass != null) {
1495+
toVisit.add(superclass);
1496+
}
1497+
}
1498+
1499+
// 3) Fallback to JDK resolution for public methods across interfaces
1500+
try {
1501+
Method method = c.getMethod(methodName, types);
1502+
secureSetAccessible(method);
1503+
return method;
1504+
} catch (NoSuchMethodException ignored) {
1505+
// Method not found anywhere
1506+
}
1507+
14461508
// Will be null if not found
1447-
return method;
1509+
return null;
14481510
});
14491511
}
14501512

0 commit comments

Comments
 (0)