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
+""");
+ }
}