diff --git a/readme.md b/readme.md index c3a45ac..903756c 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ffc26ea..082b29c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -5,7 +5,7 @@ enable latest true - 0.6.0 + 0.7.0 true true 1.0.0 diff --git a/src/EfQueryComplexity/CollectionCounter.cs b/src/EfQueryComplexity/CollectionCounter.cs index f6971f6..8666da0 100644 --- a/src/EfQueryComplexity/CollectionCounter.cs +++ b/src/EfQueryComplexity/CollectionCounter.cs @@ -16,6 +16,11 @@ sealed class CollectionCounter(IModel model) : // ThenInclude something else below it, counts that collection once HashSet includePaths = []; HashSet 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 projectedPaths = []; int projected; public static int Count(Expression query, IModel model, bool splitByDefault) @@ -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; } @@ -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; } @@ -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) { @@ -158,7 +189,7 @@ void AddIncludePaths(MethodCallExpression node) var path = new List(); var rootType = IncludeChainRoot(node, path); var type = rootType; - var key = ""; + var key = PathRoot(rootType); foreach (var name in path) { @@ -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 path) @@ -283,7 +318,7 @@ bool IsCollectionNavigation(Type type) /// /// Counts the collections a projection returns, including collections nested in them. /// - sealed class ProjectionCounter(CollectionCounter counter) : + sealed class ProjectionCounter(CollectionCounter counter, ParameterExpression? row) : ExpressionVisitor { public override Expression? Visit(Expression? node) @@ -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); @@ -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]; @@ -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; @@ -375,6 +450,44 @@ void VisitSelectors(Expression node) } } + /// + /// Finds a use of a row other than reading a member of it. + /// + 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; + } + } + /// /// Finds an entity a projection can return, which the Includes before it would load into. /// diff --git a/src/Tests/ShapeTests.cs b/src/Tests/ShapeTests.cs index 039f035..2347c97 100644 --- a/src/Tests/ShapeTests.cs +++ b/src/Tests/ShapeTests.cs @@ -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;