Skip to content

Commit ae78497

Browse files
jderegclaude
andcommitted
Fix equals() to use strict key comparison for case-insensitive CompactMaps
A case-insensitive CompactMap's equals() used containsKey()/get() with case-insensitive matching, so compact.equals(hashMap) returned true even when keys differed only in case. This violated the Map.equals() contract which is formally defined as m1.entrySet().equals(m2.entrySet()), where Entry.equals uses Object.equals for keys. Fix: For case-insensitive maps, equals() now builds a temporary HashMap from this map's entries and uses strict Object.equals() key lookup, matching the formal Map contract. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent acb6d0b commit ae78497

2 files changed

Lines changed: 217 additions & 0 deletions

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,6 +1208,14 @@ public boolean equals(Object obj) {
12081208
return false;
12091209
}
12101210

1211+
// For case-insensitive maps, use strict Object.equals() for key comparison
1212+
// to maintain the Map.equals() symmetry contract. Without this,
1213+
// compactMap.equals(hashMap) could return true while
1214+
// hashMap.equals(compactMap) returns false.
1215+
if (isCaseInsensitive()) {
1216+
return equalsStrictKeys(other);
1217+
}
1218+
12111219
if (val instanceof Object[]) { // 2 to compactSize
12121220
for (Entry<?, ?> entry : other.entrySet()) {
12131221
final Object thatKey = entry.getKey();
@@ -1238,6 +1246,31 @@ public boolean equals(Object obj) {
12381246
return entrySet().equals(other.entrySet());
12391247
}
12401248

1249+
/**
1250+
* Compares this map's entries against the other map using strict
1251+
* Object.equals() for key comparison (not case-insensitive areKeysEqual).
1252+
* This ensures the Map.equals() symmetry contract is maintained when
1253+
* comparing a case-insensitive CompactMap with a standard Map.
1254+
*/
1255+
private boolean equalsStrictKeys(Map<?, ?> other) {
1256+
// Build a HashMap from our entries for O(1) strict-equality key lookup.
1257+
// HashMap uses Object.equals()/hashCode() which is case-sensitive for Strings.
1258+
Map<Object, Object> strict = new HashMap<>(size() * 2);
1259+
for (Entry<K, V> e : entrySet()) {
1260+
strict.put(e.getKey(), e.getValue());
1261+
}
1262+
for (Entry<?, ?> entry : other.entrySet()) {
1263+
Object thatKey = entry.getKey();
1264+
if (!strict.containsKey(thatKey)) {
1265+
return false;
1266+
}
1267+
if (!Objects.equals(entry.getValue(), strict.get(thatKey))) {
1268+
return false;
1269+
}
1270+
}
1271+
return true;
1272+
}
1273+
12411274
/**
12421275
* Returns a string representation of this map.
12431276
* <p>
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package com.cedarsoftware.util;
2+
3+
import java.util.HashMap;
4+
import java.util.Map;
5+
6+
import org.junit.jupiter.api.Test;
7+
8+
import static org.junit.jupiter.api.Assertions.assertFalse;
9+
import static org.junit.jupiter.api.Assertions.assertTrue;
10+
11+
/**
12+
* Test for bug: equals() symmetry violation for case-insensitive maps.
13+
*
14+
* Bug: A case-insensitive CompactMap's equals() used containsKey()/get()
15+
* with case-insensitive matching, so compact.equals(hashMap) returned true
16+
* even when keys differed only in case. This violated the Map.equals()
17+
* contract which is formally defined as m1.entrySet().equals(m2.entrySet()),
18+
* where Entry.equals uses Object.equals for keys.
19+
*
20+
* Fix: For case-insensitive maps, equals() now uses strict Object.equals()
21+
* for key comparison via a temporary HashMap lookup, matching the formal
22+
* Map.equals() contract.
23+
*
24+
* Note: HashMap.equals(compactMap) calls compactMap.get()/containsKey()
25+
* which remain case-insensitive (by design). This means full symmetry
26+
* cannot be achieved from CompactMap's side alone — it is inherent to
27+
* case-insensitive maps. The fix ensures CompactMap's own equals() follows
28+
* the Map contract.
29+
*/
30+
class CompactMapEqualsSymmetryTest {
31+
32+
/**
33+
* Case-insensitive CompactMap's equals() must use strict key comparison
34+
* per the Map.equals() contract. compact.equals(hash) should be false
35+
* when keys differ only in case.
36+
*/
37+
@Test
38+
void testCompactEqualsUsesStrictKeysForSingleEntry() {
39+
CompactMap<String, Integer> compact = CompactMap.<String, Integer>builder()
40+
.caseSensitive(false)
41+
.build();
42+
compact.put("id", 1);
43+
44+
Map<String, Integer> hash = new HashMap<>();
45+
hash.put("ID", 1);
46+
47+
// compact.equals(hash) should be false: "id" != "ID" by Object.equals
48+
assertFalse(compact.equals(hash),
49+
"Case-insensitive CompactMap.equals() should use strict key comparison per Map contract");
50+
}
51+
52+
/**
53+
* Same test in compact array state (multiple entries).
54+
*/
55+
@Test
56+
void testCompactEqualsUsesStrictKeysForCompactArray() {
57+
CompactMap<String, Integer> compact = CompactMap.<String, Integer>builder()
58+
.caseSensitive(false)
59+
.build();
60+
compact.put("name", 1);
61+
compact.put("age", 2);
62+
63+
Map<String, Integer> hash = new HashMap<>();
64+
hash.put("Name", 1);
65+
hash.put("Age", 2);
66+
67+
assertFalse(compact.equals(hash),
68+
"Case-insensitive CompactMap.equals() should use strict key comparison in array state");
69+
}
70+
71+
/**
72+
* Same test in Map state (> compactSize entries).
73+
*/
74+
@Test
75+
void testCompactEqualsUsesStrictKeysForMapState() {
76+
CompactMap<String, Integer> compact = CompactMap.<String, Integer>builder()
77+
.caseSensitive(false)
78+
.compactSize(2)
79+
.build();
80+
compact.put("a", 1);
81+
compact.put("b", 2);
82+
compact.put("c", 3);
83+
84+
Map<String, Integer> hash = new HashMap<>();
85+
hash.put("A", 1);
86+
hash.put("B", 2);
87+
hash.put("C", 3);
88+
89+
assertFalse(compact.equals(hash),
90+
"Case-insensitive CompactMap.equals() should use strict key comparison in map state");
91+
}
92+
93+
/**
94+
* When keys match exactly (same case), both directions should return true.
95+
* This is the symmetric case.
96+
*/
97+
@Test
98+
void testEqualsSymmetrySameCaseKeys() {
99+
CompactMap<String, Integer> compact = CompactMap.<String, Integer>builder()
100+
.caseSensitive(false)
101+
.build();
102+
compact.put("id", 1);
103+
104+
Map<String, Integer> hash = new HashMap<>();
105+
hash.put("id", 1);
106+
107+
assertTrue(compact.equals(hash), "Same-case keys should be equal");
108+
assertTrue(hash.equals(compact), "Same-case keys should be equal (reverse)");
109+
}
110+
111+
/**
112+
* Two case-insensitive CompactMaps with same-case keys should be equal.
113+
*/
114+
@Test
115+
void testTwoCaseInsensitiveMapsEqualSameCase() {
116+
CompactMap<String, Integer> map1 = CompactMap.<String, Integer>builder()
117+
.caseSensitive(false)
118+
.build();
119+
map1.put("id", 1);
120+
map1.put("name", 2);
121+
122+
CompactMap<String, Integer> map2 = CompactMap.<String, Integer>builder()
123+
.caseSensitive(false)
124+
.build();
125+
map2.put("id", 1);
126+
map2.put("name", 2);
127+
128+
assertTrue(map1.equals(map2));
129+
assertTrue(map2.equals(map1));
130+
}
131+
132+
/**
133+
* Case-sensitive CompactMap should not be affected — equals works normally.
134+
*/
135+
@Test
136+
void testCaseSensitiveUnaffected() {
137+
CompactMap<String, Integer> compact = CompactMap.<String, Integer>builder()
138+
.caseSensitive(true)
139+
.build();
140+
compact.put("id", 1);
141+
142+
Map<String, Integer> hash = new HashMap<>();
143+
hash.put("id", 1);
144+
145+
assertTrue(compact.equals(hash));
146+
assertTrue(hash.equals(compact));
147+
}
148+
149+
/**
150+
* Case-sensitive CompactMap with different keys correctly returns false.
151+
*/
152+
@Test
153+
void testCaseSensitiveDifferentKeys() {
154+
CompactMap<String, Integer> compact = CompactMap.<String, Integer>builder()
155+
.caseSensitive(true)
156+
.build();
157+
compact.put("id", 1);
158+
159+
Map<String, Integer> hash = new HashMap<>();
160+
hash.put("ID", 1);
161+
162+
assertFalse(compact.equals(hash));
163+
assertFalse(hash.equals(compact));
164+
}
165+
166+
/**
167+
* Same-case keys in compact array state should be equal in both directions.
168+
*/
169+
@Test
170+
void testSameCaseMultipleEntries() {
171+
CompactMap<String, Integer> compact = CompactMap.<String, Integer>builder()
172+
.caseSensitive(false)
173+
.build();
174+
compact.put("name", 1);
175+
compact.put("age", 2);
176+
177+
Map<String, Integer> hash = new HashMap<>();
178+
hash.put("name", 1);
179+
hash.put("age", 2);
180+
181+
assertTrue(compact.equals(hash), "Same-case multi-entry should be equal");
182+
assertTrue(hash.equals(compact), "Same-case multi-entry should be equal (reverse)");
183+
}
184+
}

0 commit comments

Comments
 (0)