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
3 changes: 2 additions & 1 deletion claude.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# CLAUDE.md
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Expand Down Expand Up @@ -40,6 +40,7 @@ The service provider alone is not enough. Entity Framework keys a compiled query
| `QueryComplexityOptionsExtension.cs` | Holds the levels, and keys the internal service provider |
| `QueryInterceptor.cs` | `IQueryExpressionInterceptor`: measures shape and strips markers, once per compiled shape |
| `ShapeAnalyzer.cs` | One pass measuring nodes, depth, operators, navigations and includes |
| `CollectionCounter.cs` | The collections one SQL query loads, through collection includes and projections. A split query counts none |
| `UnboundedDetector.cs` | The types of the rows a query can return without a limit, found once per compiled shape |
| `UnboundedEntities.cs` | Which of those types `RejectUnbounded` checks: `All`, `None`, `AllExcept`, `Only` |
| `Sequences.cs` | Whether a type is a sequence, and what it holds |
Expand Down
20 changes: 20 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The checks bound what one query can ask for, whichever layer built it:
| Requesting every row | `RejectUnbounded`, `MaxTake` |
| Deeply nested or very large queries | `MaxNodes`, `MaxDepth`, `MaxOperators` |
| Long navigation chains and includes, which multiply joins | `MaxNavigationDepth`, `MaxIncludes`, `MaxIncludeDepth` |
| Several collections in one query, which multiply rows | `MaxSingleQueryCollections` |
| Huge `IN` lists | `MaxInValues` |
| A query that passes every check but is still expensive | [SQL Server cost limit](#sql-server-cost-limit) |

Expand Down Expand Up @@ -122,13 +123,32 @@ A query is checked against the throw levels before the log levels, so a throw le
| `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 |
| `MaxSingleQueryCollections` | Collections one SQL query loads | While compiled | 1 |
| `MaxTake` | The value passed to `Take` | Every execution | 1000 |
| `MaxInValues` | Values in the largest list the query sends | Every execution | 1000 |
| `RejectUnbounded` | A query returning rows with no `Take` | While compiled | `All` |

A check fires when the measured value is greater than the level. A level of `null` turns that check off.


### 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).

`MaxSingleQueryCollections` counts:

* 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.

It does not count:

* Reference navigations.
* A collection only read by an aggregate, like `_.Employees.Count()` or `_.Employees.Any()`, which is a subquery rather than a join.
* Any collection in a split query, from `AsSplitQuery()` or `UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)`, since each collection is then loaded by its own query. `AsSingleQuery()` overrides the default.

The log default of 1 matches the point where Entity Framework logs `MultipleCollectionIncludeWarning`. That warning only covers `Include`, and only when no splitting behavior is configured.


### Unbounded queries

A query is bounded when it cannot return more rows than a `Take` allows:
Expand Down
298 changes: 298 additions & 0 deletions src/EfQueryComplexity/CollectionCounter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
/// <summary>
/// Counts the collections one SQL query loads: collection Includes, and collections a projection
/// returns.
/// </summary>
/// <remarks>
/// A single query joins every collection it loads, so each multiplies the rows returned for the
/// others, a cartesian explosion. A split query loads each collection in its own query, so counts
/// none. A collection only read by an aggregate, like <c>_.Employees.Count()</c>, is a subquery rather
/// than a join, so is not counted.
/// </remarks>
sealed class CollectionCounter(IModel model) :
ExpressionVisitor
{
// Keyed on the full path from the root, so an Include chain that restates a collection, to
// ThenInclude something else below it, counts that collection once
HashSet<string> includePaths = [];
int projected;

public static int Count(Expression query, IModel model, bool splitByDefault)
{
if (IsSplit(query, splitByDefault))
{
return 0;
}

var counter = new CollectionCounter(model);
counter.Visit(query);
return counter.includePaths.Count + counter.projected;
}

// AsSplitQuery and AsSingleQuery apply to the whole query, and override the context default
static bool IsSplit(Expression query, bool splitByDefault)
{
var current = query;
while (current is MethodCallExpression {Arguments.Count: > 0} call)
{
if (call.Method.DeclaringType == typeof(RelationalQueryableExtensions))
{
switch (call.Method.Name)
{
case nameof(RelationalQueryableExtensions.AsSplitQuery):
return true;
case nameof(RelationalQueryableExtensions.AsSingleQuery):
return false;
}
}

current = call.Arguments[0];
}

return splitByDefault;
}

protected override Expression VisitMethodCall(MethodCallExpression node)
{
var method = node.Method;
var declaringType = method.DeclaringType;

if (declaringType == typeof(EntityFrameworkQueryableExtensions) &&
method.Name is "Include" or "ThenInclude")
{
AddIncludePaths(node);
return base.VisitMethodCall(node);
}

if ((declaringType == typeof(Queryable) || declaringType == typeof(Enumerable)) &&
method.Name == "Select")
{
Visit(node.Arguments[0]);
new ProjectionCounter(this).Visit(node.Arguments[1]);
return node;
}

return base.VisitMethodCall(node);
}

void AddIncludePaths(MethodCallExpression node)
{
var path = new List<string>();
var rootType = IncludeChainRoot(node, path);
var type = rootType;
var key = "";

foreach (var name in path)
{
var navigation = FindNavigation(type, name);
if (navigation == null)
{
return;
}

key += "." + name;
if (navigation.IsCollection)
{
includePaths.Add(key);
}

type = navigation.TargetEntityType.ClrType;
}
}

// 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)
{
var chain = new List<MethodCallExpression>();
var current = node;
chain.Add(current);

// A ThenInclude can only follow an Include or another ThenInclude
while (current.Method.Name == "ThenInclude")
{
current = (MethodCallExpression) current.Arguments[0];
chain.Add(current);
}

chain.Reverse();
foreach (var call in chain)
{
path.AddRange(Segments(call.Arguments[1]));
}

// Include<TEntity, TProperty>, or Include<TEntity> for the string overload
return current.Method.GetGenericArguments()[0];
}

static IEnumerable<string> Segments(Expression path)
{
// The string overload takes a dotted path
if (path is ConstantExpression {Value: string text})
{
return text.Split('.');
}

var body = ((LambdaExpression) ((UnaryExpression) path).Operand).Body;
var segments = new List<string>();

while (true)
{
switch (body)
{
// A filtered Include wraps the navigation in operators like Where and OrderBy
case MethodCallExpression call when (call.Object ?? call.Arguments.FirstOrDefault()) is { } source:
body = source;
continue;
case UnaryExpression unary:
body = unary.Operand;
continue;
case MemberExpression {Expression: { } inner} member:
segments.Add(member.Member.Name);
body = inner;
continue;
default:
segments.Reverse();
return segments;
}
}
}

INavigationBase? FindNavigation(Type type, string name)
{
var entityType = model.FindEntityType(type);
if (entityType == null)
{
return null;
}

// A derived type can declare the navigation, reached with a cast in the Include
foreach (var candidate in entityType.GetDerivedTypesInclusive())
{
var navigation = (INavigationBase?) candidate.FindNavigation(name) ??
candidate.FindSkipNavigation(name);
if (navigation != null)
{
return navigation;
}
}

return null;
}

bool IsCollectionNavigation(Type type)
{
if (!Sequences.IsSequence(type))
{
return false;
}

var element = Sequences.ElementType(type);
if (element.IsValueType ||
element == typeof(string) ||
model.IsShared(element))
{
return false;
}

var entityType = model.FindEntityType(element);

// An owned collection mapped to JSON is a column of its owner, not a join
return entityType != null &&
!entityType.IsMappedToJson();
}

/// <summary>
/// Counts the collections a projection returns, including collections nested in them.
/// </summary>
sealed class ProjectionCounter(CollectionCounter counter) :
ExpressionVisitor
{
public override Expression? Visit(Expression? node)
{
if (node == null)
{
return null;
}

if (Sequences.IsSequence(node.Type) &&
LoadsRows(node))
{
counter.projected++;

// A projection inside the collection can return further collections
VisitSelectors(node);
return node;
}

return base.Visit(node);
}

// An aggregate, like Count or Any, reads a collection without returning it
protected override Expression VisitMethodCall(MethodCallExpression node)
{
if (node.Method.IsStatic &&
node.Arguments.Count > 0 &&
Sequences.IsSequence(node.Arguments[0].Type))
{
foreach (var argument in node.Arguments.Skip(1))
{
Visit(argument);
}

return node;
}

return base.VisitMethodCall(node);
}

// A member of a collection, like List.Count, reads it without returning it
protected override Expression VisitMember(MemberExpression node)
{
if (node.Expression != null &&
Sequences.IsSequence(node.Expression.Type))
{
return node;
}

return base.VisitMember(node);
}

// Whether a sequence comes from the database: a collection navigation, or a query
bool LoadsRows(Expression node)
{
var current = node;
while (true)
{
switch (current)
{
case MemberExpression member when counter.IsCollectionNavigation(member.Type):
return true;
case MethodCallExpression {Method.IsStatic: true, Arguments.Count: > 0} call:
current = call.Arguments[0];
continue;
case UnaryExpression unary:
current = unary.Operand;
continue;
case EntityQueryRootExpression:
return true;
default:
return false;
}
}
}

void VisitSelectors(Expression node)
{
var current = node;
while (current is MethodCallExpression {Method.IsStatic: true, Arguments.Count: > 0} call)
{
foreach (var argument in call.Arguments.Skip(1))
{
Visit(argument);
}

current = call.Arguments[0];
}
}
}
}
3 changes: 2 additions & 1 deletion src/EfQueryComplexity/MarkerReader.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/// <summary>
/// <summary>
/// Reads what the marker calls in a query asked for, and removes them where they have to go.
/// </summary>
/// <remarks>
Expand Down Expand Up @@ -132,6 +132,7 @@ static QueryComplexityOverride Merge(QueryComplexityOverride? outer, QueryComple
MaxNavigationDepth = outer.MaxNavigationDepth ?? inner.MaxNavigationDepth,
MaxIncludes = outer.MaxIncludes ?? inner.MaxIncludes,
MaxIncludeDepth = outer.MaxIncludeDepth ?? inner.MaxIncludeDepth,
MaxSingleQueryCollections = outer.MaxSingleQueryCollections ?? inner.MaxSingleQueryCollections,
MaxTake = outer.MaxTake ?? inner.MaxTake,
MaxInValues = outer.MaxInValues ?? inner.MaxInValues,
RejectUnbounded = outer.RejectUnbounded ?? inner.RejectUnbounded
Expand Down
Loading
Loading