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 @@ -865,6 +865,15 @@ protected override Expression VisitMethodCall(MethodCallExpression node)
return zeroTakeRewrite;
}

// A bare/unfiltered embedded (owned) collection-navigation Count, e.g. `b.Posts.Count`, lowered by
// EF Core to Queryable.Count(Queryable.AsQueryable(EF.Property(shaper, "Posts"))). See
// TryRewriteEmbeddedCollectionNavigationCount for why this needs a null/missing-safe normalization
// the driver's own rendering of a bare Count (a server-side $size) doesn't provide.
if (TryRewriteEmbeddedCollectionNavigationCount(node, out var embeddedCountRewrite))
{
return embeddedCountRewrite;
}

return base.VisitMethodCall(node);
}

Expand Down Expand Up @@ -908,6 +917,71 @@ private void MarkJoinKeyOperands(Expression key)
Expression.Quote(falsePredicate)));
}

/// <summary>
/// Rewrites a bare embedded (owned) collection-navigation <c>Count</c>/<c>LongCount</c> (e.g.
/// <c>b.Posts.Count</c>) into <c>Enumerable.Count</c> over a <c>??</c>-normalized array read — needed
/// because the driver's bare-Count translation is a server-side <c>$size</c>, which throws on a
/// missing or null array (unlike a predicated Count, translated as null-tolerant <c>$map</c>/<c>$sum</c>).
/// </summary>
private bool TryRewriteEmbeddedCollectionNavigationCount(MethodCallExpression node, out Expression result)
{
result = null!;

if (node.Method.DeclaringType != typeof(Queryable)
|| node.Arguments.Count != 1
|| node.Method.Name is not (nameof(Queryable.Count) or nameof(Queryable.LongCount)))
{
return false;
}

if (node.Arguments[0] is not MethodCallExpression
{
Method: { Name: nameof(Queryable.AsQueryable), DeclaringType: var asQueryableDeclaring }
} asQueryableCall
|| asQueryableDeclaring != typeof(Queryable))
{
return false;
}

// Narrow to genuinely embedded (owned) collection navigations only: AsQueryable(...) also shows
// up wrapping other enumerable sources (e.g. an IGrouping in a GroupBy/Union pipeline) that are
// not a document field read at all, so rewriting unconditionally miscompiles those shapes.
if (asQueryableCall.Arguments[0] is not MethodCallExpression efPropertyCall
|| !efPropertyCall.Method.IsEFPropertyMethod()
|| efPropertyCall.Arguments[1] is not ConstantExpression { Value: string propertyName }
|| _queryContext.Context.Model.FindEntityType(efPropertyCall.Arguments[0].Type) is not { } sourceEntityType
|| sourceEntityType.FindNavigation(propertyName) is not { } navigation
|| !navigation.IsEmbedded())
{
return false;
}

var fieldAccess = Visit(asQueryableCall.Arguments[0]);
var elementType = fieldAccess?.Type.TryGetItemType();
if (elementType == null)
{
return false;
}

var countMethod = (node.Method.Name == nameof(Queryable.LongCount)
? EnumerableLongCountMethod
: EnumerableCountMethod)
.MakeGenericMethod(elementType);

var emptyCollection = Expression.Constant(CreateEmptyCollection(fieldAccess!.Type, elementType), fieldAccess.Type);
var normalizedFieldAccess = Expression.Coalesce(fieldAccess, emptyCollection);

result = Expression.Call(null, countMethod, normalizedFieldAccess);
return true;
}

// Interface-typed (IList<T>, ICollection<T>, IEnumerable<T>, ...) collection properties can't be
// built by Activator.CreateInstance; an empty T[] is assignable to all of them.
private static object CreateEmptyCollection(Type collectionType, Type elementType)
=> collectionType.IsInterface
? Array.CreateInstance(elementType, 0)
: Activator.CreateInstance(collectionType)!;

/// <summary>
/// Rewrites <c>Queryable.Count(Queryable.Where(DbSet&lt;Target&gt;(), fkPredicate))</c> (and the
/// <c>LongCount</c> variant) — the lowered form of a projected/filtered collection-navigation count
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@
*/

using System;
using System.Linq;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Query;
using MongoDB.Bson;
using MongoDB.EntityFrameworkCore.Extensions;
using MongoDB.EntityFrameworkCore.Query.Expressions;
using MongoDB.EntityFrameworkCore.Storage;

Expand Down Expand Up @@ -108,6 +110,14 @@ protected override Expression VisitExtension(Expression extensionExpression)
return navMemberRead;
}

// An unfiltered embedded-collection Count/LongCount (e.g. select new { b, N = b.Posts.Count })
// mixed alongside a whole entity reference: the driver-LINQ Select was stripped, so count the
// stored array client-side from the materialized document.
if (TryBindEmbeddedCollectionCountLeaf(sourceExpression, projectionBindingExpression.Type, out var countRead))
{
return countRead;
}

// A computed-arithmetic leaf (e.g. select new { c, Total = c.Age * c.Score }) mixed alongside
// a whole entity reference. MongoProjectionBindingExpressionVisitor registers the raw binary
// expression as a single projection-mapping leaf (see its arithmetic BinaryExpression case);
Expand Down Expand Up @@ -216,6 +226,43 @@ private bool TryBindNavigationMemberAccess(Expression? mappedExpression, Type re
return true;
}

/// <summary>
/// Binds an unfiltered embedded (owned) collection <c>Count</c>/<c>LongCount</c> projection leaf (e.g.
/// <c>select new { b, N = b.Posts.Count }</c>). <see cref="MongoProjectionBindingExpressionVisitor"/> maps
/// such a leaf as <c>source == null ? default : Count(AsQueryable(EF.Property(shaper, nav)))</c>; here the
/// stored array is read from the materialized document and its length taken, treating a missing or null
/// array as empty. Returns <see langword="false"/> for any other shape (including a filtered count).
/// </summary>
private bool TryBindEmbeddedCollectionCountLeaf(Expression? mappedExpression, Type resultType, out Expression result)
{
result = null!;

if (mappedExpression is not ConditionalExpression
{
IfFalse: MethodCallExpression
{
Method.Name: nameof(Queryable.Count) or nameof(Queryable.LongCount),
Arguments: [MethodCallExpression { Method.Name: nameof(Queryable.AsQueryable), Arguments: [var source] }]
}
}
|| source is not MethodCallExpression efPropertyCall
|| !efPropertyCall.Method.IsEFPropertyMethod()
|| efPropertyCall.Arguments[1] is not ConstantExpression { Value: string navigationName }
|| efPropertyCall.Arguments[0] is not StructuralTypeShaperExpression { StructuralType: IEntityType owner }
|| owner != _rootEntityType
|| owner.FindNavigation(navigationName) is not { } navigation
|| !navigation.IsEmbedded())
{
return false;
}

var array = BsonBinding.CreateGetBsonArray(_docParameter, navigation.TargetEntityType.GetContainingElementName()!);
Expression count = Expression.Property(
Expression.Coalesce(array, Expression.New(typeof(BsonArray))), nameof(BsonArray.Count));
result = count.Type == resultType ? count : Expression.Convert(count, resultType);
return true;
}

/// <summary>
/// Binds a computed-arithmetic projection leaf (e.g. <c>select new { c, Total = c.Age * c.Score }</c>).
/// <see cref="MongoProjectionBindingExpressionVisitor"/> registers such a leaf as the raw
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,39 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
EnumerableMethods.Select.MakeGenericMethod(method.GetGenericArguments()),
shaper,
lambda);

// A Count/LongCount over an embedded/owned collection navigation in a projection (e.g.
// `b.Posts.Count(p => p.Rank > 0)`). Kept bound to the original method-call subtree, not
// the shaper, so it push-downs to the driver's native $size/$filter — routing through the
// shaper would materialize owned Post entities, which the tracking-materializer rejects.
case nameof(Queryable.Count) when genericMethod == QueryableMethods.CountWithoutPredicate:
case nameof(Queryable.LongCount) when genericMethod == QueryableMethods.LongCountWithoutPredicate:
case nameof(Queryable.Count) when genericMethod == QueryableMethods.CountWithPredicate:
case nameof(Queryable.LongCount) when genericMethod == QueryableMethods.LongCountWithPredicate:
if (visitedSource is not CollectionShaperExpression
|| methodCallExpression.Arguments[0] is not MethodCallExpression
{
Method: { Name: nameof(Queryable.AsQueryable), DeclaringType: var asQueryableDeclaring }
} asQueryableCall
|| asQueryableDeclaring != typeof(Queryable))
{
return null;
}

// A missing/null stored array materializes to a null reference, not an empty
// collection (same gap as `.Select(e => e.Foo)`), so Count/LongCount must tolerate a
// null source ($size/$filter both reject null) — guard on the raw field access, not
// the AsQueryable-wrapped source.
var countSource = asQueryableCall.Arguments[0];
Expression nullSafeCount = Expression.Condition(
Expression.Equal(countSource, Expression.Constant(null, countSource.Type)),
Expression.Default(methodCallExpression.Type),
methodCallExpression);

var countProjectionMember = GetCurrentProjectionMember();
_projectionMapping[countProjectionMember] = nullSafeCount;

return new ProjectionBindingExpression(_queryExpression, countProjectionMember, methodCallExpression.Type);
}
}

Expand Down
Loading
Loading