diff --git a/readme.md b/readme.md index b807c27..c3a45ac 100644 --- a/readme.md +++ b/readme.md @@ -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 | @@ -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). diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 03942fe..ffc26ea 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ enable latest true - 0.5.0 + 0.6.0 true true 1.0.0 diff --git a/src/EfQueryComplexity/QueryComplexityLimits.cs b/src/EfQueryComplexity/QueryComplexityLimits.cs index fab126a..439a2a1 100644 --- a/src/EfQueryComplexity/QueryComplexityLimits.cs +++ b/src/EfQueryComplexity/QueryComplexityLimits.cs @@ -10,7 +10,10 @@ /// /// Maximum number of expression nodes in a query. /// Maximum nesting depth of a query expression. -/// Maximum number of LINQ operators, including those in subqueries. +/// +/// 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. +/// /// Maximum number of navigations in one member access chain. /// Maximum number of Include calls. /// Maximum number of navigations in one Include chain. diff --git a/src/EfQueryComplexity/QueryComplexityOverride.cs b/src/EfQueryComplexity/QueryComplexityOverride.cs index 6ee6bda..baf5675 100644 --- a/src/EfQueryComplexity/QueryComplexityOverride.cs +++ b/src/EfQueryComplexity/QueryComplexityOverride.cs @@ -17,7 +17,11 @@ public sealed record QueryComplexityOverride /// Maximum nesting depth of a query expression. public int? MaxDepth { get; init; } - /// Maximum number of LINQ operators, including those in subqueries. + /// + /// 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. + /// public int? MaxOperators { get; init; } /// Maximum number of navigations in one member access chain. diff --git a/src/EfQueryComplexity/ShapeAnalyzer.cs b/src/EfQueryComplexity/ShapeAnalyzer.cs index f463e04..057b6e8 100644 --- a/src/EfQueryComplexity/ShapeAnalyzer.cs +++ b/src/EfQueryComplexity/ShapeAnalyzer.cs @@ -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++; } @@ -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) diff --git a/src/Tests/ShapeTests.cs b/src/Tests/ShapeTests.cs index 697c29d..039f035 100644 --- a/src/Tests/ShapeTests.cs +++ b/src/Tests/ShapeTests.cs @@ -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() {