Implement LINQ FullJoin operators#127236
Conversation
Add FullJoin for Enumerable, Queryable, and AsyncEnumerable using the approved optional comparer API shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Tagging subscribers to this area: @dotnet/area-system-linq |
There was a problem hiding this comment.
Pull request overview
Adds a new LINQ FullJoin operator across Enumerable, Queryable, and AsyncEnumerable, including reference-assembly updates and new unit tests, to support full outer join semantics in .NET’s LINQ surface area.
Changes:
- Introduces
Enumerable.FullJoinimplementation andQueryable.FullJoinexpression-tree entry points. - Adds
AsyncEnumerable.FullJoin(including async key selector variants) plus ref/csproj wiring. - Adds new test coverage for
System.Linq,System.Linq.Queryable, andSystem.Linq.AsyncEnumerable.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.Linq/tests/System.Linq.Tests.csproj | Includes new FullJoinTests.cs in System.Linq test project. |
| src/libraries/System.Linq/tests/FullJoinTests.cs | Adds unit tests for Enumerable.FullJoin, including tuple overloads. |
| src/libraries/System.Linq/src/System/Linq/FullJoin.cs | Implements Enumerable.FullJoin iterator logic and public APIs. |
| src/libraries/System.Linq/src/System.Linq.csproj | Adds System\\Linq\\FullJoin.cs to the build. |
| src/libraries/System.Linq/ref/System.Linq.cs | Adds FullJoin APIs to System.Linq reference assembly. |
| src/libraries/System.Linq.Queryable/tests/System.Linq.Queryable.Tests.csproj | Includes new FullJoinTests.cs in Queryable test project. |
| src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs | Adds unit tests for Queryable.FullJoin (including tuple overloads). |
| src/libraries/System.Linq.Queryable/src/System/Linq/Queryable.cs | Adds Queryable.FullJoin methods producing MethodCallExpressions. |
| src/libraries/System.Linq.Queryable/ref/System.Linq.Queryable.cs | Adds FullJoin APIs to System.Linq.Queryable reference assembly. |
| src/libraries/System.Linq.AsyncEnumerable/tests/System.Linq.AsyncEnumerable.Tests.csproj | Includes new FullJoinTests.cs in AsyncEnumerable test project. |
| src/libraries/System.Linq.AsyncEnumerable/tests/FullJoinTests.cs | Adds tests for async result-selector overloads (validation/cancellation/etc.). |
| src/libraries/System.Linq.AsyncEnumerable/src/System/Linq/FullJoin.cs | Implements AsyncEnumerable.FullJoin (sync + async key selector variants) and tuple overloads. |
| src/libraries/System.Linq.AsyncEnumerable/src/System.Linq.AsyncEnumerable.csproj | Adds System\\Linq\\FullJoin.cs to the build. |
| src/libraries/System.Linq.AsyncEnumerable/ref/System.Linq.AsyncEnumerable.cs | Adds FullJoin APIs to System.Linq.AsyncEnumerable reference assembly. |
| AsyncLookup<TKey, TInner> innerLookup = await AsyncLookup<TKey, TInner>.CreateForJoinAsync(inner, innerKeySelector, comparer, cancellationToken); | ||
|
|
||
| HashSet<Grouping<TKey, TInner>>? matchedGroupings = innerLookup.Count != 0 | ||
| ? new HashSet<Grouping<TKey, TInner>>() | ||
| : null; |
There was a problem hiding this comment.
AsyncLookup<TKey, TInner>.CreateForJoinAsync filters out elements whose key is null (see AsyncLookup.CreateForJoinAsync), so any inner elements with a null key are never stored in innerLookup and thus will never be yielded as unmatched inner rows. This can cause FullJoin to drop inner elements entirely. Consider tracking null-key inner elements separately during lookup creation and yielding them as (default, innerItem) at the end (and adding equivalent logic for the async-key-selector overload).
| [Fact] | ||
| public void InvalidInputs_Throws() | ||
| { | ||
| AssertExtensions.Throws<ArgumentNullException>("outer", () => AsyncEnumerable.FullJoin((IAsyncEnumerable<string>)null, AsyncEnumerable.Empty<string>(), outer => outer, inner => inner, (outer, inner) => outer + inner)); | ||
| AssertExtensions.Throws<ArgumentNullException>("inner", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty<string>(), (IAsyncEnumerable<string>)null, outer => outer, inner => inner, (outer, inner) => outer + inner)); | ||
| AssertExtensions.Throws<ArgumentNullException>("outerKeySelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty<string>(), AsyncEnumerable.Empty<string>(), (Func<string, string>)null, inner => inner, (outer, inner) => outer + inner)); | ||
| AssertExtensions.Throws<ArgumentNullException>("innerKeySelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty<string>(), AsyncEnumerable.Empty<string>(), outer => outer, (Func<string, string>)null, (outer, inner) => outer + inner)); | ||
| AssertExtensions.Throws<ArgumentNullException>("resultSelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty<string>(), AsyncEnumerable.Empty<string>(), outer => outer, inner => inner, (Func<string, string, string>)null)); | ||
|
|
||
| AssertExtensions.Throws<ArgumentNullException>("outer", () => AsyncEnumerable.FullJoin((IAsyncEnumerable<string>)null, AsyncEnumerable.Empty<string>(), async (outer, ct) => outer, async (inner, ct) => inner, async (outer, inner, ct) => outer + inner)); | ||
| AssertExtensions.Throws<ArgumentNullException>("inner", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty<string>(), (IAsyncEnumerable<string>)null, async (outer, ct) => outer, async (inner, ct) => inner, async (outer, inner, ct) => outer + inner)); | ||
| AssertExtensions.Throws<ArgumentNullException>("outerKeySelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty<string>(), AsyncEnumerable.Empty<string>(), (Func<string, CancellationToken, ValueTask<string>>)null, async (inner, ct) => inner, async (outer, inner, ct) => outer + inner)); | ||
| AssertExtensions.Throws<ArgumentNullException>("innerKeySelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty<string>(), AsyncEnumerable.Empty<string>(), async (outer, ct) => outer, (Func<string, CancellationToken, ValueTask<string>>)null, async (outer, inner, ct) => outer + inner)); | ||
| AssertExtensions.Throws<ArgumentNullException>("resultSelector", () => AsyncEnumerable.FullJoin(AsyncEnumerable.Empty<string>(), AsyncEnumerable.Empty<string>(), async (outer, ct) => outer, async (inner, ct) => inner, (Func<string, string, CancellationToken, ValueTask<string>>)null)); | ||
| } |
There was a problem hiding this comment.
This test file exercises the result-selector overloads, but it doesn’t cover either of the tuple-returning FullJoin overloads (sync key selectors and async key selectors). Since these overloads have distinct code paths, it would be good to add at least one test validating tuple results (including with a non-default comparer, and ideally also for the async key-selector overload).
| Lookup<TKey, TInner> innerLookup = Lookup<TKey, TInner>.CreateForJoin(inner, innerKeySelector, comparer); | ||
|
|
||
| HashSet<Grouping<TKey, TInner>>? matchedGroupings = innerLookup.Count != 0 | ||
| ? new HashSet<Grouping<TKey, TInner>>() | ||
| : null; |
There was a problem hiding this comment.
Lookup<TKey, TInner>.CreateForJoin filters out elements whose key is null (see Lookup.CreateForJoin), so any inner elements with a null key will never be present in innerLookup and therefore will never be yielded as unmatched inner rows. That means FullJoin can silently drop inner elements, which contradicts full-outer-join semantics (“all elements from both sequences”). Consider explicitly tracking inner items with null keys while building the lookup (and yielding them as (default, innerItem)), or using a lookup construction that preserves null-key elements for the final unmatched-inner pass without allowing null-key matches if that’s desired.
Note
This PR description was generated with GitHub Copilot.
Fixes #124787.
Summary
FullJointoEnumerable,Queryable, andAsyncEnumerableIEqualityComparer<TKey>? comparer = nullparameter