CSHARP-6017: Support LINQ LeftJoin for joins and includes#2017
Conversation
| public TOuter Outer { get; init; } | ||
|
|
||
| /// <summary> | ||
| /// The inner document (null when no matching inner document exists). |
There was a problem hiding this comment.
The doc comment says Inner is "null when no matching inner document exists," but TInner is unconstrained. For a value-type inner (e.g. LeftJoinResult<Order, int>) a no-match row deserializes the missing _inner to default(TInner) (0), not null — so "no match" and a real zero become indistinguishable. Either qualify the comment or steer TInner toward reference/nullable types.
There was a problem hiding this comment.
Oleks says we shouldn't be introducing NRTs at this point so I guess we'll handle this when we do that.
| ? AstStage.Unwind("_inner", preserveNullAndEmptyArrays: true) | ||
| : AstStage.Unwind("_inner"); |
There was a problem hiding this comment.
Minor: this ternary can collapse — AstStage.Unwind already defaults preserveNullAndEmptyArrays to null, so AstStage.Unwind("_inner", preserveNullAndEmptyArrays: isLeftJoin ? true : (bool?)null) does the same thing without duplicating the "_inner" literal.
There was a problem hiding this comment.
Pull request overview
Adds LINQ3 support for left outer join semantics via a new LeftJoin API and corresponding pipeline translation (wrapping the outer document, performing $lookup, and $unwind with preserveNullAndEmptyArrays: true). This extends join/include scenarios (including upcoming .NET Queryable.LeftJoin) and introduces a LeftJoinResult<TOuter, TInner> helper result type.
Changes:
- Introduces
MongoQueryable.LeftJoinoverloads andLeftJoinResult<TOuter, TInner>. - Extends LINQ3 pipeline translation to recognize and translate
LeftJoin, including chained joins. - Adds integration/smoke tests covering translation shape, chaining, reserved-name collisions, and round-tripping.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/SmokeTests/MongoDB.Driver.SmokeTests.Sdk/LeftJoinTests.cs | Net10 smoke test validating basic left-join behavior against a live server. |
| tests/MongoDB.Driver.Tests/Linq/Linq3Implementation/Jira/CSharpLeftJoinTests.cs | LINQ3 integration tests for translation stages, chaining, collision checks, and serialization round-trips. |
| src/MongoDB.Driver/Linq/MongoQueryable.cs | Adds public LeftJoin extension methods for IMongoCollection and IQueryable inner sources. |
| src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToPipelineTranslators/JoinMethodToPipelineTranslator.cs | Implements $lookup + $unwind(preserveNullAndEmptyArrays:true) translation and handles filtered inner queryables. |
| src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToPipelineTranslators/ExpressionToPipelineTranslator.cs | Routes LeftJoin method calls to the join translator. |
| src/MongoDB.Driver/Linq/Linq3Implementation/SerializerFinders/SerializerFinderVisitMethodCall.cs | Adds serializer deduction support for LeftJoin. |
| src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/QueryableMethod.cs | Adds reflection handle for BCL Queryable.LeftJoin (when available). |
| src/MongoDB.Driver/Linq/Linq3Implementation/Reflection/MongoQueryableMethod.cs | Adds reflection handle for MongoQueryable.LeftJoin. |
| src/MongoDB.Driver/Linq/LeftJoinResult.cs | New LeftJoinResult<TOuter, TInner> result struct with nullable Inner. |
| CSHARP-6017-review-findings.md | Adds a review-findings document (currently out of sync with the implementation in this PR). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| string innerCollectionName; | ||
| IBsonSerializer innerSerializer; | ||
| AstPipeline innerFilterPipeline = null; | ||
|
|
||
| if (innerExpression is ConstantExpression) | ||
| { | ||
| (innerCollectionName, innerSerializer) = innerExpression.GetCollectionInfoFromQueryable(containerExpression: expression); | ||
| } | ||
| else | ||
| { | ||
| var rootInnerExpression = GetRootQueryableExpression(innerExpression); | ||
| (innerCollectionName, innerSerializer) = rootInnerExpression.GetCollectionInfoFromQueryable(containerExpression: expression); | ||
| var innerTranslation = ExpressionToPipelineTranslator.Translate(context, innerExpression); | ||
| innerSerializer = innerTranslation.OutputSerializer; | ||
| innerFilterPipeline = innerTranslation.Ast.Stages.Count > 0 ? innerTranslation.Ast : null; | ||
| } | ||
|
|
||
| var localField = outerKeySelectorLambda.TranslateToDottedFieldName(context, wrappedOuterSerializer); | ||
| var foreignField = innerKeySelectorLambda.TranslateToDottedFieldName(context, innerSerializer); | ||
|
|
||
| var lookupStage = AstStage.Lookup( | ||
| from: innerCollectionName, | ||
| localField, | ||
| foreignField, | ||
| @as: "_inner"); | ||
| var lookupStage = innerFilterPipeline != null | ||
| ? AstStage.Lookup(innerCollectionName, localField, foreignField, [], innerFilterPipeline, "_inner") | ||
| : AstStage.Lookup(from: innerCollectionName, localField, foreignField, @as: "_inner"); | ||
|
|
||
| var unwindStage = AstStage.Unwind("_inner"); | ||
| var unwindStage = isLeftJoin | ||
| ? AstStage.Unwind("_inner", preserveNullAndEmptyArrays: true) | ||
| : AstStage.Unwind("_inner"); |
|
|
||
| namespace MongoDB.Driver.Tests.Linq.Linq3Implementation.SerializerFinders; | ||
|
|
||
| public class LeftJoinTests |
There was a problem hiding this comment.
Thank you for adding this tests. Looks really good. The only thing - we might want to merge these test cases into existing EnumerableTests, where other tests for Quaryable lives.
|
|
||
| namespace MongoDB.Driver.Tests.Linq.Integration; | ||
|
|
||
| public class CSharpLeftJoinTests : LinqIntegrationTest<CSharpLeftJoinTests.ClassFixture> |
There was a problem hiding this comment.
Thank you for moving the file here. Please remove CSharp from the name, and name it QueryableLeftJoingTests instead.
| new OrderDetail { Id = 100, OrderId = 1, ProductId = "P1" }, | ||
| new OrderDetail { Id = 101, OrderId = 2, ProductId = "P2" }]); | ||
| #if NET10_0_OR_GREATER | ||
| BlogsCollection = CreateCollection<Blog>("blogs"); |
There was a problem hiding this comment.
Should we put some data into the collection?
…e serializer finder cases into EnumerableTests
| throw new ExpressionNotSupportedException(expression); | ||
| } | ||
|
|
||
| private static Expression GetRootQueryableExpression(Expression expression) |
There was a problem hiding this comment.
Please check if TranslationContext.GetUltimateSource should be used here instead.
Adds support for left outer join semantics in the LINQ3 provider, translating to a
$project→$lookup→$unwind→$projectaggregation pipeline.Motivation
Queryable.LeftJointo the BCL which we'll want to translateMongoQueryable.LeftJoinis also added so users on earlier runtimes can use the same APIPublic API
LeftJoinResult<TOuter, TInner>is an immutable struct (matching theLookupResultconvention) whoseInnerisnullwhen no matching document exists.Translation
preserveNullAndEmptyArrays: trueon the$unwindis the single line that distinguishes a left join from an inner join. Outer documents with no matching inner survive with_inner: null.Notes
LeftJoincalls compose naturally; each re-wraps the current pipeline output in_outerso subsequentlocalFieldpaths resolve correctly.$unwindsemantics).ExpressionNotSupportedExceptionif the outer document type has a BSON element already named_outeror_inner.localField/foreignField$lookupform, which requires MongoDB 3.2+. The driver already targets 3.6+, so no additional version guard is needed.