Skip to content

Introduce FullJoin() LINQ operator #124787

@roji

Description

@roji

See dotnet/efcore#37633 for the EF/database motivation for this.

Background and motivation

LINQ provides Join (inner join), LeftJoin, and RightJoin, but no full outer join. A full outer join returns all elements from both sequences: matched elements are paired, while unmatched elements from either side appear with default for the missing counterpart. This is one of the most common relational join types and its absence forces users to write verbose, error-prone manual implementations combining LeftJoin with additional Except/Concat logic.

This proposal follows the same design established by LeftJoin/RightJoin (#113) and adds tuple-returning overloads per #120596.

API Proposal

namespace System.Linq;

public static partial class Enumerable
{
    public static IEnumerable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        Func<TOuter?, TInner?, TResult> resultSelector);

    public static IEnumerable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        Func<TOuter?, TInner?, TResult> resultSelector,
        IEqualityComparer<TKey>? comparer);

    public static IEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin<TOuter, TInner, TKey>(
        this IEnumerable<TOuter> outer,
        IEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector);

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

public static partial class Queryable
{
    public static IQueryable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector,
        Expression<Func<TOuter?, TInner?, TResult>> resultSelector);

    public static IQueryable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector,
        Expression<Func<TOuter?, TInner?, TResult>> resultSelector,
        IEqualityComparer<TKey>? comparer);

    public static IQueryable<(TOuter? Outer, TInner? Inner)> FullJoin<TOuter, TInner, TKey>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector);

    public static IQueryable<(TOuter? Outer, TInner? Inner)> FullJoin<TOuter, TInner, TKey>(
        this IQueryable<TOuter> outer,
        IEnumerable<TInner> inner,
        Expression<Func<TOuter, TKey>> outerKeySelector,
        Expression<Func<TInner, TKey>> innerKeySelector,
        IEqualityComparer<TKey>? comparer);
}
namespace System.Linq;

public static partial class AsyncEnumerable
{
    public static IAsyncEnumerable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(
        this IAsyncEnumerable<TOuter> outer,
        IAsyncEnumerable<TInner> inner,
        Func<TOuter, TKey> outerKeySelector,
        Func<TInner, TKey> innerKeySelector,
        Func<TOuter?, TInner?, TResult> resultSelector,
        IEqualityComparer<TKey>? comparer = null);

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

    public static IAsyncEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin<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)> FullJoin<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);
}

API Usage

// Result selector overload
var results = customers.FullJoin(
    orders,
    c => c.CustomerId,
    o => o.CustomerId,
    (customer, order) => new
    {
        CustomerName = customer?.Name ?? "(no customer)",
        OrderId = order?.Id ?? 0
    });

// Tuple overload (no result selector needed)
var pairs = customers.FullJoin(
    orders,
    c => c.CustomerId,
    o => o.CustomerId);

foreach (var (customer, order) in pairs)
{
    if (customer is not null && order is not null)
        Console.WriteLine($"Matched: {customer.Name} - Order #{order.Id}");
    else if (customer is not null)
        Console.WriteLine($"Customer with no orders: {customer.Name}");
    else
        Console.WriteLine($"Orphaned order: #{order!.Id}");
}

Design Decisions

  • Result selector receives TOuter? and TInner? (both nullable) — unlike LeftJoin where only the inner side is nullable, or RightJoin where only the outer side is nullable. In a full join, either side can be default.
  • Tuple overloads return (TOuter? Outer, TInner? Inner) — consistent with the tuple-returning overload pattern proposed in Add Join/LeftJoin/RightJoin overloads without a result selector, returning tuples #120596.
  • Algorithm uses HashSet<Grouping> to track matched inner groupingsGrouping<TKey, TElement> is a sealed class so default reference equality works correctly. This avoids iterating the inner lookup twice.
  • Deferred execution — consistent with all other join operators.

Alternative Designs

Users can manually compose LeftJoin + RightJoin + filtering, but this is verbose, requires two passes over the inner sequence, and is error-prone. A dedicated FullJoin is more discoverable, efficient, and idiomatic.

Risks

  • No source-breaking changesFullJoin is a new method name with no overload conflicts against existing LINQ operators.
  • No binary-breaking changes — purely additive.

Related Issues

Prototype

eiriktsarpalis@26b48d9

Metadata

Metadata

Labels

No fields configured for Feature.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions