Skip to content

Join/ToStringEnumerable with NullItemHandling.Throw enumerate the source twice: one-shot sequences join to "" and late nulls slip through #138

Description

@matt-edmondson

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)

  1. 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).

  2. 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.

  3. 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.

Activity

  1. matt-edmondson commented on Sep 27, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingreadyFully specified; implement as written

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions