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/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 0d5dc2c9ff4..01b3644d757 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,32 +207,75 @@ 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; + + var queryable = orders.AsQueryable() + .LeftJoin( + 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 LeftJoin_with_ordered_and_limited_inner_queryable_should_throw() + { var orders = Fixture.OrdersCollection; - // Only Alice (id=10) should participate as an inner match. 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 }); + + var exception = Record.Exception(() => Translate(orders, queryable)); + 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 results = queryable.ToList(); - results.Should().HaveCount(3); + var exception = Record.Exception(() => Translate(orders, queryable)); + exception.Should().BeOfType(); + } - // 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(); + [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