Skip to content

Commit 59bcb37

Browse files
Merge pull request #74 from marius-bughiu/feat/contains-value
Add ContainsValue on IntDictionary, LongDictionary, CelerityDictionary
2 parents 1d6534a + ec73ccc commit 59bcb37

5 files changed

Lines changed: 390 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ All notable changes to Celerity are documented here. This project follows [Keep
1212

1313
### Added
1414

15+
- `ContainsValue(TValue? value)` on `IntDictionary<TValue, THasher>`, `LongDictionary<TValue, THasher>`, and `CelerityDictionary<TKey, TValue, THasher>` — BCL-parity `O(n)` linear scan that returns `true` if any entry's value equals `value` under `EqualityComparer<TValue>.Default`, matching `Dictionary<TKey, TValue>.ContainsValue(TValue)`. The scan walks the probe table (skipping `EMPTY_KEY` / `default(TKey)` slots so the empty `default(TValue)` payload there is not mistaken for a real entry) and, when present, the out-of-band zero-key / default-key slot. No allocation on the hot path beyond the cached `EqualityComparer<TValue>.Default` access. Closes #73.
16+
- `ContainsValueTests` — coverage on all three dictionaries: empty-map false return, match in a regular slot, match found only in the zero-key / default-key / null-string-key slot, missing-value false return, default-`TValue` (`0`) lookup on both empty and populated dictionaries (regression check that `EMPTY_KEY` slots are skipped and not reported as `0` matches), `null`-`TValue` lookup on a reference-type value, duplicate values short-circuiting, post-resize correctness across a 100-entry insert from a tiny initial capacity, and post-`Remove` / post-`Clear` invalidation.
1517
- `IEnumerable<T>` constructor on `IntSet`, `IntSet<THasher>`, and `CeleritySet<T, THasher>` (and the `IntSet` convenience subclass), mirroring the dictionary `IEnumerable<KeyValuePair<,>>` ctor shipped in 1.1.2. Throws `ArgumentNullException` on a null source. Unlike the dictionary ctor, duplicate elements (including duplicate `default(T)` / zero entries) are silently deduplicated to match BCL `HashSet<T>(IEnumerable<T>)` semantics — sets do not have a duplicate-key contract. When the source implements `ICollection<T>`, its `Count` is used to size the backing storage; otherwise the caller-supplied `capacity` parameter is used. The out-of-band `default(T)` / zero slot is populated correctly when the source contains it. Closes the last set-side API-parity gap for milestone 1.1.0 and unblocks future `IReadOnlySet<T>` work. Closes #69.
1618
- `SetIEnumerableConstructorTests` — coverage for both sets: null-source and invalid-load-factor validation, empty sources, array / list / non-collection enumerable sources, duplicate-element silent dedupe (including duplicate zero / `null` / `default(T)` entries), zero-element / null-reference-element capture, 500-entry large-source round-trip, source-independence after construction, caller-specified capacity dominating the source count, cross-set copy (`CeleritySet` from an `IntSet` enumeration), and an open-generic `IntSet<Int32WangNaiveHasher>` smoke test.
1719
- `README.md` — new "Choosing a collection" section: a decision table mapping common workloads (`int`-keyed, `long`-keyed, `Guid` / `string` / other-keyed dictionaries, the two set shapes) onto the right Celerity type, plus a short note on picking a hasher and an honest "where Celerity is not the right answer today" list (concurrent access, mutable `IDictionary<,>` consumers, `FrozenDictionary`-style build-once lookups). Sits between the Quick start and Benchmarks sections so a reader who has scanned the API surface can pick the right type without spelunking `docs/api/`. Implements the "Document when to use which collection" item from issue #15.
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
using Celerity.Collections;
2+
using Celerity.Hashing;
3+
4+
namespace Celerity.Tests.Collections;
5+
6+
public class ContainsValueTests
7+
{
8+
// ---------------- IntDictionary ----------------
9+
10+
[Fact]
11+
public void IntDictionary_EmptyMap_ReturnsFalse()
12+
{
13+
var map = new IntDictionary<int>();
14+
Assert.False(map.ContainsValue(0));
15+
Assert.False(map.ContainsValue(42));
16+
}
17+
18+
[Fact]
19+
public void IntDictionary_FindsValueInRegularSlot()
20+
{
21+
var map = new IntDictionary<int> { [1] = 100, [2] = 200, [3] = 300 };
22+
Assert.True(map.ContainsValue(200));
23+
}
24+
25+
[Fact]
26+
public void IntDictionary_ReturnsFalseForMissingValue()
27+
{
28+
var map = new IntDictionary<int> { [1] = 100, [2] = 200 };
29+
Assert.False(map.ContainsValue(999));
30+
}
31+
32+
[Fact]
33+
public void IntDictionary_FindsValueOnlyInZeroKeySlot()
34+
{
35+
var map = new IntDictionary<int>();
36+
map[0] = 777;
37+
Assert.True(map.ContainsValue(777));
38+
}
39+
40+
[Fact]
41+
public void IntDictionary_DefaultValueLookup_ZeroValue()
42+
{
43+
// After insert, value 0 must be reachable via ContainsValue.
44+
var map = new IntDictionary<int> { [5] = 0, [6] = 1 };
45+
Assert.True(map.ContainsValue(0));
46+
47+
// But an empty dictionary must NOT report 0 — the EMPTY_KEY slots
48+
// are filled with default(TValue) and must be skipped by the scan.
49+
var empty = new IntDictionary<int>();
50+
Assert.False(empty.ContainsValue(0));
51+
}
52+
53+
[Fact]
54+
public void IntDictionary_DefaultValueLookup_OnlyZeroKeyHasDefaultValue()
55+
{
56+
// Same trap as above but with the value sitting only in the
57+
// out-of-band zero-key slot.
58+
var map = new IntDictionary<int>();
59+
map[0] = 0;
60+
Assert.True(map.ContainsValue(0));
61+
}
62+
63+
[Fact]
64+
public void IntDictionary_NullValueLookup_ReferenceType()
65+
{
66+
var map = new IntDictionary<string>();
67+
map[1] = "one";
68+
map[2] = null;
69+
Assert.True(map.ContainsValue(null));
70+
71+
var noNulls = new IntDictionary<string> { [1] = "one", [2] = "two" };
72+
Assert.False(noNulls.ContainsValue(null));
73+
}
74+
75+
[Fact]
76+
public void IntDictionary_DuplicateValues_ReturnsTrue()
77+
{
78+
var map = new IntDictionary<int>
79+
{
80+
[1] = 42,
81+
[2] = 42,
82+
[3] = 42,
83+
};
84+
Assert.True(map.ContainsValue(42));
85+
}
86+
87+
[Fact]
88+
public void IntDictionary_SurvivesResize()
89+
{
90+
var map = new IntDictionary<int>(capacity: 4);
91+
for (int i = 1; i <= 100; i++)
92+
map[i] = i * 10;
93+
94+
Assert.True(map.ContainsValue(770));
95+
Assert.False(map.ContainsValue(-1));
96+
}
97+
98+
[Fact]
99+
public void IntDictionary_AfterRemove_ReturnsFalse()
100+
{
101+
var map = new IntDictionary<int> { [1] = 100, [2] = 200 };
102+
map.Remove(1);
103+
Assert.False(map.ContainsValue(100));
104+
Assert.True(map.ContainsValue(200));
105+
}
106+
107+
[Fact]
108+
public void IntDictionary_AfterZeroKeyRemove_ReturnsFalse()
109+
{
110+
var map = new IntDictionary<int>();
111+
map[0] = 555;
112+
map[1] = 100;
113+
map.Remove(0);
114+
Assert.False(map.ContainsValue(555));
115+
Assert.True(map.ContainsValue(100));
116+
}
117+
118+
// ---------------- LongDictionary ----------------
119+
120+
[Fact]
121+
public void LongDictionary_EmptyMap_ReturnsFalse()
122+
{
123+
var map = new LongDictionary<int>();
124+
Assert.False(map.ContainsValue(0));
125+
Assert.False(map.ContainsValue(42));
126+
}
127+
128+
[Fact]
129+
public void LongDictionary_FindsValueInRegularSlot()
130+
{
131+
var map = new LongDictionary<int> { [1L] = 100, [2L] = 200, [3L] = 300 };
132+
Assert.True(map.ContainsValue(200));
133+
}
134+
135+
[Fact]
136+
public void LongDictionary_ReturnsFalseForMissingValue()
137+
{
138+
var map = new LongDictionary<int> { [1L] = 100, [2L] = 200 };
139+
Assert.False(map.ContainsValue(999));
140+
}
141+
142+
[Fact]
143+
public void LongDictionary_FindsValueOnlyInZeroKeySlot()
144+
{
145+
var map = new LongDictionary<int>();
146+
map[0L] = 777;
147+
Assert.True(map.ContainsValue(777));
148+
}
149+
150+
[Fact]
151+
public void LongDictionary_DefaultValueLookup_ZeroValue()
152+
{
153+
var empty = new LongDictionary<int>();
154+
Assert.False(empty.ContainsValue(0));
155+
156+
var map = new LongDictionary<int> { [5L] = 0, [6L] = 1 };
157+
Assert.True(map.ContainsValue(0));
158+
}
159+
160+
[Fact]
161+
public void LongDictionary_NullValueLookup_ReferenceType()
162+
{
163+
var map = new LongDictionary<string>();
164+
map[1L] = "one";
165+
map[2L] = null;
166+
Assert.True(map.ContainsValue(null));
167+
}
168+
169+
[Fact]
170+
public void LongDictionary_SurvivesResize()
171+
{
172+
var map = new LongDictionary<int>(capacity: 4);
173+
for (long i = 1; i <= 100; i++)
174+
map[i] = (int)(i * 10);
175+
176+
Assert.True(map.ContainsValue(770));
177+
Assert.False(map.ContainsValue(-1));
178+
}
179+
180+
// ---------------- CelerityDictionary ----------------
181+
182+
[Fact]
183+
public void CelerityDictionary_EmptyMap_ReturnsFalse()
184+
{
185+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
186+
Assert.False(map.ContainsValue(0));
187+
Assert.False(map.ContainsValue(42));
188+
}
189+
190+
[Fact]
191+
public void CelerityDictionary_FindsValueInRegularSlot()
192+
{
193+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher> { [1] = 100, [2] = 200, [3] = 300 };
194+
Assert.True(map.ContainsValue(200));
195+
}
196+
197+
[Fact]
198+
public void CelerityDictionary_ReturnsFalseForMissingValue()
199+
{
200+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher> { [1] = 100, [2] = 200 };
201+
Assert.False(map.ContainsValue(999));
202+
}
203+
204+
[Fact]
205+
public void CelerityDictionary_FindsValueOnlyInDefaultKeySlot_IntKey()
206+
{
207+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
208+
map[0] = 777;
209+
Assert.True(map.ContainsValue(777));
210+
}
211+
212+
[Fact]
213+
public void CelerityDictionary_FindsValueOnlyInDefaultKeySlot_NullStringKey()
214+
{
215+
var map = new CelerityDictionary<string, int, StringFnV1AHasher>();
216+
map[null!] = 777;
217+
Assert.True(map.ContainsValue(777));
218+
}
219+
220+
[Fact]
221+
public void CelerityDictionary_DefaultValueLookup_ZeroValue()
222+
{
223+
// EMPTY_KEY slots in the probe array are populated with default(TKey)
224+
// and default(TValue). ContainsValue must skip those.
225+
var empty = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
226+
Assert.False(empty.ContainsValue(0));
227+
228+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher> { [5] = 0, [6] = 1 };
229+
Assert.True(map.ContainsValue(0));
230+
}
231+
232+
[Fact]
233+
public void CelerityDictionary_NullValueLookup_ReferenceType()
234+
{
235+
var map = new CelerityDictionary<int, string, Int32WangNaiveHasher>();
236+
map[1] = "one";
237+
map[2] = null;
238+
Assert.True(map.ContainsValue(null));
239+
240+
var noNulls = new CelerityDictionary<int, string, Int32WangNaiveHasher> { [1] = "one", [2] = "two" };
241+
Assert.False(noNulls.ContainsValue(null));
242+
}
243+
244+
[Fact]
245+
public void CelerityDictionary_DuplicateValues_ReturnsTrue()
246+
{
247+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>
248+
{
249+
[1] = 42,
250+
[2] = 42,
251+
[3] = 42,
252+
};
253+
Assert.True(map.ContainsValue(42));
254+
}
255+
256+
[Fact]
257+
public void CelerityDictionary_SurvivesResize()
258+
{
259+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>(capacity: 4);
260+
for (int i = 1; i <= 100; i++)
261+
map[i] = i * 10;
262+
263+
Assert.True(map.ContainsValue(770));
264+
Assert.False(map.ContainsValue(-1));
265+
}
266+
267+
[Fact]
268+
public void CelerityDictionary_AfterDefaultKeyRemove_ReturnsFalse()
269+
{
270+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
271+
map[0] = 555;
272+
map[1] = 100;
273+
map.Remove(0);
274+
Assert.False(map.ContainsValue(555));
275+
Assert.True(map.ContainsValue(100));
276+
}
277+
278+
[Fact]
279+
public void CelerityDictionary_AfterClear_ReturnsFalse()
280+
{
281+
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher> { [1] = 100, [2] = 200 };
282+
map[0] = 300;
283+
map.Clear();
284+
Assert.False(map.ContainsValue(100));
285+
Assert.False(map.ContainsValue(200));
286+
Assert.False(map.ContainsValue(300));
287+
}
288+
}

src/Celerity/Collections/CelerityDictionary.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,42 @@ public bool ContainsKey(TKey key)
186186
return ProbeForKey(key) >= 0;
187187
}
188188

189+
/// <summary>
190+
/// Determines whether the dictionary contains the specified value.
191+
/// </summary>
192+
/// <param name="value">
193+
/// The value to locate. Equality is determined via
194+
/// <see cref="EqualityComparer{T}.Default"/>, matching BCL
195+
/// <see cref="Dictionary{TKey, TValue}.ContainsValue(TValue)"/> semantics.
196+
/// </param>
197+
/// <returns><c>true</c> if a matching value is found; otherwise, <c>false</c>.</returns>
198+
/// <remarks>
199+
/// This operation is <c>O(n)</c> in the dictionary's count: it scans the
200+
/// probe table (skipping empty slots) and, when present, the out-of-band
201+
/// default-key slot.
202+
/// </remarks>
203+
public bool ContainsValue(TValue? value)
204+
{
205+
var valueComparer = EqualityComparer<TValue?>.Default;
206+
207+
if (_hasDefaultKey && valueComparer.Equals(_defaultKeyValue, value))
208+
return true;
209+
210+
var keyComparer = EqualityComparer<TKey>.Default;
211+
TKey?[] keys = _keys;
212+
TValue?[] values = _values;
213+
for (int i = 0; i < keys.Length; i++)
214+
{
215+
if (!keyComparer.Equals(keys[i], default(TKey)) &&
216+
valueComparer.Equals(values[i], value))
217+
{
218+
return true;
219+
}
220+
}
221+
222+
return false;
223+
}
224+
189225
/// <summary>
190226
/// Attempts to get the value associated with the specified key.
191227
/// </summary>

src/Celerity/Collections/IntDictionary.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,38 @@ public bool ContainsKey(int key)
241241
return ProbeForKey(key) >= 0;
242242
}
243243

244+
/// <summary>
245+
/// Determines whether the dictionary contains the specified value.
246+
/// </summary>
247+
/// <param name="value">
248+
/// The value to locate. Equality is determined via
249+
/// <see cref="EqualityComparer{T}.Default"/>, matching BCL
250+
/// <see cref="Dictionary{TKey, TValue}.ContainsValue(TValue)"/> semantics.
251+
/// </param>
252+
/// <returns><c>true</c> if a matching value is found; otherwise, <c>false</c>.</returns>
253+
/// <remarks>
254+
/// This operation is <c>O(n)</c> in the dictionary's count: it scans the
255+
/// probe table (skipping empty slots) and, when present, the out-of-band
256+
/// zero-key slot.
257+
/// </remarks>
258+
public bool ContainsValue(TValue? value)
259+
{
260+
var comparer = EqualityComparer<TValue?>.Default;
261+
262+
if (_hasZeroKey && comparer.Equals(_zeroValue, value))
263+
return true;
264+
265+
int[] keys = _keys;
266+
TValue?[] values = _values;
267+
for (int i = 0; i < keys.Length; i++)
268+
{
269+
if (keys[i] != EMPTY_KEY && comparer.Equals(values[i], value))
270+
return true;
271+
}
272+
273+
return false;
274+
}
275+
244276
/// <summary>
245277
/// Attempts to get the value associated with the specified key.
246278
/// </summary>

0 commit comments

Comments
 (0)