diff --git a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.ExpressionVisitors.cs b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.ExpressionVisitors.cs index 6412fd4ec26..22351fee95f 100644 --- a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.ExpressionVisitors.cs +++ b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.ExpressionVisitors.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore.Internal; using ExpressionExtensions = Microsoft.EntityFrameworkCore.Infrastructure.ExpressionExtensions; @@ -596,6 +597,30 @@ protected override Expression VisitMember(MemberExpression memberExpression) { return memberExpression; } + + // Handle member access on NavigationTreeExpression with NewExpression + // When accessing a specific member (like x.Job.Id), only expand that member + // instead of reconstructing the entire anonymous type + if (memberExpression.Expression is NavigationTreeExpression navigationTreeExpression + && navigationTreeExpression.Value is NewExpression newExpression + && newExpression.Members != null) + { + // Find which argument corresponds to the accessed member + for (var i = 0; i < newExpression.Members.Count; i++) + { + if (newExpression.Members[i] == memberExpression.Member) + { + var argument = newExpression.Arguments[i]; + + // Visit just this specific argument + var visitedArgument = Visit(argument); + + // Return a member access on the navigation tree expression + // This ensures we don't reconstruct the entire anonymous type + return visitedArgument; + } + } + } } return base.VisitMember(memberExpression); @@ -1051,6 +1076,315 @@ MethodCallExpression e when e.Method.IsEFPropertyMethod() } } + /// + /// Prunes NavigationTreeExpression members when only specific members are accessed. + /// Handles patterns like ((NavigationTreeExpression).Member1).Member2 where only + /// Member2 should be kept from the nested navigation tree. + /// + private sealed class NavigationTreeMemberPruningVisitor : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) + { + if (node.Expression is NavigationTreeExpression navTree + && navTree.Value is NewExpression newExpr + && newExpr.Members != null) + { + var memberIndex = FindMemberIndex(newExpr, node.Member.Name); + if (memberIndex >= 0) + { + var memberValue = Visit(newExpr.Arguments[memberIndex]); + + if (memberValue is NavigationTreeExpression) + { + return memberValue; + } + + return new NavigationTreeExpression(memberValue); + } + } + + return base.VisitMember(node); + } + + protected override Expression VisitNew(NewExpression node) + { + if (node.Members != null) + { + var newArguments = new Expression[node.Arguments.Count]; + for (var i = 0; i < node.Arguments.Count; i++) + { + newArguments[i] = Visit(node.Arguments[i]); + } + + return node.Update(newArguments); + } + + return base.VisitNew(node); + } + + protected override Expression VisitExtension(Expression node) + { + if (node is NavigationTreeExpression navTree && navTree.Value is NewExpression newExpr && newExpr.Members != null) + { + var visitedValue = Visit(navTree.Value); + if (visitedValue != navTree.Value) + { + return new NavigationTreeExpression(visitedValue); + } + } + + return base.VisitExtension(node); + } + + private static int FindMemberIndex(NewExpression newExpression, string memberName) + { + if (newExpression.Members != null) + { + for (var i = 0; i < newExpression.Members.Count; i++) + { + if (newExpression.Members[i].Name == memberName) + { + return i; + } + } + } + + return -1; + } + } + + /// + /// Collects which members are accessed from a parameter in an expression tree. + /// Used to determine which properties to keep when pruning navigation expansions. + /// + private sealed class MemberAccessCollector : ExpressionVisitor + { + private ParameterExpression? _parameter; + private readonly HashSet _accessedPaths = []; + private readonly Stack _currentPath = []; + + public HashSet Collect(Expression expression, ParameterExpression parameter) + { + _parameter = parameter; + Visit(expression); + return _accessedPaths; + } + + protected override Expression VisitMember(MemberExpression memberExpression) + { + if (IsAccessingParameter(memberExpression, out var path)) + { + _accessedPaths.Add(path); + } + + return base.VisitMember(memberExpression); + } + + private bool IsAccessingParameter(MemberExpression memberExpression, [NotNullWhen(true)] out string[]? path) + { + path = null; + var members = new Stack(); + var current = (Expression?)memberExpression; + + while (current is MemberExpression member) + { + members.Push(member.Member.Name); + current = member.Expression; + } + + if (current == _parameter && members.Count > 0) + { + path = [.. members]; + return true; + } + + return false; + } + } + + /// + /// Replaces parameter with pending selector while pruning unused navigation members. + /// Only keeps the members that are actually accessed in the selector body. + /// + private sealed class MemberPruningReplacer( + ParameterExpression parameter, + Expression pendingSelector, + HashSet accessedMembers) : ExpressionVisitor + { + protected override Expression VisitParameter(ParameterExpression node) + { + if (node == parameter) + { + return pendingSelector; + } + + return base.VisitParameter(node); + } + + protected override Expression VisitMember(MemberExpression memberExpression) + { + if (IsAccessingParameter(memberExpression, out var path)) + { + return PruneAndReplace(memberExpression, path); + } + + return base.VisitMember(memberExpression); + } + + private Expression PruneAndReplace(MemberExpression original, string[] fullPath) + { + var replacement = pendingSelector; + + for (var i = 0; i < fullPath.Length; i++) + { + replacement = AccessMember(replacement, fullPath[i], fullPath[..(i + 1)]); + } + + return replacement; + } + + private Expression AccessMember(Expression expression, string memberName, string[] pathToMember) + { + if (expression is NavigationTreeExpression navTree && navTree.Value is NewExpression newExpr && newExpr.Members != null) + { + var memberIndex = FindMemberIndex(newExpr, memberName); + if (memberIndex >= 0) + { + var memberValue = newExpr.Arguments[memberIndex]; + + if (memberValue is NavigationTreeExpression innerNavTree + && innerNavTree.Value is NewExpression innerNewExpr + && innerNewExpr.Members != null) + { + var prunedValue = PruneNavigationTree(innerNewExpr, pathToMember); + return new NavigationTreeExpression(prunedValue); + } + + return memberValue; + } + } + + if (expression is NewExpression newExpression && newExpression.Members != null) + { + var memberIndex = FindMemberIndex(newExpression, memberName); + if (memberIndex >= 0) + { + var memberValue = newExpression.Arguments[memberIndex]; + + if (memberValue is NavigationTreeExpression navTree2 + && navTree2.Value is NewExpression innerNewExpr2 + && innerNewExpr2.Members != null) + { + var prunedValue = PruneNavigationTree(innerNewExpr2, pathToMember); + return new NavigationTreeExpression(prunedValue); + } + + return memberValue; + } + } + + var member = expression.Type.GetMember(memberName).FirstOrDefault(); + return member != null ? Expression.MakeMemberAccess(expression, member) : expression; + } + + private NewExpression PruneNavigationTree(NewExpression newExpr, string[] basePath) + { + var keptIndices = new HashSet(); + + for (var i = 0; i < newExpr.Members!.Count; i++) + { + var memberName = newExpr.Members[i].Name; + var testPath = new string[basePath.Length + 1]; + Array.Copy(basePath, testPath, basePath.Length); + testPath[basePath.Length] = memberName; + + if (IsPathOrPrefixAccessed(testPath)) + { + keptIndices.Add(i); + } + } + + if (keptIndices.Count == newExpr.Members.Count || keptIndices.Count == 0) + { + return newExpr; + } + + var arguments = new List(); + var members = new List(); + + foreach (var index in keptIndices.OrderBy(x => x)) + { + arguments.Add(newExpr.Arguments[index]); + members.Add(newExpr.Members[index]); + } + + return Expression.New(newExpr.Constructor!, arguments, members); + } + + private bool IsPathOrPrefixAccessed(string[] path) + { + foreach (var accessedPath in accessedMembers) + { + if (accessedPath.Length >= path.Length) + { + var match = true; + for (var i = 0; i < path.Length; i++) + { + if (accessedPath[i] != path[i]) + { + match = false; + break; + } + } + + if (match) + { + return true; + } + } + } + + return false; + } + + private bool IsAccessingParameter(MemberExpression memberExpression, [NotNullWhen(true)] out string[]? path) + { + path = null; + var members = new Stack(); + var current = (Expression?)memberExpression; + + while (current is MemberExpression member) + { + members.Push(member.Member.Name); + current = member.Expression; + } + + if (current == parameter && members.Count > 0) + { + path = [.. members]; + return true; + } + + return false; + } + + private static int FindMemberIndex(NewExpression newExpression, string memberName) + { + if (newExpression.Members != null) + { + for (var i = 0; i < newExpression.Members.Count; i++) + { + if (newExpression.Members[i].Name == memberName) + { + return i; + } + } + } + + return -1; + } + } + /// /// Marks as nullable when coming from a left join. /// Nullability is required to figure out if the navigation from this entity should be a left join or diff --git a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs index 80ab9768824..d597ea4a4f2 100644 --- a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs +++ b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs @@ -1417,6 +1417,8 @@ private static NavigationExpansionExpression ProcessSelect(NavigationExpansionEx source.PendingSelector, selector.Body); + selectorBody = new NavigationTreeMemberPruningVisitor().Visit(selectorBody); + source.ApplySelector(selectorBody); return source; diff --git a/test/EFCore.Relational.Specification.Tests/Query/AdHocNavigationsQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/AdHocNavigationsQueryRelationalTestBase.cs index fb4caf63ef4..122fe6c5e48 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/AdHocNavigationsQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/AdHocNavigationsQueryRelationalTestBase.cs @@ -75,4 +75,83 @@ public class OtherEntity } #endregion + + #region ConditionalProjection + + [ConditionalTheory] + [InlineData(false)] + [InlineData(true)] + public virtual async Task Consecutive_selects_with_conditional_projection_should_not_include_unnecessary_joins(bool async) + { + var contextFactory = await InitializeAsync( + seed: c => c.SeedAsync()); + + using var context = contextFactory.CreateContext(); + + var query = context.Users + .Select(x => new + { + x.Id, + Job = x.Job == null ? null : new + { + x.Job.Id, + Address = new + { + x.Job.Address.Id, + x.Job.Address.Street + } + } + }) + .Select(x => new + { + x.Id, + Job = x.Job == null ? null : new + { + x.Job.Id + } + }) + .Where(x => x.Id == 1); + + var result = async ? await query.FirstOrDefaultAsync() : query.FirstOrDefault(); + + Assert.NotNull(result); + Assert.Equal(1, result.Id); + } + + protected class ContextConditionalProjection(DbContextOptions options) : DbContext(options) + { + public DbSet Users { get; set; } + + public async Task SeedAsync() + { + var address = new Address { Street = "123 Main St" }; + var job = new Job { Address = address }; + var user = new User { Job = job }; + + Add(user); + await SaveChangesAsync(); + } + + public class User + { + public long Id { get; set; } + public long? JobId { get; set; } + public Job Job { get; set; } + } + + public class Job + { + public long Id { get; set; } + public long AddressId { get; set; } + public Address Address { get; set; } + } + + public class Address + { + public long Id { get; set; } + public string Street { get; set; } + } + } + + #endregion } diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/AdHocNavigationsQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/AdHocNavigationsQuerySqliteTest.cs index f19141e389d..fff2abb6b61 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/AdHocNavigationsQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/AdHocNavigationsQuerySqliteTest.cs @@ -40,4 +40,18 @@ public override async Task SelectMany_and_collection_in_projection_in_FirstOrDef AssertSql(); } + + public override async Task Consecutive_selects_with_conditional_projection_should_not_include_unnecessary_joins(bool async) + { + await base.Consecutive_selects_with_conditional_projection_should_not_include_unnecessary_joins(async); + + AssertSql( + """ +SELECT "u"."Id", "j"."Id" IS NULL, "j"."Id" +FROM "Users" AS "u" +LEFT JOIN "Job" AS "j" ON "u"."JobId" = "j"."Id" +WHERE "u"."Id" = 1 +LIMIT 1 +"""); + } }