Skip to content
Closed
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 @@ -258,6 +258,19 @@ protected override Expression VisitExtension(Expression extensionExpression)
/// <inheritdoc />
protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression)
{
// A projected embedded (owned) collection-navigation Count (e.g. select b.Posts.Count / select new
// { ..., N = b.Posts.Count }). EF Core lowers this to Queryable.Count(Queryable.AsQueryable(source))
// — even though the navigation is a plain in-document List<T>, not a real IQueryable — because EF's
// navigation expansion always represents a collection navigation as queryable. Left unhandled, the
// generic fallback at the bottom of this method re-visits "source" (which resolves to the embedded
// navigation's already-materialized CLR list) and tries to rebuild Queryable.Count's Expression.Call
// with it directly, which throws ArgumentException because a List<T> doesn't satisfy the method's
// IQueryable<T> parameter.
if (TryBindEmbeddedCollectionNavigationCount(methodCallExpression, out var boundEmbeddedCount))
{
return boundEmbeddedCount;
}

// A projected cross-collection collection navigation (e.g. select new { ..., Orders = c.Orders.ToList() }).
// EF Core lowers this to Enumerable.ToList(Queryable.Select(Queryable.Where(DbSet<Target>(), joinPred), selector)).
// There is no enclosing IncludeExpression to set up the $lookup, so bind it here to a CollectionShaperExpression
Expand Down Expand Up @@ -435,6 +448,62 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
return updatedMethodCallExpression;
}

/// <summary>
/// Detects and binds a projected embedded (owned) collection-navigation Count/LongCount such as
/// <c>select b.Posts.Count</c> or <c>select new { ..., N = b.Posts.Count }</c>, where <c>Posts</c> is an
/// owned collection navigation stored in-document. See the call site in <see cref="VisitMethodCall"/> for
/// why the generic fallback cannot handle this shape.
/// </summary>
private bool TryBindEmbeddedCollectionNavigationCount(
MethodCallExpression methodCallExpression,
out Expression result)
{
result = null!;

if (methodCallExpression.Method.DeclaringType != typeof(Queryable)
|| methodCallExpression.Arguments.Count != 1)
{
return false;
}

MethodInfo nullSafeCountMethod;
switch (methodCallExpression.Method.Name)
{
case nameof(Queryable.Count):
nullSafeCountMethod = NullSafeCountMethodInfo;
break;
case nameof(Queryable.LongCount):
nullSafeCountMethod = NullSafeLongCountMethodInfo;
break;
default:
return false;
}

// The cross-collection shape (TryBindProjectedCollectionNavigationCount) has its source as a bare
// Queryable.Where(...) — no AsQueryable wrapper, since a DbSet-rooted query is already IQueryable.
// Only an embedded navigation (a plain in-document List<T>) gets wrapped in AsQueryable by EF's
// navigation expansion.
if (methodCallExpression.Arguments[0] is not MethodCallExpression
{
Method: { Name: nameof(Queryable.AsQueryable), DeclaringType: var asQueryableDeclaring }
} asQueryableCall
|| asQueryableDeclaring != typeof(Queryable))
{
return false;
}

if (Visit(asQueryableCall.Arguments[0]) is not CollectionShaperExpression collectionShaper)
{
return false;
}

// A missing or explicitly-null stored array materializes the CollectionShaperExpression to a null
// reference rather than an empty collection (the same gap plain `.Select(e => e.children)` has), so
// Count/LongCount must tolerate a null source rather than throwing ArgumentNullException.
result = Expression.Call(nullSafeCountMethod.MakeGenericMethod(collectionShaper.ElementType), collectionShaper);
return true;
}

/// <inheritdoc />
protected override Expression VisitNew(NewExpression newExpression)
{
Expand Down Expand Up @@ -672,6 +741,27 @@ private static readonly MethodInfo GetParameterValueMethodInfo
= typeof(MongoProjectionBindingExpressionVisitor)
.GetTypeInfo().GetDeclaredMethod(nameof(GetParameterValue));

private static readonly MethodInfo NullSafeCountMethodInfo
= typeof(MongoProjectionBindingExpressionVisitor)
.GetTypeInfo().GetDeclaredMethod(nameof(NullSafeCount));

private static readonly MethodInfo NullSafeLongCountMethodInfo
= typeof(MongoProjectionBindingExpressionVisitor)
.GetTypeInfo().GetDeclaredMethod(nameof(NullSafeLongCount));

/// <summary>
/// A missing or explicitly-null stored array materializes an embedded collection navigation to a null
/// reference rather than an empty collection, so a projected Count over it must tolerate a null source.
/// </summary>
private static int NullSafeCount<T>(IEnumerable<T> source)
=> source?.Count() ?? 0;
Comment thread
ajcvickers marked this conversation as resolved.

/// <summary>
/// The <see cref="long"/>-returning sibling of <see cref="NullSafeCount{T}"/>, for a projected LongCount.
/// </summary>
private static long NullSafeLongCount<T>(IEnumerable<T> source)
=> source?.LongCount() ?? 0;

#if EF8 || EF9
private static T GetParameterValue<T>(
QueryContext queryContext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,51 @@ public void OwnedEntity_can_go_multiple_levels_deep_querying_collection()
}
}

[Fact]
public void OwnedEntity_collection_bare_count_projection_returns_element_count()
{
var collection = database.CreateCollection<A>();

{
using var db = SingleEntityDbContext.Create(collection);
db.Entities.AddRange(
new A { _id = "1", children = [new B { name = "child1" }, new B { name = "child2" }] },
new A { _id = "2", children = [] },
new A { _id = "3", children = null! });
db.SaveChanges();
}

using var db2 = SingleEntityDbContext.Create(collection);

var counts = db2.Entities.AsNoTracking().OrderBy(e => e._id).Select(e => e.children.Count).ToList();
Assert.Equal([2, 0, 0], counts);

var longCounts = db2.Entities.AsNoTracking().OrderBy(e => e._id).Select(e => e.children.LongCount()).ToList();
Assert.Equal([2L, 0L, 0L], longCounts);
}

[Fact]
public void OwnedEntity_collection_count_projection_wrapped_in_arithmetic_returns_element_count()
{
var collection = database.CreateCollection<A>();

{
using var db = SingleEntityDbContext.Create(collection);
db.Entities.AddRange(
new A { _id = "1", children = [new B { name = "child1" }, new B { name = "child2" }] },
new A { _id = "2", children = null! });
db.SaveChanges();
}

using var db2 = SingleEntityDbContext.Create(collection);

var doubled = db2.Entities.AsNoTracking().OrderBy(e => e._id)
.Select(e => new { e._id, N = e.children.Count * 2 })
.ToList();

Assert.Equal([4, 0], doubled.Select(r => r.N).ToArray());
}

[Fact]
public void OwnedEntity_collection_can_be_tested_for_not_null()
{
Expand Down