Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions Containers.Test/NullKeyParamNameTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Containers.Tests;

using System.Collections;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// A null key reports the parameter's own name, as <see cref="Dictionary{TKey, TValue}"/> does,
/// not the text of the cast that checks it (ktsu-dev/Containers#146).
/// </summary>
[TestClass]
public class NullKeyParamNameTests
{
private static void AssertParamName(string expected, Action action)
{
ArgumentNullException exception = Assert.ThrowsExactly<ArgumentNullException>(action);
Assert.AreEqual(expected, exception.ParamName);
}

private static void AssertParamName(string expected, Func<object?> func) =>
AssertParamName(expected, () => { _ = func(); });

private static bool KeysContain(ICollection<string> keys) => keys.Contains(null!);

[TestMethod]
public void ContiguousMap_NullKey_ReportsParameterName()
{
ContiguousMap<string, int> map = new() { ["a"] = 1 };

AssertParamName("key", () => map[null!]);
AssertParamName("key", () => map[null!] = 1);
AssertParamName("key", () => map.Add(null!, 1));
AssertParamName("key", () => map.Remove(null!));
AssertParamName("key", () => map.ContainsKey(null!));
AssertParamName("key", () => map.TryGetValue(null!, out _));
AssertParamName("item", () => KeysContain(map.Keys));
}

[TestMethod]
public void InsertionOrderMap_NullKey_ReportsParameterName()
{
InsertionOrderMap<string, int> map = new() { ["a"] = 1 };

AssertParamName("key", () => map[null!]);
AssertParamName("key", () => map[null!] = 1);
AssertParamName("key", () => map.Add(null!, 1));
AssertParamName("key", () => map.Remove(null!));
AssertParamName("key", () => map.ContainsKey(null!));
AssertParamName("key", () => map.TryGetValue(null!, out _));
AssertParamName("item", () => KeysContain(map.Keys));
}

[TestMethod]
public void OrderedMap_NullKey_ReportsParameterName()
{
OrderedMap<string, int> map = new() { ["a"] = 1 };

AssertParamName("key", () => map[null!]);
AssertParamName("key", () => map[null!] = 1);
AssertParamName("key", () => map.Add(null!, 1));
AssertParamName("key", () => map.Remove(null!));
AssertParamName("key", () => map.ContainsKey(null!));
AssertParamName("key", () => map.TryGetValue(null!, out _));
AssertParamName("item", () => map.Contains(new KeyValuePair<string, int>(null!, 1)));
AssertParamName("item", () => map.Remove(new KeyValuePair<string, int>(null!, 1)));
AssertParamName("item", () => KeysContain(map.Keys));
}

[TestMethod]
public void OrderedMap_DictionaryConstructorWithNullKey_ReportsDictionary()
{
NullKeyDictionary source = new();

AssertParamName("dictionary", () => new OrderedMap<string, int>(source));
AssertParamName("dictionary", () => new OrderedMap<string, int>(source, StringComparer.Ordinal));
}

/// <summary>
/// A dictionary that enumerates one pair with a null key, which <see cref="Dictionary{TKey, TValue}"/> can't hold.
/// </summary>
private sealed class NullKeyDictionary : IDictionary<string, int>
{
private readonly List<KeyValuePair<string, int>> pairs = [new(null!, 1)];

public int this[string key] { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. NullKeyDictionary is a test-only stub with one job: to enumerate a pair with a null key into the OrderedMap constructors, because a real Dictionary can't hold one. The constructors only read it through Count and enumeration, so every other member, the indexer setter included, throws NotSupportedException instead of pretending to work. Leaving it as is.


Generated by Claude Code

public ICollection<string> Keys => throw new NotSupportedException();
public ICollection<int> Values => throw new NotSupportedException();
public int Count => pairs.Count;
public bool IsReadOnly => true;
public void Add(string key, int value) => throw new NotSupportedException();
public void Add(KeyValuePair<string, int> item) => throw new NotSupportedException();
public void Clear() => throw new NotSupportedException();
public bool Contains(KeyValuePair<string, int> item) => throw new NotSupportedException();
public bool ContainsKey(string key) => throw new NotSupportedException();
public void CopyTo(KeyValuePair<string, int>[] array, int arrayIndex) => pairs.CopyTo(array, arrayIndex);
public IEnumerator<KeyValuePair<string, int>> GetEnumerator() => pairs.GetEnumerator();
public bool Remove(string key) => throw new NotSupportedException();
public bool Remove(KeyValuePair<string, int> item) => throw new NotSupportedException();
public bool TryGetValue(string key, out int value) => throw new NotSupportedException();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
16 changes: 8 additions & 8 deletions Containers/ContiguousMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,15 @@ public TValue this[TKey key]
{
get
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

return !keyToIndex.TryGetValue(key, out int index)
? throw new KeyNotFoundException($"The key '{key}' was not found in the map.")
: items[index].Value;
}
set
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

if (keyToIndex.TryGetValue(key, out int index))
{
Expand Down Expand Up @@ -344,7 +344,7 @@ public ContiguousMap(IDictionary<TKey, TValue> dictionary, IEqualityComparer<TKe
/// </remarks>
public void Add(TKey key, TValue value)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

if (keyToIndex.ContainsKey(key))
{
Expand Down Expand Up @@ -378,7 +378,7 @@ public void Add(TKey key, TValue value)
/// </remarks>
public bool Remove(TKey key)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

if (!keyToIndex.TryGetValue(key, out int index))
{
Expand Down Expand Up @@ -421,7 +421,7 @@ public bool Remove(TKey key)
/// </remarks>
public bool ContainsKey(TKey key)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);
return keyToIndex.ContainsKey(key);
}

Expand All @@ -437,7 +437,7 @@ public bool ContainsKey(TKey key)
/// </remarks>
public bool TryGetValue(TKey key, out TValue value)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

if (keyToIndex.TryGetValue(key, out int index))
{
Expand Down Expand Up @@ -602,7 +602,7 @@ public void TrimExcess()
/// </remarks>
public ref TValue GetValueRefOrNullRef(TKey key)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

if (keyToIndex.TryGetValue(key, out int index))
{
Expand Down Expand Up @@ -706,7 +706,7 @@ public bool Remove(TKey item) =>

public bool Contains(TKey item)
{
Ensure.NotNull((object?)item);
NullKey.ThrowIfNullItem(item);
return map.ContainsKey(item);
}

Expand Down
14 changes: 7 additions & 7 deletions Containers/InsertionOrderMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,15 @@ public TValue this[TKey key]
{
get
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

return !keyToIndex.TryGetValue(key, out int index)
? throw new KeyNotFoundException($"The key '{key}' was not found in the map.")
: items[index].Value;
}
set
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

if (keyToIndex.TryGetValue(key, out int index))
{
Expand Down Expand Up @@ -242,7 +242,7 @@ public InsertionOrderMap(IDictionary<TKey, TValue> dictionary, IEqualityComparer
/// </remarks>
public void Add(TKey key, TValue value)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

if (keyToIndex.ContainsKey(key))
{
Expand All @@ -269,7 +269,7 @@ public void Add(TKey key, TValue value)
/// </remarks>
public bool Remove(TKey key)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

if (!keyToIndex.TryGetValue(key, out int index))
{
Expand Down Expand Up @@ -302,7 +302,7 @@ public bool Remove(TKey key)
/// </remarks>
public bool ContainsKey(TKey key)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);
return keyToIndex.ContainsKey(key);
}

Expand All @@ -322,7 +322,7 @@ public bool TryGetValue(TKey key,
#endif
out TValue value)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

if (keyToIndex.TryGetValue(key, out int index))
{
Expand Down Expand Up @@ -434,7 +434,7 @@ public bool Remove(TKey item) =>

public bool Contains(TKey item)
{
Ensure.NotNull((object?)item);
NullKey.ThrowIfNullItem(item);
return map.ContainsKey(item);
}

Expand Down
36 changes: 36 additions & 0 deletions Containers/NullKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Containers;

/// <summary>
/// Rejects a null key for a map whose key type is unconstrained, naming the parameter the caller passed it as.
/// </summary>
/// <remarks>
/// <see cref="Ensure.NotNull{T}(T, string)"/> takes its name from <c>[CallerArgumentExpression]</c>, which records
/// the whole argument text. At a call site, the <c>(object?)</c> cast an unconstrained key needs would end up in
/// <see cref="ArgumentException.ParamName"/>. Each check here takes the key through a parameter named for what
/// <see cref="Dictionary{TKey, TValue}"/> reports, so the recorded expression is exactly that name.
/// </remarks>
internal static class NullKey
{
/// <summary>
/// Throws when a key passed as <c>key</c> is null.
/// </summary>
/// <param name="key">The key to check.</param>
/// <exception cref="ArgumentNullException">Thrown, with a ParamName of <c>key</c>, when <paramref name="key"/> is null.</exception>
internal static void ThrowIfNull(object? key) => Ensure.NotNull(key);

/// <summary>
/// Throws when a key passed as <c>item</c>, as to a key collection's <c>Contains</c>, is null.
/// </summary>
/// <param name="item">The key to check.</param>
/// <exception cref="ArgumentNullException">Thrown, with a ParamName of <c>item</c>, when <paramref name="item"/> is null.</exception>
internal static void ThrowIfNullItem(object? item) => Ensure.NotNull(item);

/// <summary>
/// Throws when a key read from a source dictionary passed as <c>dictionary</c> is null.
/// </summary>
/// <param name="dictionary">The key to check.</param>
/// <exception cref="ArgumentNullException">Thrown, with a ParamName of <c>dictionary</c>, when <paramref name="dictionary"/> is null.</exception>
internal static void ThrowIfNullIn(object? dictionary) => Ensure.NotNull(dictionary);
}
22 changes: 11 additions & 11 deletions Containers/OrderedMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public TValue this[TKey key]
{
get
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

int index = BinarySearchByKey(key);
return index < 0
Expand All @@ -109,7 +109,7 @@ public TValue this[TKey key]
}
set
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

int index = BinarySearchByKey(key);
if (index >= 0)
Expand Down Expand Up @@ -246,7 +246,7 @@ private static List<Entry> BuildSorted(IDictionary<TKey, TValue> dictionary, ICo
List<Entry> list = new(dictionary.Count);
foreach (KeyValuePair<TKey, TValue> pair in dictionary)
{
Ensure.NotNull((object?)pair.Key);
NullKey.ThrowIfNullIn(pair.Key);
list.Add(new Entry(pair.Key, pair.Value));
}

Expand Down Expand Up @@ -275,7 +275,7 @@ private static List<Entry> BuildSorted(IDictionary<TKey, TValue> dictionary, ICo
/// <exception cref="ArgumentException">Thrown when key already exists in the map.</exception>
public void Add(TKey key, TValue value)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

int index = BinarySearchByKey(key);
if (index >= 0)
Expand All @@ -296,7 +296,7 @@ public void Add(TKey key, TValue value)
/// <exception cref="ArgumentNullException">Thrown when key is null.</exception>
public bool Remove(TKey key)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

int index = BinarySearchByKey(key);
if (index < 0)
Expand All @@ -317,7 +317,7 @@ public bool Remove(TKey key)
/// <exception cref="ArgumentNullException">Thrown when key is null.</exception>
public bool ContainsKey(TKey key)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

return BinarySearchByKey(key) >= 0;
}
Expand All @@ -335,7 +335,7 @@ public bool TryGetValue(TKey key,
#endif
out TValue value)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

int index = BinarySearchByKey(key);
if (index >= 0)
Expand Down Expand Up @@ -376,7 +376,7 @@ public void Clear()
/// <returns>True if the key-value pair is found; otherwise, false.</returns>
public bool Contains(KeyValuePair<TKey, TValue> item)
{
Ensure.NotNull((object?)item.Key);
NullKey.ThrowIfNullItem(item.Key);

int index = BinarySearchByKey(item.Key);
return (index >= 0)
Expand Down Expand Up @@ -419,7 +419,7 @@ public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
/// <returns>True if the key-value pair was successfully removed; otherwise, false.</returns>
public bool Remove(KeyValuePair<TKey, TValue> item)
{
Ensure.NotNull((object?)item.Key);
NullKey.ThrowIfNullItem(item.Key);

int index = BinarySearchByKey(item.Key);
if (index < 0)
Expand Down Expand Up @@ -464,7 +464,7 @@ public bool Remove(KeyValuePair<TKey, TValue> item)
/// <exception cref="ArgumentNullException">Thrown when key is null.</exception>
private int BinarySearchByKey(TKey key)
{
Ensure.NotNull((object?)key);
NullKey.ThrowIfNull(key);

int left = 0;
int right = items.Count - 1;
Expand Down Expand Up @@ -511,7 +511,7 @@ public bool Remove(TKey item) =>

public bool Contains(TKey item)
{
Ensure.NotNull((object?)item);
NullKey.ThrowIfNullItem(item);
return map.ContainsKey(item);
}

Expand Down
Loading