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
12 changes: 11 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ A query is checked against the throw levels before the log levels, so a throw le
| --- | --- | --- | --- |
| `MaxNodes` | Expression nodes in the query | While compiled | 1000 |
| `MaxDepth` | Nesting depth of the query expression | While compiled | 50 |
| `MaxOperators` | LINQ operators, including in subqueries | While compiled | 30 |
| `MaxOperators` | [LINQ operators](#operators), including in subqueries | While compiled | 30 |
| `MaxNavigationDepth` | Navigations in one member access chain | While compiled | 3 |
| `MaxIncludes` | `Include` calls | While compiled | 6 |
| `MaxIncludeDepth` | Navigations in one `Include` chain | While compiled | 3 |
Expand All @@ -131,6 +131,16 @@ A query is checked against the throw levels before the log levels, so a throw le
A check fires when the measured value is greater than the level. A level of `null` turns that check off.


### Operators

`MaxOperators` counts the calls that change what a query returns, or add to what it loads: `Where`, `Select`, `OrderBy`, `Join`, `Take`, `Count`, `Include`, `ThenInclude` and the rest of LINQ, in the query and in every subquery inside it.

It does not count:

* A call that only says what to hold the results in: `ToList`, `ToArray`, `ToHashSet`, `AsEnumerable` and `AsQueryable`. A projection ends each collection it returns with one, so `Names = _.Employees.Select(_ => _.Name).ToList()` is one operator.
* A call that only says how to run the query: `AsNoTracking`, `AsNoTrackingWithIdentityResolution`, `AsTracking`, `AsSplitQuery`, `AsSingleQuery`, `IgnoreAutoIncludes`, `IgnoreQueryFilters`, `TagWith` and `TagWithCallSite`.


### Collections in a single query

A single SQL query joins every collection it loads, so each multiplies the rows returned for the others. Loading 10 departments with 50 employees and 20 projects each returns 10,000 rows for 710 entities. This is a [cartesian explosion](https://learn.microsoft.com/en-us/ef/core/querying/single-split-queries).
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.5.0</Version>
<Version>0.6.0</Version>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AssemblyVersion>1.0.0</AssemblyVersion>
Expand Down
5 changes: 4 additions & 1 deletion src/EfQueryComplexity/QueryComplexityLimits.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
/// </remarks>
/// <param name="MaxNodes">Maximum number of expression nodes in a query.</param>
/// <param name="MaxDepth">Maximum nesting depth of a query expression.</param>
/// <param name="MaxOperators">Maximum number of LINQ operators, including those in subqueries.</param>
/// <param name="MaxOperators">
/// Maximum number of LINQ operators, including those in subqueries. A call that only says what to
/// hold the results in, like ToList, or how to run the query, like AsNoTracking, is not counted.
/// </param>
/// <param name="MaxNavigationDepth">Maximum number of navigations in one member access chain.</param>
/// <param name="MaxIncludes">Maximum number of Include calls.</param>
/// <param name="MaxIncludeDepth">Maximum number of navigations in one Include chain.</param>
Expand Down
6 changes: 5 additions & 1 deletion src/EfQueryComplexity/QueryComplexityOverride.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ public sealed record QueryComplexityOverride
/// <summary>Maximum nesting depth of a query expression.</summary>
public int? MaxDepth { get; init; }

/// <summary>Maximum number of LINQ operators, including those in subqueries.</summary>
/// <summary>
/// Maximum number of LINQ operators, including those in subqueries. A call that only says what
/// to hold the results in, like ToList, or how to run the query, like AsNoTracking, is not
/// counted.
/// </summary>
public int? MaxOperators { get; init; }

/// <summary>Maximum number of navigations in one member access chain.</summary>
Expand Down
43 changes: 39 additions & 4 deletions src/EfQueryComplexity/ShapeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,7 @@ protected override Expression VisitMethodCall(MethodCallExpression node)
var method = node.Method;
var declaringType = method.DeclaringType;

if (declaringType == typeof(Queryable) ||
declaringType == typeof(Enumerable) ||
declaringType == typeof(EntityFrameworkQueryableExtensions) ||
declaringType == typeof(RelationalQueryableExtensions))
if (IsOperator(method))
{
operators++;
}
Expand Down Expand Up @@ -89,6 +86,44 @@ protected override Expression VisitMember(MemberExpression node)
return base.VisitMember(node);
}

// A call that changes what the query returns, or adds to what it loads. One that only says what
// to hold the results in, or how to run the query, adds nothing to it. A projection ends every
// collection it returns with a ToList, so counting those would count each collection twice.
static bool IsOperator(MethodInfo method)
{
var declaringType = method.DeclaringType;
var name = method.Name;

if (declaringType == typeof(Queryable) ||
declaringType == typeof(Enumerable))
{
return name is not
(nameof(Enumerable.ToList) or
nameof(Enumerable.ToArray) or
nameof(Enumerable.ToHashSet) or
nameof(Enumerable.AsEnumerable) or
nameof(Queryable.AsQueryable));
}

// Include and ThenInclude count, since each adds to what is loaded
if (declaringType == typeof(EntityFrameworkQueryableExtensions))
{
return name is not
(nameof(EntityFrameworkQueryableExtensions.AsNoTracking) or
nameof(EntityFrameworkQueryableExtensions.AsNoTrackingWithIdentityResolution) or
nameof(EntityFrameworkQueryableExtensions.AsTracking) or
nameof(EntityFrameworkQueryableExtensions.IgnoreAutoIncludes) or
nameof(EntityFrameworkQueryableExtensions.IgnoreQueryFilters) or
nameof(EntityFrameworkQueryableExtensions.TagWith) or
nameof(EntityFrameworkQueryableExtensions.TagWithCallSite));
}

return declaringType == typeof(RelationalQueryableExtensions) &&
name is not
(nameof(RelationalQueryableExtensions.AsSplitQuery) or
nameof(RelationalQueryableExtensions.AsSingleQuery));
}

// The depth of the Include chain ending at this call. An inner call of the same chain measures a
// shorter depth, and the longest wins.
static int ChainDepth(MethodCallExpression node)
Expand Down
71 changes: 71 additions & 0 deletions src/Tests/ShapeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,77 @@ await Assert.That(
Includes))
.IsEqualTo(2);

[Test]
public async Task OperatorsCountSubqueries() =>
await Assert.That(
Measure(
context => context.Companies
.Where(_ => _.Name != "")
.Select(
_ => new
{
_.Name,
Departments = _.Departments
.Where(department => department.Name != "")
.OrderBy(department => department.Name)
.Select(department => department.Name)
}),
Operators))
.IsEqualTo(5);

// What the results are held in is not part of the query. A projection ends each collection it
// returns with one of these, so counting them would count every collection twice.
[Test]
public async Task OperatorsIgnoreWhatHoldsTheResults() =>
await Assert.That(
Measure(
context => context.Companies.Select(
_ => new
{
Names = _.Departments
.Select(department => department.Name)
.ToList(),
Employees = _.Departments
.AsQueryable()
.SelectMany(department => department.Employees)
.ToArray(),
Sizes = _.Departments
.AsEnumerable()
.Select(department => department.Employees.Count)
.ToHashSet()
}),
Operators))
.IsEqualTo(4);

[Test]
public async Task OperatorsIgnoreOptions() =>
await Assert.That(
Measure(
context => context.Employees
.AsNoTracking()
.AsTracking()
.AsNoTrackingWithIdentityResolution()
.AsSplitQuery()
.AsSingleQuery()
.IgnoreAutoIncludes()
.IgnoreQueryFilters()
.TagWith("tag")
.TagWithCallSite()
.Where(_ => _.Salary > 10),
Operators))
.IsEqualTo(1);

// Each adds to what the query loads
[Test]
public async Task OperatorsCountIncludes() =>
await Assert.That(
Measure(
context => context.Companies
.Include(_ => _.Departments)
.ThenInclude(_ => _.Employees),
Operators))
.IsEqualTo(2);

[Test]
public Task Message()
{
Expand Down
Loading