From 5f0aa35e590b9b630463823e77f7b3a0a58d7014 Mon Sep 17 00:00:00 2001 From: Damien Guard Date: Wed, 15 Jul 2026 12:37:50 +0100 Subject: [PATCH 1/2] CSHARP-6125: Reject non-collection inner sequences in LINQ Join/LeftJoin --- .../JoinMethodToPipelineTranslator.cs | 28 +++----------- .../Integration/QueryableLeftJoinTests.cs | 38 +++++++++++-------- 2 files changed, 28 insertions(+), 38 deletions(-) diff --git a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToPipelineTranslators/JoinMethodToPipelineTranslator.cs b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToPipelineTranslators/JoinMethodToPipelineTranslator.cs index 7ca8d1f2333..c13263531ca 100644 --- a/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToPipelineTranslators/JoinMethodToPipelineTranslator.cs +++ b/src/MongoDB.Driver/Linq/Linq3Implementation/Translators/ExpressionToPipelineTranslators/JoinMethodToPipelineTranslator.cs @@ -64,32 +64,16 @@ public static TranslatedPipeline Translate(TranslationContext context, MethodCal AstProject.Exclude("_id")); var wrappedOuterSerializer = WrappedValueSerializer.Create("_outer", outerSerializer); - string innerCollectionName; - IBsonSerializer innerSerializer; - AstPipeline innerFilterPipeline = null; - - if (innerExpression is ConstantExpression) - { - (innerCollectionName, innerSerializer) = innerExpression.GetCollectionInfoFromQueryable(containerExpression: expression); - } - else - { - var rootInnerExpression = TranslationContext.GetUltimateSource(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; - } + // Only a bare collection is supported as the inner sequence. A non-collection inner sequence + // (e.g. one with OrderBy/Take/Skip) would be translated into a correlated $lookup pipeline that + // applies per outer document rather than once globally, producing wrong results. Reject it here; + // proper support for such subqueries is tracked in CSHARP-6118. + var (innerCollectionName, innerSerializer) = innerExpression.GetCollectionInfoFromQueryable(containerExpression: expression); var localField = outerKeySelectorLambda.TranslateToDottedFieldName(context, wrappedOuterSerializer); var foreignField = innerKeySelectorLambda.TranslateToDottedFieldName(context, innerSerializer); - // When the inner sequence is filtered we emit a $lookup that combines localField/foreignField - // with a pipeline. That concise syntax requires MongoDB 5.0+ (Feature.LookupConciseSyntax); - // a bare inner sequence uses the simpler localField/foreignField form supported by all servers. - var lookupStage = innerFilterPipeline != null - ? AstStage.Lookup(innerCollectionName, localField, foreignField, [], innerFilterPipeline, "_inner") - : AstStage.Lookup(from: innerCollectionName, localField, foreignField, @as: "_inner"); + var lookupStage = AstStage.Lookup(from: innerCollectionName, localField, foreignField, @as: "_inner"); var unwindStage = AstStage.Unwind("_inner", preserveNullAndEmptyArrays: isLeftJoin ? true : null); diff --git a/tests/MongoDB.Driver.Tests/Linq/Integration/QueryableLeftJoinTests.cs b/tests/MongoDB.Driver.Tests/Linq/Integration/QueryableLeftJoinTests.cs index 0d5dc2c9ff4..142fbb2601b 100644 --- a/tests/MongoDB.Driver.Tests/Linq/Integration/QueryableLeftJoinTests.cs +++ b/tests/MongoDB.Driver.Tests/Linq/Integration/QueryableLeftJoinTests.cs @@ -16,8 +16,6 @@ using System; using System.Linq; using FluentAssertions; -using MongoDB.Driver.Core.Misc; -using MongoDB.Driver.Core.TestHelpers.XunitExtensions; using MongoDB.Driver.Linq; using MongoDB.Driver.TestHelpers; using Xunit; @@ -209,18 +207,15 @@ public void LeftJoin_should_preserve_outer_when_no_matching_inner() noMatch.CustomerName.Should().BeNull(); } - // A filter chained onto the inner queryable must be honored: a row whose only candidate - // inner match is filtered out gets a null inner, preserving left-join semantics. + // A non-collection inner sequence (filtered, ordered, or limited) is not supported: translating it + // into a correlated $lookup pipeline would apply the operation per outer document rather than once + // globally, producing wrong results (CSHARP-6125). Such subqueries must be rejected until proper + // support is added (CSHARP-6118). [Fact] - public void LeftJoin_with_filtered_inner_queryable_should_apply_filter() + public void LeftJoin_with_filtered_inner_queryable_should_throw() { - // A filtered inner sequence is translated to a $lookup that combines localField/foreignField - // with a pipeline, which requires the concise $lookup syntax introduced in MongoDB 5.0. - RequireServer.Check().Supports(Feature.LookupConciseSyntax); - var orders = Fixture.OrdersCollection; - // Only Alice (id=10) should participate as an inner match. var queryable = orders.AsQueryable() .LeftJoin( Fixture.CustomersCollection.AsQueryable().Where(c => c.Name == "Alice"), @@ -228,13 +223,24 @@ public void LeftJoin_with_filtered_inner_queryable_should_apply_filter() c => c.Id, (o, c) => new { OrderId = o.Id, CustomerName = c.Name }); - var results = queryable.ToList(); - results.Should().HaveCount(3); + var exception = Record.Exception(() => Translate(orders, queryable)); + exception.Should().BeOfType(); + } + + [Fact] + public void LeftJoin_with_ordered_and_limited_inner_queryable_should_throw() + { + var orders = Fixture.OrdersCollection; + + var queryable = orders.AsQueryable() + .LeftJoin( + Fixture.CustomersCollection.AsQueryable().OrderBy(c => c.Name).Take(4), + o => o.CustomerId, + c => c.Id, + (o, c) => new { OrderId = o.Id, CustomerName = c.Name }); - // Order 2 (CustomerId=20) only matches Bob, who is filtered out of the inner source, - // so its inner match is null. - var order2 = results.Single(r => r.OrderId == 2); - order2.CustomerName.Should().BeNull(); + var exception = Record.Exception(() => Translate(orders, queryable)); + exception.Should().BeOfType(); } #if NET10_0_OR_GREATER From 77ce828a8e3125eaef63037bfb6d086510ec4b4b Mon Sep 17 00:00:00 2001 From: Damien Guard Date: Tue, 28 Jul 2026 16:20:57 +0100 Subject: [PATCH 2/2] CSHARP-6125: Add Join rejection tests and correct LeftJoin XML docs --- src/MongoDB.Driver/Linq/MongoQueryable.cs | 5 +-- .../Integration/QueryableLeftJoinTests.cs | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/MongoDB.Driver/Linq/MongoQueryable.cs b/src/MongoDB.Driver/Linq/MongoQueryable.cs index 102f6a165d3..3f556c9cd79 100644 --- a/src/MongoDB.Driver/Linq/MongoQueryable.cs +++ b/src/MongoDB.Driver/Linq/MongoQueryable.cs @@ -838,8 +838,9 @@ public static IQueryable Join(this IQuer /// An that contains elements of type obtained by performing a left outer join on two sequences. /// /// - /// When carries additional query operators (such as Where) it is translated to a - /// $lookup that combines localField/foreignField with a pipeline, which requires MongoDB 5.0 or later. + /// must be a bare collection. A non-collection inner sequence (one carrying + /// additional query operators such as Where, OrderBy, Skip, or Take) is not supported and throws + /// during translation. /// public static IQueryable LeftJoin(this IQueryable outer, IQueryable inner, Expression> outerKeySelector, Expression> innerKeySelector, Expression> resultSelector) { diff --git a/tests/MongoDB.Driver.Tests/Linq/Integration/QueryableLeftJoinTests.cs b/tests/MongoDB.Driver.Tests/Linq/Integration/QueryableLeftJoinTests.cs index 142fbb2601b..01b3644d757 100644 --- a/tests/MongoDB.Driver.Tests/Linq/Integration/QueryableLeftJoinTests.cs +++ b/tests/MongoDB.Driver.Tests/Linq/Integration/QueryableLeftJoinTests.cs @@ -243,6 +243,41 @@ public void LeftJoin_with_ordered_and_limited_inner_queryable_should_throw() exception.Should().BeOfType(); } + // The same restriction applies to an inner join (Queryable.Join). The MongoDB LINQ Join overload + // only accepts an IMongoCollection, so a non-collection inner can only arrive via the BCL + // Queryable.Join; it must be rejected for the same reason (CSHARP-6125). + [Fact] + public void Join_with_filtered_inner_queryable_should_throw() + { + var orders = Fixture.OrdersCollection; + + var queryable = orders.AsQueryable() + .Join( + Fixture.CustomersCollection.AsQueryable().Where(c => c.Name == "Alice"), + o => o.CustomerId, + c => c.Id, + (o, c) => new { OrderId = o.Id, CustomerName = c.Name }); + + var exception = Record.Exception(() => Translate(orders, queryable)); + exception.Should().BeOfType(); + } + + [Fact] + public void Join_with_ordered_and_limited_inner_queryable_should_throw() + { + var orders = Fixture.OrdersCollection; + + var queryable = orders.AsQueryable() + .Join( + Fixture.CustomersCollection.AsQueryable().OrderBy(c => c.Name).Take(4), + o => o.CustomerId, + c => c.Id, + (o, c) => new { OrderId = o.Id, CustomerName = c.Name }); + + var exception = Record.Exception(() => Translate(orders, queryable)); + exception.Should().BeOfType(); + } + #if NET10_0_OR_GREATER // A nested Enumerable.LeftJoin on an array member is routed to the aggregation-expression // translator (inside the Select body), not the $lookup pipeline translator. It is currently