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
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<Version>0.6.0</Version>
<Version>0.7.0</Version>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AssemblyVersion>1.0.0</AssemblyVersion>
Expand Down
123 changes: 118 additions & 5 deletions src/EfQueryComplexity/CollectionCounter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ sealed class CollectionCounter(IModel model) :
// ThenInclude something else below it, counts that collection once
HashSet<string> includePaths = [];
HashSet<MethodCallExpression> 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<string> projectedPaths = [];
int projected;

public static int Count(Expression query, IModel model, bool splitByDefault)
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
{
Expand All @@ -158,7 +189,7 @@ void AddIncludePaths(MethodCallExpression node)
var path = new List<string>();
var rootType = IncludeChainRoot(node, path);
var type = rootType;
var key = "";
var key = PathRoot(rootType);

foreach (var name in path)
{
Expand All @@ -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<string> path)
Expand Down Expand Up @@ -283,7 +318,7 @@ bool IsCollectionNavigation(Type type)
/// <summary>
/// Counts the collections a projection returns, including collections nested in them.
/// </summary>
sealed class ProjectionCounter(CollectionCounter counter) :
sealed class ProjectionCounter(CollectionCounter counter, ParameterExpression? row) :
ExpressionVisitor
{
public override Expression? Visit(Expression? node)
Expand All @@ -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);
Expand Down Expand Up @@ -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];
Expand All @@ -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;
Expand All @@ -375,6 +450,44 @@ void VisitSelectors(Expression node)
}
}

/// <summary>
/// Finds a use of a row other than reading a member of it.
/// </summary>
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;
}
}

/// <summary>
/// Finds an entity a projection can return, which the Includes before it would load into.
/// </summary>
Expand Down
47 changes: 47 additions & 0 deletions src/Tests/ShapeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading