diff --git a/claude.md b/claude.md
index 0f4edd8..1e6856f 100644
--- a/claude.md
+++ b/claude.md
@@ -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.
@@ -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 |
diff --git a/readme.md b/readme.md
index 638dcbe..bc31c57 100644
--- a/readme.md
+++ b/readme.md
@@ -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) |
@@ -122,6 +123,7 @@ 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` |
@@ -129,6 +131,24 @@ 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.
+### 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:
diff --git a/src/EfQueryComplexity/CollectionCounter.cs b/src/EfQueryComplexity/CollectionCounter.cs
new file mode 100644
index 0000000..7d90964
--- /dev/null
+++ b/src/EfQueryComplexity/CollectionCounter.cs
@@ -0,0 +1,298 @@
+///
+/// Counts the collections one SQL query loads: collection Includes, and collections a projection
+/// returns.
+///
+///
+/// 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 _.Employees.Count(), is a subquery rather
+/// than a join, so is not counted.
+///
+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 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();
+ 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 path)
+ {
+ var chain = new List();
+ 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, or Include for the string overload
+ return current.Method.GetGenericArguments()[0];
+ }
+
+ static IEnumerable 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();
+
+ 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();
+ }
+
+ ///
+ /// Counts the collections a projection returns, including collections nested in them.
+ ///
+ 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];
+ }
+ }
+ }
+}
diff --git a/src/EfQueryComplexity/MarkerReader.cs b/src/EfQueryComplexity/MarkerReader.cs
index ed43303..34db919 100644
--- a/src/EfQueryComplexity/MarkerReader.cs
+++ b/src/EfQueryComplexity/MarkerReader.cs
@@ -1,4 +1,4 @@
-///
+///
/// Reads what the marker calls in a query asked for, and removes them where they have to go.
///
///
@@ -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
diff --git a/src/EfQueryComplexity/QueryComplexityLimits.cs b/src/EfQueryComplexity/QueryComplexityLimits.cs
index 20840a2..63fbcfa 100644
--- a/src/EfQueryComplexity/QueryComplexityLimits.cs
+++ b/src/EfQueryComplexity/QueryComplexityLimits.cs
@@ -1,4 +1,4 @@
-namespace EfQueryComplexity;
+namespace EfQueryComplexity;
///
/// A set of complexity levels for a query.
@@ -14,6 +14,10 @@ namespace EfQueryComplexity;
/// Maximum number of navigations in one member access chain.
/// Maximum number of Include calls.
/// Maximum number of navigations in one Include chain.
+///
+/// Maximum number of collections one SQL query loads, through collection Includes and collections in
+/// a projection. Each multiplies the rows returned for the others. A split query counts none.
+///
/// Maximum value passed to Take. Checked on every execution.
/// Maximum number of values in a list the query sends, such as a Contains list. Checked on every execution.
///
@@ -29,7 +33,8 @@ public sealed record QueryComplexityLimits(
int? MaxIncludeDepth,
int? MaxTake,
int? MaxInValues,
- UnboundedEntities? RejectUnbounded)
+ UnboundedEntities? RejectUnbounded,
+ int? MaxSingleQueryCollections = null)
{
///
/// The levels used for logging when none are passed to UseQueryComplexity.
@@ -47,7 +52,8 @@ public sealed record QueryComplexityLimits(
MaxIncludeDepth: 3,
MaxTake: 1000,
MaxInValues: 1000,
- RejectUnbounded: UnboundedEntities.All);
+ RejectUnbounded: UnboundedEntities.All,
+ MaxSingleQueryCollections: 1);
// Measured while a query is compiled
internal bool HasShapeLevels =>
@@ -57,6 +63,7 @@ public sealed record QueryComplexityLimits(
MaxNavigationDepth != null ||
MaxIncludes != null ||
MaxIncludeDepth != null ||
+ MaxSingleQueryCollections != null ||
RejectUnbounded is {IsNone: false};
// Measured on every execution, since the values only exist then
@@ -80,6 +87,7 @@ internal QueryComplexityLimits Apply(QueryComplexityOverride? @override)
@override.MaxIncludeDepth ?? MaxIncludeDepth,
@override.MaxTake ?? MaxTake,
@override.MaxInValues ?? MaxInValues,
- @override.RejectUnbounded ?? RejectUnbounded);
+ @override.RejectUnbounded ?? RejectUnbounded,
+ @override.MaxSingleQueryCollections ?? MaxSingleQueryCollections);
}
}
diff --git a/src/EfQueryComplexity/QueryComplexityOptionsExtension.cs b/src/EfQueryComplexity/QueryComplexityOptionsExtension.cs
index 3a1dbe5..eee35e5 100644
--- a/src/EfQueryComplexity/QueryComplexityOptionsExtension.cs
+++ b/src/EfQueryComplexity/QueryComplexityOptionsExtension.cs
@@ -1,4 +1,4 @@
-sealed class QueryComplexityOptionsExtension(
+sealed class QueryComplexityOptionsExtension(
QueryComplexityLimits logAt,
QueryComplexityLimits? throwAt,
int? sqlServerCostLimit) :
@@ -45,6 +45,7 @@ public void Validate(IDbContextOptions options)
Check(nameof(QueryComplexityLimits.MaxNavigationDepth), LogAt.MaxNavigationDepth, ThrowAt.MaxNavigationDepth);
Check(nameof(QueryComplexityLimits.MaxIncludes), LogAt.MaxIncludes, ThrowAt.MaxIncludes);
Check(nameof(QueryComplexityLimits.MaxIncludeDepth), LogAt.MaxIncludeDepth, ThrowAt.MaxIncludeDepth);
+ Check(nameof(QueryComplexityLimits.MaxSingleQueryCollections), LogAt.MaxSingleQueryCollections, ThrowAt.MaxSingleQueryCollections);
Check(nameof(QueryComplexityLimits.MaxTake), LogAt.MaxTake, ThrowAt.MaxTake);
Check(nameof(QueryComplexityLimits.MaxInValues), LogAt.MaxInValues, ThrowAt.MaxInValues);
}
diff --git a/src/EfQueryComplexity/QueryComplexityOverride.cs b/src/EfQueryComplexity/QueryComplexityOverride.cs
index ece730f..5f7c5a2 100644
--- a/src/EfQueryComplexity/QueryComplexityOverride.cs
+++ b/src/EfQueryComplexity/QueryComplexityOverride.cs
@@ -1,4 +1,4 @@
-namespace EfQueryComplexity;
+namespace EfQueryComplexity;
///
/// Levels that replace the configured ones for a single query.
@@ -29,6 +29,9 @@ public sealed record QueryComplexityOverride
/// Maximum number of navigations in one Include chain.
public int? MaxIncludeDepth { get; init; }
+ /// Maximum number of collections one SQL query loads.
+ public int? MaxSingleQueryCollections { get; init; }
+
/// Maximum value passed to Take.
public int? MaxTake { get; init; }
diff --git a/src/EfQueryComplexity/QueryInterceptor.cs b/src/EfQueryComplexity/QueryInterceptor.cs
index 6d0c32c..3037f2f 100644
--- a/src/EfQueryComplexity/QueryInterceptor.cs
+++ b/src/EfQueryComplexity/QueryInterceptor.cs
@@ -1,4 +1,4 @@
-///
+///
/// Measures a query and removes the marker calls, while the query is compiled.
///
///
@@ -47,7 +47,7 @@ public Expression QueryCompilationStarting(Expression query, QueryExpressionEven
return stripped;
}
- var shape = ShapeAnalyzer.Analyze(stripped, context.Model);
+ var shape = ShapeAnalyzer.Analyze(stripped, context.Model, SplitByDefault(context));
if (throwAt != null)
{
@@ -70,4 +70,12 @@ public Expression QueryCompilationStarting(Expression query, QueryExpressionEven
return stripped;
}
+
+ // UseQuerySplittingBehavior on the provider, for example UseSqlServer(_ => _.UseQuerySplittingBehavior(...))
+ static bool SplitByDefault(DbContext context) =>
+ context.GetService()
+ .Extensions
+ .OfType()
+ .FirstOrDefault()
+ ?.QuerySplittingBehavior == QuerySplittingBehavior.SplitQuery;
}
diff --git a/src/EfQueryComplexity/QueryShape.cs b/src/EfQueryComplexity/QueryShape.cs
index 54cc8c8..504621a 100644
--- a/src/EfQueryComplexity/QueryShape.cs
+++ b/src/EfQueryComplexity/QueryShape.cs
@@ -1,4 +1,4 @@
-///
+///
/// What a query measures, independent of the values it is executed with.
///
readonly record struct QueryShape(
@@ -8,4 +8,5 @@ readonly record struct QueryShape(
int NavigationDepth,
int Includes,
int IncludeDepth,
+ int SingleQueryCollections,
IReadOnlyList UnboundedTypes);
diff --git a/src/EfQueryComplexity/ShapeAnalyzer.cs b/src/EfQueryComplexity/ShapeAnalyzer.cs
index 8cffb60..baf7190 100644
--- a/src/EfQueryComplexity/ShapeAnalyzer.cs
+++ b/src/EfQueryComplexity/ShapeAnalyzer.cs
@@ -1,4 +1,4 @@
-///
+///
/// Measures a query in a single pass.
///
sealed class ShapeAnalyzer(IModel model) :
@@ -15,7 +15,7 @@ sealed class ShapeAnalyzer(IModel model) :
int includeDepth;
int navigationDepth;
- public static QueryShape Analyze(Expression query, IModel model)
+ public static QueryShape Analyze(Expression query, IModel model, bool splitByDefault)
{
var analyzer = new ShapeAnalyzer(model);
analyzer.Visit(query);
@@ -26,6 +26,7 @@ public static QueryShape Analyze(Expression query, IModel model)
analyzer.navigationDepth,
analyzer.includes,
analyzer.includeDepth,
+ CollectionCounter.Count(query, model, splitByDefault),
UnboundedDetector.Find(query));
}
diff --git a/src/EfQueryComplexity/Violations.cs b/src/EfQueryComplexity/Violations.cs
index e4a9df2..ab5704d 100644
--- a/src/EfQueryComplexity/Violations.cs
+++ b/src/EfQueryComplexity/Violations.cs
@@ -1,4 +1,4 @@
-///
+///
/// Compares what a query measures against a set of levels, and describes what was exceeded.
///
static class Violations
@@ -12,6 +12,7 @@ public static List ForShape(QueryShape shape, QueryCom
Add(violations, nameof(QueryComplexityLimits.MaxNavigationDepth), limits.MaxNavigationDepth, shape.NavigationDepth);
Add(violations, nameof(QueryComplexityLimits.MaxIncludes), limits.MaxIncludes, shape.Includes);
Add(violations, nameof(QueryComplexityLimits.MaxIncludeDepth), limits.MaxIncludeDepth, shape.IncludeDepth);
+ Add(violations, nameof(QueryComplexityLimits.MaxSingleQueryCollections), limits.MaxSingleQueryCollections, shape.SingleQueryCollections);
var rowTypes = CheckedTypes(shape.UnboundedTypes, limits.RejectUnbounded);
if (rowTypes != null)
diff --git a/src/Tests/LoggingTests.LogDefaults.verified.txt b/src/Tests/LoggingTests.LogDefaults.verified.txt
index fc69c2e..6d26f64 100644
--- a/src/Tests/LoggingTests.LogDefaults.verified.txt
+++ b/src/Tests/LoggingTests.LogDefaults.verified.txt
@@ -7,5 +7,6 @@
MaxIncludeDepth: 3,
MaxTake: 1000,
MaxInValues: 1000,
- RejectUnbounded: All
+ RejectUnbounded: All,
+ MaxSingleQueryCollections: 1
}
\ No newline at end of file
diff --git a/src/Tests/ShapeTests.cs b/src/Tests/ShapeTests.cs
index 40ef531..17b131a 100644
--- a/src/Tests/ShapeTests.cs
+++ b/src/Tests/ShapeTests.cs
@@ -1,4 +1,4 @@
-public class ShapeTests
+public class ShapeTests
{
static QueryComplexityLimits Nodes(QueryComplexityLimits limits, int value) =>
limits with {MaxNodes = value};
@@ -18,6 +18,9 @@ static QueryComplexityLimits Includes(QueryComplexityLimits limits, int value) =
static QueryComplexityLimits IncludeDepth(QueryComplexityLimits limits, int value) =>
limits with {MaxIncludeDepth = value};
+ static QueryComplexityLimits SingleQueryCollections(QueryComplexityLimits limits, int value) =>
+ limits with {MaxSingleQueryCollections = value};
+
[Test]
public Task NodeLevel() =>
AssertLevel(context => context.Employees.Where(_ => _.Salary > 10), Nodes);
@@ -167,6 +170,113 @@ public async Task LogsOncePerShape()
await Assert.That(logs.Count).IsEqualTo(1);
}
+ [Test]
+ public Task SingleQueryCollectionsLevel() =>
+ AssertLevel(context => context.Companies.Include(_ => _.Departments).ThenInclude(_ => _.Employees), SingleQueryCollections);
+
+ // Departments is restated to ThenInclude below it, and is one collection
+ [Test]
+ public async Task SingleQueryCollectionsCountsRestatedIncludeOnce() =>
+ await Assert.That(
+ Measure(
+ context => context.Companies
+ .Include(_ => _.Departments)
+ .ThenInclude(_ => _.Employees)
+ .Include(_ => _.Departments)
+ .ThenInclude(_ => _.Company),
+ SingleQueryCollections))
+ .IsEqualTo(2);
+
+ [Test]
+ public async Task SingleQueryCollectionsCountsStringInclude() =>
+ await Assert.That(
+ Measure(
+ context => context.Companies.Include("Departments.Employees.Tasks"),
+ SingleQueryCollections))
+ .IsEqualTo(3);
+
+ [Test]
+ public async Task SingleQueryCollectionsCountsProjectedCollections() =>
+ await Assert.That(
+ Measure(
+ context => context.Companies.Select(
+ _ => new
+ {
+ _.Name,
+ Departments = _.Departments
+ .Select(department => new
+ {
+ department.Name,
+ Employees = department.Employees.ToList()
+ })
+ .ToList()
+ }),
+ SingleQueryCollections))
+ .IsEqualTo(2);
+
+ // AsSingleQuery overrides a context that splits by default
+ [Test]
+ public async Task SingleQueryCollectionsCountsAsSingleQuery()
+ {
+ var (context, _) = ContextBuilder.Build(
+ throwAt: SingleQueryCollections(Limits.None, 0),
+ configure: SplitByDefault);
+ var exception = Assert.Throws(
+ () => context.Companies
+ .Include(_ => _.Departments)
+ .AsSingleQuery()
+ .ToQueryString());
+ await Assert.That(exception.Violations.Single().Actual).IsEqualTo(1);
+ }
+
+ [Test]
+ public Task SingleQueryCollectionsIgnoresReferences() =>
+ AssertNoCollections(context => context.Employees.Include(_ => _.Department).ThenInclude(_ => _.Company));
+
+ // A collection only read by an aggregate is a subquery rather than a join
+ [Test]
+ public Task SingleQueryCollectionsIgnoresAggregates() =>
+ AssertNoCollections(
+ context => context.Companies.Select(
+ _ => new
+ {
+ _.Name,
+ Departments = _.Departments.Count(),
+ Staffed = _.Departments.Any(department => department.Employees.Count > 0)
+ }));
+
+ [Test]
+ public Task SingleQueryCollectionsIgnoresAsSplitQuery() =>
+ AssertNoCollections(
+ context => context.Companies
+ .Include(_ => _.Departments)
+ .ThenInclude(_ => _.Employees)
+ .AsSplitQuery());
+
+ [Test]
+ public Task SingleQueryCollectionsIgnoresSplitByDefault() =>
+ AssertNoCollections(
+ context => context.Companies
+ .Include(_ => _.Departments)
+ .ThenInclude(_ => _.Employees),
+ SplitByDefault);
+
+ static void SplitByDefault(DbContextOptionsBuilder builder) =>
+ builder.UseSqlServer(
+ "Server=.;Database=Test;",
+ _ => _.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
+
+ static async Task AssertNoCollections(
+ Func query,
+ Action>? configure = null)
+ {
+ var (context, logs) = ContextBuilder.Build(
+ logAt: SingleQueryCollections(Limits.None, 0),
+ configure: configure);
+ query(context).ToQueryString();
+ await Assert.That(logs.Count).IsEqualTo(0);
+ }
+
static async Task AssertLevel(
Func query,
Func level)