Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
5 changes: 3 additions & 2 deletions src/MongoDB.Driver/Linq/MongoQueryable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -838,8 +838,9 @@ public static IQueryable<TResult> Join<TOuter, TInner, TKey, TResult>(this IQuer
/// An <see cref="IQueryable{TResult}" /> that contains elements of type <typeparamref name="TResult" /> obtained by performing a left outer join on two sequences.
/// </returns>
/// <remarks>
/// When <paramref name="inner" /> 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.
/// <paramref name="inner" /> 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
/// <see cref="ExpressionNotSupportedException" /> during translation.
/// </remarks>
public static IQueryable<TResult> LeftJoin<TOuter, TInner, TKey, TResult>(this IQueryable<TOuter> outer, IQueryable<TInner> inner, Expression<Func<TOuter, TKey>> outerKeySelector, Expression<Func<TInner, TKey>> innerKeySelector, Expression<Func<TOuter, TInner, TResult>> resultSelector)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Comment on lines +210 to +213

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds reasonable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

[Fact]
public void LeftJoin_with_filtered_inner_queryable_should_apply_filter()
public void LeftJoin_with_filtered_inner_queryable_should_throw()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need similar tests for Join?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now added.

{
// 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<ExpressionNotSupportedException>();
}

[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<ExpressionNotSupportedException>();
}

// 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<ExpressionNotSupportedException>();
}

// 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<ExpressionNotSupportedException>();
}

#if NET10_0_OR_GREATER
Expand Down