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
63 changes: 63 additions & 0 deletions Extensions.Test/EnumerableExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace ktsu.Extensions.Tests;

using System.Collections.Concurrent;
using System.Globalization;

[TestClass]
public class EnumerableExtensionsTests
{
Expand Down Expand Up @@ -393,4 +396,64 @@
// Act & Assert
Assert.ThrowsExactly<InvalidOperationException>(() => items.Join(separator, NullItemHandling.Throw));
}

[TestMethod]
public void JoinWithNullItemHandlingThrowReadsAOneShotSequence()
{
using BlockingCollection<string> items = ["x", "y"];
items.CompleteAdding();

Assert.AreEqual("x,y", items.GetConsumingEnumerable().Join(",", NullItemHandling.Throw));

Check warning on line 406 in Extensions.Test/EnumerableExtensionsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Extensions&issues=AaERCU9xQ1H2ISYeojjm&open=AaERCU9xQ1H2ISYeojjm&pullRequest=157
}

[TestMethod]
public void JoinWithNullItemHandlingThrowEnumeratesTheSourceOnce()
{
int calls = 0;
IEnumerable<string> items = Enumerable.Range(0, 3).Select(i =>
{
calls++;
return i.ToString(CultureInfo.InvariantCulture);
});

Assert.AreEqual("0,1,2", items.Join(",", NullItemHandling.Throw));
Assert.AreEqual(3, calls);
}

[TestMethod]
public void ToStringEnumerableWithNullItemHandlingThrowReadsAOneShotSequence()
{
using BlockingCollection<string> items = ["x", "y"];
items.CompleteAdding();

CollectionAssert.AreEqual(
new List<string?> { "x", "y" },
items.GetConsumingEnumerable().ToStringEnumerable(NullItemHandling.Throw).ToList());

Check warning on line 431 in Extensions.Test/EnumerableExtensionsTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Extensions&issues=AaERCU9xQ1H2ISYeojjo&open=AaERCU9xQ1H2ISYeojjo&pullRequest=157

Check warning on line 431 in Extensions.Test/EnumerableExtensionsTests.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_Extensions&issues=AaERCU9xQ1H2ISYeojjn&open=AaERCU9xQ1H2ISYeojjn&pullRequest=157
}

[TestMethod]
public void ToStringEnumerableWithNullItemHandlingThrowEnumeratesTheSourceOnce()
{
int calls = 0;
IEnumerable<int> items = Enumerable.Range(0, 3).Select(i =>
{
calls++;
return i;
});

List<string?> result = [.. items.ToStringEnumerable(NullItemHandling.Throw)];

Assert.HasCount(3, result);
Assert.AreEqual(3, calls);
}

[TestMethod]
public void ToStringEnumerableWithNullItemHandlingThrowCatchesANullAddedBeforeEnumeration()
{
List<string?> items = ["a", "b"];
IEnumerable<string?> result = items.ToStringEnumerable(NullItemHandling.Throw);
items.Add(null);

Assert.ThrowsExactly<InvalidOperationException>(() => result.ToList());
}
}
39 changes: 20 additions & 19 deletions Extensions/EnumerableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
#pragma warning disable KTSU0004 // Use Ensure.NotNull instead of manual null check
if (items is null)
{
throw new ArgumentNullException(nameof(items), "Items cannot be null.");

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.

Check warning on line 33 in Extensions/EnumerableExtensions.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'Items cannot be null.' 7 times.
}
#pragma warning restore KTSU0004 // Use Ensure.NotNull instead of manual null check

Expand Down Expand Up @@ -191,7 +191,7 @@
/// <param name="items">The enumerable to convert.</param>
/// <param name="nullItemHandling">Specifies how to handle null items.</param>
/// <returns>An enumerable of strings.</returns>
/// <exception cref="InvalidOperationException">Thrown if <paramref name="nullItemHandling"/> is set to <see cref="NullItemHandling.Throw"/> and the enumerable contains null items.</exception>
/// <exception cref="InvalidOperationException">Thrown while enumerating the result if <paramref name="nullItemHandling"/> is set to <see cref="NullItemHandling.Throw"/> and the enumerable contains null items.</exception>
public static IEnumerable<string?> ToStringEnumerable<T>(this IEnumerable<T> items, NullItemHandling nullItemHandling)
{
#pragma warning disable KTSU0004 // Use Ensure.NotNull instead of manual null check
Expand All @@ -201,16 +201,10 @@
}
#pragma warning restore KTSU0004 // Use Ensure.NotNull instead of manual null check

if (nullItemHandling is NullItemHandling.Throw)
{
if (items.AnyNull())
{
throw new InvalidOperationException("The enumerable contains a null item.");
}
}

// The argument check above stays eager; the null check happens during the one enumeration the
// caller makes, so a single-pass source is not consumed early and a null added later is caught.
return items
.Select(item => item?.ToString())
.Select(item => ThrowIfNullItem(item, nullItemHandling)?.ToString())
.Where(item => nullItemHandling is NullItemHandling.Include || item is not null);
}

Expand Down Expand Up @@ -266,14 +260,21 @@
}
#pragma warning restore KTSU0004 // Use Ensure.NotNull instead of manual null check

if (nullItemHandling is NullItemHandling.Throw)
{
if (items.AnyNull())
{
throw new InvalidOperationException("The enumerable contains a null item.");
}
}

return string.Join(separator, items.Where(item => nullItemHandling is NullItemHandling.Include || item is not null).Select(i => i?.ToString()));
return string.Join(separator, items
.Select(item => ThrowIfNullItem(item, nullItemHandling))
.Where(item => nullItemHandling is NullItemHandling.Include || item is not null)
.Select(i => i?.ToString()));
}

/// <summary>
/// Returns <paramref name="item"/> unchanged, or throws if it is null and <paramref name="nullItemHandling"/> is <see cref="NullItemHandling.Throw"/>.
/// </summary>
/// <remarks>
/// Checking each item as it is projected keeps the source to a single enumeration, unlike a separate
/// <see cref="AnyNull{T}(IEnumerable{T})"/> pass, which consumes a one-shot sequence before it can be read.
/// </remarks>
private static T ThrowIfNullItem<T>(T item, NullItemHandling nullItemHandling) =>
item is null && nullItemHandling is NullItemHandling.Throw
? throw new InvalidOperationException("The enumerable contains a null item.")
: item;
}
Loading