From 2e2fe5e8b3be85b39d63e6f650226c4bdc45e6bd Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 13 Oct 2026 09:54:47 +1100 Subject: [PATCH] Count a projected and included collection once MaxSingleQueryCollections adds the collections Includes name to the collections a projection returns. A query can name one collection both ways: .Include("Tasks.Employee") .Select(_ => new Employee { Id = _.Id, Tasks = _.Tasks.ToList() }) The Include there only says what to load under the collection the projection returns, so one collection is loaded, and it was counted as two. An Include path is no longer counted when the projection returns that collection from the same row. Both still count when the projection returns the row itself as well, since the Include then loads the collection into it a second time. A further collection included under the projected one still counts. Version 0.7.0. --- readme.md | 2 + src/Directory.Build.props | 2 +- src/EfQueryComplexity/CollectionCounter.cs | 123 ++++++++++++++++++++- src/Tests/ShapeTests.cs | 47 ++++++++ 4 files changed, 168 insertions(+), 6 deletions(-) diff --git a/readme.md b/readme.md index c3a45ac..903756c 100644 --- a/readme.md +++ b/readme.md @@ -150,6 +150,8 @@ A single SQL query joins every collection it loads, so each multiplies the rows * Collection navigations in `Include` and `ThenInclude`, including string paths. A collection restated to `ThenInclude` something below it counts once. * Collections a projection returns, for example `Employees = _.Employees.ToList()`, including collections nested in them. +A collection that a projection returns and an `Include` also names counts once, as in `.Include("Employees.Manager").Select(_ => new Department { Employees = _.Employees.ToList() })`. The `Include` there only says what to load under the collection. Both count when the projection returns the row itself as well, since the `Include` then loads the collection into it a second time. + It does not count: * Reference navigations. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ffc26ea..082b29c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ enable latest true - 0.6.0 + 0.7.0 true true 1.0.0 diff --git a/src/EfQueryComplexity/CollectionCounter.cs b/src/EfQueryComplexity/CollectionCounter.cs index f6971f6..8666da0 100644 --- a/src/EfQueryComplexity/CollectionCounter.cs +++ b/src/EfQueryComplexity/CollectionCounter.cs @@ -16,6 +16,11 @@ sealed class CollectionCounter(IModel model) : // ThenInclude something else below it, counts that collection once HashSet includePaths = []; HashSet ignoredIncludes = []; + + // The collections a projection returns in place of the entity they belong to, keyed like + // includePaths. An Include of one of them only says what to load under it, so the two are one + // collection. + HashSet projectedPaths = []; int projected; public static int Count(Expression query, IModel model, bool splitByDefault) @@ -27,6 +32,7 @@ public static int Count(Expression query, IModel model, bool splitByDefault) var counter = new CollectionCounter(model); counter.Visit(query); + counter.includePaths.ExceptWith(counter.projectedPaths); return counter.includePaths.Count + counter.projected; } @@ -83,7 +89,7 @@ protected override Expression VisitMethodCall(MethodCallExpression node) } Visit(node.Arguments[0]); - new ProjectionCounter(this).Visit(selector); + new ProjectionCounter(this, RowNotReturned(selector)).Visit(selector); return node; } @@ -139,6 +145,31 @@ bool ReturnsEntities(Expression selector) return finder.Found; } + // The row a selector is given, when the selector only reads members of it. A row that is also + // returned, or passed to a method, has its Includes loaded into it, as well as whatever the + // projection loads. + static ParameterExpression? RowNotReturned(Expression selector) + { + if (selector is UnaryExpression {NodeType: ExpressionType.Quote} quote) + { + selector = quote.Operand; + } + + if (selector is not LambdaExpression {Parameters: [var row, ..]} lambda) + { + return null; + } + + var finder = new BareUseFinder(row); + finder.Visit(lambda.Body); + if (finder.Found) + { + return null; + } + + return row; + } + // Whether a value is, or holds, entities. A shared type counts, since any entity could use it. bool HoldsEntities(Type type) { @@ -158,7 +189,7 @@ void AddIncludePaths(MethodCallExpression node) var path = new List(); var rootType = IncludeChainRoot(node, path); var type = rootType; - var key = ""; + var key = PathRoot(rootType); foreach (var name in path) { @@ -178,6 +209,10 @@ void AddIncludePaths(MethodCallExpression node) } } + // Paths start with the entity they are from, so one only matches a path from the same entity + static string PathRoot(Type type) => + type.FullName ?? type.Name; + // Fills path with the navigation names from the root entity to the end of this Include chain, // and returns the root entity type static Type IncludeChainRoot(MethodCallExpression node, List path) @@ -283,7 +318,7 @@ bool IsCollectionNavigation(Type type) /// /// Counts the collections a projection returns, including collections nested in them. /// - sealed class ProjectionCounter(CollectionCounter counter) : + sealed class ProjectionCounter(CollectionCounter counter, ParameterExpression? row) : ExpressionVisitor { public override Expression? Visit(Expression? node) @@ -294,9 +329,10 @@ sealed class ProjectionCounter(CollectionCounter counter) : } if (Sequences.IsSequence(node.Type) && - LoadsRows(node)) + LoadsRows(node, out var navigation)) { counter.projected++; + AddProjectedPath(navigation); // A projection inside the collection can return further collections VisitSelectors(node); @@ -337,14 +373,16 @@ protected override Expression VisitMember(MemberExpression node) } // Whether a sequence comes from the database: a collection navigation, or a query - bool LoadsRows(Expression node) + bool LoadsRows(Expression node, out MemberExpression? navigation) { + navigation = null; var current = node; while (true) { switch (current) { case MemberExpression member when counter.IsCollectionNavigation(member.Type): + navigation = member; return true; case MethodCallExpression {Method.IsStatic: true, Arguments.Count: > 0} call: current = call.Arguments[0]; @@ -360,6 +398,43 @@ bool LoadsRows(Expression node) } } + // Records a collection read from the row the projection is given, by the path an Include + // would name it with + void AddProjectedPath(MemberExpression? navigation) + { + if (navigation == null || + row == null) + { + return; + } + + var path = ""; + Expression? current = navigation; + while (true) + { + switch (current) + { + case MemberExpression member: + path = "." + member.Member.Name + path; + current = member.Expression; + continue; + case UnaryExpression + { + NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked or ExpressionType.TypeAs + } cast: + current = cast.Operand; + continue; + } + + break; + } + + if (current == row) + { + counter.projectedPaths.Add(PathRoot(row.Type) + path); + } + } + void VisitSelectors(Expression node) { var current = node; @@ -375,6 +450,44 @@ void VisitSelectors(Expression node) } } + /// + /// Finds a use of a row other than reading a member of it. + /// + sealed class BareUseFinder(ParameterExpression row) : + ExpressionVisitor + { + public bool Found { get; private set; } + + protected override Expression VisitMember(MemberExpression node) + { + var source = node.Expression; + while (source is UnaryExpression + { + NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked or ExpressionType.TypeAs + } cast) + { + source = cast.Operand; + } + + if (source == row) + { + return node; + } + + return base.VisitMember(node); + } + + protected override Expression VisitParameter(ParameterExpression node) + { + if (node == row) + { + Found = true; + } + + return node; + } + } + /// /// Finds an entity a projection can return, which the Includes before it would load into. /// diff --git a/src/Tests/ShapeTests.cs b/src/Tests/ShapeTests.cs index 039f035..2347c97 100644 --- a/src/Tests/ShapeTests.cs +++ b/src/Tests/ShapeTests.cs @@ -425,6 +425,53 @@ await Assert.That( SingleQueryCollections)) .IsEqualTo(2); + // The Include only says what to load under the collection the projection returns + [Test] + public async Task SingleQueryCollectionsCountsAProjectedAndIncludedCollectionOnce() => + await Assert.That( + Measure( + context => context.Employees + .Include("Tasks.Employee") + .Select( + _ => new Employee + { + Id = _.Id, + Tasks = _.Tasks.ToList() + }), + SingleQueryCollections)) + .IsEqualTo(1); + + [Test] + public async Task SingleQueryCollectionsCountsACollectionIncludedUnderAProjectedOne() => + await Assert.That( + Measure( + context => context.Companies + .Include("Departments.Employees") + .Select( + _ => new Company + { + Id = _.Id, + Departments = _.Departments.ToList() + }), + SingleQueryCollections)) + .IsEqualTo(2); + + // The Include loads the tasks into the employee, and the projection loads them again + [Test] + public async Task SingleQueryCollectionsCountsBothWhenTheRowIsReturnedToo() => + await Assert.That( + Measure( + context => context.Employees + .Include(_ => _.Tasks) + .Select( + _ => new + { + Employee = _, + Tasks = _.Tasks.ToList() + }), + SingleQueryCollections)) + .IsEqualTo(2); + static string Describe(Company company) => company.Name;