What's wrong
In Throw mode, both Join<T>(items, separator, NullItemHandling) (Extensions/EnumerableExtensions.cs ~L262-270) and ToStringEnumerable<T>(items, NullItemHandling) (~L204-214) first call items.AnyNull() — a full enumeration — and then enumerate items a second time (string.Join(...) in Join, a lazy Select/Where returned to the caller in ToStringEnumerable). Remove and Include enumerate only once.
Failure scenarios (verified with a console probe against the net9.0 build)
-
One-shot sources silently produce empty output
var bc = new BlockingCollection<string> { "x", "y" };
bc.CompleteAdding();
bc.GetConsumingEnumerable().Join(",", NullItemHandling.Throw); // "" — expected "x,y"
The null check consumes the sequence, so the join sees nothing. Same for any IEnumerable that can only be read once (streams, yield iterators over readers, network cursors).
-
Side effects run twice / checked values ≠ joined values
Enumerable.Range(0, 3).Select(countingSelector).Join(",", NullItemHandling.Throw) invokes the selector 6 times instead of 3 (same for ToStringEnumerable). With a non-deterministic selector, the values validated are not the values emitted.
-
ToStringEnumerable(Throw) validates eagerly but enumerates lazily, so it degrades to Remove
var src = new List<string?> { "a", "b" };
var e = src.ToStringEnumerable(NullItemHandling.Throw);
src.Add(null);
e.ToList(); // [a, b] — no exception; the Where clause silently drops the null
Suggested fix
Enumerate once and detect nulls during that pass:
Join: project with something like item => item is null ? (nullItemHandling is NullItemHandling.Throw ? throw new InvalidOperationException(...) : null) : item.ToString() (with the existing Remove/Include filtering), or materialize once before checking.
ToStringEnumerable: implement as an iterator (yield return) that throws when it reaches a null in Throw mode; drop the AnyNull() pre-pass. (Note this makes the throw deferred to enumeration, consistent with LINQ semantics — argument-null checks can stay eager via a wrapper method.)
Acceptance criteria
- Tests using a one-shot enumerable (e.g.
BlockingCollection.GetConsumingEnumerable() or a custom single-pass iterator) and a counting selector show a single enumeration and correct output for Throw.
- A null added to the source after calling
ToStringEnumerable(Throw) but before enumeration throws InvalidOperationException.
What's wrong
In
Throwmode, bothJoin<T>(items, separator, NullItemHandling)(Extensions/EnumerableExtensions.cs~L262-270) andToStringEnumerable<T>(items, NullItemHandling)(~L204-214) first callitems.AnyNull()— a full enumeration — and then enumerateitemsa second time (string.Join(...)inJoin, a lazySelect/Wherereturned to the caller inToStringEnumerable).RemoveandIncludeenumerate only once.Failure scenarios (verified with a console probe against the net9.0 build)
One-shot sources silently produce empty output
The null check consumes the sequence, so the join sees nothing. Same for any
IEnumerablethat can only be read once (streams,yielditerators over readers, network cursors).Side effects run twice / checked values ≠ joined values
Enumerable.Range(0, 3).Select(countingSelector).Join(",", NullItemHandling.Throw)invokes the selector 6 times instead of 3 (same forToStringEnumerable). With a non-deterministic selector, the values validated are not the values emitted.ToStringEnumerable(Throw)validates eagerly but enumerates lazily, so it degrades toRemoveSuggested fix
Enumerate once and detect nulls during that pass:
Join: project with something likeitem => item is null ? (nullItemHandling is NullItemHandling.Throw ? throw new InvalidOperationException(...) : null) : item.ToString()(with the existing Remove/Include filtering), or materialize once before checking.ToStringEnumerable: implement as an iterator (yield return) that throws when it reaches a null inThrowmode; drop theAnyNull()pre-pass. (Note this makes the throw deferred to enumeration, consistent with LINQ semantics — argument-null checks can stay eager via a wrapper method.)Acceptance criteria
BlockingCollection.GetConsumingEnumerable()or a custom single-pass iterator) and a counting selector show a single enumeration and correct output forThrow.ToStringEnumerable(Throw)but before enumeration throwsInvalidOperationException.