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

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

Metadata

Metadata

Assignees

No one assigned

    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