diff --git a/.github/skills/api-proposal/SKILL.md b/.github/skills/api-proposal/SKILL.md new file mode 100644 index 00000000000000..aa1959b2518475 --- /dev/null +++ b/.github/skills/api-proposal/SKILL.md @@ -0,0 +1,287 @@ +--- +name: api-proposal +description: Create prototype backed API proposals for dotnet/runtime. Use when asked to draft an API proposal or to refine a vague API idea into a complete proposal. +--- + +# API Proposal Skill + +Create complete, terse, and empirically grounded API proposals for dotnet/runtime. The output should have a high chance of passing the [API review process](https://github.com/dotnet/runtime/blob/main/docs/project/api-review-process.md). + +> 🚨 **NEVER** submit a proposal without a working prototype. The prototype is the evidence that the API works. Proposals without prototypes are speculative. + +> 🚨 **NEVER** use `gh pr review --approve` or `--request-changes`. Only `--comment` is allowed. + +## When to Use This Skill + +Use this skill when: +- Asked to propose a new API for dotnet/runtime +- Given a vague API idea or incomplete sketch that needs to be turned into a complete proposal +- Given an existing underdeveloped `api-suggestion` issue to refine +- Asked to prototype an API and draft a proposal +- Asked to "write an API proposal", "draft an api-suggestion", or "improve this proposal" + +## Core Principles + +1. **TERSENESS**: Proposals are reviewed live by humans during API review meetings who often lack prior context. Long text is counterproductive unless warranted by design complexity. Focus on WHAT problem and HOW to solve it. + +2. **Empirically grounded**: Build and test a working prototype BEFORE writing the proposal. The prototype validates the design, surfaces edge cases, and produces the exact API surface via ref source generation. + +3. **Claims backed by evidence**: Every motivating claim must have at least one concrete scenario. "This is useful" without showing *who* needs it and *how* they'd use it is the #1 reason proposals get sent back. + +4. **Context-driven depth**: The amount of supporting text should be proportional to how much **new information** the proposal introduces — not just API surface size. A small API introducing a novel concept needs more justification than a large API adding async counterparts to existing sync methods. + +## Modular Phases + +This skill has 6 phases. Each can run independently (e.g., "just draft the proposal from my existing prototype"). When running the full pipeline, execute in order. + +--- + +### Phase 0: Gather Input & Assess Context + +1. **Accept input** in any form: issue URL, text description, API sketch, or vague idea. + +2. **If the input is an existing GitHub issue**, read it in full and identify: + - What sections are missing or underdeveloped + - Whether the proposed API surface is concrete or still vague + - Any reviewer feedback in comments (especially if `api-needs-work` label is present) + +3. **Identify the target**: namespace, area label, affected types, target library under `src/libraries/`. + +4. **Assess novelty**: Is this a well-understood pattern extension (async variant, new overload, casing option) or something introducing a novel concept? This determines the depth of the proposal. + +5. **Evaluate existing workarounds**: Before proceeding, research what users can do TODAY without this API. + - Present the workaround(s) to the user for evaluation + - Explain trade-offs: performance penalty? excessive boilerplate? bad practices? + - **This is a checkpoint**: If a workaround is acceptable, the user may decide to shelve the proposal + - Only proceed to prototyping if workarounds are genuinely insufficient + +6. **Search for prior proposals** on the same topic: + - Search dotnet/runtime issues for related `api-suggestion` / `api-approved` / `api-needs-work` issues + - If duplicates exist, surface them — don't block work, but note them for linking later + - There is usually a reason why existing proposals haven't been approved; understand it + +7. Ask clarifying questions if the proposal is too vague to prototype. + +--- + +### Phase 1: Research + +The skill contains baked-in examples and guidelines (see [references/proposal-examples.md](references/proposal-examples.md) and [references/api-proposal-checklist.md](references/api-proposal-checklist.md)). The agent does NOT need to search for `api-approved` issues at runtime. + +**What the agent DOES at runtime:** + +1. **Read the Framework Design Guidelines digest** at `docs/coding-guidelines/framework-design-guidelines-digest.md`. Validate that proposed names follow the conventions. + +2. **Read existing APIs in the target namespace** to ensure consistency: + - Naming patterns (e.g., `TryX` pattern, `XAsync` pattern, overload shapes) + - Type hierarchies and interface implementations + - Parameter ordering conventions + +3. **Read the reference documentation for updating ref source** at `docs/coding-guidelines/updating-ref-source.md`. + +--- + +### Phase 2: Prototype + +> **If the user already has a prototype**, ask for the published branch link and skip to Phase 3. + +1. Create a working branch: `api-proposal/` + +2. Implement the API surface with: + - Complete triple-slash XML documentation on all public members + - Proper `#if` guards for TFM-specific APIs + +3. Write comprehensive tests: + - Use `[Theory]` with `[InlineData]`/`[MemberData]` where applicable + - Cover edge cases, null inputs, boundary conditions + - Test any interaction with existing APIs + +#### Prototype Validation (all steps required) + +**Step 1: Build the `src/` project** + +```bash +cd src/libraries//src +dotnet build +``` + +This catches compilation errors and runs ApiCompat binary compatibility checks automatically. + +**Step 2: Check TFM compatibility** + +Inspect the library's `.csproj` for `TargetFrameworks`. If it ships netstandard2.0 or net462 artifacts: +- Verify the prototype compiles for ALL target frameworks, not just `$(NetCoreAppCurrent)` +- Ensure .NET Core APIs form a **superset** of netstandard/netfx APIs +- Use `#if` guards where types like `DateOnly`, `IParsable` restrict parts of the surface to .NET Core +- Failure to maintain superset relationship risks breaking changes on upgrade/type-forward + +**Step 3: Generate reference assembly source** + +```bash +cd src/libraries//src +dotnet msbuild /t:GenerateReferenceAssemblySource +``` + +For System.Runtime, use `dotnet build --no-incremental /t:GenerateReferenceAssemblySource`. + +This: +- Produces the **exact public API diff** to use in the proposal +- Validates that only intended APIs were added (no accidental public surface leakage) +- The `ref/` folder changes **must be committed** as part of the prototype + +**Step 4: Build the test project** + +```bash +cd src/libraries//tests +dotnet build +``` + +This is critical for detecting **source breaking changes** that ApiCompat won't catch: +- New overloads/extension methods causing wrong method binding in existing code +- New generic overloads causing overload resolution ambiguity +- Pay attention to compilation **warnings**, not just errors + +**Step 5: Run the tests** + +```bash +cd src/libraries/ +dotnet build /t:test ./tests/.csproj +``` + +All tests must pass with zero failures. + +**Step 6: System.Private.CoreLib** (if applicable) + +```bash +./build.sh clr.corelib+clr.nativecorelib+libs.pretest -rc checked +``` + +**The flow is**: vague input → working prototype → extract exact API surface from ref source → write the proposal. The prototype comes BEFORE the exact API proposal. + +--- + +### Phase 3: Review (encapsulates code-review skill) — BLOCKING + +1. Invoke the **code-review** skill against the prototype diff. + +2. **All errors and warnings must be fixed** before proceeding to the draft phase. + +3. If the API change could affect performance (hot paths, allocations, new collection types), suggest running the **performance-benchmark** skill. + +4. Re-run tests after any review-driven changes to confirm nothing regressed. + +--- + +### Phase 4: Draft Proposal + +**Core principle: TERSENESS.** Focus on WHAT problem and HOW to solve it. Do not generate long text unless the design complexity warrants it. + +Write the proposal matching the spirit of the [issue template](https://github.com/dotnet/runtime/blob/main/.github/ISSUE_TEMPLATE/02_api_proposal.yml). Skip inapplicable fields rather than filling them with "N/A". + +#### Proposal Structure + +**1. Background and motivation** + +- WHAT concrete user problem are we solving? Show scenario(s). +- Are there existing workarounds? Why are they insufficient (perf, boilerplate, bad practices)? +- Reference prior art in other ecosystems where relevant. + +**2. API Proposal** + +The exact API surface, extracted from the `GenerateReferenceAssemblySource` output: + +- **New self-contained types**: Clean declaration format (no diff markers). Example: + +```csharp +namespace System.Collections.Generic; + +public class PriorityQueue +{ + public PriorityQueue(); + public PriorityQueue(IComparer? comparer); + public int Count { get; } + public void Enqueue(TElement element, TPriority priority); + public TElement Dequeue(); + // ... +} +``` + +- **Additions to existing types**: Markdown `diff` blocks showing only relevant context (sibling overloads, not every member): + +```diff +namespace System.Text.Json; + +public partial class JsonNamingPolicy +{ + public static JsonNamingPolicy CamelCase { get; } ++ public static JsonNamingPolicy SnakeLowerCase { get; } ++ public static JsonNamingPolicy SnakeUpperCase { get; } +} +``` + +Rules: +- **No implementation code.** Ever. +- **No extensive XML docs.** Comments only as brief clarifications for the review board. +- Naming must follow the [Framework Design Guidelines](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/framework-design-guidelines-digest.md). + +**3. API Usage** + +Realistic, compilable code examples demonstrating the primary scenarios. Number and depth should match the novelty of the API, not just its size. A simple new overload may need one example; a new collection type may need several showing different use patterns. + +**4. Design Decisions** (nontrivial only) + +For any decision where reasonable alternatives exist, briefly explain the reasoning. Omit for self-evident decisions. List format works well: + +- "Uses a quaternary heap instead of binary for better cache locality" +- "Does not implement `IEnumerable` because elements cannot be efficiently enumerated in priority order" + +**5. Alternative Designs** + +The agent has the burden of proof when claiming no viable alternatives exist. Show that alternatives were genuinely considered and explain why the proposed design is preferred. + +**6. Risks** + +The agent has the burden of proof when claiming absence of risks. Evaluate: +- Binary breaking changes (caught by ApiCompat) +- Source breaking changes (overload resolution, method binding) +- Performance implications +- TFM compatibility + +**7. Open Questions** (if any) + +List unresolved design questions with tentative answers. Surfacing uncertainty is a feature, not a weakness. Example from PriorityQueue: +- "Should we use `KeyValuePair` instead of tuples? + +**8. Scope considerations** (if applicable) + +If the proposal could naturally extend to neighboring APIs (e.g., "should this also apply to `ToHashSet`?"), flag it as an open question. + +**9. Related issues** + +Link any related/duplicate proposals found during Phase 0 research. + +**10. Prototype** + +Link to the published branch with the working implementation. + +#### After Drafting + +Present the complete draft to the user for review. Iterate based on feedback before publishing. + +--- + +### Phase 5: Publish + +1. Commit prototype changes and push branch to the user's fork (default) or ask for an alternative remote. + +2. **If the input was an existing issue**, offer the user a choice: + - **Post as comment** on the existing issue (for the user to manually edit the OP) + - **Create a new issue** via `gh issue create` + +3. **If the input was NOT an existing issue**, offer to file via: + ```bash + gh issue create --label api-suggestion --title "[API Proposal]: " --body "<proposal>" + ``` + No area label — repo automation handles that. + +4. Include the prototype branch link and related issue links in the body. diff --git a/.github/skills/api-proposal/references/api-proposal-checklist.md b/.github/skills/api-proposal/references/api-proposal-checklist.md new file mode 100644 index 00000000000000..f4d672fc75d687 --- /dev/null +++ b/.github/skills/api-proposal/references/api-proposal-checklist.md @@ -0,0 +1,78 @@ +# API Proposal Quality Checklist + +Use this checklist to validate an API proposal before publishing. Items are ordered by importance. + +## Background and Motivation + +- [ ] **DO** state the concrete user problem with at least one real-world scenario +- [ ] **DO** evaluate existing workarounds and explain why they are insufficient (performance, boilerplate, bad practices) +- [ ] **DO** reference prior art in other ecosystems where relevant (e.g., Python, Java, Rust equivalents) +- [ ] **DO NOT** assume the reviewers are subject matter experts of the library being augmented (even though they're .NET experts) +- [ ] **DO NOT** make unsubstantiated claims (e.g., "this is commonly needed" without showing who needs it) +- [ ] **DO NOT** write motivation text that is longer than what the design complexity warrants + +## API Surface + +- [ ] **DO** extract the exact API surface from `GenerateReferenceAssemblySource` output +- [ ] **DO** use clean declaration format for new self-contained types +- [ ] **DO** use `diff` blocks showing relevant context (sibling overloads) for additions to existing types +- [ ] **DO** validate all names against the [Framework Design Guidelines](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/framework-design-guidelines-digest.md) +- [ ] **DO** verify naming consistency with existing APIs in the target namespace +- [ ] **DO NOT** include implementation code in the API surface +- [ ] **DO NOT** include extensive XML documentation in the proposal diff — comments only as brief clarifications +- [ ] **DO NOT** expose types from the `System` namespace without strong justification + +## Scope Completeness + +- [ ] **DO** consider whether neighboring APIs need the same treatment (e.g., adding to `ToDictionary`? what about `ToHashSet`?) +- [ ] **DO** consider whether APIs require common parameters (e.g., `CancellationToken`, `IEqualityComparer<T>`) +- [ ] **DO** consider async counterparts if adding sync APIs, and vice versa +- [ ] **DO** consider overload consistency with existing method families +- [ ] **DO NOT** propose a narrow addition without evaluating the broader scope — reviewers will ask about it + +## Prototype Validation + +- [ ] **DO** verify the prototype builds for all target frameworks in the library's `.csproj` +- [ ] **DO** verify .NET Core APIs form a superset of netstandard/netfx APIs (if the library ships both) +- [ ] **DO** generate reference assembly source and commit the `ref/` changes +- [ ] **DO** build the test project separately to catch source breaking changes (overload resolution ambiguity, wrong method binding) +- [ ] **DO** verify all tests pass with zero failures +- [ ] **DO NOT** skip ApiCompat validation — binary compatibility must be maintained + +## Usage Examples + +- [ ] **DO** provide realistic, compilable code examples +- [ ] **DO** demonstrate the primary scenarios the API is designed to address +- [ ] **DO** match the number of examples to the novelty of the API, not just its size +- [ ] **DO NOT** provide only trivial/toy examples that don't demonstrate real usage + +## Design Decisions + +- [ ] **DO** explain the reasoning for nontrivial design choices +- [ ] **DO** list explicit trade-offs that were made (e.g., "no update support for 2-3x better performance") +- [ ] **DO NOT** explain self-evident decisions — this wastes reviewer time + +## Alternatives and Risks + +- [ ] **DO** demonstrate that alternatives were genuinely considered +- [ ] **DO** evaluate risks specifically: binary breaking changes, source breaking changes, performance, TFM compatibility +- [ ] **DO NOT** write "N/A" without demonstrating you've actually evaluated — it signals lack of research +- [ ] **DO NOT** dismiss risks with hand-wavy language ("low risk", "unlikely to cause issues") + +## Open Questions + +- [ ] **DO** list unresolved design questions with tentative answers +- [ ] **DO** surface uncertainty rather than hiding it — surprises during review are worse +- [ ] **DO NOT** leave fundamental design tensions unresolved without data to back a position + +## Common Reviewer Feedback Patterns (DO NOT) + +The following patterns consistently result in proposals being sent back for rework: + +- **DO NOT** state claims as facts without backing scenarios ("this is commonly needed" → show who and how) +- **DO NOT** propose a narrow scope without evaluating the full picture (reviewers will ask "what about X?") +- **DO NOT** use imprecise naming that violates Framework Design Guidelines conventions +- **DO NOT** propose APIs without checking for prior art ("has this been done before?" is a standard reviewer question) +- **DO NOT** oversimplify a complex design space — if the problem is nuanced, the proposal should acknowledge the nuance +- **DO NOT** hide open design questions — acknowledge them upfront with tentative answers +- **DO NOT** propose an API without a working prototype — speculation is not evidence diff --git a/.github/skills/api-proposal/references/proposal-examples.md b/.github/skills/api-proposal/references/proposal-examples.md new file mode 100644 index 00000000000000..200c1e94b7ab41 --- /dev/null +++ b/.github/skills/api-proposal/references/proposal-examples.md @@ -0,0 +1,118 @@ +# API Proposal Examples + +This document contains curated examples of successful API proposals to guide the drafting of new proposals. Study the structure, not just the content. + +## PriorityQueue (#43957) — Large Proposal + +> Source: https://github.com/dotnet/runtime/issues/43957 + +**Scale**: Major new collection type (`PriorityQueue<TElement, TPriority>`). + +**What made it succeed**: +- Backed by empirical research: surveyed .NET codebases for usage patterns, ran benchmarks across prototypes. Key finding: "90% of use cases do not require priority updates" and "implementations without update support are 2-3x faster." +- 8 explicit design decisions with rationale (e.g., "does not implement `IEnumerable` because elements cannot be efficiently enumerated in priority order") +- Open questions listed with tentative answers (e.g., "Should we use `KeyValuePair` instead of tuples? — We will use tuple types.") +- Link to a working prototype repo +- Implementation checklist (product code, benchmarks, property-based tests, API docs) +- Clean declaration format for the full API surface — no diff markers, no implementation code + +**Key lesson**: This proposal succeeded where the original PriorityQueue proposal (#14032) had stalled for years. The difference was doing the research first — empirical data (benchmarks, codebase surveys) resolved the design tensions that had blocked progress. The prototype was the evidence. + +--- + +## Annotated Summaries + +### PriorityQueue.DequeueEnqueue (#75070) — Small Proposal + +> Source: https://github.com/dotnet/runtime/issues/75070 + +**Scale**: Single method addition to an existing type. + +**What made it succeed**: +- Concise motivation: "extract-then-insert operations are generally more efficient than sequential extract/insert" +- Referenced prior art: Python's `heapq.heapreplace` +- Showed a concrete use case (linked list merge with priority queue) +- API surface was a single method with a clear `diff` block +- Risk section was brief but specific: "must be correctly optimized to outperform sequential Dequeue/Enqueue" + +**Key lesson**: A small proposal can be very short. The motivation was one paragraph. The usage example was one code block. This was sufficient because the concept (optimized pop-push) is well-understood. + +--- + +### snake_case Naming Policies (#782) — Medium Proposal + +> Source: https://github.com/dotnet/runtime/issues/782 + +**Scale**: Adding 4 static properties and 4 enum members across 2 types. + +**What made it succeed**: +- Clean `diff` format showing the additions alongside the existing `CamelCase` property +- Referenced Newtonsoft.Json behavior ("same behavior as Newtonsoft.Json") +- Pointed to GitHub's API as a concrete, widely-known use case for snake_case +- Scope was complete — covered both lower and upper variants for snake_case AND kebab-case + +**Key lesson**: The proposal succeeded because the reviewer could immediately understand it from the diff alone. The motivation was brief because snake_case support is a well-understood need. + +--- + +### Convert.ToHexString Lowercase (#60393) — Small Proposal + +> Source: https://github.com/dotnet/runtime/issues/60393 + +**Scale**: Adding a parameter to existing overloads. + +**What made it succeed**: +- Pointed to internal code that already supported the feature (`HexConverter.ToString` accepts casing) +- Motivation was brief: "in some scenarios lowercase is required" +- The implementation was trivial because the capability already existed internally + +**Key lesson**: When the internal infrastructure already supports a feature, the proposal can be minimal. The key evidence was "the internal class already supports this." + +--- + +### Async ZipFile APIs (#1541) — Large Pattern-Extension Proposal + +> Source: https://github.com/dotnet/runtime/issues/1541 + +**Scale**: Large API surface (30+ async counterparts across 3 types and 1 extension class), but following a well-understood pattern. + +**What made it succeed**: +- Motivation was one sentence: "All ZipFile APIs are currently synchronous. This means manipulations to zip files will always block a thread." +- Clean `diff` format showing each async method alongside its sync counterpart — the reviewer could immediately see the 1:1 mapping +- Explicitly addressed scope decisions: noted that `CreateEntry` had no async work inside, so `CreateEntryAsync` was proposed as optional +- Multiple usage examples covering different scenarios (read, update, create from directory) +- Implementation plan with a phased checklist + +**Key lesson**: A large API surface that follows a well-understood pattern (async counterparts for sync APIs) needs minimal motivation — the case is self-evident. The proposal's value was in being thorough about scope (which methods genuinely benefit from async) and providing clean, reviewable diffs. Depth of text was minimal despite the large surface area. + +--- + +### Options ValidateOnStart (#36391) — Medium Behavioral Proposal + +> Source: https://github.com/dotnet/runtime/issues/36391 + +**Scale**: Single extension method, but with significant behavioral implications for application startup. + +**What made it succeed**: +- Clear problem statement: "we want to get immediate feedback on validation problems — exceptions thrown on app startup rather than later" +- Terse API surface — just one extension method — but with detailed explanation of how it interacts with the existing `IOptions`/`IOptionsSnapshot`/`IOptionsMonitor` ecosystem +- Usage example was 3 lines of fluent builder code that immediately communicated the intent +- Explicitly scoped: "these APIs don't trigger for IOptionsSnapshot and IOptionsMonitor, where values may get recomputed on every request" + +**Key lesson**: Even a single method can require behavioral context when it changes when things happen (startup vs. lazy). The proposal was terse on API surface but precise on behavioral semantics — which aspects of the existing system it interacts with and which it doesn't. + +--- + +### JsonSerializerOptions.RespectNullableAnnotations (#74385) — Medium Proposal + +> Source: https://github.com/dotnet/runtime/issues/74385 + +**Scale**: Adding a boolean property with significant behavioral implications. + +**What made it succeed**: +- Strong community demand (83 reactions) established the motivation +- Detailed behavior specification covering edge cases +- Addressed the "what about source generators?" question proactively +- Backward compatibility was carefully designed (opt-in behavior) + +**Key lesson**: Even a single boolean property can require extensive proposal text when the behavioral implications are significant. The depth was proportional to the novelty of the concept, not the API surface size. 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..7759d9a31fa80a 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<TSource>(this System.Linq.IQueryable<TSource> source, TSource defaultValue) { throw null; } public static TSource First<TSource>(this System.Linq.IQueryable<TSource> source) { throw null; } public static TSource First<TSource>(this System.Linq.IQueryable<TSource> source, System.Linq.Expressions.Expression<System.Func<TSource, bool>> predicate) { throw null; } + public static System.Linq.IQueryable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(this System.Linq.IQueryable<TOuter> outer, System.Collections.Generic.IEnumerable<TInner> inner, System.Linq.Expressions.Expression<System.Func<TOuter, TKey>> outerKeySelector, System.Linq.Expressions.Expression<System.Func<TInner, TKey>> innerKeySelector, System.Linq.Expressions.Expression<System.Func<TOuter?, TInner?, TResult>> resultSelector) { throw null; } + public static System.Linq.IQueryable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(this System.Linq.IQueryable<TOuter> outer, System.Collections.Generic.IEnumerable<TInner> inner, System.Linq.Expressions.Expression<System.Func<TOuter, TKey>> outerKeySelector, System.Linq.Expressions.Expression<System.Func<TInner, TKey>> innerKeySelector, System.Linq.Expressions.Expression<System.Func<TOuter?, TInner?, TResult>> resultSelector, System.Collections.Generic.IEqualityComparer<TKey>? comparer) { throw null; } public static System.Linq.IQueryable<System.Linq.IGrouping<TKey, TSource>> GroupBy<TSource, TKey>(this System.Linq.IQueryable<TSource> source, System.Linq.Expressions.Expression<System.Func<TSource, TKey>> keySelector) { throw null; } public static System.Linq.IQueryable<System.Linq.IGrouping<TKey, TSource>> GroupBy<TSource, TKey>(this System.Linq.IQueryable<TSource> source, System.Linq.Expressions.Expression<System.Func<TSource, TKey>> keySelector, System.Collections.Generic.IEqualityComparer<TKey>? comparer) { throw null; } public static System.Linq.IQueryable<System.Linq.IGrouping<TKey, TElement>> GroupBy<TSource, TKey, TElement>(this System.Linq.IQueryable<TSource> source, System.Linq.Expressions.Expression<System.Func<TSource, TKey>> keySelector, System.Linq.Expressions.Expression<System.Func<TSource, TElement>> 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..25ac889502d0c9 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,111 @@ public static IQueryable<TResult> RightJoin<TOuter, TInner, TKey, TResult>(this outer.Expression, GetSourceExpression(inner), Expression.Quote(outerKeySelector), Expression.Quote(innerKeySelector), Expression.Quote(resultSelector), Expression.Constant(comparer, typeof(IEqualityComparer<TKey>)))); } + /// <summary> + /// Correlates the elements of two sequences based on matching keys. Elements from either sequence that have no match in the other sequence are included with a default counterpart. + /// The default equality comparer is used to compare keys. + /// </summary> + /// <param name="outer">The first sequence to join.</param> + /// <param name="inner">The sequence to join to the first sequence.</param> + /// <param name="outerKeySelector">A function to extract the join key from each element of the first sequence.</param> + /// <param name="innerKeySelector">A function to extract the join key from each element of the second sequence.</param> + /// <param name="resultSelector">A function to create a result element from two matching elements.</param> + /// <typeparam name="TOuter">The type of the elements of the first sequence.</typeparam> + /// <typeparam name="TInner">The type of the elements of the second sequence.</typeparam> + /// <typeparam name="TKey">The type of the keys returned by the key selector functions.</typeparam> + /// <typeparam name="TResult">The type of the result elements.</typeparam> + /// <returns>An <see cref="IEnumerable{T}" /> that has elements of type <typeparamref name="TResult" /> that are obtained by performing a full outer join on two sequences.</returns> + /// <exception cref="ArgumentNullException"><paramref name="outer" /> or <paramref name="inner" /> or <paramref name="outerKeySelector" /> or <paramref name="innerKeySelector" /> or <paramref name="resultSelector" /> is <see langword="null" />.</exception> + /// <remarks> + /// <para> + /// This method has at least one parameter of type <see cref="Expression{TDelegate}"/> whose type argument is one + /// of the <see cref="Func{T,TResult}"/> types. + /// For these parameters, you can pass in a lambda expression and it will be compiled to an <see cref="Expression{TDelegate}"/>. + /// </para> + /// <para> + /// The <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IQueryable{TOuter}, IEnumerable{TInner}, Expression{Func{TOuter, TKey}}, Expression{Func{TInner, TKey}}, Expression{Func{TOuter, TInner, TResult}})"/> method generates a <see cref="MethodCallExpression"/> that represents + /// calling <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IQueryable{TOuter}, IEnumerable{TInner}, Expression{Func{TOuter, TKey}}, Expression{Func{TInner, TKey}}, Expression{Func{TOuter, TInner, TResult}})"/> itself as a constructed generic method. + /// It then passes the <see cref="MethodCallExpression"/> to the <see cref="IQueryProvider.CreateQuery{TElement}(Expression)"/> method + /// of the <see cref="IQueryProvider"/> represented by the <see cref="IQueryable.Provider"/> property of the <paramref name="outer"/> parameter. + /// </para> + /// <para> + /// The query behavior that occurs as a result of executing an expression tree that represents calling + /// <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IQueryable{TOuter}, IEnumerable{TInner}, Expression{Func{TOuter, TKey}}, Expression{Func{TInner, TKey}}, Expression{Func{TOuter, TInner, TResult}})"/> depends on the implementation of the type of the <paramref name="outer"/> parameter. + /// The expected behavior is that it correlates elements from both sequences based on equal keys. + /// These keys are compared for equality to match elements from each sequence. + /// A pair of elements is stored for each match, plus a pair for each element in <paramref name="outer" /> or <paramref name="inner" /> that has no matches in the other sequence. + /// Then the <paramref name="resultSelector" /> function is invoked to project a result object from each pair of elements. + /// </para> + /// </remarks> + [DynamicDependency("FullJoin`4", typeof(Enumerable))] + public static IQueryable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(this IQueryable<TOuter> outer, IEnumerable<TInner> inner, Expression<Func<TOuter, TKey>> outerKeySelector, Expression<Func<TInner, TKey>> innerKeySelector, Expression<Func<TOuter?, TInner?, TResult>> resultSelector) + { + ArgumentNullException.ThrowIfNull(outer); + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(outerKeySelector); + ArgumentNullException.ThrowIfNull(innerKeySelector); + ArgumentNullException.ThrowIfNull(resultSelector); + + return outer.Provider.CreateQuery<TResult>( + Expression.Call( + null, + new Func<IQueryable<TOuter>, IEnumerable<TInner>, Expression<Func<TOuter, TKey>>, Expression<Func<TInner, TKey>>, Expression<Func<TOuter?, TInner?, TResult>>, IQueryable<TResult>>(FullJoin).Method, + outer.Expression, GetSourceExpression(inner), Expression.Quote(outerKeySelector), Expression.Quote(innerKeySelector), Expression.Quote(resultSelector))); + } + + /// <summary> + /// Correlates the elements of two sequences based on matching keys. Elements from either sequence that have no match in the other sequence are included with a default counterpart. + /// A specified <see cref="IEqualityComparer{T}" /> is used to compare keys. + /// </summary> + /// <param name="outer">The first sequence to join.</param> + /// <param name="inner">The sequence to join to the first sequence.</param> + /// <param name="outerKeySelector">A function to extract the join key from each element of the first sequence.</param> + /// <param name="innerKeySelector">A function to extract the join key from each element of the second sequence.</param> + /// <param name="resultSelector">A function to create a result element from two matching elements.</param> + /// <param name="comparer">An <see cref="IEqualityComparer{T}" /> to hash and compare keys.</param> + /// <typeparam name="TOuter">The type of the elements of the first sequence.</typeparam> + /// <typeparam name="TInner">The type of the elements of the second sequence.</typeparam> + /// <typeparam name="TKey">The type of the keys returned by the key selector functions.</typeparam> + /// <typeparam name="TResult">The type of the result elements.</typeparam> + /// <returns>An <see cref="IEnumerable{T}" /> that has elements of type <typeparamref name="TResult" /> that are obtained by performing a full outer join on two sequences.</returns> + /// <exception cref="ArgumentNullException"><paramref name="outer" /> or <paramref name="inner" /> or <paramref name="outerKeySelector" /> or <paramref name="innerKeySelector" /> or <paramref name="resultSelector" /> is <see langword="null" />.</exception> + /// <remarks> + /// <para> + /// This method has at least one parameter of type <see cref="Expression{TDelegate}"/> whose type argument is one + /// of the <see cref="Func{T,TResult}"/> types. + /// For these parameters, you can pass in a lambda expression and it will be compiled to an <see cref="Expression{TDelegate}"/>. + /// </para> + /// <para> + /// The <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IQueryable{TOuter}, IEnumerable{TInner}, Expression{Func{TOuter, TKey}}, Expression{Func{TInner, TKey}}, Expression{Func{TOuter, TInner, TResult}}, IEqualityComparer{TKey})"/> method generates a <see cref="MethodCallExpression"/> that represents + /// calling <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IQueryable{TOuter}, IEnumerable{TInner}, Expression{Func{TOuter, TKey}}, Expression{Func{TInner, TKey}}, Expression{Func{TOuter, TInner, TResult}}, IEqualityComparer{TKey})"/> itself as a constructed generic method. + /// It then passes the <see cref="MethodCallExpression"/> to the <see cref="IQueryProvider.CreateQuery{TElement}(Expression)"/> method + /// of the <see cref="IQueryProvider"/> represented by the <see cref="IQueryable.Provider"/> property of the <paramref name="outer"/> parameter. + /// </para> + /// <para> + /// The query behavior that occurs as a result of executing an expression tree that represents calling + /// <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IQueryable{TOuter}, IEnumerable{TInner}, Expression{Func{TOuter, TKey}}, Expression{Func{TInner, TKey}}, Expression{Func{TOuter, TInner, TResult}}, IEqualityComparer{TKey})"/> depends on the implementation of the type of the <paramref name="outer"/> parameter. + /// The expected behavior is that it correlates elements from both sequences based on equal keys. + /// These keys are compared for equality to match elements from each sequence. + /// A pair of elements is stored for each match, plus a pair for each element in <paramref name="outer" /> or <paramref name="inner" /> that has no matches in the other sequence. + /// Then the <paramref name="resultSelector" /> function is invoked to project a result object from each pair of elements. + /// </para> + /// </remarks> + [DynamicDependency("FullJoin`4", typeof(Enumerable))] + public static IQueryable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(this IQueryable<TOuter> outer, IEnumerable<TInner> inner, Expression<Func<TOuter, TKey>> outerKeySelector, Expression<Func<TInner, TKey>> innerKeySelector, Expression<Func<TOuter?, TInner?, TResult>> resultSelector, IEqualityComparer<TKey>? comparer) + { + ArgumentNullException.ThrowIfNull(outer); + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(outerKeySelector); + ArgumentNullException.ThrowIfNull(innerKeySelector); + ArgumentNullException.ThrowIfNull(resultSelector); + + return outer.Provider.CreateQuery<TResult>( + Expression.Call( + null, + new Func<IQueryable<TOuter>, IEnumerable<TInner>, Expression<Func<TOuter, TKey>>, Expression<Func<TInner, TKey>>, Expression<Func<TOuter?, TInner?, TResult>>, IEqualityComparer<TKey>, IQueryable<TResult>>(FullJoin).Method, + outer.Expression, GetSourceExpression(inner), Expression.Quote(outerKeySelector), Expression.Quote(innerKeySelector), Expression.Quote(resultSelector), Expression.Constant(comparer, typeof(IEqualityComparer<TKey>)))); + } + [DynamicDependency("ThenBy`2", typeof(Enumerable))] public static IOrderedQueryable<TSource> ThenBy<TSource, TKey>(this IOrderedQueryable<TSource> source, Expression<Func<TSource, TKey>> 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..6e48c235a531ad --- /dev/null +++ b/src/libraries/System.Linq.Queryable/tests/FullJoinTests.cs @@ -0,0 +1,151 @@ +// 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 JoinRec + { + public string name; + public int orderID; + public int total; + } + + [Fact] + public void MixedMatchAndUnmatched() + { + 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 = string.Empty, 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 ?? string.Empty, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void NullComparer() + { + CustomerRec[] outer = { + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 } + }; + OrderRec[] inner = { + new OrderRec{ orderID = 45321, custID = 98022, total = 50 }, + new OrderRec{ orderID = 95421, custID = 99021, total = 9 } + }; + JoinRec[] expected = { + new JoinRec{ name = "Prakash", orderID = 45321, total = 50 }, + new JoinRec{ name = "Tim", orderID = 95421, total = 9 } + }; + + Assert.Equal(expected, outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e.custID, e => e.custID, (cr, or) => new JoinRec { name = cr.name ?? string.Empty, orderID = or.orderID, total = or.total }, null)); + } + + [Fact] + public void OuterNull() + { + IQueryable<CustomerRec> outer = null; + OrderRec[] inner = { new OrderRec { orderID = 45321, custID = 98022, total = 50 } }; + + AssertExtensions.Throws<ArgumentNullException>("outer", () => outer.FullJoin(inner.AsQueryable(), e => e.custID, e => e.custID, (cr, or) => new JoinRec { name = cr.name ?? string.Empty, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void InnerNull() + { + CustomerRec[] outer = { new CustomerRec { name = "Prakash", custID = 98022 } }; + IQueryable<OrderRec> inner = null; + + AssertExtensions.Throws<ArgumentNullException>("inner", () => outer.AsQueryable().FullJoin(inner, e => e.custID, e => e.custID, (cr, or) => new JoinRec { name = cr.name ?? string.Empty, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void OuterKeySelectorNull() + { + CustomerRec[] outer = { new CustomerRec { name = "Prakash", custID = 98022 } }; + OrderRec[] inner = { new OrderRec { orderID = 45321, custID = 98022, total = 50 } }; + + AssertExtensions.Throws<ArgumentNullException>("outerKeySelector", () => outer.AsQueryable().FullJoin(inner.AsQueryable(), null, e => e.custID, (cr, or) => new JoinRec { name = cr.name ?? string.Empty, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void InnerKeySelectorNull() + { + CustomerRec[] outer = { new CustomerRec { name = "Prakash", custID = 98022 } }; + OrderRec[] inner = { new OrderRec { orderID = 45321, custID = 98022, total = 50 } }; + + AssertExtensions.Throws<ArgumentNullException>("innerKeySelector", () => outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e.custID, null, (cr, or) => new JoinRec { name = cr.name ?? string.Empty, orderID = or.orderID, total = or.total })); + } + + [Fact] + public void ResultSelectorNull() + { + CustomerRec[] outer = { new CustomerRec { name = "Prakash", custID = 98022 } }; + OrderRec[] inner = { new OrderRec { orderID = 45321, custID = 98022, total = 50 } }; + + AssertExtensions.Throws<ArgumentNullException>("resultSelector", () => outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e.custID, e => e.custID, (Expression<Func<CustomerRec, OrderRec, JoinRec>>)null)); + } + + [Fact] + public void OuterNullWithComparer() + { + IQueryable<CustomerRec> outer = null; + OrderRec[] inner = { new OrderRec { orderID = 45321, custID = 98022, total = 50 } }; + + AssertExtensions.Throws<ArgumentNullException>("outer", () => outer.FullJoin(inner.AsQueryable(), e => e.custID, e => e.custID, (cr, or) => new JoinRec { name = cr.name ?? string.Empty, orderID = or.orderID, total = or.total }, EqualityComparer<int>.Default)); + } + + [Fact] + public void ResultSelectorNullWithComparer() + { + CustomerRec[] outer = { new CustomerRec { name = "Prakash", custID = 98022 } }; + OrderRec[] inner = { new OrderRec { orderID = 45321, custID = 98022, total = 50 } }; + + AssertExtensions.Throws<ArgumentNullException>("resultSelector", () => outer.AsQueryable().FullJoin(inner.AsQueryable(), e => e.custID, e => e.custID, (Expression<Func<CustomerRec, OrderRec, JoinRec>>)null, EqualityComparer<int>.Default)); + } + + [Fact] + public void FullJoinNoComparer() + { + 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); // 0-unmatched, 1+1, 2+2, 3-unmatched + } + + [Fact] + public void FullJoinWithComparer() + { + var count = new[] { 0, 1, 2 }.AsQueryable().FullJoin(new[] { 1, 2, 3 }, n1 => n1, n2 => n2, (n1, n2) => n1 + n2, EqualityComparer<int>.Default).Count(); + Assert.Equal(4, count); + } + } +} 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..aa2ab4c462d7fb 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 @@ -33,6 +33,7 @@ <Compile Include="LastTests.cs" /> <Compile Include="ShuffleTests.cs" /> <Compile Include="RightJoinTests.cs" /> + <Compile Include="FullJoinTests.cs" /> <Compile Include="LongCountTests.cs" /> <Compile Include="MaxTests.cs" /> <Compile Include="MinTests.cs" /> diff --git a/src/libraries/System.Linq/ref/System.Linq.cs b/src/libraries/System.Linq/ref/System.Linq.cs index 0c2d82b2111766..92436fe0cdb67f 100644 --- a/src/libraries/System.Linq/ref/System.Linq.cs +++ b/src/libraries/System.Linq/ref/System.Linq.cs @@ -4,17 +4,15 @@ // Changes to this file must follow the https://aka.ms/api-review process. // ------------------------------------------------------------------------------ -using System.Collections.Generic; - namespace System.Linq { public static partial class Enumerable { + public static System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<TKey, TAccumulate>> AggregateBy<TSource, TKey, TAccumulate>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, System.Func<TKey, TAccumulate> seedSelector, System.Func<TAccumulate, TSource, TAccumulate> func, System.Collections.Generic.IEqualityComparer<TKey>? keyComparer = null) where TKey : notnull { throw null; } + public static System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<TKey, TAccumulate>> AggregateBy<TSource, TKey, TAccumulate>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, TAccumulate seed, System.Func<TAccumulate, TSource, TAccumulate> func, System.Collections.Generic.IEqualityComparer<TKey>? keyComparer = null) where TKey : notnull { throw null; } public static TSource Aggregate<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TSource, TSource> func) { throw null; } public static TAccumulate Aggregate<TSource, TAccumulate>(this System.Collections.Generic.IEnumerable<TSource> source, TAccumulate seed, System.Func<TAccumulate, TSource, TAccumulate> func) { throw null; } public static TResult Aggregate<TSource, TAccumulate, TResult>(this System.Collections.Generic.IEnumerable<TSource> source, TAccumulate seed, System.Func<TAccumulate, TSource, TAccumulate> func, System.Func<TAccumulate, TResult> resultSelector) { throw null; } - public static System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<TKey, TAccumulate>> AggregateBy<TSource, TKey, TAccumulate>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, TAccumulate seed, System.Func<TAccumulate, TSource, TAccumulate> func, System.Collections.Generic.IEqualityComparer<TKey>? keyComparer = null) where TKey : notnull { throw null; } - public static System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<TKey, TAccumulate>> AggregateBy<TSource, TKey, TAccumulate>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, System.Func<TKey, TAccumulate> seedSelector, System.Func<TAccumulate, TSource, TAccumulate> func, System.Collections.Generic.IEqualityComparer<TKey>? keyComparer = null) where TKey : notnull { throw null; } public static bool All<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate) { throw null; } public static bool Any<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } public static bool Any<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate) { throw null; } @@ -40,18 +38,14 @@ public static partial class Enumerable public static double? Average<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, long?> selector) { throw null; } public static float? Average<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, float?> selector) { throw null; } public static float Average<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, float> selector) { throw null; } - public static System.Collections.Generic.IEnumerable< -#nullable disable - TResult -#nullable restore - > Cast<TResult>(this System.Collections.IEnumerable source) { throw null; } + public static System.Collections.Generic.IEnumerable<TResult> Cast<TResult>(this System.Collections.IEnumerable source) { throw null; } public static System.Collections.Generic.IEnumerable<TSource[]> Chunk<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, int size) { throw null; } public static System.Collections.Generic.IEnumerable<TSource> Concat<TSource>(this System.Collections.Generic.IEnumerable<TSource> first, System.Collections.Generic.IEnumerable<TSource> second) { throw null; } public static bool Contains<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource value) { throw null; } public static bool Contains<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource value, System.Collections.Generic.IEqualityComparer<TSource>? comparer) { throw null; } + public static System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<TKey, int>> CountBy<TSource, TKey>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, System.Collections.Generic.IEqualityComparer<TKey>? keyComparer = null) where TKey : notnull { throw null; } public static int Count<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } public static int Count<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate) { throw null; } - public static System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<TKey, int>> CountBy<TSource, TKey>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, System.Collections.Generic.IEqualityComparer<TKey>? keyComparer = null) where TKey : notnull { throw null; } public static System.Collections.Generic.IEnumerable<TSource?> DefaultIfEmpty<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } public static System.Collections.Generic.IEnumerable<TSource> DefaultIfEmpty<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource defaultValue) { throw null; } public static System.Collections.Generic.IEnumerable<TSource> DistinctBy<TSource, TKey>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector) { throw null; } @@ -68,11 +62,13 @@ public static System.Collections.Generic.IEnumerable< public static System.Collections.Generic.IEnumerable<TSource> Except<TSource>(this System.Collections.Generic.IEnumerable<TSource> first, System.Collections.Generic.IEnumerable<TSource> second) { throw null; } public static System.Collections.Generic.IEnumerable<TSource> Except<TSource>(this System.Collections.Generic.IEnumerable<TSource> first, System.Collections.Generic.IEnumerable<TSource> second, System.Collections.Generic.IEqualityComparer<TSource>? comparer) { throw null; } public static TSource? FirstOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } - public static TSource FirstOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource defaultValue) { throw null; } public static TSource? FirstOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate) { throw null; } public static TSource FirstOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate, TSource defaultValue) { throw null; } + public static TSource FirstOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource defaultValue) { throw null; } public static TSource First<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } public static TSource First<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate) { throw null; } + public static System.Collections.Generic.IEnumerable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(this System.Collections.Generic.IEnumerable<TOuter> outer, System.Collections.Generic.IEnumerable<TInner> inner, System.Func<TOuter, TKey> outerKeySelector, System.Func<TInner, TKey> innerKeySelector, System.Func<TOuter?, TInner?, TResult> resultSelector) { throw null; } + public static System.Collections.Generic.IEnumerable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(this System.Collections.Generic.IEnumerable<TOuter> outer, System.Collections.Generic.IEnumerable<TInner> inner, System.Func<TOuter, TKey> outerKeySelector, System.Func<TInner, TKey> innerKeySelector, System.Func<TOuter?, TInner?, TResult> resultSelector, System.Collections.Generic.IEqualityComparer<TKey>? comparer) { throw null; } public static System.Collections.Generic.IEnumerable<System.Linq.IGrouping<TKey, TSource>> GroupBy<TSource, TKey>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector) { throw null; } public static System.Collections.Generic.IEnumerable<System.Linq.IGrouping<TKey, TSource>> GroupBy<TSource, TKey>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, System.Collections.Generic.IEqualityComparer<TKey>? comparer) { throw null; } public static System.Collections.Generic.IEnumerable<System.Linq.IGrouping<TKey, TElement>> GroupBy<TSource, TKey, TElement>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TKey> keySelector, System.Func<TSource, TElement> elementSelector) { throw null; } @@ -92,9 +88,9 @@ public static System.Collections.Generic.IEnumerable< public static System.Collections.Generic.IEnumerable<TResult> Join<TOuter, TInner, TKey, TResult>(this System.Collections.Generic.IEnumerable<TOuter> outer, System.Collections.Generic.IEnumerable<TInner> inner, System.Func<TOuter, TKey> outerKeySelector, System.Func<TInner, TKey> innerKeySelector, System.Func<TOuter, TInner, TResult> resultSelector) { throw null; } public static System.Collections.Generic.IEnumerable<TResult> Join<TOuter, TInner, TKey, TResult>(this System.Collections.Generic.IEnumerable<TOuter> outer, System.Collections.Generic.IEnumerable<TInner> inner, System.Func<TOuter, TKey> outerKeySelector, System.Func<TInner, TKey> innerKeySelector, System.Func<TOuter, TInner, TResult> resultSelector, System.Collections.Generic.IEqualityComparer<TKey>? comparer) { throw null; } public static TSource? LastOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } - public static TSource LastOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource defaultValue) { throw null; } public static TSource? LastOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate) { throw null; } public static TSource LastOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate, TSource defaultValue) { throw null; } + public static TSource LastOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource defaultValue) { throw null; } public static TSource Last<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } public static TSource Last<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate) { throw null; } public static System.Collections.Generic.IEnumerable<TResult> LeftJoin<TOuter, TInner, TKey, TResult>(this System.Collections.Generic.IEnumerable<TOuter> outer, System.Collections.Generic.IEnumerable<TInner> inner, System.Func<TOuter, TKey> outerKeySelector, System.Func<TInner, TKey> innerKeySelector, System.Func<TOuter, TInner?, TResult> resultSelector) { throw null; } @@ -173,14 +169,14 @@ public static System.Collections.Generic.IEnumerable< public static System.Collections.Generic.IEnumerable<TResult> SelectMany<TSource, TCollection, TResult>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, int, System.Collections.Generic.IEnumerable<TCollection>> collectionSelector, System.Func<TSource, TCollection, TResult> resultSelector) { throw null; } public static System.Collections.Generic.IEnumerable<TResult> Select<TSource, TResult>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, int, TResult> selector) { throw null; } public static System.Collections.Generic.IEnumerable<TResult> Select<TSource, TResult>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, TResult> selector) { throw null; } - public static System.Collections.Generic.IEnumerable<T> Sequence<T>(T start, T endInclusive, T step) where T : System.Numerics.INumber<T> { throw null; } public static bool SequenceEqual<TSource>(this System.Collections.Generic.IEnumerable<TSource> first, System.Collections.Generic.IEnumerable<TSource> second) { throw null; } public static bool SequenceEqual<TSource>(this System.Collections.Generic.IEnumerable<TSource> first, System.Collections.Generic.IEnumerable<TSource> second, System.Collections.Generic.IEqualityComparer<TSource>? comparer) { throw null; } + public static System.Collections.Generic.IEnumerable<T> Sequence<T>(T start, T endInclusive, T step) where T : System.Numerics.INumber<T> { throw null; } public static System.Collections.Generic.IEnumerable<TSource> Shuffle<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } public static TSource? SingleOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } - public static TSource SingleOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource defaultValue) { throw null; } public static TSource? SingleOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate) { throw null; } public static TSource SingleOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate, TSource defaultValue) { throw null; } + public static TSource SingleOrDefault<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource defaultValue) { throw null; } public static TSource Single<TSource>(this System.Collections.Generic.IEnumerable<TSource> source) { throw null; } public static TSource Single<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, System.Func<TSource, bool> predicate) { throw null; } public static System.Collections.Generic.IEnumerable<TSource> SkipLast<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, int count) { 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 @@ <Compile Include="System\Linq\Enumerable.cs" /> <Compile Include="System\Linq\Except.cs" /> <Compile Include="System\Linq\First.cs" /> + <Compile Include="System\Linq\FullJoin.cs" /> <Compile Include="System\Linq\Grouping.cs" /> <Compile Include="System\Linq\Grouping.SpeedOpt.cs" /> <Compile Include="System\Linq\GroupJoin.cs" /> 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..6d8fdc1c5bd2e9 --- /dev/null +++ b/src/libraries/System.Linq/src/System/Linq/FullJoin.cs @@ -0,0 +1,295 @@ +// 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 + { + /// <summary> + /// Correlates the elements of two sequences based on matching keys. Elements from either sequence that have no match in the other sequence are included with a default counterpart. + /// The default equality comparer is used to compare keys. + /// </summary> + /// <param name="outer">The first sequence to join.</param> + /// <param name="inner">The sequence to join to the first sequence.</param> + /// <param name="outerKeySelector">A function to extract the join key from each element of the first sequence.</param> + /// <param name="innerKeySelector">A function to extract the join key from each element of the second sequence.</param> + /// <param name="resultSelector">A function to create a result element from two matching elements.</param> + /// <typeparam name="TOuter">The type of the elements of the first sequence.</typeparam> + /// <typeparam name="TInner">The type of the elements of the second sequence.</typeparam> + /// <typeparam name="TKey">The type of the keys returned by the key selector functions.</typeparam> + /// <typeparam name="TResult">The type of the result elements.</typeparam> + /// <returns>An <see cref="IEnumerable{T}" /> that has elements of type <typeparamref name="TResult" /> that are obtained by performing a full outer join on two sequences.</returns> + /// <exception cref="ArgumentNullException"><paramref name="outer" /> or <paramref name="inner" /> or <paramref name="outerKeySelector" /> or <paramref name="innerKeySelector" /> or <paramref name="resultSelector" /> is <see langword="null" />.</exception> + /// <example> + /// <para> + /// The following code example demonstrates how to use <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IEnumerable{TOuter}, IEnumerable{TInner}, Func{TOuter, TKey}, Func{TInner, TKey}, Func{TOuter, TInner, TResult})" /> to perform a full outer join of two sequences based on a common key. + /// </para> + /// <code> + /// class Person + /// { + /// public string Name { get; set; } + /// } + /// + /// class Pet + /// { + /// public string Name { get; set; } + /// public Person Owner { get; set; } + /// } + /// + /// public static void FullJoin() + /// { + /// Person magnus = new Person { Name = "Hedlund, Magnus" }; + /// Person terry = new Person { Name = "Adams, Terry" }; + /// Person charlotte = new Person { Name = "Weiss, Charlotte" }; + /// Person tom = new Person { Name = "Chapkin, Tom" }; + /// + /// 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 }; + /// Pet stray = new Pet { Name = "Stray", Owner = null }; + /// + /// List{Person} people = new List{Person} { terry, charlotte, tom }; + /// List{Pet} pets = new List{Pet} { barley, boots, whiskers, daisy, stray }; + /// + /// // Create a list of Person-Pet pairs where + /// // each element is an anonymous type that contains a + /// // Pet's name and the name of the Person that owns the Pet. + /// // All people and all pets are included, even if unmatched. + /// var query = + /// people.FullJoin(pets, + /// person => person, + /// pet => pet.Owner, + /// (person, pet) => + /// new { OwnerName = person?.Name, Pet = pet?.Name }); + /// + /// foreach (var obj in query) + /// { + /// Console.WriteLine( + /// "{0} - {1}", + /// obj.OwnerName ?? "NONE", + /// obj.Pet ?? "NONE"); + /// } + /// } + /// + /// /* + /// This code produces the following output: + /// + /// Adams, Terry - Barley + /// Adams, Terry - Boots + /// Weiss, Charlotte - Whiskers + /// Chapkin, Tom - NONE + /// NONE - Daisy + /// */ + /// </code> + /// </example> + /// <remarks> + /// <para> + /// 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 <c>GetEnumerator</c> method directly or by + /// using <c>foreach</c> in C# or <c>For Each</c> in Visual Basic. + /// </para> + /// <para> + /// The default equality comparer, <see cref="EqualityComparer{T}.Default" />, is used to hash and compare keys. + /// </para> + /// <para> + /// A join refers to the operation of correlating the elements of two sources of information based on a common key. + /// <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IEnumerable{TOuter}, IEnumerable{TInner}, Func{TOuter, TKey}, Func{TInner, TKey}, Func{TOuter, TInner, TResult})" /> + /// brings the two information sources and the keys by which they are matched together in one method call. + /// </para> + /// <para> + /// In relational database terms, the <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IEnumerable{TOuter}, IEnumerable{TInner}, Func{TOuter, TKey}, Func{TInner, TKey}, Func{TOuter, TInner, TResult})" /> method implements a full outer equijoin. + /// 'Full outer' means that elements of both the first and second 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. + /// An inner join - where only elements that have a match in the other sequence are included in the results - can be performed using the + /// <see cref="Join{TOuter, TInner, TKey, TResult}(IEnumerable{TOuter}, IEnumerable{TInner}, Func{TOuter, TKey}, Func{TInner, TKey}, Func{TOuter, TInner, TResult})" /> method. + /// For more information, see <see href="/dotnet/csharp/linq/standard-query-operators/join-operations">Join operations</see>. + /// </para> + /// </remarks> + public static IEnumerable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(this IEnumerable<TOuter> outer, IEnumerable<TInner> inner, Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector, Func<TOuter?, TInner?, TResult> resultSelector) => + FullJoin(outer, inner, outerKeySelector, innerKeySelector, resultSelector, comparer: null); + + /// <summary> + /// Correlates the elements of two sequences based on matching keys. Elements from either sequence that have no match in the other sequence are included with a default counterpart. + /// A specified <see cref="IEqualityComparer{T}" /> is used to compare keys. + /// </summary> + /// <param name="outer">The first sequence to join.</param> + /// <param name="inner">The sequence to join to the first sequence.</param> + /// <param name="outerKeySelector">A function to extract the join key from each element of the first sequence.</param> + /// <param name="innerKeySelector">A function to extract the join key from each element of the second sequence.</param> + /// <param name="resultSelector">A function to create a result element from two matching elements.</param> + /// <param name="comparer">An <see cref="IEqualityComparer{T}" /> to hash and compare keys.</param> + /// <typeparam name="TOuter">The type of the elements of the first sequence.</typeparam> + /// <typeparam name="TInner">The type of the elements of the second sequence.</typeparam> + /// <typeparam name="TKey">The type of the keys returned by the key selector functions.</typeparam> + /// <typeparam name="TResult">The type of the result elements.</typeparam> + /// <returns>An <see cref="IEnumerable{T}" /> that has elements of type <typeparamref name="TResult" /> that are obtained by performing a full outer join on two sequences.</returns> + /// <exception cref="ArgumentNullException"><paramref name="outer" /> or <paramref name="inner" /> or <paramref name="outerKeySelector" /> or <paramref name="innerKeySelector" /> or <paramref name="resultSelector" /> is <see langword="null" />.</exception> + /// <example> + /// <para> + /// The following code example demonstrates how to use <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IEnumerable{TOuter}, IEnumerable{TInner}, Func{TOuter, TKey}, Func{TInner, TKey}, Func{TOuter, TInner, TResult}, IEqualityComparer{TKey})" /> to perform a full outer join of two sequences based on a common key. + /// </para> + /// <code> + /// class Person + /// { + /// public string Name { get; set; } + /// } + /// + /// class Pet + /// { + /// public string Name { get; set; } + /// public Person Owner { get; set; } + /// } + /// + /// public static void FullJoin() + /// { + /// Person magnus = new Person { Name = "Hedlund, Magnus" }; + /// Person terry = new Person { Name = "Adams, Terry" }; + /// Person charlotte = new Person { Name = "Weiss, Charlotte" }; + /// Person tom = new Person { Name = "Chapkin, Tom" }; + /// + /// 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 }; + /// Pet stray = new Pet { Name = "Stray", Owner = null }; + /// + /// List{Person} people = new List{Person} { terry, charlotte, tom }; + /// List{Pet} pets = new List{Pet} { barley, boots, whiskers, daisy, stray }; + /// + /// // Create a list of Person-Pet pairs where + /// // each element is an anonymous type that contains a + /// // Pet's name and the name of the Person that owns the Pet. + /// // All people and all pets are included, even if unmatched. + /// var query = + /// people.FullJoin(pets, + /// person => person, + /// pet => pet.Owner, + /// (person, pet) => + /// new { OwnerName = person?.Name, Pet = pet?.Name }); + /// + /// foreach (var obj in query) + /// { + /// Console.WriteLine( + /// "{0} - {1}", + /// obj.OwnerName ?? "NONE", + /// obj.Pet ?? "NONE"); + /// } + /// } + /// + /// /* + /// This code produces the following output: + /// + /// Adams, Terry - Barley + /// Adams, Terry - Boots + /// Weiss, Charlotte - Whiskers + /// Chapkin, Tom - NONE + /// NONE - Daisy + /// */ + /// </code> + /// </example> + /// <remarks> + /// <para> + /// 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 <c>GetEnumerator</c> method directly or by + /// using <c>foreach</c> in C# or <c>For Each</c> in Visual Basic. + /// </para> + /// <para> + /// The default equality comparer, <see cref="EqualityComparer{T}.Default" />, is used to hash and compare keys. + /// </para> + /// <para> + /// A join refers to the operation of correlating the elements of two sources of information based on a common key. + /// <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IEnumerable{TOuter}, IEnumerable{TInner}, Func{TOuter, TKey}, Func{TInner, TKey}, Func{TOuter, TInner, TResult}, IEqualityComparer{TKey})" /> + /// brings the two information sources and the keys by which they are matched together in one method call. + /// </para> + /// <para> + /// In relational database terms, the <see cref="FullJoin{TOuter, TInner, TKey, TResult}(IEnumerable{TOuter}, IEnumerable{TInner}, Func{TOuter, TKey}, Func{TInner, TKey}, Func{TOuter, TInner, TResult}, IEqualityComparer{TKey})" /> method implements a full outer equijoin. + /// 'Full outer' means that elements of both the first and second 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. + /// An inner join - where only elements that have a match in the other sequence are included in the results - can be performed using the + /// <see cref="Join{TOuter, TInner, TKey, TResult}(IEnumerable{TOuter}, IEnumerable{TInner}, Func{TOuter, TKey}, Func{TInner, TKey}, Func{TOuter, TInner, TResult}, IEqualityComparer{TKey})" /> method. + /// For more information, see <see href="/dotnet/csharp/linq/standard-query-operators/join-operations">Join operations</see>. + /// </para> + /// </remarks> + public static IEnumerable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(this IEnumerable<TOuter> outer, IEnumerable<TInner> inner, Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector, Func<TOuter?, TInner?, TResult> resultSelector, IEqualityComparer<TKey>? comparer) + { + 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); + } + + private static IEnumerable<TResult> FullJoinIterator<TOuter, TInner, TKey, TResult>(IEnumerable<TOuter> outer, IEnumerable<TInner> inner, Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector, Func<TOuter?, TInner?, TResult> resultSelector, IEqualityComparer<TKey>? comparer) + { + 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; + + foreach (TOuter outerItem in outer) + { + Grouping<TKey, TInner>? g = innerLookup.GetGrouping(outerKeySelector(outerItem), create: false); + if (g is null) + { + yield return resultSelector(outerItem, default); + } + else + { + matchedGroupings!.Add(g); + int count = g._count; + TInner[] elements = g._elements; + for (int i = 0; i != count; ++i) + { + yield return resultSelector(outerItem, elements[i]); + } + } + } + + // Yield inner elements that had no matching outer element. + Grouping<TKey, TInner>? lastGrouping = innerLookup._lastGrouping; + if (lastGrouping is not null) + { + Grouping<TKey, TInner>? g = lastGrouping; + do + { + g = g._next!; + if (!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 != 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..538dd7a6de5a05 --- /dev/null +++ b/src/libraries/System.Linq/tests/FullJoinTests.cs @@ -0,0 +1,376 @@ +// 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 ?? string.Empty, + 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 = string.Empty, orderID = 45321, total = 50 }, + new JoinRec{ name = string.Empty, 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 AllMatch() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 } + ]; + OrderRec[] inner = + [ + new OrderRec{ orderID = 45321, custID = 98022, total = 50 }, + new OrderRec{ orderID = 95421, custID = 99021, total = 9 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 45321, total = 50 }, + new JoinRec{ name = "Tim", orderID = 95421, total = 9 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void NoMatch() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 } + ]; + OrderRec[] inner = + [ + new OrderRec{ orderID = 45321, custID = 11111, total = 50 }, + new OrderRec{ orderID = 95421, custID = 22222, total = 9 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 0, total = 0 }, + new JoinRec{ name = "Tim", orderID = 0, total = 0 }, + new JoinRec{ name = string.Empty, orderID = 45321, total = 50 }, + new JoinRec{ name = string.Empty, orderID = 95421, total = 9 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void MixedMatchAndUnmatched() + { + 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 = string.Empty, orderID = 43421, total = 20 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void InnerSameKeyMoreThanOneElement() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Tim", custID = 99021 } + ]; + OrderRec[] inner = + [ + new OrderRec{ orderID = 45321, custID = 98022, total = 50 }, + new OrderRec{ orderID = 45421, custID = 98022, total = 10 }, + 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 = "Tim", orderID = 95421, total = 9 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void OuterSameKeyMoreThanOneElement() + { + CustomerRec[] outer = + [ + new CustomerRec{ name = "Prakash", custID = 98022 }, + new CustomerRec{ name = "Bob", custID = 99022 }, + new CustomerRec{ name = "Robert", custID = 99022 } + ]; + OrderRec[] inner = + [ + new OrderRec{ orderID = 45321, custID = 98022, total = 50 }, + new OrderRec{ orderID = 43421, custID = 99022, total = 20 } + ]; + JoinRec[] expected = + [ + new JoinRec{ name = "Prakash", orderID = 45321, total = 50 }, + new JoinRec{ name = "Bob", orderID = 43421, total = 20 }, + new JoinRec{ name = "Robert", orderID = 43421, total = 20 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [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 } + ]; + + static JoinRec createRec(CustomerRec cr, AnagramRec or) => + new JoinRec { name = cr.name ?? string.Empty, orderID = or.orderID, total = or.total }; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.name, e => e.name, createRec, new AnagramEqualityComparer())); + } + + [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 = string.Empty, orderID = 43455, total = 10 } + ]; + + static JoinRec createRec(CustomerRec cr, AnagramRec or) => + new JoinRec { name = cr.name ?? string.Empty, orderID = or.orderID, total = or.total }; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.name, e => e.name, createRec, null)); + } + + [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 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 = string.Empty, orderID = 45321, total = 50 } + ]; + + Assert.Equal(expected, outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void OuterNull() + { + CustomerRec[] outer = null; + OrderRec[] inner = [new OrderRec { orderID = 45321, custID = 98022, total = 50 }]; + + AssertExtensions.Throws<ArgumentNullException>("outer", () => outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void InnerNull() + { + CustomerRec[] outer = [new CustomerRec { name = "Prakash", custID = 98022 }]; + OrderRec[] inner = null; + + AssertExtensions.Throws<ArgumentNullException>("inner", () => outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec)); + } + + [Fact] + public void OuterKeySelectorNull() + { + CustomerRec[] outer = [new CustomerRec { name = "Prakash", custID = 98022 }]; + OrderRec[] inner = [new OrderRec { orderID = 45321, custID = 98022, total = 50 }]; + + AssertExtensions.Throws<ArgumentNullException>("outerKeySelector", () => outer.FullJoin(inner, null, e => e.custID, createJoinRec)); + } + + [Fact] + public void InnerKeySelectorNull() + { + CustomerRec[] outer = [new CustomerRec { name = "Prakash", custID = 98022 }]; + OrderRec[] inner = [new OrderRec { orderID = 45321, custID = 98022, total = 50 }]; + + AssertExtensions.Throws<ArgumentNullException>("innerKeySelector", () => outer.FullJoin(inner, e => e.custID, (Func<OrderRec, int>)null, createJoinRec)); + } + + [Fact] + public void ResultSelectorNull() + { + CustomerRec[] outer = [new CustomerRec { name = "Prakash", custID = 98022 }]; + OrderRec[] inner = [new OrderRec { orderID = 45321, custID = 98022, total = 50 }]; + + AssertExtensions.Throws<ArgumentNullException>("resultSelector", () => outer.FullJoin(inner, e => e.custID, e => e.custID, (Func<CustomerRec, OrderRec, JoinRec>)null)); + } + + [Fact] + public void OuterNullNoComparer() + { + CustomerRec[] outer = null; + OrderRec[] inner = [new OrderRec { orderID = 45321, custID = 98022, total = 50 }]; + + AssertExtensions.Throws<ArgumentNullException>("outer", () => outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec, EqualityComparer<int>.Default)); + } + + [Fact] + public void InnerNullWithComparer() + { + CustomerRec[] outer = [new CustomerRec { name = "Prakash", custID = 98022 }]; + OrderRec[] inner = null; + + AssertExtensions.Throws<ArgumentNullException>("inner", () => outer.FullJoin(inner, e => e.custID, e => e.custID, createJoinRec, EqualityComparer<int>.Default)); + } + + [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<string>.Default)); + } + } +} diff --git a/src/libraries/System.Linq/tests/System.Linq.Tests.csproj b/src/libraries/System.Linq/tests/System.Linq.Tests.csproj index 207dda3b8c0050..03d8c827e74b7a 100644 --- a/src/libraries/System.Linq/tests/System.Linq.Tests.csproj +++ b/src/libraries/System.Linq/tests/System.Linq.Tests.csproj @@ -34,6 +34,7 @@ <Compile Include="FirstTests.cs" /> <Compile Include="GroupByTests.cs" /> <Compile Include="GroupJoinTests.cs" /> + <Compile Include="FullJoinTests.cs" /> <Compile Include="IndexTests.cs" /> <Compile Include="InfiniteSequenceTests.cs" /> <Compile Include="IntersectTests.cs" />