Skip to content

Add Join/LeftJoin/RightJoin tuple overloads to LINQ APIs#121998

Merged
eiriktsarpalis merged 7 commits intomainfrom
copilot/add-linq-join-return-tuple
Apr 6, 2026
Merged

Add Join/LeftJoin/RightJoin tuple overloads to LINQ APIs#121998
eiriktsarpalis merged 7 commits intomainfrom
copilot/add-linq-join-return-tuple

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Nov 26, 2025

Implements tuple-returning overloads for Join, LeftJoin, and RightJoin that eliminate the need for a resultSelector lambda when you just want the joined elements as a tuple.

Changes

  • System.Linq.Enumerable: Added Join<TOuter, TInner, TKey>, LeftJoin<TOuter, TInner, TKey>, RightJoin<TOuter, TInner, TKey> returning (TOuter Outer, TInner Inner) tuples (with nullable element for outer joins)
  • System.Linq.Queryable: Added corresponding overloads with Expression<Func<>> key selectors
  • System.Linq.AsyncEnumerable: Added overloads for both sync and async key selector variants
  • All methods use a single overload with an optional IEqualityComparer<TKey>? comparer = null parameter

Example

Before:

foreach (var (s, pair) in keys.Join(dict, k => k, p => p.Value, (outer, inner) => (outer, inner)))
    Console.WriteLine(s + " : " + pair.Key);

After:

foreach (var (s, pair) in keys.Join(dict, k => k, p => p.Value))
    Console.WriteLine(s + " : " + pair.Key);

Fixes #120596

Original prompt

This section details on the original issue you should resolve

<issue_title>[API Proposal]: Linq Join return tuple similar to Zip</issue_title>
<issue_description>### Background and motivation

For simplicity of Join it should just return (TOuter,TInner) instead of the need for resultSelector

API Proposal

namespace System.Linq;

public static class Enumerable
{
    public static IEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IEnumerable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IEnumerable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

public static class Queryable
{
    public static IQueryable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IQueryable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IQueryable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

public static class AsyncEnumerable
{
    public static IAsyncEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer, 
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, CancellationToken, ValueTask<TKey>> outerKeySelector,
        Func<TInner, CancellationToken, ValueTask<TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer,
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer, 
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, CancellationToken, ValueTask<TKey>> outerKeySelector,
        Func<TInner, CancellationToken, ValueTask<TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter Outer, TInner? Inner)> LeftJoin<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer,
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer, 
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, CancellationToken, ValueTask<TKey>> outerKeySelector,
        Func<TInner, CancellationToken, ValueTask<TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);

    public static IAsyncEnumerable<(TOuter? Outer, TInner Inner)> RightJoin<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer,
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

API Usage

var keys = new[] { "x" ,"y" ,"z" };
var dict = new Dictionary<int,string>();

foreach (var (s,pair) in keys.Join(dict,(key) => key,(pair) => pair.Value))
    Console.WriteLine(s + " : " pair.Key);

Alternative Designs

Without this it need to make another lambda just for return tuple

foreach (var (s,pair) in keys.Join(dict,(key) => key,(pair) => pair.Value,(outer,inner) => (outer,inner)))
    Console.WriteLine(s + " : " pair.Key);
```...

</details>

- Fixes dotnet/runtime#120596

<!-- START COPILOT CODING AGENT TIPS -->
---Let Copilot coding agent [set things up for you](https://github.com/dotnet/runtime/issues/new?title=✨+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot) — coding agent works faster and does higher quality work when set up for your repo.

@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-linq
See info in area-owners.md if you want to be subscribed.

@stephentoub
Copy link
Copy Markdown
Member

@copilot please invoke the code-review skill and post your analysis/results as comment on this PR

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Feb 11, 2026

@copilot please invoke the code-review skill and post your analysis/results as comment on this PR

🤖 Copilot Code Review — PR #121998

Holistic Assessment

Motivation: The PR adds tuple-returning overloads for Join, LeftJoin, and RightJoin, following the established precedent of Zip<TFirst, TSecond> which similarly added a convenience tuple overload alongside the resultSelector-based version. This is an approved API (reviewed in the API review video).

Approach: The implementation duplicates the iterator logic rather than delegating to the resultSelector overload with (outer, inner) => (outer, inner). This is consistent with how Zip is implemented — it avoids a delegate allocation and per-element delegate invocation overhead. The code closely mirrors the existing resultSelector iterators, which reduces risk.

Summary: ⚠️ Needs Human Review. The implementation is correct and follows established patterns, but there are test coverage gaps for Queryable and AsyncEnumerable, and a couple of minor issues worth calling out.


Detailed Findings

✅ API surface matches approved design

The ref assemblies match the approved API from the API review. All overloads use a single method with IEqualityComparer<TKey>? comparer = null (not two separate overloads), consistent with reviewer feedback. DynamicDependency attributes correctly reference Join`3, LeftJoin`3, RightJoin`3 (3-arg generic arity). Tuple element names (Outer, Inner) are consistent across all three libraries.

✅ Implementation correctness — Enumerable

The new JoinIterator, LeftJoinIterator, and RightJoinIterator methods are structurally identical to the existing resultSelector variants, just replacing resultSelector(item, elements[i]) with (item, elements[i]). The early-exit optimization for empty arrays (IsEmptyArray) is correctly applied: outer for Join/LeftJoin, inner for RightJoin — matching the existing overloads.

✅ Implementation correctness — AsyncEnumerable

Both sync and async key selector variants are provided. The IsKnownEmpty() optimization is correctly applied. The [EnumeratorCancellation] pattern is correct. The iterator logic matches the existing resultSelector overloads in structure.

✅ Queryable expression tree construction

The Expression.Call pattern correctly constructs the method reference with 5 parameters (including comparer), and passes Expression.Constant(comparer, typeof(IEqualityComparer<TKey>)) even when comparer is null. This is consistent with the existing resultSelector overloads that include a comparer parameter.

⚠️ Missing tests for Queryable and AsyncEnumerable — advisory, follow-up

Tests were only added for System.Linq.Enumerable. No tests were added for the System.Linq.Queryable or System.Linq.AsyncEnumerable tuple overloads. While the Enumerable tests validate the core logic, the Queryable overloads have distinct code paths (expression tree construction) that should be validated. The existing resultSelector overloads in both Queryable and AsyncEnumerable have their own test files — the new tuple overloads should follow the same pattern. This is a follow-up item, not a merge blocker, since the expression tree code is straightforward and follows existing patterns exactly.

⚠️ TupleLeftJoin_WithComparer test assertion is fragile — advisory

In LeftJoinTests.cs, the assertion r.Inner.orderID == 0 checks for a no-match case by comparing against the default struct value. Since TInner? on unconstrained value types doesn't produce Nullable<T>, the unmatched case yields default(AnagramRec) (a zero-initialized struct). Asserting r.Inner.orderID == 0 works but is fragile — it would pass even if the join mistakenly returned a different AnagramRec whose orderID happens to be 0. A more robust assertion would verify the name field is null: r.Outer.name == "Prakash" && r.Inner.name == null. The same consideration applies to TupleRightJoin_WithComparer in RightJoinTests.cs which asserts r.Outer.name == null (this one is actually better since it checks a reference-type field).

💡 Iterator code duplication could be reduced — optional, follow-up

Each of the 3 methods (Join, LeftJoin, RightJoin) × 3 libraries (Enumerable, AsyncEnumerable sync, AsyncEnumerable async) has a full copy of the iterator logic. This is 9 iterator implementations that are nearly identical to the existing resultSelector iterators. This is consistent with how Zip works and is a reasonable trade-off for performance (avoiding delegate overhead), but it means any future bug fix to the join logic must be applied in twice as many places. This is noted for awareness, not a change request.

💡 XML doc comments — minor inconsistency

The Enumerable XML docs say "A specified IEqualityComparer<T> is used to compare keys" but don't mention that null falls back to the default comparer. The existing resultSelector overloads with comparer have similar phrasing, so this is consistent, but the overloads without comparer explicitly state "The default equality comparer, EqualityComparer<T>.Default, is used to hash and compare keys." Since the new overloads combine both behaviors (optional comparer defaults to null), the doc could mention the fallback behavior. This is a very minor nit.

Copilot AI requested a review from stephentoub February 11, 2026 04:25
@roji roji self-assigned this Feb 25, 2026
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Draft Pull Request was automatically closed for 30 days of inactivity. Please let us know if you'd like to reopen it.

@roji roji reopened this Mar 27, 2026
Copilot AI and others added 4 commits April 3, 2026 17:42
… AsyncEnumerable

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
…omparer parameter

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
@roji roji force-pushed the copilot/add-linq-join-return-tuple branch from c5d0ac1 to 95b9985 Compare April 3, 2026 15:43
@github-actions

This comment has been minimized.

@roji roji marked this pull request as ready for review April 3, 2026 17:04
Copilot AI review requested due to automatic review settings April 3, 2026 17:04
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds tuple-returning overloads for LINQ Join, LeftJoin, and RightJoin across Enumerable, Queryable, and AsyncEnumerable, allowing callers to omit a resultSelector when they just want paired results.

Changes:

  • Added tuple-returning overloads for Enumerable.Join/LeftJoin/RightJoin and corresponding Queryable and AsyncEnumerable APIs.
  • Implemented new iterators that yield named tuples (with default/null for unmatched outer-join side).
  • Added/updated reference assemblies and expanded test coverage for the new overloads.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/libraries/System.Linq/src/System/Linq/Join.cs Adds Enumerable.Join overload returning (TOuter Outer, TInner Inner).
src/libraries/System.Linq/src/System/Linq/LeftJoin.cs Adds Enumerable.LeftJoin overload returning (TOuter Outer, TInner? Inner).
src/libraries/System.Linq/src/System/Linq/RightJoin.cs Adds Enumerable.RightJoin overload returning (TOuter? Outer, TInner Inner).
src/libraries/System.Linq/ref/System.Linq.cs Exposes new Enumerable tuple overloads in the public ref surface.
src/libraries/System.Linq/tests/JoinTests.cs Adds tests for tuple Enumerable.Join.
src/libraries/System.Linq/tests/LeftJoinTests.cs Adds tests for tuple Enumerable.LeftJoin.
src/libraries/System.Linq/tests/RightJoinTests.cs Adds tests for tuple Enumerable.RightJoin.
src/libraries/System.Linq.Queryable/src/System/Linq/Queryable.cs Adds tuple overloads for Queryable.Join/LeftJoin/RightJoin building corresponding expression trees.
src/libraries/System.Linq.Queryable/ref/System.Linq.Queryable.cs Exposes new Queryable tuple overloads in the public ref surface.
src/libraries/System.Linq.Queryable/tests/JoinTests.cs Adds tests for tuple Queryable.Join.
src/libraries/System.Linq.Queryable/tests/LeftJoinTests.cs Adds tests for tuple Queryable.LeftJoin.
src/libraries/System.Linq.Queryable/tests/RightJoinTests.cs Adds tests for tuple Queryable.RightJoin.
src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/Join.cs Adds tuple overloads for AsyncEnumerable.Join (sync + async key selector variants).
src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/LeftJoin.cs Adds tuple overloads for AsyncEnumerable.LeftJoin (sync + async key selector variants).
src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/RightJoin.cs Adds tuple overloads for AsyncEnumerable.RightJoin (sync + async key selector variants).
src/libraries/System.Linq.AsyncEnumerable/ref/System.Linq.AsyncEnumerable.cs Exposes new AsyncEnumerable tuple overloads in the public ref surface.
src/libraries/System.Linq.AsyncEnumerable/tests/JoinTests.cs Adds tests for tuple AsyncEnumerable.Join.
src/libraries/System.Linq.AsyncEnumerable/tests/LeftJoinTests.cs Adds tests for tuple AsyncEnumerable.LeftJoin.
src/libraries/System.Linq.AsyncEnumerable/tests/RightJoinTests.cs Adds tests for tuple AsyncEnumerable.RightJoin.

@github-actions

This comment has been minimized.

…gthen tuple tests

- Update XML doc summaries and comparer param docs on all tuple-returning
  Join/LeftJoin/RightJoin overloads (Enumerable and Queryable) to clarify
  that null/omitted comparer falls back to EqualityComparer<TKey>.Default.
- Replace Count/Contains assertions in TupleJoin_Basic, TupleLeftJoin_Basic,
  and TupleRightJoin_Basic with exact sequence equality checks to verify
  ordering and multiplicity semantics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 6, 2026

🤖 Copilot Code Review — PR #121998

Note

This review was generated by Copilot and should be verified by a human reviewer.

Holistic Assessment

Motivation: Well-justified quality-of-life improvement. The Join methods are analogous to Zip, which already has tuple-returning overloads (added in .NET 6). Eliminating the boilerplate (o, i) => (o, i) result selector is a clear ergonomic win, and the linked issue (#120596) has api-approved with concrete user demand.

Approach: The implementation is clean and follows established codebase patterns faithfully. Each tuple overload mirrors its existing resultSelector counterpart with a dedicated iterator (consistent with how Zip handles its tuple overload rather than delegating). Coverage spans Enumerable, Queryable, and AsyncEnumerable consistently.

Summary: ⚠️ Needs Human Review. The code is correct and well-structured, but the implementation deviates from the bartonjs-approved API shape in two ways (optional comparer parameter and AsyncEnumerable overloads not in the approved shape). Both deviations were explicitly requested/approved by area owners (eiriktsarpalis and roji), so a human reviewer should confirm this is sufficient or whether an API review update is needed.


Detailed Findings

⚠️ API Shape — Deviates from approved shape (per maintainer direction)

The approved API shape from bartonjs lists methods without an IEqualityComparer<TKey>? parameter and does not include AsyncEnumerable overloads:

// Approved (bartonjs):
IEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
    this IEnumerable<TOuter> outer, IEnumerable<TInner> inner,
    Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector);

// Implemented:
IEnumerable<(TOuter Outer, TInner Inner)> Join<TOuter, TInner, TKey>(
    this IEnumerable<TOuter> outer, IEnumerable<TInner> inner,
    Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector,
    IEqualityComparer<TKey>? comparer = null);  // <-- extra optional param

The deviations are:

  1. Optional comparer parameter: eiriktsarpalis explicitly requested consolidating two overloads into one with an optional comparer. This is source-compatible with the approved shape (callers can omit the comparer).
  2. AsyncEnumerable overloads: Not in the bartonjs approval but were in the original proposal. Added with 6 new methods (sync + async key selectors for Join/LeftJoin/RightJoin).

Both changes appear to have maintainer consent (roji approved the PR), but a human reviewer should confirm whether a follow-up API review update is needed for the record, especially for the AsyncEnumerable additions.

✅ Implementation Correctness — Follows existing patterns exactly

All iterator implementations correctly mirror the existing resultSelector overloads:

  • Join tuple: Only yields when a matching grouping exists (inner join semantics) — correct.
  • LeftJoin tuple: Yields (item, default) for unmatched outer elements — correct for left outer join.
  • RightJoin tuple: Yields (default, item) for unmatched inner elements — correct for right outer join.

Empty-sequence optimizations are correct:

  • Join: IsEmptyArray(outer) → empty (and inner.IsKnownEmpty() in async) — correct since no outer elements means no results.
  • LeftJoin: IsEmptyArray(outer) only (not inner) — correct since outer elements still produce results with default inner.
  • RightJoin: IsEmptyArray(inner) only (not outer) — correct since inner elements still produce results with default outer.

Null validation uses the same pattern as existing overloads: ThrowHelper in Enumerable, ArgumentNullException.ThrowIfNull in Queryable/AsyncEnumerable.

✅ Queryable Expression Trees — Correct

The Queryable implementations correctly:

  • Use DynamicDependency("Join\3", typeof(Enumerable))(3 type params, matching the new tuple overloads vs. 4 forresultSelector` overloads).
  • Build expression trees with Expression.Constant(comparer, typeof(IEqualityComparer(TKey))) for the optional comparer.
  • Use the standard new Func<...>(MethodName).Method pattern for MethodInfo reflection.

✅ Test Coverage — Adequate

Tests cover the key scenarios for all three libraries:

  • Basic join with matching/non-matching keys
  • Empty outer/inner sequences
  • Custom comparer
  • Null argument validation
  • Equivalence with the existing resultSelector overloads (via Assert.Equal(expected, result))
  • AsyncEnumerable: interface call counts, empty source identity checks, cross-validation against Enumerable

💡 Test Style — Individual [Fact] methods vs [Theory]

The null validation tests (TupleJoin_OuterNull, TupleJoin_InnerNull, TupleJoin_OuterKeySelectorNull, TupleJoin_InnerKeySelectorNull) could be consolidated into a single [Theory] method. However, this matches the existing test style in the same files (which also use individual [Fact] methods for similar null validation tests), so this is consistent with established convention. Not a blocking concern.

💡 XML Documentation — Minor inconsistency across libraries

The Enumerable and Queryable XML docs mention the optional comparer in the <summary>:

If comparer is null or omitted, the default equality comparer is used to compare keys.

The AsyncEnumerable XML docs use a shorter style:

An IEqualityComparer{T} to use to hash and compare keys.

This is consistent with how the existing AsyncEnumerable overloads document the comparer (shorter style), so it follows existing convention. Not a concern.

Generated by Code Review for issue #121998 ·

@eiriktsarpalis eiriktsarpalis enabled auto-merge (squash) April 6, 2026 11:22
@eiriktsarpalis
Copy link
Copy Markdown
Member

/ba-g all tests passing.

@eiriktsarpalis eiriktsarpalis merged commit 2916d73 into main Apr 6, 2026
22 of 25 checks passed
@eiriktsarpalis eiriktsarpalis deleted the copilot/add-linq-join-return-tuple branch April 6, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Join/LeftJoin/RightJoin overloads without a result selector, returning tuples

5 participants