From 43003caf3d344b3b167b519220fd8f632313e83d Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Tue, 21 Apr 2026 21:35:09 +0300 Subject: [PATCH 1/4] Implement LINQ FullJoin operators Add FullJoin for Enumerable, Queryable, and AsyncEnumerable using the approved optional comparer API shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ref/System.Linq.AsyncEnumerable.cs | 4 + .../src/System.Linq.AsyncEnumerable.csproj | 1 + .../src/System/Linq/FullJoin.cs | 242 +++++++++ .../tests/FullJoinTests.cs | 175 ++++++ .../System.Linq.AsyncEnumerable.Tests.csproj | 1 + .../ref/System.Linq.Queryable.cs | 2 + .../src/System/Linq/Queryable.cs | 83 +++ .../tests/FullJoinTests.cs | 298 +++++++++++ .../tests/System.Linq.Queryable.Tests.csproj | 1 + src/libraries/System.Linq/ref/System.Linq.cs | 2 + .../System.Linq/src/System.Linq.csproj | 1 + .../System.Linq/src/System/Linq/FullJoin.cs | 149 ++++++ .../System.Linq/tests/FullJoinTests.cs | 498 ++++++++++++++++++ .../tests/System.Linq.Tests.csproj | 1 + 14 files changed, 1458 insertions(+) create mode 100644 src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs create mode 100644 src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs create mode 100644 src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs create mode 100644 src/libraries/System.Linq/src/System/Linq/FullJoin.cs create mode 100644 src/libraries/System.Linq/tests/FullJoinTests.cs diff --git a/src/libraries/System.Linq.AsyncEnumerable/ref/System.Linq.AsyncEnumerable.cs b/src/libraries/System.Linq.AsyncEnumerable/ref/System.Linq.AsyncEnumerable.cs index 881b679a19f3ae..5ab4bf6eb0522b 100644 --- a/src/libraries/System.Linq.AsyncEnumerable/ref/System.Linq.AsyncEnumerable.cs +++ b/src/libraries/System.Linq.AsyncEnumerable/ref/System.Linq.AsyncEnumerable.cs @@ -65,6 +65,10 @@ public static partial class AsyncEnumerable public static System.Threading.Tasks.ValueTask FirstOrDefaultAsync(this System.Collections.Generic.IAsyncEnumerable source, System.Func> predicate, TSource defaultValue, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.ValueTask FirstOrDefaultAsync(this System.Collections.Generic.IAsyncEnumerable source, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.ValueTask FirstOrDefaultAsync(this System.Collections.Generic.IAsyncEnumerable source, TSource defaultValue, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Collections.Generic.IAsyncEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin(this System.Collections.Generic.IAsyncEnumerable outer, System.Collections.Generic.IAsyncEnumerable inner, System.Func> outerKeySelector, System.Func> innerKeySelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } + public static System.Collections.Generic.IAsyncEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin(this System.Collections.Generic.IAsyncEnumerable outer, System.Collections.Generic.IAsyncEnumerable inner, System.Func outerKeySelector, System.Func innerKeySelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } + public static System.Collections.Generic.IAsyncEnumerable FullJoin(this System.Collections.Generic.IAsyncEnumerable outer, System.Collections.Generic.IAsyncEnumerable inner, System.Func> outerKeySelector, System.Func> innerKeySelector, System.Func> resultSelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } + public static System.Collections.Generic.IAsyncEnumerable FullJoin(this System.Collections.Generic.IAsyncEnumerable outer, System.Collections.Generic.IAsyncEnumerable inner, System.Func outerKeySelector, System.Func innerKeySelector, System.Func resultSelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } public static System.Collections.Generic.IAsyncEnumerable> GroupBy(this System.Collections.Generic.IAsyncEnumerable source, System.Func> keySelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } public static System.Collections.Generic.IAsyncEnumerable> GroupBy(this System.Collections.Generic.IAsyncEnumerable source, System.Func keySelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } public static System.Collections.Generic.IAsyncEnumerable> GroupBy(this System.Collections.Generic.IAsyncEnumerable source, System.Func> keySelector, System.Func> elementSelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } diff --git a/src/libraries/System.Linq.AsyncEnumerable/src/System.Linq.AsyncEnumerable.csproj b/src/libraries/System.Linq.AsyncEnumerable/src/System.Linq.AsyncEnumerable.csproj index 62dda55139c872..75da8be90d810e 100644 --- a/src/libraries/System.Linq.AsyncEnumerable/src/System.Linq.AsyncEnumerable.csproj +++ b/src/libraries/System.Linq.AsyncEnumerable/src/System.Linq.AsyncEnumerable.csproj @@ -40,6 +40,7 @@ + diff --git a/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs b/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs new file mode 100644 index 00000000000000..0e7c7ecb228484 --- /dev/null +++ b/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs @@ -0,0 +1,242 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Linq +{ + public static partial class AsyncEnumerable + { + /// Correlates the elements of two async sequences based on matching keys, producing a result for matched and unmatched elements. + /// The first sequence to join. + /// The sequence to join to the first sequence. + /// A function to extract the join key from each element of the first sequence. + /// A function to extract the join key from each element of the second sequence. + /// A function to create a result element from two matching elements. + /// An to use to hash and compare keys. + /// The type of the elements of the first sequence. + /// The type of the elements of the second sequence. + /// The type of the keys returned by the key selector functions. + /// The type of the result elements. + /// An that has elements of type that are obtained by performing a full outer join on two sequences. + /// is . + /// is . + /// is . + /// is . + /// is . + public static IAsyncEnumerable FullJoin( + this IAsyncEnumerable outer, + IAsyncEnumerable inner, + Func outerKeySelector, + Func innerKeySelector, + Func resultSelector, + IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(outer); + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(outerKeySelector); + ArgumentNullException.ThrowIfNull(innerKeySelector); + ArgumentNullException.ThrowIfNull(resultSelector); + + return Impl(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer, default); + + static async IAsyncEnumerable Impl( + IAsyncEnumerable outer, IAsyncEnumerable inner, + Func outerKeySelector, + Func innerKeySelector, + Func resultSelector, + IEqualityComparer? comparer, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + AsyncLookup innerLookup = await AsyncLookup.CreateForJoinAsync(inner, innerKeySelector, comparer, cancellationToken); + + HashSet>? matchedGroupings = innerLookup.Count != 0 + ? new HashSet>() + : null; + + await foreach (TOuter item in outer.WithCancellation(cancellationToken)) + { + Grouping? g = innerLookup.GetGrouping(outerKeySelector(item), create: false); + if (g is null) + { + yield return resultSelector(item, default); + } + else + { + matchedGroupings!.Add(g); + int count = g._count; + TInner[] elements = g._elements; + for (int i = 0; i != count; ++i) + { + yield return resultSelector(item, elements[i]); + } + } + } + + if (matchedGroupings is null || matchedGroupings.Count < innerLookup.Count) + { + Grouping? g = innerLookup._lastGrouping; + if (g is not null) + { + do + { + g = g._next!; + if (matchedGroupings is null || !matchedGroupings.Contains(g)) + { + int count = g._count; + TInner[] elements = g._elements; + for (int i = 0; i != count; ++i) + { + yield return resultSelector(default, elements[i]); + } + } + } + while (g != innerLookup._lastGrouping); + } + } + } + } + + /// Correlates the elements of two async sequences based on matching keys, producing a result for matched and unmatched elements. + /// The first sequence to join. + /// The sequence to join to the first sequence. + /// A function to extract the join key from each element of the first sequence. + /// A function to extract the join key from each element of the second sequence. + /// A function to create a result element from two matching elements. + /// An to use to hash and compare keys. + /// The type of the elements of the first sequence. + /// The type of the elements of the second sequence. + /// The type of the keys returned by the key selector functions. + /// The type of the result elements. + /// An that has elements of type that are obtained by performing a full outer join on two sequences. + /// is . + /// is . + /// is . + /// is . + /// is . + public static IAsyncEnumerable FullJoin( + this IAsyncEnumerable outer, + IAsyncEnumerable inner, + Func> outerKeySelector, + Func> innerKeySelector, + Func> resultSelector, + IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(outer); + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(outerKeySelector); + ArgumentNullException.ThrowIfNull(innerKeySelector); + ArgumentNullException.ThrowIfNull(resultSelector); + + return Impl(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer, default); + + static async IAsyncEnumerable Impl( + IAsyncEnumerable outer, + IAsyncEnumerable inner, + Func> outerKeySelector, + Func> innerKeySelector, + Func> resultSelector, + IEqualityComparer? comparer, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + AsyncLookup innerLookup = await AsyncLookup.CreateForJoinAsync(inner, innerKeySelector, comparer, cancellationToken); + + HashSet>? matchedGroupings = innerLookup.Count != 0 + ? new HashSet>() + : null; + + await foreach (TOuter item in outer.WithCancellation(cancellationToken)) + { + Grouping? g = innerLookup.GetGrouping(await outerKeySelector(item, cancellationToken), create: false); + if (g is null) + { + yield return await resultSelector(item, default, cancellationToken); + } + else + { + matchedGroupings!.Add(g); + int count = g._count; + TInner[] elements = g._elements; + for (int i = 0; i != count; ++i) + { + yield return await resultSelector(item, elements[i], cancellationToken); + } + } + } + + if (matchedGroupings is null || matchedGroupings.Count < innerLookup.Count) + { + Grouping? g = innerLookup._lastGrouping; + if (g is not null) + { + do + { + g = g._next!; + if (matchedGroupings is null || !matchedGroupings.Contains(g)) + { + int count = g._count; + TInner[] elements = g._elements; + for (int i = 0; i != count; ++i) + { + yield return await resultSelector(default, elements[i], cancellationToken); + } + } + } + while (g != innerLookup._lastGrouping); + } + } + } + } + + /// Correlates the elements of two async sequences based on matching keys, producing a tuple for matched and unmatched elements. + /// The first sequence to join. + /// The sequence to join to the first sequence. + /// A function to extract the join key from each element of the first sequence. + /// A function to extract the join key from each element of the second sequence. + /// An to use to hash and compare keys. + /// The type of the elements of the first sequence. + /// The type of the elements of the second sequence. + /// The type of the keys returned by the key selector functions. + /// An that has elements of type (TOuter?, TInner?) that are obtained by performing a full outer join on two sequences. + /// is . + /// is . + /// is . + /// is . + public static IAsyncEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin( + this IAsyncEnumerable outer, + IAsyncEnumerable inner, + Func outerKeySelector, + Func innerKeySelector, + IEqualityComparer? comparer = null) + { + return FullJoin(outer, inner, outerKeySelector, innerKeySelector, static (outer, inner) => (outer, inner), comparer); + } + + /// Correlates the elements of two async sequences based on matching keys, producing a tuple for matched and unmatched elements. + /// The first sequence to join. + /// The sequence to join to the first sequence. + /// A function to extract the join key from each element of the first sequence. + /// A function to extract the join key from each element of the second sequence. + /// An to use to hash and compare keys. + /// The type of the elements of the first sequence. + /// The type of the elements of the second sequence. + /// The type of the keys returned by the key selector functions. + /// An that has elements of type (TOuter?, TInner?) that are obtained by performing a full outer join on two sequences. + /// is . + /// is . + /// is . + /// is . + public static IAsyncEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin( + this IAsyncEnumerable outer, + IAsyncEnumerable inner, + Func> outerKeySelector, + Func> innerKeySelector, + IEqualityComparer? comparer = null) + { + return FullJoin(outer, inner, outerKeySelector, innerKeySelector, static (outer, inner, ct) => new ValueTask<(TOuter?, TInner?)>((outer, inner)), comparer); + } + } +} diff --git a/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs b/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs new file mode 100644 index 00000000000000..e858d6530997a9 --- /dev/null +++ b/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs @@ -0,0 +1,175 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace System.Linq.Tests +{ + public class FullJoinTests : AsyncEnumerableTests + { + [Fact] + public void InvalidInputs_Throws() + { + AssertExtensions.Throws("outer", () => AsyncEnumerable.FullJoin((IAsyncEnumerable)null, AsyncEnumerable.Empty(), outer => outer, inner => inner, (outer, inner) => outer + inner)); + AssertExtensions.Throws("inner", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty(), (IAsyncEnumerable)null, outer => outer, inner => inner, (outer, inner) => outer + inner)); + AssertExtensions.Throws("outerKeySelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty(), AsyncEnumerable.Empty(), (Func)null, inner => inner, (outer, inner) => outer + inner)); + AssertExtensions.Throws("innerKeySelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty(), AsyncEnumerable.Empty(), outer => outer, (Func)null, (outer, inner) => outer + inner)); + AssertExtensions.Throws("resultSelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty(), AsyncEnumerable.Empty(), outer => outer, inner => inner, (Func)null)); + + AssertExtensions.Throws("outer", () => AsyncEnumerable.FullJoin((IAsyncEnumerable)null, AsyncEnumerable.Empty(), async (outer, ct) => outer, async (inner, ct) => inner, async (outer, inner, ct) => outer + inner)); + AssertExtensions.Throws("inner", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty(), (IAsyncEnumerable)null, async (outer, ct) => outer, async (inner, ct) => inner, async (outer, inner, ct) => outer + inner)); + AssertExtensions.Throws("outerKeySelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty(), AsyncEnumerable.Empty(), (Func>)null, async (inner, ct) => inner, async (outer, inner, ct) => outer + inner)); + AssertExtensions.Throws("innerKeySelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty(), AsyncEnumerable.Empty(), async (outer, ct) => outer, (Func>)null, async (outer, inner, ct) => outer + inner)); + AssertExtensions.Throws("resultSelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty(), AsyncEnumerable.Empty(), async (outer, ct) => outer, async (inner, ct) => inner, (Func>)null)); + } + + [Fact] + public async Task BothEmpty_ProducesEmpty() + { + IAsyncEnumerable empty = AsyncEnumerable.Empty(); + + Assert.Empty(await empty.FullJoin(empty, s => s, s => s, (s1, s2) => s1).ToListAsync()); + Assert.Empty(await empty.FullJoin(empty, async (s, ct) => s, async (s, ct) => s, async (s1, s2, ct) => s1).ToListAsync()); + } + +#if NET + [Fact] + public async Task VariousValues_MatchesEnumerable_String() + { + Random rand = new(42); + foreach (int length in new[] { 0, 1, 2, 1000 }) + { + string[] values = new string[length]; + FillRandom(rand, values); + + foreach (IAsyncEnumerable source in CreateSources(values)) + { + await AssertEqual( + values.FullJoin(values, s => s.Length > 0 ? s[0] : ' ', s => s.Length > 1 ? s[1] : ' ', (outer, inner) => outer + inner), + source.FullJoin(source, s => s.Length > 0 ? s[0] : ' ', s => s.Length > 1 ? s[1] : ' ', (outer, inner) => outer + inner)); + + await AssertEqual( + values.FullJoin(values, s => s.Length > 0 ? s[0] : ' ', s => s.Length > 1 ? s[1] : ' ', (outer, inner) => outer + inner), + source.FullJoin(source, async (s, ct) => s.Length > 0 ? s[0] : ' ', async (s, ct) => s.Length > 1 ? s[1] : ' ', async (outer, inner, ct) => outer + inner)); + } + } + } +#endif + + [Fact] + public async Task Cancellation_Cancels() + { + IAsyncEnumerable source = CreateSource(2, 4, 8, 16); + + await Assert.ThrowsAsync(async () => + { + CancellationTokenSource cts = new(); + await ConsumeAsync(source.FullJoin(source, outer => + { + cts.Cancel(); + return outer; + }, + inner => + { + return inner; + }, + (outer, inner) => + { + return outer + inner; + }).WithCancellation(cts.Token)); + }); + + await Assert.ThrowsAsync(async () => + { + CancellationTokenSource cts = new(); + await ConsumeAsync(source.FullJoin(source, + async (outer, ct) => + { + Assert.Equal(cts.Token, ct); + await Task.Yield(); + cts.Cancel(); + return outer; + }, + async (inner, ct) => + { + return inner; + }, + async (outer, inner, ct) => + { + return outer + inner; + }).WithCancellation(cts.Token)); + }); + + await Assert.ThrowsAsync(async () => + { + CancellationTokenSource cts = new(); + await ConsumeAsync(source.FullJoin(source, + async (outer, ct) => + { + return outer; + }, + async (inner, ct) => + { + Assert.Equal(cts.Token, ct); + await Task.Yield(); + cts.Cancel(); + return inner; + }, + async (outer, inner, ct) => + { + return outer + inner; + }).WithCancellation(cts.Token)); + }); + + await Assert.ThrowsAsync(async () => + { + CancellationTokenSource cts = new(); + await ConsumeAsync(source.FullJoin(source, + async (outer, ct) => + { + return outer; + }, + async (inner, ct) => + { + return inner; + }, + async (outer, inner, ct) => + { + Assert.Equal(cts.Token, ct); + await Task.Yield(); + cts.Cancel(); + return outer + inner; + }).WithCancellation(cts.Token)); + }); + } + + [Fact] + public async Task InterfaceCalls_ExpectedCounts() + { + TrackingAsyncEnumerable outer, inner; + + outer = CreateSource(2, 4, 8, 16).Track(); + inner = CreateSource(1, 2, 3, 4).Track(); + await ConsumeAsync(outer.FullJoin(inner, outer => outer, inner => inner, (outer, inner) => outer + inner)); + Assert.Equal(5, outer.MoveNextAsyncCount); + Assert.Equal(4, outer.CurrentCount); + Assert.Equal(1, outer.DisposeAsyncCount); + Assert.Equal(5, inner.MoveNextAsyncCount); + Assert.Equal(4, inner.CurrentCount); + Assert.Equal(1, inner.DisposeAsyncCount); + + outer = CreateSource(2, 4, 8, 16).Track(); + inner = CreateSource(1, 2, 3, 4).Track(); + await ConsumeAsync(outer.FullJoin(inner, async (outer, ct) => outer, async (inner, ct) => inner, async (outer, inner, ct) => outer + inner)); + Assert.Equal(5, outer.MoveNextAsyncCount); + Assert.Equal(4, outer.CurrentCount); + Assert.Equal(1, outer.DisposeAsyncCount); + Assert.Equal(5, inner.MoveNextAsyncCount); + Assert.Equal(4, inner.CurrentCount); + Assert.Equal(1, inner.DisposeAsyncCount); + } + } +} diff --git a/src/libraries/System.Linq.AsyncEnumerable/tests/System.Linq.AsyncEnumerable.Tests.csproj b/src/libraries/System.Linq.AsyncEnumerable/tests/System.Linq.AsyncEnumerable.Tests.csproj index db26a34f5d4dd1..184897f9038f40 100644 --- a/src/libraries/System.Linq.AsyncEnumerable/tests/System.Linq.AsyncEnumerable.Tests.csproj +++ b/src/libraries/System.Linq.AsyncEnumerable/tests/System.Linq.AsyncEnumerable.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/src/libraries/System.Linq.Queryable/ref/System.Linq.Queryable.cs b/src/libraries/System.Linq.Queryable/ref/System.Linq.Queryable.cs index 2d63b858f0bd26..ad760708a59573 100644 --- a/src/libraries/System.Linq.Queryable/ref/System.Linq.Queryable.cs +++ b/src/libraries/System.Linq.Queryable/ref/System.Linq.Queryable.cs @@ -100,6 +100,8 @@ public static partial class Queryable public static TSource FirstOrDefault(this System.Linq.IQueryable source, TSource defaultValue) { throw null; } public static TSource First(this System.Linq.IQueryable source) { throw null; } public static TSource First(this System.Linq.IQueryable source, System.Linq.Expressions.Expression> predicate) { throw null; } + public static System.Linq.IQueryable<(TOuter? Outer, TInner? Inner)> FullJoin(this System.Linq.IQueryable outer, System.Collections.Generic.IEnumerable inner, System.Linq.Expressions.Expression> outerKeySelector, System.Linq.Expressions.Expression> innerKeySelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } + public static System.Linq.IQueryable FullJoin(this System.Linq.IQueryable outer, System.Collections.Generic.IEnumerable inner, System.Linq.Expressions.Expression> outerKeySelector, System.Linq.Expressions.Expression> innerKeySelector, System.Linq.Expressions.Expression> resultSelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } public static System.Linq.IQueryable> GroupBy(this System.Linq.IQueryable source, System.Linq.Expressions.Expression> keySelector) { throw null; } public static System.Linq.IQueryable> GroupBy(this System.Linq.IQueryable source, System.Linq.Expressions.Expression> keySelector, System.Collections.Generic.IEqualityComparer? comparer) { throw null; } public static System.Linq.IQueryable> GroupBy(this System.Linq.IQueryable source, System.Linq.Expressions.Expression> keySelector, System.Linq.Expressions.Expression> elementSelector) { throw null; } diff --git a/src/libraries/System.Linq.Queryable/src/System/Linq/Queryable.cs b/src/libraries/System.Linq.Queryable/src/System/Linq/Queryable.cs index 1290b8f6819e23..3f4b9db8bd0691 100644 --- a/src/libraries/System.Linq.Queryable/src/System/Linq/Queryable.cs +++ b/src/libraries/System.Linq.Queryable/src/System/Linq/Queryable.cs @@ -1094,6 +1094,89 @@ public static IQueryable RightJoin(this outer.Expression, GetSourceExpression(inner), Expression.Quote(outerKeySelector), Expression.Quote(innerKeySelector), Expression.Quote(resultSelector), Expression.Constant(comparer, typeof(IEqualityComparer)))); } + /// + /// Correlates the elements of two sequences based on matching keys, producing a result for each element + /// in either sequence that has a match as well as for elements that have no match. + /// A default or specified equality comparer is used to compare keys. + /// + /// The first sequence to join. + /// The sequence to join to the first sequence. + /// A function to extract the join key from each element of the first sequence. + /// A function to extract the join key from each element of the second sequence. + /// A function to create a result element from two matching elements. + /// An to hash and compare keys, or to use the default equality comparer. + /// The type of the elements of the first sequence. + /// The type of the elements of the second sequence. + /// The type of the keys returned by the key selector functions. + /// The type of the result elements. + /// An that has elements of type that are obtained by performing a full outer join on two sequences. + /// or or or or is . + /// + /// If is , the default equality comparer, , is used to hash and compare keys. + /// + /// This method has at least one parameter of type whose type argument is one of the types. + /// For these parameters, you can pass in a lambda expression and it will be compiled to an . + /// + /// + /// The method + /// generates a that represents calling + /// + /// itself as a constructed generic method. + /// It then passes the to the method of the represented by the property of the parameter. + /// + /// + /// The query behavior that occurs as a result of executing an expression tree that represents calling + /// + /// depends on the implementation of the type of the parameter. + /// The expected behavior is that of a full outer join. + /// + /// + [DynamicDependency("FullJoin`4", typeof(Enumerable))] + public static IQueryable FullJoin(this IQueryable outer, IEnumerable inner, Expression> outerKeySelector, Expression> innerKeySelector, Expression> resultSelector, IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(outer); + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(outerKeySelector); + ArgumentNullException.ThrowIfNull(innerKeySelector); + ArgumentNullException.ThrowIfNull(resultSelector); + + return outer.Provider.CreateQuery( + Expression.Call( + null, + new Func, IEnumerable, Expression>, Expression>, Expression>, IEqualityComparer, IQueryable>(FullJoin).Method, + outer.Expression, GetSourceExpression(inner), Expression.Quote(outerKeySelector), Expression.Quote(innerKeySelector), Expression.Quote(resultSelector), Expression.Constant(comparer, typeof(IEqualityComparer)))); + } + + /// + /// Correlates the elements of two sequences based on matching keys, producing a tuple for each element + /// in either sequence that has a match as well as for elements that have no match. + /// A default or specified equality comparer is used to compare keys. + /// + /// The first sequence to join. + /// The sequence to join to the first sequence. + /// A function to extract the join key from each element of the first sequence. + /// A function to extract the join key from each element of the second sequence. + /// An to hash and compare keys, or to use the default equality comparer. + /// The type of the elements of the first sequence. + /// The type of the elements of the second sequence. + /// The type of the keys returned by the key selector functions. + /// An that has elements of type (TOuter?, TInner?) that are obtained by performing a full outer join on two sequences. + /// or or or is . + [DynamicDependency("FullJoin`3", typeof(Enumerable))] + public static IQueryable<(TOuter? Outer, TInner? Inner)> FullJoin(this IQueryable outer, IEnumerable inner, Expression> outerKeySelector, Expression> innerKeySelector, IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(outer); + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(outerKeySelector); + ArgumentNullException.ThrowIfNull(innerKeySelector); + + return outer.Provider.CreateQuery<(TOuter? Outer, TInner? Inner)>( + Expression.Call( + null, + new Func, IEnumerable, Expression>, Expression>, IEqualityComparer, IQueryable<(TOuter? Outer, TInner? Inner)>>(FullJoin).Method, + outer.Expression, GetSourceExpression(inner), Expression.Quote(outerKeySelector), Expression.Quote(innerKeySelector), Expression.Constant(comparer, typeof(IEqualityComparer)))); + } + [DynamicDependency("ThenBy`2", typeof(Enumerable))] public static IOrderedQueryable ThenBy(this IOrderedQueryable source, Expression> keySelector) { diff --git a/src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs b/src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs new file mode 100644 index 00000000000000..860e6e5f61f31b --- /dev/null +++ b/src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs @@ -0,0 +1,298 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Linq.Expressions; +using Xunit; + +namespace System.Linq.Tests +{ + public class FullJoinTests : EnumerableBasedTests + { + public struct CustomerRec + { + public string name; + public int custID; + } + + public struct OrderRec + { + public int orderID; + public int custID; + public int total; + } + + public struct AnagramRec + { + public string name; + public int orderID; + public int total; + } + + public struct JoinRec + { + public string name; + public int orderID; + public int total; + } + + [Fact] + public void FirstOuterMatchesLastInnerLastOuterMatchesFirstInnerSameNumberElements() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + OrderRec[] inner = { + new OrderRec{ orderID = 45321, custID = 99022, total = 50 }, + new OrderRec{ orderID = 43421, custID = 29022, total = 20 }, + new OrderRec{ orderID = 95421, custID = 98022, total = 9 } + }; + JoinRec[] expected = { + new JoinRec{ name = "Prakash", orderID = 95421, total = 9 }, + new JoinRec{ name = "Tim", orderID = 0, total = 0 }, + new JoinRec{ name = "Robert", orderID = 45321, total = 50 }, + new JoinRec{ name = null, orderID = 43421, total = 20 } + }; + + Assert.Equal(expected, outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e.custID, e => e.custID, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void NullComparer() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + JoinRec[] expected = { + new JoinRec{ name = "Prakash", orderID = 323232, total = 9 }, + new JoinRec{ name = "Tim", orderID = 0, total = 0 }, + new JoinRec{ name = "Robert", orderID = 0, total = 0 }, + new JoinRec{ name = null, orderID = 43455, total = 10 } + }; + + Assert.Equal(expected, outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e.name, e => e.name, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total }, null)); + } + + [Fact] + public void CustomComparer() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + JoinRec[] expected = { + new JoinRec{ name = "Prakash", orderID = 323232, total = 9 }, + new JoinRec{ name = "Tim", orderID = 43455, total = 10 }, + new JoinRec{ name = "Robert", orderID = 0, total = 0 } + }; + + Assert.Equal(expected, outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e.name, e => e.name, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total }, new AnagramEqualityComparer())); + } + + [Fact] + public void OuterNull() + { + IQueryable outer = null; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + + AssertExtensions.Throws("outer", () => outer.FullJoin(inner.AsQueryable(), e => e.name, e => e.name, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total }, new AnagramEqualityComparer())); + } + + [Fact] + public void InnerNull() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + IQueryable inner = null; + + AssertExtensions.Throws("inner", () => outer.AsQueryable().FullJoin(inner, e => e.name, e => e.name, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total }, new AnagramEqualityComparer())); + } + + [Fact] + public void OuterKeySelectorNull() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + + AssertExtensions.Throws("outerKeySelector", () => outer.AsQueryable().FullJoin(inner.AsQueryable(), null, e => e.name, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total }, new AnagramEqualityComparer())); + } + + [Fact] + public void InnerKeySelectorNull() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + + AssertExtensions.Throws("innerKeySelector", () => outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e.name, null, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total }, new AnagramEqualityComparer())); + } + + [Fact] + public void ResultSelectorNull() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + + AssertExtensions.Throws("resultSelector", () => Queryable.FullJoin(outer.AsQueryable(), inner.AsQueryable(), e => e.name, e => e.name, (Expression>)null, new AnagramEqualityComparer())); + } + + [Fact] + public void OuterNullNoComparer() + { + IQueryable outer = null; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + + AssertExtensions.Throws("outer", () => outer.FullJoin(inner.AsQueryable(), e => e.name, e => e.name, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void InnerNullNoComparer() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + IQueryable inner = null; + + AssertExtensions.Throws("inner", () => outer.AsQueryable().FullJoin(inner, e => e.name, e => e.name, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void OuterKeySelectorNullNoComparer() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + + AssertExtensions.Throws("outerKeySelector", () => outer.AsQueryable().FullJoin(inner.AsQueryable(), null, e => e.name, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void InnerKeySelectorNullNoComparer() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + + AssertExtensions.Throws("innerKeySelector", () => outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e.name, null, (cr, or) => new JoinRec { name = cr.name, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void ResultSelectorNullNoComparer() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + }; + AnagramRec[] inner = { + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + }; + + AssertExtensions.Throws("resultSelector", () => Queryable.FullJoin(outer.AsQueryable(), inner.AsQueryable(), e => e.name, e => e.name, (Expression>)null)); + } + + [Fact] + public void SelectorsReturnNull() + { + int?[] outer = { null, null }; + int?[] inner = { null, null, null }; + int?[] expected = { null, null }; + + Assert.Equal(expected, outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e, e => e, (x, y) => x)); + Assert.Equal(expected, outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e, e => e, (x, y) => y)); + } + + [Fact] + public void Join1() + { + var count = new[] { 0, 1, 2 }.AsQueryable().FullJoin(new[] { 1, 2, 3 }, n1 => n1, n2 => n2, (n1, n2) => n1 + n2).Count(); + Assert.Equal(4, count); + } + + [Fact] + public void Join2() + { + var count = new[] { 0, 1, 2 }.AsQueryable().FullJoin(new[] { 1, 2, 3 }, n1 => n1, n2 => n2, (n1, n2) => n1 + n2, EqualityComparer.Default).Count(); + Assert.Equal(4, count); + } + + [Fact] + public void TupleOverload() + { + var result = new[] { 0, 1, 2 }.AsQueryable().FullJoin(new[] { 1, 2, 3 }.AsQueryable(), n1 => n1, n2 => n2).ToArray(); + Assert.Equal(4, result.Length); + Assert.Equal((0, 0), result[0]); + Assert.Equal((1, 1), result[1]); + Assert.Equal((2, 2), result[2]); + Assert.Equal((0, 3), result[3]); + } + + [Fact] + public void TupleOverloadWithComparer() + { + var result = new[] { 0, 1, 2 }.AsQueryable().FullJoin(new[] { 1, 2, 3 }.AsQueryable(), n1 => n1, n2 => n2, EqualityComparer.Default).ToArray(); + Assert.Equal(4, result.Length); + Assert.Equal((0, 0), result[0]); + Assert.Equal((1, 1), result[1]); + Assert.Equal((2, 2), result[2]); + Assert.Equal((0, 3), result[3]); + } + } +} diff --git a/src/libraries/System.Linq.Queryable/tests/System.Linq.Queryable.Tests.csproj b/src/libraries/System.Linq.Queryable/tests/System.Linq.Queryable.Tests.csproj index 9fcf767d3e81b0..8b6c03c7a5a7e0 100644 --- a/src/libraries/System.Linq.Queryable/tests/System.Linq.Queryable.Tests.csproj +++ b/src/libraries/System.Linq.Queryable/tests/System.Linq.Queryable.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/src/libraries/System.Linq/ref/System.Linq.cs b/src/libraries/System.Linq/ref/System.Linq.cs index 0c2d82b2111766..e7119079e9f4b0 100644 --- a/src/libraries/System.Linq/ref/System.Linq.cs +++ b/src/libraries/System.Linq/ref/System.Linq.cs @@ -73,6 +73,8 @@ public static System.Collections.Generic.IEnumerable< public static TSource FirstOrDefault(this System.Collections.Generic.IEnumerable source, System.Func predicate, TSource defaultValue) { throw null; } public static TSource First(this System.Collections.Generic.IEnumerable source) { throw null; } public static TSource First(this System.Collections.Generic.IEnumerable source, System.Func predicate) { throw null; } + public static System.Collections.Generic.IEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin(this System.Collections.Generic.IEnumerable outer, System.Collections.Generic.IEnumerable inner, System.Func outerKeySelector, System.Func innerKeySelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } + public static System.Collections.Generic.IEnumerable FullJoin(this System.Collections.Generic.IEnumerable outer, System.Collections.Generic.IEnumerable inner, System.Func outerKeySelector, System.Func innerKeySelector, System.Func resultSelector, System.Collections.Generic.IEqualityComparer? comparer = null) { throw null; } public static System.Collections.Generic.IEnumerable> GroupBy(this System.Collections.Generic.IEnumerable source, System.Func keySelector) { throw null; } public static System.Collections.Generic.IEnumerable> GroupBy(this System.Collections.Generic.IEnumerable source, System.Func keySelector, System.Collections.Generic.IEqualityComparer? comparer) { throw null; } public static System.Collections.Generic.IEnumerable> GroupBy(this System.Collections.Generic.IEnumerable source, System.Func keySelector, System.Func elementSelector) { throw null; } diff --git a/src/libraries/System.Linq/src/System.Linq.csproj b/src/libraries/System.Linq/src/System.Linq.csproj index f032db1a303ee1..d6157849ad26df 100644 --- a/src/libraries/System.Linq/src/System.Linq.csproj +++ b/src/libraries/System.Linq/src/System.Linq.csproj @@ -29,6 +29,7 @@ + diff --git a/src/libraries/System.Linq/src/System/Linq/FullJoin.cs b/src/libraries/System.Linq/src/System/Linq/FullJoin.cs new file mode 100644 index 00000000000000..ce8d9e3591c7f3 --- /dev/null +++ b/src/libraries/System.Linq/src/System/Linq/FullJoin.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; + +namespace System.Linq +{ + public static partial class Enumerable + { + /// + /// Correlates the elements of two sequences based on matching keys, producing a result for each element + /// in either sequence that has a match as well as for elements that have no match. + /// A default or specified equality comparer is used to compare keys. + /// + /// The first sequence to join. + /// The sequence to join to the first sequence. + /// A function to extract the join key from each element of the first sequence. + /// A function to extract the join key from each element of the second sequence. + /// A function to create a result element from two matching elements. + /// An to hash and compare keys, or to use the default equality comparer. + /// The type of the elements of the first sequence. + /// The type of the elements of the second sequence. + /// The type of the keys returned by the key selector functions. + /// The type of the result elements. + /// An that has elements of type that are obtained by performing a full outer join on two sequences. + /// or or or or is . + /// + /// + /// This method is implemented by using deferred execution. The immediate return value is an object that stores + /// all the information that is required to perform the action. The query represented by this method is not + /// executed until the object is enumerated either by calling its GetEnumerator method directly or by + /// using foreach in C# or For Each in Visual Basic. + /// + /// If is , the default equality comparer, , is used to hash and compare keys. + /// + /// In relational database terms, the method implements a full outer equijoin. + /// 'Full outer' means that elements of both sequences are returned regardless of whether matching elements are found in the other sequence. + /// An 'equijoin' is a join in which the keys are compared for equality. + /// + /// + public static IEnumerable FullJoin(this IEnumerable outer, IEnumerable inner, Func outerKeySelector, Func innerKeySelector, Func resultSelector, IEqualityComparer? comparer = null) + { + if (outer is null) + { + ThrowHelper.ThrowArgumentNullException(ExceptionArgument.outer); + } + + if (inner is null) + { + ThrowHelper.ThrowArgumentNullException(ExceptionArgument.inner); + } + + if (outerKeySelector is null) + { + ThrowHelper.ThrowArgumentNullException(ExceptionArgument.outerKeySelector); + } + + if (innerKeySelector is null) + { + ThrowHelper.ThrowArgumentNullException(ExceptionArgument.innerKeySelector); + } + + if (resultSelector is null) + { + ThrowHelper.ThrowArgumentNullException(ExceptionArgument.resultSelector); + } + + return FullJoinIterator(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer); + } + + /// + /// Correlates the elements of two sequences based on matching keys, producing a tuple for each element + /// in either sequence that has a match as well as for elements that have no match. + /// A default or specified equality comparer is used to compare keys. + /// + /// The first sequence to join. + /// The sequence to join to the first sequence. + /// A function to extract the join key from each element of the first sequence. + /// A function to extract the join key from each element of the second sequence. + /// An to hash and compare keys, or to use the default equality comparer. + /// The type of the elements of the first sequence. + /// The type of the elements of the second sequence. + /// The type of the keys returned by the key selector functions. + /// An that has elements of type (TOuter?, TInner?) that are obtained by performing a full outer join on two sequences. + /// or or or is . + /// + /// + /// This method is implemented by using deferred execution. The immediate return value is an object that stores + /// all the information that is required to perform the action. The query represented by this method is not + /// executed until the object is enumerated either by calling its GetEnumerator method directly or by + /// using foreach in C# or For Each in Visual Basic. + /// + /// If is , the default equality comparer, , is used to hash and compare keys. + /// + public static IEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin(this IEnumerable outer, IEnumerable inner, Func outerKeySelector, Func innerKeySelector, IEqualityComparer? comparer = null) => + FullJoin(outer, inner, outerKeySelector, innerKeySelector, static (outer, inner) => (outer, inner), comparer); + + private static IEnumerable FullJoinIterator(IEnumerable outer, IEnumerable inner, Func outerKeySelector, Func innerKeySelector, Func resultSelector, IEqualityComparer? comparer) + { + Lookup innerLookup = Lookup.CreateForJoin(inner, innerKeySelector, comparer); + + HashSet>? matchedGroupings = innerLookup.Count != 0 + ? new HashSet>() + : null; + + foreach (TOuter item in outer) + { + Grouping? g = innerLookup.GetGrouping(outerKeySelector(item), create: false); + if (g is null) + { + yield return resultSelector(item, default); + } + else + { + matchedGroupings!.Add(g); + int count = g._count; + TInner[] elements = g._elements; + for (int i = 0; i != count; ++i) + { + yield return resultSelector(item, elements[i]); + } + } + } + + // Yield inner elements that had no matching outer element. + if (matchedGroupings is null || matchedGroupings.Count < innerLookup.Count) + { + Grouping? g = innerLookup._lastGrouping; + if (g is not null) + { + do + { + g = g._next!; + if (matchedGroupings is null || !matchedGroupings.Contains(g)) + { + int count = g._count; + TInner[] elements = g._elements; + for (int i = 0; i != count; ++i) + { + yield return resultSelector(default, elements[i]); + } + } + } + while (g != innerLookup._lastGrouping); + } + } + } + } +} diff --git a/src/libraries/System.Linq/tests/FullJoinTests.cs b/src/libraries/System.Linq/tests/FullJoinTests.cs new file mode 100644 index 00000000000000..1166745e422e45 --- /dev/null +++ b/src/libraries/System.Linq/tests/FullJoinTests.cs @@ -0,0 +1,498 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using Xunit; + +namespace System.Linq.Tests +{ + public class FullJoinTests : EnumerableTests + { + public struct CustomerRec + { + public string name; + public int custID; + } + + public struct OrderRec + { + public int orderID; + public int custID; + public int total; + } + + public struct AnagramRec + { + public string name; + public int orderID; + public int total; + } + + public struct JoinRec + { + public string name; + public int orderID; + public int total; + } + + public static JoinRec createJoinRec(CustomerRec cr, OrderRec or) + { + return new JoinRec { name = cr.name, orderID = or.orderID, total = or.total }; + } + + public static JoinRec createJoinRec(CustomerRec cr, AnagramRec or) + { + return new JoinRec { name = cr.name, orderID = or.orderID, total = or.total }; + } + + [Fact] + public void BothEmpty() + { + CustomerRec[] outer = []; + OrderRec[] inner = []; + + Assert.Empty(outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void OuterEmptyInnerNonEmpty() + { + CustomerRec[] outer = []; + OrderRec[] inner = + [ + new OrderRec{ orderID = 45321, custID = 98022, total = 50 }, + new OrderRec{ orderID = 97865, custID = 32103, total = 25 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = null, orderID = 45321, total = 50 }, + new JoinRec{ name = null, orderID = 97865, total = 25 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void OuterNonEmptyInnerEmpty() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Tim", custID = 43434 }, + new CustomerRec{ name = "Bob", custID = 34093 } + ]; + OrderRec[] inner = []; + JoinRec[] expected = + [ + new JoinRec{ name = "Tim", orderID = 0, total = 0 }, + new JoinRec{ name = "Bob", orderID = 0, total = 0 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void SingleElementEachAndMatches() + { + CustomerRec[] outer = [new CustomerRec { name = "Prakash", custID = 98022 }]; + OrderRec[] inner = [new OrderRec { orderID = 45321, custID = 98022, total = 50 }]; + JoinRec[] expected = [new JoinRec { name = "Prakash", orderID = 45321, total = 50 }]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void SingleElementEachAndDoesntMatch() + { + CustomerRec[] outer = [new CustomerRec { name = "Prakash", custID = 98922 }]; + OrderRec[] inner = [new OrderRec { orderID = 45321, custID = 98022, total = 50 }]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 0, total = 0 }, + new JoinRec{ name = null, orderID = 45321, total = 50 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void FirstOuterMatchesLastInnerLastOuterMatchesFirstInnerSameNumberElements() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + OrderRec[] inner = + [ + new OrderRec{ orderID = 45321, custID = 99022, total = 50 }, + new OrderRec{ orderID = 43421, custID = 29022, total = 20 }, + new OrderRec{ orderID = 95421, custID = 98022, total = 9 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 95421, total = 9 }, + new JoinRec{ name = "Tim", orderID = 0, total = 0 }, + new JoinRec{ name = "Robert", orderID = 45321, total = 50 }, + new JoinRec{ name = null, orderID = 43421, total = 20 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void NoMatches() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Bob", custID = 99022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + OrderRec[] inner = + [ + new OrderRec{ orderID = 45321, custID = 18022, total = 50 }, + new OrderRec{ orderID = 43421, custID = 29022, total = 20 }, + new OrderRec{ orderID = 95421, custID = 39021, total = 9 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 0, total = 0 }, + new JoinRec{ name = "Bob", orderID = 0, total = 0 }, + new JoinRec{ name = "Tim", orderID = 0, total = 0 }, + new JoinRec{ name = "Robert", orderID = 0, total = 0 }, + new JoinRec{ name = null, orderID = 45321, total = 50 }, + new JoinRec{ name = null, orderID = 43421, total = 20 }, + new JoinRec{ name = null, orderID = 95421, total = 9 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void InnerSameKeyMoreThanOneElementAndMatches() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + OrderRec[] inner = + [ + new OrderRec{ orderID = 45321, custID = 98022, total = 50 }, + new OrderRec{ orderID = 45421, custID = 98022, total = 10 }, + new OrderRec{ orderID = 43421, custID = 99022, total = 20 }, + new OrderRec{ orderID = 85421, custID = 98022, total = 18 }, + new OrderRec{ orderID = 95421, custID = 99021, total = 9 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 45321, total = 50 }, + new JoinRec{ name = "Prakash", orderID = 45421, total = 10 }, + new JoinRec{ name = "Prakash", orderID = 85421, total = 18 }, + new JoinRec{ name = "Tim", orderID = 95421, total = 9 }, + new JoinRec{ name = "Robert", orderID = 43421, total = 20 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void OuterSameKeyMoreThanOneElementAndMatches() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Bob", custID = 99022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + OrderRec[] inner = + [ + new OrderRec{ orderID = 45321, custID = 98022, total = 50 }, + new OrderRec{ orderID = 43421, custID = 99022, total = 20 }, + new OrderRec{ orderID = 95421, custID = 99021, total = 9 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 45321, total = 50 }, + new JoinRec{ name = "Bob", orderID = 43421, total = 20 }, + new JoinRec{ name = "Tim", orderID = 95421, total = 9 }, + new JoinRec{ name = "Robert", orderID = 43421, total = 20 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void NullComparer() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 323232, total = 9 }, + new JoinRec{ name = "Tim", orderID = 0, total = 0 }, + new JoinRec{ name = "Robert", orderID = 0, total = 0 }, + new JoinRec{ name = null, orderID = 43455, total = 10 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.name, e => e.name, createJoinRec, null)); + } + + [Fact] + public void CustomComparer() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 323232, total = 9 }, + new JoinRec{ name = "Tim", orderID = 43455, total = 10 }, + new JoinRec{ name = "Robert", orderID = 0, total = 0 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.name, e => e.name, createJoinRec, new AnagramEqualityComparer())); + } + + [Fact] + public void SelectorsReturnNull() + { + int?[] outer = [null, null]; + int?[] inner = [null, null, null]; + int?[] expected = [null, null]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e, e => e, (x, y) => x)); + Assert.Equal(expected, outer.FullJoin(inner, e => e, e => e, (x, y) => y)); + } + + [Fact] + public void NullElements() + { + string[] outer = [null, string.Empty]; + string[] inner = [null, string.Empty]; + string[] expected = [null, string.Empty]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e, e => e, (x, y) => y, EqualityComparer.Default)); + } + + [Fact] + public void OuterNull() + { + CustomerRec[] outer = null; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + + AssertExtensions.Throws("outer", () => outer.FullJoin(inner, e => e.name, e => e.name, createJoinRec, new AnagramEqualityComparer())); + } + + [Fact] + public void InnerNull() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = null; + + AssertExtensions.Throws("inner", () => outer.FullJoin(inner, e => e.name, e => e.name, createJoinRec, new AnagramEqualityComparer())); + } + + [Fact] + public void OuterKeySelectorNull() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + + AssertExtensions.Throws("outerKeySelector", () => outer.FullJoin(inner, null, e => e.name, createJoinRec, new AnagramEqualityComparer())); + } + + [Fact] + public void InnerKeySelectorNull() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + + AssertExtensions.Throws("innerKeySelector", () => outer.FullJoin(inner, e => e.name, null, createJoinRec, new AnagramEqualityComparer())); + } + + [Fact] + public void ResultSelectorNull() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + + AssertExtensions.Throws("resultSelector", () => outer.FullJoin(inner, e => e.name, e => e.name, (Func)null, new AnagramEqualityComparer())); + } + + [Fact] + public void OuterNullNoComparer() + { + CustomerRec[] outer = null; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + + AssertExtensions.Throws("outer", () => outer.FullJoin(inner, e => e.name, e => e.name, createJoinRec)); + } + + [Fact] + public void InnerNullNoComparer() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = null; + + AssertExtensions.Throws("inner", () => outer.FullJoin(inner, e => e.name, e => e.name, createJoinRec)); + } + + [Fact] + public void OuterKeySelectorNullNoComparer() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + + AssertExtensions.Throws("outerKeySelector", () => outer.FullJoin(inner, null, e => e.name, createJoinRec)); + } + + [Fact] + public void InnerKeySelectorNullNoComparer() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + + AssertExtensions.Throws("innerKeySelector", () => outer.FullJoin(inner, e => e.name, null, createJoinRec)); + } + + [Fact] + public void ResultSelectorNullNoComparer() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + AnagramRec[] inner = + [ + new AnagramRec{ name = "miT", orderID = 43455, total = 10 }, + new AnagramRec{ name = "Prakash", orderID = 323232, total = 9 } + ]; + + AssertExtensions.Throws("resultSelector", () => outer.FullJoin(inner, e => e.name, e => e.name, (Func)null)); + } + + [Fact] + public void TupleOverloadMatchesResultSelector() + { + int[] outer = [1, 2, 3]; + int[] inner = [2, 3, 4]; + + var result = outer.FullJoin(inner, o => o, i => i).ToArray(); + + Assert.Equal(4, result.Length); + Assert.Equal((1, 0), result[0]); + Assert.Equal((2, 2), result[1]); + Assert.Equal((3, 3), result[2]); + Assert.Equal((0, 4), result[3]); + } + + [Fact] + public void TupleOverloadWithComparer() + { + string[] outer = ["a", "B"]; + string[] inner = ["A", "c"]; + + var result = outer.FullJoin(inner, o => o, i => i, StringComparer.OrdinalIgnoreCase).ToArray(); + + Assert.Equal(3, result.Length); + Assert.Equal(("a", "A"), result[0]); + Assert.Equal(("B", null), result[1]); + Assert.Equal((null, "c"), result[2]); + } + + [Fact] + public void ForcedToEnumeratorDoesntEnumerate() + { + var iterator = NumberRangeGuaranteedNotCollectionType(0, 3).FullJoin(Enumerable.Empty(), i => i, i => i, (o, i) => i); + var en = iterator as IEnumerator; + Assert.False(en is not null && en.MoveNext()); + } + } +} diff --git a/src/libraries/System.Linq/tests/System.Linq.Tests.csproj b/src/libraries/System.Linq/tests/System.Linq.Tests.csproj index 207dda3b8c0050..27d95117f37f51 100644 --- a/src/libraries/System.Linq/tests/System.Linq.Tests.csproj +++ b/src/libraries/System.Linq/tests/System.Linq.Tests.csproj @@ -32,6 +32,7 @@ + From ca425dc37c281b6638af5ba7369e1ed796a2fa59 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 22 Apr 2026 18:32:15 +0300 Subject: [PATCH 2/4] Address null-key handling in LINQ FullJoin Preserve unmatched null-key rows in FullJoin and add regression coverage across Enumerable, Queryable, and AsyncEnumerable join operators. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Linq/FullJoin.cs | 40 ++++++++++-- .../tests/FullJoinTests.cs | 62 +++++++++++++++++++ .../tests/LeftJoinTests.cs | 29 +++++++++ .../tests/RightJoinTests.cs | 30 +++++++++ .../tests/FullJoinTests.cs | 20 +++++- .../tests/LeftJoinTests.cs | 15 +++++ .../tests/RightJoinTests.cs | 16 +++++ .../System.Linq/src/System/Linq/FullJoin.cs | 7 ++- .../System.Linq/tests/FullJoinTests.cs | 22 ++++++- .../System.Linq/tests/LeftJoinTests.cs | 15 +++++ .../System.Linq/tests/RightJoinTests.cs | 16 +++++ 11 files changed, 263 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs b/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs index 0e7c7ecb228484..96f166f7c61c0a 100644 --- a/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs +++ b/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs @@ -51,7 +51,7 @@ static async IAsyncEnumerable Impl( IEqualityComparer? comparer, [EnumeratorCancellation] CancellationToken cancellationToken) { - AsyncLookup innerLookup = await AsyncLookup.CreateForJoinAsync(inner, innerKeySelector, comparer, cancellationToken); + AsyncLookup innerLookup = await CreateLookupPreservingNullKeysAsync(inner, innerKeySelector, comparer, cancellationToken); HashSet>? matchedGroupings = innerLookup.Count != 0 ? new HashSet>() @@ -59,7 +59,8 @@ static async IAsyncEnumerable Impl( await foreach (TOuter item in outer.WithCancellation(cancellationToken)) { - Grouping? g = innerLookup.GetGrouping(outerKeySelector(item), create: false); + TKey key = outerKeySelector(item); + Grouping? g = key is null ? null : innerLookup.GetGrouping(key, create: false); if (g is null) { yield return resultSelector(item, default); @@ -142,7 +143,7 @@ static async IAsyncEnumerable Impl( IEqualityComparer? comparer, [EnumeratorCancellation] CancellationToken cancellationToken) { - AsyncLookup innerLookup = await AsyncLookup.CreateForJoinAsync(inner, innerKeySelector, comparer, cancellationToken); + AsyncLookup innerLookup = await CreateLookupPreservingNullKeysAsync(inner, innerKeySelector, comparer, cancellationToken); HashSet>? matchedGroupings = innerLookup.Count != 0 ? new HashSet>() @@ -150,7 +151,8 @@ static async IAsyncEnumerable Impl( await foreach (TOuter item in outer.WithCancellation(cancellationToken)) { - Grouping? g = innerLookup.GetGrouping(await outerKeySelector(item, cancellationToken), create: false); + TKey key = await outerKeySelector(item, cancellationToken); + Grouping? g = key is null ? null : innerLookup.GetGrouping(key, create: false); if (g is null) { yield return await resultSelector(item, default, cancellationToken); @@ -238,5 +240,35 @@ static async IAsyncEnumerable Impl( { return FullJoin(outer, inner, outerKeySelector, innerKeySelector, static (outer, inner, ct) => new ValueTask<(TOuter?, TInner?)>((outer, inner)), comparer); } + + private static async ValueTask> CreateLookupPreservingNullKeysAsync( + IAsyncEnumerable source, + Func keySelector, + IEqualityComparer? comparer, + CancellationToken cancellationToken) + { + AsyncLookup lookup = new(comparer); + await foreach (TElement item in source.WithCancellation(cancellationToken)) + { + lookup.GetGrouping(keySelector(item), create: true)!.Add(item); + } + + return lookup; + } + + private static async ValueTask> CreateLookupPreservingNullKeysAsync( + IAsyncEnumerable source, + Func> keySelector, + IEqualityComparer? comparer, + CancellationToken cancellationToken) + { + AsyncLookup lookup = new(comparer); + await foreach (TElement item in source.WithCancellation(cancellationToken)) + { + lookup.GetGrouping(await keySelector(item, cancellationToken), create: true)!.Add(item); + } + + return lookup; + } } } diff --git a/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs b/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs index e858d6530997a9..a9f10bf16955e7 100644 --- a/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs +++ b/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs @@ -35,6 +35,68 @@ public async Task BothEmpty_ProducesEmpty() Assert.Empty(await empty.FullJoin(empty, async (s, ct) => s, async (s, ct) => s, async (s1, s2, ct) => s1).ToListAsync()); } + [Fact] + public async Task NullKeysAreUnmatchedButPreserved() + { + string[] expected = + [ + "#o1:", + "a:A", + "#o2:", + ":#i1", + ":#i2", + ":b" + ]; + + Assert.Equal( + expected, + await CreateSource("#o1", "a", "#o2").FullJoin( + CreateSource("#i1", "A", "#i2", "b"), + s => s[0] == '#' ? null : s, + s => s[0] == '#' ? null : s, + (outer, inner) => $"{outer ?? ""}:{inner ?? ""}", + StringComparer.OrdinalIgnoreCase).ToListAsync()); + + Assert.Equal( + expected, + await CreateSource("#o1", "a", "#o2").FullJoin( + CreateSource("#i1", "A", "#i2", "b"), + async (s, ct) => s[0] == '#' ? null : s, + async (s, ct) => s[0] == '#' ? null : s, + async (outer, inner, ct) => $"{outer ?? ""}:{inner ?? ""}", + StringComparer.OrdinalIgnoreCase).ToListAsync()); + } + + [Fact] + public async Task TupleOverloads_PreserveNullKeyRows() + { + (string? Outer, string? Inner)[] expected = + [ + ("#o1", null), + ("a", "A"), + ("#o2", null), + (null, "#i1"), + (null, "#i2"), + (null, "b") + ]; + + Assert.Equal( + expected, + await CreateSource("#o1", "a", "#o2").FullJoin( + CreateSource("#i1", "A", "#i2", "b"), + s => s[0] == '#' ? null : s, + s => s[0] == '#' ? null : s, + StringComparer.OrdinalIgnoreCase).ToListAsync()); + + Assert.Equal( + expected, + await CreateSource("#o1", "a", "#o2").FullJoin( + CreateSource("#i1", "A", "#i2", "b"), + async (s, ct) => s[0] == '#' ? null : s, + async (s, ct) => s[0] == '#' ? null : s, + StringComparer.OrdinalIgnoreCase).ToListAsync()); + } + #if NET [Fact] public async Task VariousValues_MatchesEnumerable_String() diff --git a/src/libraries/System.Linq.AsyncEnumerable/tests/LeftJoinTests.cs b/src/libraries/System.Linq.AsyncEnumerable/tests/LeftJoinTests.cs index 759ae3ab1ed1d8..36ea1e8d928806 100644 --- a/src/libraries/System.Linq.AsyncEnumerable/tests/LeftJoinTests.cs +++ b/src/libraries/System.Linq.AsyncEnumerable/tests/LeftJoinTests.cs @@ -66,6 +66,35 @@ await AssertEqual( } #endif + [Fact] + public async Task NullKeysRemainUnmatched() + { + string[] expected = + [ + "#o1:", + "a:A", + "#o2:" + ]; + + Assert.Equal( + expected, + await CreateSource("#o1", "a", "#o2").LeftJoin( + CreateSource("#i1", "A", "#i2", "b"), + s => s[0] == '#' ? null : s, + s => s[0] == '#' ? null : s, + (outer, inner) => $"{outer}:{inner ?? ""}", + StringComparer.OrdinalIgnoreCase).ToListAsync()); + + Assert.Equal( + expected, + await CreateSource("#o1", "a", "#o2").LeftJoin( + CreateSource("#i1", "A", "#i2", "b"), + async (s, ct) => s[0] == '#' ? null : s, + async (s, ct) => s[0] == '#' ? null : s, + async (outer, inner, ct) => $"{outer}:{inner ?? ""}", + StringComparer.OrdinalIgnoreCase).ToListAsync()); + } + [Fact] public async Task Cancellation_Cancels() { diff --git a/src/libraries/System.Linq.AsyncEnumerable/tests/RightJoinTests.cs b/src/libraries/System.Linq.AsyncEnumerable/tests/RightJoinTests.cs index e7f123a464e463..f0f064121a2bac 100644 --- a/src/libraries/System.Linq.AsyncEnumerable/tests/RightJoinTests.cs +++ b/src/libraries/System.Linq.AsyncEnumerable/tests/RightJoinTests.cs @@ -66,6 +66,36 @@ await AssertEqual( } #endif + [Fact] + public async Task NullKeysRemainUnmatched() + { + string[] expected = + [ + ":#i1", + "a:A", + ":#i2", + ":b" + ]; + + Assert.Equal( + expected, + await CreateSource("#o1", "a", "#o2").RightJoin( + CreateSource("#i1", "A", "#i2", "b"), + s => s[0] == '#' ? null : s, + s => s[0] == '#' ? null : s, + (outer, inner) => $"{outer ?? ""}:{inner}", + StringComparer.OrdinalIgnoreCase).ToListAsync()); + + Assert.Equal( + expected, + await CreateSource("#o1", "a", "#o2").RightJoin( + CreateSource("#i1", "A", "#i2", "b"), + async (s, ct) => s[0] == '#' ? null : s, + async (s, ct) => s[0] == '#' ? null : s, + async (outer, inner, ct) => $"{outer ?? ""}:{inner}", + StringComparer.OrdinalIgnoreCase).ToListAsync()); + } + [Fact] public async Task Cancellation_Cancels() { diff --git a/src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs b/src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs index 860e6e5f61f31b..b996709dc797ed 100644 --- a/src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs +++ b/src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs @@ -253,12 +253,30 @@ public void SelectorsReturnNull() { int?[] outer = { null, null }; int?[] inner = { null, null, null }; - int?[] expected = { null, null }; + int?[] expected = { null, null, null, null, null }; Assert.Equal(expected, outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e, e => e, (x, y) => x)); Assert.Equal(expected, outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e, e => e, (x, y) => y)); } + [Fact] + public void NullKeysAreUnmatchedButPreserved() + { + string[] outer = { "#o1", "a", "#o2" }; + string[] inner = { "#i1", "A", "#i2", "b" }; + (string? Outer, string? Inner)[] expected = + { + ("#o1", null), + ("a", "A"), + ("#o2", null), + (null, "#i1"), + (null, "#i2"), + (null, "b") + }; + + Assert.Equal(expected, outer.AsQueryable().FullJoin(inner.AsQueryable(), s => s[0] == '#' ? null : s, s => s[0] == '#' ? null : s, StringComparer.OrdinalIgnoreCase)); + } + [Fact] public void Join1() { diff --git a/src/libraries/System.Linq.Queryable/tests/LeftJoinTests.cs b/src/libraries/System.Linq.Queryable/tests/LeftJoinTests.cs index 9666a1c7c8bb29..3307cdacdc4706 100644 --- a/src/libraries/System.Linq.Queryable/tests/LeftJoinTests.cs +++ b/src/libraries/System.Linq.Queryable/tests/LeftJoinTests.cs @@ -257,6 +257,21 @@ public void SelectorsReturnNull() Assert.Equal(expected, outer.AsQueryable().LeftJoin(inner.AsQueryable(), e => e, e => e, (x, y) => y)); } + [Fact] + public void NullKeysRemainUnmatched() + { + string[] outer = { "#o1", "a", "#o2" }; + string[] inner = { "#i1", "A", "#i2", "b" }; + string[] expected = + { + "#o1:", + "a:A", + "#o2:" + }; + + Assert.Equal(expected, outer.AsQueryable().LeftJoin(inner.AsQueryable(), s => s[0] == '#' ? null : s, s => s[0] == '#' ? null : s, (o, i) => $"{o}:{i ?? ""}", StringComparer.OrdinalIgnoreCase)); + } + [Fact] public void Join1() { diff --git a/src/libraries/System.Linq.Queryable/tests/RightJoinTests.cs b/src/libraries/System.Linq.Queryable/tests/RightJoinTests.cs index 4ca4930a350edf..39f0b4b8a78c75 100644 --- a/src/libraries/System.Linq.Queryable/tests/RightJoinTests.cs +++ b/src/libraries/System.Linq.Queryable/tests/RightJoinTests.cs @@ -256,6 +256,22 @@ public void SelectorsReturnNull() Assert.Equal(expected, outer.AsQueryable().RightJoin(inner.AsQueryable(), e => e, e => e, (x, y) => y)); } + [Fact] + public void NullKeysRemainUnmatched() + { + string[] outer = { "#o1", "a", "#o2" }; + string[] inner = { "#i1", "A", "#i2", "b" }; + string[] expected = + { + ":#i1", + "a:A", + ":#i2", + ":b" + }; + + Assert.Equal(expected, outer.AsQueryable().RightJoin(inner.AsQueryable(), s => s[0] == '#' ? null : s, s => s[0] == '#' ? null : s, (o, i) => $"{o ?? ""}:{i}", StringComparer.OrdinalIgnoreCase)); + } + [Fact] public void Join1() { diff --git a/src/libraries/System.Linq/src/System/Linq/FullJoin.cs b/src/libraries/System.Linq/src/System/Linq/FullJoin.cs index ce8d9e3591c7f3..9b0cfee4099c25 100644 --- a/src/libraries/System.Linq/src/System/Linq/FullJoin.cs +++ b/src/libraries/System.Linq/src/System/Linq/FullJoin.cs @@ -97,7 +97,9 @@ public static IEnumerable FullJoin(this private static IEnumerable FullJoinIterator(IEnumerable outer, IEnumerable inner, Func outerKeySelector, Func innerKeySelector, Func resultSelector, IEqualityComparer? comparer) { - Lookup innerLookup = Lookup.CreateForJoin(inner, innerKeySelector, comparer); + // FullJoin needs to preserve inner elements with null keys so they can be emitted + // as unmatched rows, even though null keys still never participate in matches. + Lookup innerLookup = Lookup.Create(inner, innerKeySelector, comparer); HashSet>? matchedGroupings = innerLookup.Count != 0 ? new HashSet>() @@ -105,7 +107,8 @@ private static IEnumerable FullJoinIterator? g = innerLookup.GetGrouping(outerKeySelector(item), create: false); + TKey key = outerKeySelector(item); + Grouping? g = key is null ? null : innerLookup.GetGrouping(key, create: false); if (g is null) { yield return resultSelector(item, default); diff --git a/src/libraries/System.Linq/tests/FullJoinTests.cs b/src/libraries/System.Linq/tests/FullJoinTests.cs index 1166745e422e45..d9d046831c1d8c 100644 --- a/src/libraries/System.Linq/tests/FullJoinTests.cs +++ b/src/libraries/System.Linq/tests/FullJoinTests.cs @@ -280,7 +280,7 @@ public void SelectorsReturnNull() { int?[] outer = [null, null]; int?[] inner = [null, null, null]; - int?[] expected = [null, null]; + int?[] expected = [null, null, null, null, null]; Assert.Equal(expected, outer.FullJoin(inner, e => e, e => e, (x, y) => x)); Assert.Equal(expected, outer.FullJoin(inner, e => e, e => e, (x, y) => y)); @@ -291,11 +291,29 @@ public void NullElements() { string[] outer = [null, string.Empty]; string[] inner = [null, string.Empty]; - string[] expected = [null, string.Empty]; + string[] expected = [null, string.Empty, null]; Assert.Equal(expected, outer.FullJoin(inner, e => e, e => e, (x, y) => y, EqualityComparer.Default)); } + [Fact] + public void NullKeysAreUnmatchedButPreserved() + { + string[] outer = ["#o1", "a", "#o2"]; + string[] inner = ["#i1", "A", "#i2", "b"]; + (string? Outer, string? Inner)[] expected = + [ + ("#o1", null), + ("a", "A"), + ("#o2", null), + (null, "#i1"), + (null, "#i2"), + (null, "b") + ]; + + Assert.Equal(expected, outer.FullJoin(inner, s => s[0] == '#' ? null : s, s => s[0] == '#' ? null : s, StringComparer.OrdinalIgnoreCase)); + } + [Fact] public void OuterNull() { diff --git a/src/libraries/System.Linq/tests/LeftJoinTests.cs b/src/libraries/System.Linq/tests/LeftJoinTests.cs index 43e81014aad5b1..fdbedf23710d43 100644 --- a/src/libraries/System.Linq/tests/LeftJoinTests.cs +++ b/src/libraries/System.Linq/tests/LeftJoinTests.cs @@ -355,6 +355,21 @@ public void SelectorsReturnNull() Assert.Equal(expected, outer.LeftJoin(inner, e => e, e => e, (x, y) => y)); } + [Fact] + public void NullKeysRemainUnmatched() + { + string[] outer = ["#o1", "a", "#o2"]; + string[] inner = ["#i1", "A", "#i2", "b"]; + string[] expected = + [ + "#o1:", + "a:A", + "#o2:" + ]; + + Assert.Equal(expected, outer.LeftJoin(inner, s => s[0] == '#' ? null : s, s => s[0] == '#' ? null : s, (o, i) => $"{o}:{i ?? ""}", StringComparer.OrdinalIgnoreCase)); + } + [Fact] public void InnerSameKeyMoreThanOneElementAndMatches() { diff --git a/src/libraries/System.Linq/tests/RightJoinTests.cs b/src/libraries/System.Linq/tests/RightJoinTests.cs index 9eb51920f48be3..95d7d788c144c7 100644 --- a/src/libraries/System.Linq/tests/RightJoinTests.cs +++ b/src/libraries/System.Linq/tests/RightJoinTests.cs @@ -353,6 +353,22 @@ public void SelectorsReturnNull() Assert.Equal(expected, outer.RightJoin(inner, e => e, e => e, (x, y) => y)); } + [Fact] + public void NullKeysRemainUnmatched() + { + string[] outer = ["#o1", "a", "#o2"]; + string[] inner = ["#i1", "A", "#i2", "b"]; + string[] expected = + [ + ":#i1", + "a:A", + ":#i2", + ":b" + ]; + + Assert.Equal(expected, outer.RightJoin(inner, s => s[0] == '#' ? null : s, s => s[0] == '#' ? null : s, (o, i) => $"{o ?? ""}:{i}", StringComparer.OrdinalIgnoreCase)); + } + [Fact] public void InnerSameKeyMoreThanOneElementAndMatches() { From 0127e408ecbeb92d2f1730fd6404f854bb123e05 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 22 Apr 2026 20:25:32 +0300 Subject: [PATCH 3/4] Add FullJoin empty-inner fast path Avoid building the inner lookup and matched-group set when Enumerable.FullJoin is given Array.Empty() for the inner sequence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../System.Linq/src/System/Linq/FullJoin.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Linq/src/System/Linq/FullJoin.cs b/src/libraries/System.Linq/src/System/Linq/FullJoin.cs index 9b0cfee4099c25..44cac3a8f83d7e 100644 --- a/src/libraries/System.Linq/src/System/Linq/FullJoin.cs +++ b/src/libraries/System.Linq/src/System/Linq/FullJoin.cs @@ -65,7 +65,17 @@ public static IEnumerable FullJoin(this ThrowHelper.ThrowArgumentNullException(ExceptionArgument.resultSelector); } - return FullJoinIterator(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer); + return + IsEmptyArray(inner) ? EmptyInnerIterator(outer, resultSelector) : + FullJoinIterator(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer); + + static IEnumerable EmptyInnerIterator(IEnumerable outer, Func resultSelector) + { + foreach (TOuter item in outer) + { + yield return resultSelector(item, default); + } + } } /// From 53da6ed879b965b2401e2749428163a5b4885c33 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 22 Apr 2026 20:43:03 +0300 Subject: [PATCH 4/4] Optimize FullJoin empty-sequence paths Add the requested empty-side fast paths for Enumerable and AsyncEnumerable FullJoin, plus targeted async regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/System/Linq/FullJoin.cs | 60 +++++++++++++++- .../tests/FullJoinTests.cs | 13 +++- .../System.Linq/src/System/Linq/FullJoin.cs | 69 +++++++++++++++++++ 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs b/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs index 96f166f7c61c0a..d5728a4fa419b6 100644 --- a/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs +++ b/src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs @@ -41,7 +41,13 @@ public static IAsyncEnumerable FullJoin( ArgumentNullException.ThrowIfNull(innerKeySelector); ArgumentNullException.ThrowIfNull(resultSelector); - return Impl(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer, default); + return + outer.IsKnownEmpty() ? inner.IsKnownEmpty() ? + Empty() : + UnmatchedInner(inner, resultSelector, default) : + inner.IsKnownEmpty() ? + UnmatchedOuter(outer, resultSelector, default) : + Impl(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer, default); static async IAsyncEnumerable Impl( IAsyncEnumerable outer, IAsyncEnumerable inner, @@ -99,6 +105,28 @@ static async IAsyncEnumerable Impl( } } } + + static async IAsyncEnumerable UnmatchedOuter( + IAsyncEnumerable outer, + Func resultSelector, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (TOuter item in outer.WithCancellation(cancellationToken)) + { + yield return resultSelector(item, default); + } + } + + static async IAsyncEnumerable UnmatchedInner( + IAsyncEnumerable inner, + Func resultSelector, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (TInner item in inner.WithCancellation(cancellationToken)) + { + yield return resultSelector(default, item); + } + } } /// Correlates the elements of two async sequences based on matching keys, producing a result for matched and unmatched elements. @@ -132,7 +160,13 @@ public static IAsyncEnumerable FullJoin( ArgumentNullException.ThrowIfNull(innerKeySelector); ArgumentNullException.ThrowIfNull(resultSelector); - return Impl(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer, default); + return + outer.IsKnownEmpty() ? inner.IsKnownEmpty() ? + Empty() : + UnmatchedInner(inner, resultSelector, default) : + inner.IsKnownEmpty() ? + UnmatchedOuter(outer, resultSelector, default) : + Impl(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer, default); static async IAsyncEnumerable Impl( IAsyncEnumerable outer, @@ -191,6 +225,28 @@ static async IAsyncEnumerable Impl( } } } + + static async IAsyncEnumerable UnmatchedOuter( + IAsyncEnumerable outer, + Func> resultSelector, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (TOuter item in outer.WithCancellation(cancellationToken)) + { + yield return await resultSelector(item, default, cancellationToken); + } + } + + static async IAsyncEnumerable UnmatchedInner( + IAsyncEnumerable inner, + Func> resultSelector, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (TInner item in inner.WithCancellation(cancellationToken)) + { + yield return await resultSelector(default, item, cancellationToken); + } + } } /// Correlates the elements of two async sequences based on matching keys, producing a tuple for matched and unmatched elements. diff --git a/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs b/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs index a9f10bf16955e7..539cb838ec2302 100644 --- a/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs +++ b/src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs @@ -27,12 +27,19 @@ public void InvalidInputs_Throws() } [Fact] - public async Task BothEmpty_ProducesEmpty() + public void Empty_ProducesExpectedBehavior() // validating an optimization / implementation detail { IAsyncEnumerable empty = AsyncEnumerable.Empty(); + IAsyncEnumerable nonEmpty = CreateSource("1", "2", "3"); - Assert.Empty(await empty.FullJoin(empty, s => s, s => s, (s1, s2) => s1).ToListAsync()); - Assert.Empty(await empty.FullJoin(empty, async (s, ct) => s, async (s, ct) => s, async (s1, s2, ct) => s1).ToListAsync()); + Assert.Same(AsyncEnumerable.Empty(), empty.FullJoin(empty, s => s, s => s, (s1, s2) => s1)); + Assert.Same(AsyncEnumerable.Empty(), empty.FullJoin(empty, async (s, ct) => s, async (s, ct) => s, async (s1, s2, ct) => s1)); + + Assert.NotSame(AsyncEnumerable.Empty(), nonEmpty.FullJoin(empty, s => s, s => s, (s1, s2) => s1)); + Assert.NotSame(AsyncEnumerable.Empty(), nonEmpty.FullJoin(empty, async (s, ct) => s, async (s, ct) => s, async (s1, s2, ct) => s1)); + + Assert.NotSame(AsyncEnumerable.Empty(), empty.FullJoin(nonEmpty, s => s, s => s, (s1, s2) => s1)); + Assert.NotSame(AsyncEnumerable.Empty(), empty.FullJoin(nonEmpty, async (s, ct) => s, async (s, ct) => s, async (s1, s2, ct) => s1)); } [Fact] diff --git a/src/libraries/System.Linq/src/System/Linq/FullJoin.cs b/src/libraries/System.Linq/src/System/Linq/FullJoin.cs index 44cac3a8f83d7e..0fa22ee67c4e68 100644 --- a/src/libraries/System.Linq/src/System/Linq/FullJoin.cs +++ b/src/libraries/System.Linq/src/System/Linq/FullJoin.cs @@ -24,6 +24,66 @@ public static partial class Enumerable /// The type of the result elements. /// An that has elements of type that are obtained by performing a full outer join on two sequences. /// or or or or is . + /// + /// + /// The following code example demonstrates how to use + /// to perform a full outer join of two sequences based on a common key. + /// + /// + /// class Person + /// { + /// public string Name { get; set; } + /// } + /// + /// class Pet + /// { + /// public string Name { get; set; } + /// public Person Owner { get; set; } + /// } + /// + /// public static void FullJoin() + /// { + /// Person terry = new Person { Name = "Adams, Terry" }; + /// Person charlotte = new Person { Name = "Weiss, Charlotte" }; + /// Person tom = new Person { Name = "Chapkin, Tom" }; + /// Person magnus = new Person { Name = "Hedlund, Magnus" }; + /// + /// Pet barley = new Pet { Name = "Barley", Owner = terry }; + /// Pet boots = new Pet { Name = "Boots", Owner = terry }; + /// Pet whiskers = new Pet { Name = "Whiskers", Owner = charlotte }; + /// Pet daisy = new Pet { Name = "Daisy", Owner = magnus }; + /// + /// List{Person} people = new List{Person} { terry, charlotte, tom }; + /// List{Pet} pets = new List{Pet} { barley, boots, whiskers, daisy }; + /// + /// var query = + /// people.FullJoin( + /// pets, + /// person => person, + /// pet => pet.Owner, + /// (person, pet) => new + /// { + /// OwnerName = person?.Name ?? "NONE", + /// PetName = pet?.Name ?? "NONE" + /// }); + /// + /// foreach (var obj in query) + /// { + /// Console.WriteLine("{0} - {1}", obj.OwnerName, obj.PetName); + /// } + /// } + /// + /// /* + /// This code produces the following output: + /// + /// Adams, Terry - Barley + /// Adams, Terry - Boots + /// Weiss, Charlotte - Whiskers + /// Chapkin, Tom - NONE + /// NONE - Daisy + /// */ + /// + /// /// /// /// This method is implemented by using deferred execution. The immediate return value is an object that stores @@ -66,9 +126,18 @@ public static IEnumerable FullJoin(this } return + IsEmptyArray(outer) ? EmptyOuterIterator(inner, resultSelector) : IsEmptyArray(inner) ? EmptyInnerIterator(outer, resultSelector) : FullJoinIterator(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer); + static IEnumerable EmptyOuterIterator(IEnumerable inner, Func resultSelector) + { + foreach (TInner item in inner) + { + yield return resultSelector(default, item); + } + } + static IEnumerable EmptyInnerIterator(IEnumerable outer, Func resultSelector) { foreach (TOuter item in outer)