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;