From 0d596f922c93e1ad26f492480ce451eef3b36f96 Mon Sep 17 00:00:00 2001 From: Arthur Vickers Date: Wed, 12 Aug 2026 16:23:24 +0100 Subject: [PATCH] EF-357: Fix ArgumentException on bare embedded-collection Count projections db.Set().Select(e => e.OwnedCollection.Count) threw ArgumentException in every case (bare and arithmetic-wrapped alike), because EF Core lowers a collection-navigation Count to Queryable.Count(Queryable.AsQueryable(source)) even for an in-document embedded List, and MongoProjectionBindingExpressionVisitor had no case for that shape - it fell into the generic fallback, which tried to rebuild the Queryable.Count call with the already-materialized List, which isn't an IQueryable. Add TryBindEmbeddedCollectionNavigationCount to recognize the shape and rewrite it as a null-safe Enumerable.Count/LongCount over the navigation's CollectionShaperExpression (null-safe because a missing/explicitly-null stored array materializes to null rather than an empty collection). --- ...MongoProjectionBindingExpressionVisitor.cs | 90 +++++++++++++++++++ .../Query/OwnedEntityTests.cs | 45 ++++++++++ 2 files changed, 135 insertions(+) diff --git a/src/MongoDB.EntityFrameworkCore/Query/Visitors/MongoProjectionBindingExpressionVisitor.cs b/src/MongoDB.EntityFrameworkCore/Query/Visitors/MongoProjectionBindingExpressionVisitor.cs index d6542f8a3..9598ac80d 100644 --- a/src/MongoDB.EntityFrameworkCore/Query/Visitors/MongoProjectionBindingExpressionVisitor.cs +++ b/src/MongoDB.EntityFrameworkCore/Query/Visitors/MongoProjectionBindingExpressionVisitor.cs @@ -258,6 +258,19 @@ protected override Expression VisitExtension(Expression extensionExpression) /// 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, 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 doesn't satisfy the method's + // IQueryable 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(), joinPred), selector)). // There is no enclosing IncludeExpression to set up the $lookup, so bind it here to a CollectionShaperExpression @@ -435,6 +448,62 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp return updatedMethodCallExpression; } + /// + /// Detects and binds a projected embedded (owned) collection-navigation Count/LongCount such as + /// select b.Posts.Count or select new { ..., N = b.Posts.Count }, where Posts is an + /// owned collection navigation stored in-document. See the call site in for + /// why the generic fallback cannot handle this shape. + /// + 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) 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; + } + /// protected override Expression VisitNew(NewExpression newExpression) { @@ -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)); + + /// + /// 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. + /// + private static int NullSafeCount(IEnumerable source) + => source?.Count() ?? 0; + + /// + /// The -returning sibling of , for a projected LongCount. + /// + private static long NullSafeLongCount(IEnumerable source) + => source?.LongCount() ?? 0; + #if EF8 || EF9 private static T GetParameterValue( QueryContext queryContext, diff --git a/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Query/OwnedEntityTests.cs b/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Query/OwnedEntityTests.cs index 36245ded8..13fbd672e 100644 --- a/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Query/OwnedEntityTests.cs +++ b/tests/MongoDB.EntityFrameworkCore.FunctionalTests/Query/OwnedEntityTests.cs @@ -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(); + + { + 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(); + + { + 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() {