Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Nov 26, 2025

  • Understand the issue: Add a simplified GroupJoin overload returning IGrouping<TOuter, TInner>
  • Implement Enumerable.GroupJoin overload in System.Linq
  • Add reference assembly declaration for System.Linq
  • Implement Queryable.GroupJoin overload in System.Linq.Queryable
  • Add reference assembly declaration for System.Linq.Queryable
  • Add tests for System.Linq GroupJoin
  • Add tests for System.Linq.Queryable GroupJoin
  • Build and test the changes
  • Add IEqualityComparer parameter overloads (per reviewer feedback)
  • Add IAsyncEnumerable GroupJoin overloads (per reviewer feedback)
  • Run code review and codeql checker

Summary

This PR implements the approved API proposal from issue #120597 to add a simplified GroupJoin overload.

Approved API (Enumerable and Queryable):

namespace System.Linq;

public static partial class Enumerable
{
    public static IEnumerable<IGrouping<TOuter, TInner>> GroupJoin<TOuter, TInner, TKey>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

public static partial class Queryable
{
    public static IQueryable<IGrouping<TOuter, TInner>> GroupJoin<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<IGrouping<TOuter, TInner>> GroupJoin<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<IGrouping<TOuter, TInner>> GroupJoin<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer,
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

This new overload removes the need to specify a result selector and returns IGrouping<TOuter, TInner> where the Key is the outer element and the Elements are the correlated inner elements.

Original prompt

This section details on the original issue you should resolve

<issue_title>[API Proposal]: Add a GroupJoin overload returning a tuple of joined entries.</issue_title>
<issue_description># Motivation and Background
GroupJoin currently forces users to pass in a result selector which determines the shape of the return type. In most basic usages, users project out to an anonymous type (or a tuple) which wraps the returned TInner and TOuters - this proposal adds an additional overload of GroupJoin that removes to need to specify a result selector, and returns a tuple. We expect this will cover and simplify a large majority of the use-cases.

This proposal mirrors the simplified Zip overload, and also #120596 for Join.

API Proposal

namespace System.Linq;

public static partial class Enumerable
{
    public static IEnumerable<IGrouping<TOuter, TInner>> GroupJoin<TOuter, TInner, TKey>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

public static partial class Queryable
{
    public static IQueryable<IGrouping<TOuter, TInner>> GroupJoin<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<IGrouping<TOuter, TInner>> GroupJoin<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<IGrouping<TOuter, TInner>> Join<TOuter, TInner, TKey>(
        this IAsyncEnumerable<TOuter> outer,
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        IEqualityComparer<TKey>? comparer = null);
}

Usage

var departments = new List<Department>
{
    new() { Id = 1, Name = "HR" },
    new() { Id = 2, Name = "IT" },
    new() { Id = 3, Name = "Finance" }
};

var employees = new List<Employee>
{
    new() { Name = "Alice", DepartmentId = 1 },
    new() { Name = "Bob", DepartmentId = 2 },
    new() { Name = "Charlie", DepartmentId = 2 },
    new() { Name = "David", DepartmentId = 3 }
};

// New proposed simplified usage:
foreach (var (emp, depts) in employees.GroupJoin(departments, e => e.DepartmentId, d => d.Id))
{
    Console.WriteLine($"Employee: {emp.Name}, departments: {string.Join(", ", depts.Select(i => i.Name))}");
}

// Existing usage: explicit projection out to tuple required
foreach (var (emp, depts) in employees.GroupJoin(departments, e => e.DepartmentId, d => d.Id, (e, d) => (e, d)))
{
    Console.WriteLine($"Employee: {emp.Name}, departments: {string.Join(", ", depts.Select(i => i.Name))}");
}

class Department
{
    public int Id { get; set; }
    public string Name { get; set; }
}

class Employee
{
    public string Name { get; set; }
    public int DepartmentId { get; set; }
}
```</issue_description>

<agent_instructions>Please implement the new public APIs exactly as described in the API proposal section of the original post.</agent_instructions>

## Comments on the Issue (you are @copilot in this section)

<comments>
<comment_new><author>@eiriktsarpalis</author><body>
Why wouldn't this return `(TOuter, IEnumerable<TInner>)` following the spirit of your other proposal in https://github.com/dotnet/runtime/issues/120596?</body></comment_new>
<comment_new><author>@roji</author><body>
I think it's problematic for the returned IGrouping to have `(TKey, TOuter)` as its key:

* For one thing, Join returns only TOuter and TInner (no key); the same question has been [raised](https://github.com/dotnet/runtime/issues/120596#issuecomment-3389882707) for the proposed simplified Join syntax (should it also return the keys?), and that proposal doesn't return the key for (I believe) reasons which apply here as well.
* Just like for Join, the current result selectors for the existing GroupJoin (and Join) accept only the outers and inners, and not the key. If one really wants the keys, one can always reference them again on the outer/inner inside the selector; so there's no reason to impose the extra complexity on everyone, and for the few who do need the key, they can get it (at least for cases where the key selector isn't a complex expression that's potentially expensive to evaluate).
* Having the IGrouping have its TKey be a tuple of the GroupJoin's TKey and TOuter is, well, quite confusing and unwieldy, as TKey means two completely different things (it's the correlation...

</details>

- Fixes dotnet/runtime#120587

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
public static System.Linq.IQueryable<TResult> GroupBy<TSource, TKey, TResult>(this System.Linq.IQueryable<TSource> source, System.Linq.Expressions.Expression<System.Func<TSource, TKey>> keySelector, System.Linq.Expressions.Expression<System.Func<TKey, System.Collections.Generic.IEnumerable<TSource>, TResult>> resultSelector, System.Collections.Generic.IEqualityComparer<TKey>? comparer) { throw null; }
public static System.Linq.IQueryable<TResult> GroupBy<TSource, TKey, TElement, TResult>(this System.Linq.IQueryable<TSource> source, System.Linq.Expressions.Expression<System.Func<TSource, TKey>> keySelector, System.Linq.Expressions.Expression<System.Func<TSource, TElement>> elementSelector, System.Linq.Expressions.Expression<System.Func<TKey, System.Collections.Generic.IEnumerable<TElement>, TResult>> resultSelector) { throw null; }
public static System.Linq.IQueryable<TResult> GroupBy<TSource, TKey, TElement, TResult>(this System.Linq.IQueryable<TSource> source, System.Linq.Expressions.Expression<System.Func<TSource, TKey>> keySelector, System.Linq.Expressions.Expression<System.Func<TSource, TElement>> elementSelector, System.Linq.Expressions.Expression<System.Func<TKey, System.Collections.Generic.IEnumerable<TElement>, TResult>> resultSelector, System.Collections.Generic.IEqualityComparer<TKey>? comparer) { throw null; }
public static System.Linq.IQueryable<System.Linq.IGrouping<TOuter, TInner>> GroupJoin<TOuter, TInner, TKey>(this System.Linq.IQueryable<TOuter> outer, System.Collections.Generic.IEnumerable<TInner> inner, System.Linq.Expressions.Expression<System.Func<TOuter, TKey>> outerKeySelector, System.Linq.Expressions.Expression<System.Func<TInner, TKey>> innerKeySelector) { throw null; }
Copy link
Member

Choose a reason for hiding this comment

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

@copilot you missed the optional IEqualityComparer parameter here. Please add it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added the IEqualityComparer<TKey>? comparer parameter overloads for both Enumerable.GroupJoin and Queryable.GroupJoin in commit d8cc66d.

public static System.Collections.Generic.IEnumerable<TResult> GroupBy<TSource, TKey, TResult>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, System.Func<TKey, System.Collections.Generic.IEnumerable<TSource>, TResult> resultSelector, System.Collections.Generic.IEqualityComparer<TKey>? comparer) { throw null; }
public static System.Collections.Generic.IEnumerable<TResult> GroupBy<TSource, TKey, TElement, TResult>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, System.Func<TSource, TElement> elementSelector, System.Func<TKey, System.Collections.Generic.IEnumerable<TElement>, TResult> resultSelector) { throw null; }
public static System.Collections.Generic.IEnumerable<TResult> GroupBy<TSource, TKey, TElement, TResult>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, System.Func<TSource, TElement> elementSelector, System.Func<TKey, System.Collections.Generic.IEnumerable<TElement>, TResult> resultSelector, System.Collections.Generic.IEqualityComparer<TKey>? comparer) { throw null; }
public static System.Collections.Generic.IEnumerable<System.Linq.IGrouping<TOuter, TInner>> GroupJoin<TOuter, TInner, TKey>(this System.Collections.Generic.IEnumerable<TOuter> outer, System.Collections.Generic.IEnumerable<TInner> inner, System.Func<TOuter, TKey> outerKeySelector, System.Func<TInner, TKey> innerKeySelector) { throw null; }
Copy link
Member

Choose a reason for hiding this comment

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

@copilot likewise, this is missing an IEqualityComparer parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The IEqualityComparer parameter was already added in commit d8cc66d - see line 85 in the ref file.

Copy link
Member

@eiriktsarpalis eiriktsarpalis left a comment

Choose a reason for hiding this comment

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

@copilot you forgot to add the corresponding IAsyncEnumerable overloads in the System.Linq.AsyncEnumerable project.

Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Nov 27, 2025

@copilot you forgot to add the corresponding IAsyncEnumerable overloads in the System.Linq.AsyncEnumerable project.

Added the IAsyncEnumerable GroupJoin overloads (both sync and async key selector variants) in commit 05ca46a.

Copilot finished work on behalf of eiriktsarpalis November 27, 2025 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants