Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ PublishScripts/
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# and except a Unity project's Packages/, which is source: Unity's package manifest and its
# resolved lock file are both meant to be committed, and a NuGet restore folder never contains
# a file by either name.
!**/[Pp]ackages/manifest.json
!**/[Pp]ackages/packages-lock.json
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
Expand Down Expand Up @@ -651,3 +656,16 @@ Temporary Items

# ImGui.ini files
imgui.ini

# Game engine projects
#
# Godot: the import cache, and the mono/temp bin+obj a C# build writes.
.godot/

# Unity: .meta files are source, not the Visual Studio C++ build artifact that the `*.meta` rule
# further up targets. Unity generates one per asset and it carries the GUID that scenes, prefabs
# and serialized references point at, so ignoring them gives every clone fresh GUIDs and silently
# breaks those references - including for a plug-in whose .dll is itself a build output. This
# negation has to come after that rule to win, and is scoped to the asset tree so the Visual
# Studio artifact stays ignored everywhere else.
!**/[Aa]ssets/**/*.meta
125 changes: 125 additions & 0 deletions DeepClone.Test/DictionaryComparerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.DeepClone.Test;

using System.Collections.Concurrent;
using System.Collections.Immutable;

/// <summary>
/// Tests that cloning a dictionary through <see cref="IDictionary{TKey, TValue}"/> or
/// <see cref="IReadOnlyDictionary{TKey, TValue}"/> keeps the source's key comparer for every
/// dictionary type that exposes one (ktsu-dev/DeepClone#83).
/// </summary>
[TestClass]
public class DictionaryComparerTests
{
private static readonly string[] DescendingKeys = ["d", "c", "b", "a"];

/// <summary>
/// Tests that a concurrent dictionary cloned through IDictionary keeps a case-insensitive comparer
/// and stays concurrent.
/// </summary>
[TestMethod]
public void ConcurrentDictionary_ThroughIDictionary_KeepsComparer()
{
ConcurrentDictionary<string, int> original = new(StringComparer.OrdinalIgnoreCase);
original["Key"] = 1;

IDictionary<string, int> clone = ((IDictionary<string, int>)original).DeepClone();

Assert.IsInstanceOfType<ConcurrentDictionary<string, int>>(clone);
Assert.IsTrue(clone.ContainsKey("key"));
}

/// <summary>
/// Tests that a concurrent dictionary using reference equality, holding two equal but distinct keys,
/// clones without throwing and keeps both entries.
/// </summary>
[TestMethod]
public void ConcurrentDictionary_WithReferenceEquality_ClonesEqualKeys()
{
ConcurrentDictionary<object, int> original = new(ReferenceEqualityComparer.Instance);
original[new string('k', 1)] = 1;
original[new string('k', 1)] = 2;

IDictionary<object, int> clone = ((IDictionary<object, int>)original).DeepClone();

Assert.HasCount(2, clone);
}

/// <summary>
/// Tests that a sorted list cloned through IDictionary stays a sorted list with the source's comparer,
/// so it keeps its order on later inserts.
/// </summary>
[TestMethod]
public void SortedList_ThroughIDictionary_KeepsComparerAndOrder()
{
Comparer<string> descending = Comparer<string>.Create((x, y) => string.CompareOrdinal(y, x));
SortedList<string, int> original = new(descending) { ["a"] = 1, ["c"] = 3, ["b"] = 2 };

IDictionary<string, int> clone = ((IDictionary<string, int>)original).DeepClone();
clone["d"] = 4;

SortedList<string, int> sortedClone = Assert.IsInstanceOfType<SortedList<string, int>>(clone);
Assert.AreSame(descending, sortedClone.Comparer);
CollectionAssert.AreEqual(DescendingKeys, sortedClone.Keys.ToArray());

Check warning on line 65 in DeepClone.Test/DictionaryComparerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_DeepClone&issues=AaENKqyRVuPOYTU5u6Tm&open=AaENKqyRVuPOYTU5u6Tm&pullRequest=97
}

/// <summary>
/// Tests that a sorted list cloned through IReadOnlyDictionary keeps a case-insensitive comparer.
/// </summary>
[TestMethod]
public void SortedList_ThroughIReadOnlyDictionary_KeepsComparer()
{
SortedList<string, int> original = new(StringComparer.OrdinalIgnoreCase) { ["Key"] = 1 };

IReadOnlyDictionary<string, int> clone = ((IReadOnlyDictionary<string, int>)original).DeepClone();

Assert.IsTrue(clone.ContainsKey("key"));
}

/// <summary>
/// Tests that an immutable dictionary cloned through IDictionary keeps its case-insensitive key comparer.
/// </summary>
[TestMethod]
public void ImmutableDictionary_ThroughIDictionary_KeepsComparer()
{
ImmutableDictionary<string, int> original = ImmutableDictionary.Create<string, int>(StringComparer.OrdinalIgnoreCase).Add("Key", 1);

IDictionary<string, int> clone = ((IDictionary<string, int>)original).DeepClone();

Assert.IsTrue(clone.ContainsKey("key"));
}

/// <summary>
/// Tests that an immutable dictionary using reference equality, holding two equal but distinct keys,
/// clones without throwing and keeps both entries.
/// </summary>
[TestMethod]
public void ImmutableDictionary_WithReferenceEquality_ClonesEqualKeys()
{
ImmutableDictionary<object, int> original = ImmutableDictionary.Create<object, int>(ReferenceEqualityComparer.Instance)
.Add(new string('k', 1), 1)
.Add(new string('k', 1), 2);

IReadOnlyDictionary<object, int> clone = ((IReadOnlyDictionary<object, int>)original).DeepClone();

Assert.HasCount(2, clone);
}

/// <summary>
/// Tests that an immutable sorted dictionary cloned through IDictionary stays sorted by the source's comparer.
/// </summary>
[TestMethod]
public void ImmutableSortedDictionary_ThroughIDictionary_KeepsComparerAndOrder()
{
Comparer<string> descending = Comparer<string>.Create((x, y) => string.CompareOrdinal(y, x));
ImmutableSortedDictionary<string, int> original = ImmutableSortedDictionary.Create<string, int>(descending)
.Add("a", 1).Add("c", 3).Add("b", 2);

IDictionary<string, int> clone = ((IDictionary<string, int>)original).DeepClone();
clone["d"] = 4;

CollectionAssert.AreEqual(DescendingKeys, clone.Keys.ToArray());

Check warning on line 123 in DeepClone.Test/DictionaryComparerTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_DeepClone&issues=AaENKqyRVuPOYTU5u6Tn&open=AaENKqyRVuPOYTU5u6Tn&pullRequest=97
}
}
33 changes: 26 additions & 7 deletions DeepClone/DeepCloneContainerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

using System.Collections.Concurrent;
using System.Collections.ObjectModel;
#if NET
using System.Collections.Immutable;
#endif

/// <summary>
/// Extension methods for deep cloning collections of objects.
Expand Down Expand Up @@ -171,7 +174,7 @@
/// </code>
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown if source is null.</exception>
public static IEnumerable<T> DeepClone<T>(this IEnumerable<T> source)

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.

Check warning on line 177 in DeepClone/DeepCloneContainerExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'DeepClone' method overloads should be adjacent.
{
Ensure.NotNull(source);

Expand All @@ -188,9 +191,12 @@
/// otherwise containing the original keys and values.</returns>
/// <remarks>
/// This method returns a new dictionary with cloned key-value pairs. Both keys and values
/// are deep cloned if they implement IDeepCloneable. A <see cref="SortedDictionary{TKey, TValue}"/>
/// is cloned as a sorted dictionary, and the source's key comparer is kept when the runtime type
/// exposes one (<see cref="Dictionary{TKey, TValue}"/> or <see cref="SortedDictionary{TKey, TValue}"/>).
/// are deep cloned if they implement IDeepCloneable. The source's key comparer is kept when the runtime
/// type exposes one: <see cref="Dictionary{TKey, TValue}"/>, <see cref="SortedDictionary{TKey, TValue}"/>,
/// <see cref="SortedList{TKey, TValue}"/>, <see cref="ConcurrentDictionary{TKey, TValue}"/> (.NET 6 and later),
/// and, on .NET, <c>ImmutableDictionary</c> and <c>ImmutableSortedDictionary</c>. A sorted source stays sorted,
/// a sorted list or concurrent dictionary is cloned as the same type, and an immutable source is cloned as the
/// mutable dictionary of the same kind.
///
/// Example usage:
/// <code>
Expand Down Expand Up @@ -309,8 +315,11 @@
/// otherwise containing the original keys and values.</returns>
/// <remarks>
/// This method returns a new read-only dictionary with cloned key-value pairs. As with the
/// <see cref="IDictionary{TKey, TValue}"/> overload, a sorted dictionary stays sorted and the source's
/// key comparer is kept when the runtime type exposes one.
/// <see cref="IDictionary{TKey, TValue}"/> overload, a sorted source stays sorted and the source's key comparer
/// is kept when the runtime type exposes one: <see cref="Dictionary{TKey, TValue}"/>,
/// <see cref="SortedDictionary{TKey, TValue}"/>, <see cref="SortedList{TKey, TValue}"/>,
/// <see cref="ConcurrentDictionary{TKey, TValue}"/> (.NET 6 and later), and, on .NET, <c>ImmutableDictionary</c>
/// and <c>ImmutableSortedDictionary</c>.
///
/// Example usage:
/// <code>
Expand All @@ -334,15 +343,25 @@
/// <typeparam name="TValue">The type of values in the dictionary.</typeparam>
/// <param name="source">The dictionary being cloned, inspected for its runtime type and comparer.</param>
/// <param name="pairs">The key-value pairs of the dictionary being cloned.</param>
/// <returns>A <see cref="SortedDictionary{TKey, TValue}"/> for a sorted source, otherwise a <see cref="Dictionary{TKey, TValue}"/>.
/// Both implement <see cref="IReadOnlyDictionary{TKey, TValue}"/>.</returns>
/// <returns>A <see cref="SortedList{TKey, TValue}"/> or <see cref="ConcurrentDictionary{TKey, TValue}"/> for a source of
/// that type, a <see cref="SortedDictionary{TKey, TValue}"/> for any other sorted source, otherwise a
/// <see cref="Dictionary{TKey, TValue}"/>. All of them implement <see cref="IReadOnlyDictionary{TKey, TValue}"/>.</returns>
private static IDictionary<TKey, TValue> CloneDictionary<TKey, TValue>(object source, IEnumerable<KeyValuePair<TKey, TValue>> pairs)
where TKey : notnull
{
IDictionary<TKey, TValue> clone = source switch
{
SortedDictionary<TKey, TValue> sorted => new SortedDictionary<TKey, TValue>(sorted.Comparer),
Dictionary<TKey, TValue> dictionary => new Dictionary<TKey, TValue>(dictionary.Count, dictionary.Comparer),
SortedList<TKey, TValue> sortedList => new SortedList<TKey, TValue>(sortedList.Count, sortedList.Comparer),
#if NET6_0_OR_GREATER
ConcurrentDictionary<TKey, TValue> concurrent => new ConcurrentDictionary<TKey, TValue>(concurrent.Comparer),
#endif
#if NET
// The immutable types cannot be filled in place, so the clone is the mutable type with the same lookup.
ImmutableSortedDictionary<TKey, TValue> immutableSorted => new SortedDictionary<TKey, TValue>(immutableSorted.KeyComparer),
ImmutableDictionary<TKey, TValue> immutable => new Dictionary<TKey, TValue>(immutable.Count, immutable.KeyComparer),
#endif
_ => new Dictionary<TKey, TValue>(),
};
AddClonedPairs(clone, pairs);
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,8 +601,8 @@ Extension methods for deep cloning various container types.
| Name | Container Type | Description |
|------|---------------|-------------|
| `DeepClone<T>` | `IEnumerable<T?>?` | Creates a deep clone of an enumerable collection |
| `DeepClone<TKey, TValue>` | `IDictionary<TKey, TValue?>?` | Creates a deep clone of a dictionary |
| `DeepClone<TKey, TValue>` | `IReadOnlyDictionary<TKey, TValue?>?` | Creates a deep clone of a read-only dictionary |
| `DeepClone<TKey, TValue>` | `IDictionary<TKey, TValue?>?` | Creates a deep clone of a dictionary, preserving the key comparer where the runtime type exposes one |
| `DeepClone<TKey, TValue>` | `IReadOnlyDictionary<TKey, TValue?>?` | Creates a deep clone of a read-only dictionary, preserving the key comparer where the runtime type exposes one |
| `DeepClone<T>` | `HashSet<T?>?` | Creates a deep clone of a hash set, preserving the comparer |
| `DeepClone<T>` | `SortedSet<T?>?` | Creates a deep clone of a sorted set, preserving the comparer |
| `DeepClone<T>` | `Stack<T?>?` | Creates a deep clone of a stack, preserving the element order |
Expand Down
Loading