From d69d84a14692859e67c45c2e54b80b0ae4e50ad1 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Tue, 24 Feb 2026 12:42:45 +0100 Subject: [PATCH] Fix ExecuteUpdate over scalar projections Fixes #37771 --- .../NavigationExpandingExpressionVisitor.cs | 18 +++++++-- .../NorthwindBulkUpdatesRelationalTestBase.cs | 38 +++++++++++++++++++ .../NorthwindBulkUpdatesSqlServerTest.cs | 28 ++++++++++++++ .../NorthwindBulkUpdatesSqliteTest.cs | 26 +++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs index 6164e159e28..1d1d91b0b5f 100644 --- a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs +++ b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs @@ -61,6 +61,9 @@ private static readonly PropertyInfo QueryContextContextPropertyInfo private static readonly bool UseOldBehavior37247 = AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37247", out var enabled) && enabled; + private static readonly bool UseOldBehavior37771 = + AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37771", out var enabled) && enabled; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in @@ -1071,11 +1074,18 @@ private Expression ProcessExecuteUpdate(NavigationExpansionExpression source, Me { // Apply any pending selector before processing the ExecuteUpdate setters; this adds a Select() (if necessary) before // ExecuteUpdate, to avoid the pending selector flowing into each setter lambda and making it more complicated. + // However, only do this when the pending selector produces entity/structural type references (i.e. the snapshot is not just + // a DefaultExpression). When the pending selector projects only scalar values (e.g. select new { p.Used, n.Qty }), + // applying it would lose the connection between the projected scalar and the original entity property, breaking + // ExecuteUpdate's property selector recognition (#37771). var newStructure = SnapshotExpression(source.PendingSelector); - var queryable = Reduce(source); - var navigationTree = new NavigationTreeExpression(newStructure); - var parameterName = source.CurrentParameter.Name ?? GetParameterName("e"); - source = new NavigationExpansionExpression(queryable, navigationTree, navigationTree, parameterName); + if (newStructure is not DefaultExpression || UseOldBehavior37771) + { + var queryable = Reduce(source); + var navigationTree = new NavigationTreeExpression(newStructure); + var parameterName = source.CurrentParameter.Name ?? GetParameterName("e"); + source = new NavigationExpansionExpression(queryable, navigationTree, navigationTree, parameterName); + } } NewArrayExpression settersArray; diff --git a/test/EFCore.Relational.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesRelationalTestBase.cs index 55b973e4059..a1408db4073 100644 --- a/test/EFCore.Relational.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesRelationalTestBase.cs @@ -99,6 +99,44 @@ FROM [Customers] } }); + [ConditionalTheory, MemberData(nameof(IsAsyncData))] // #37771 + public virtual Task Update_with_select_mixed_entity_scalar_anonymous_projection(bool async) + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + () => Fixture.CreateContext(), + (facade, transaction) => Fixture.UseTransaction(facade, transaction), + async context => + { + var queryable = context.Set().Select(c => new { Entity = c, c.ContactName }); + + if (async) + { + await queryable.ExecuteUpdateAsync(s => s.SetProperty(c => c.Entity.ContactName, "Updated")); + } + else + { + queryable.ExecuteUpdate(s => s.SetProperty(c => c.Entity.ContactName, "Updated")); + } + }); + + [ConditionalTheory, MemberData(nameof(IsAsyncData))] // #37771 + public virtual Task Update_with_select_scalar_anonymous_projection(bool async) + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + () => Fixture.CreateContext(), + (facade, transaction) => Fixture.UseTransaction(facade, transaction), + async context => + { + var queryable = context.Set().Select(c => new { c.ContactName, c.City }); + + if (async) + { + await queryable.ExecuteUpdateAsync(s => s.SetProperty(c => c.ContactName, "Updated")); + } + else + { + queryable.ExecuteUpdate(s => s.SetProperty(c => c.ContactName, "Updated")); + } + }); + protected static async Task AssertTranslationFailed(string details, Func query) => Assert.Contains( CoreStrings.NonQueryTranslationFailedWithDetails("", details)[21..], diff --git a/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs index 530e0220f14..b600255d6d2 100644 --- a/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs @@ -1651,6 +1651,34 @@ OFFSET @p ROWS """); } + public override async Task Update_with_select_mixed_entity_scalar_anonymous_projection(bool async) + { + await base.Update_with_select_mixed_entity_scalar_anonymous_projection(async); + + AssertSql( + """ +@p='Updated' (Size = 30) + +UPDATE [c] +SET [c].[ContactName] = @p +FROM [Customers] AS [c] +"""); + } + + public override async Task Update_with_select_scalar_anonymous_projection(bool async) + { + await base.Update_with_select_scalar_anonymous_projection(async); + + AssertSql( + """ +@p='Updated' (Size = 30) + +UPDATE [c] +SET [c].[ContactName] = @p +FROM [Customers] AS [c] +"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); diff --git a/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs index 96eba3256ab..fe1bce5f8c5 100644 --- a/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs @@ -1558,6 +1558,32 @@ ORDER BY "o"."OrderID" """); } + public override async Task Update_with_select_mixed_entity_scalar_anonymous_projection(bool async) + { + await base.Update_with_select_mixed_entity_scalar_anonymous_projection(async); + + AssertSql( + """ +@p='Updated' (Size = 7) + +UPDATE "Customers" AS "c" +SET "ContactName" = @p +"""); + } + + public override async Task Update_with_select_scalar_anonymous_projection(bool async) + { + await base.Update_with_select_scalar_anonymous_projection(async); + + AssertSql( + """ +@p='Updated' (Size = 7) + +UPDATE "Customers" AS "c" +SET "ContactName" = @p +"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected);