diff --git a/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs b/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs index 58d36d3f39c..ade3e0e7450 100644 --- a/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs +++ b/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs @@ -2101,7 +2101,7 @@ bool PreserveConvertNode(Expression expression) // In many databases, parameter names must start with a letter or underscore. // The same is true for C# variable names, from which we derive the parameter name, so in principle we shouldn't see an issue; // but just in case, prepend an underscore if the parameter name doesn't start with a letter or underscore. - if (!char.IsLetter(parameterName[0]) && parameterName[0] != '_') + if (parameterName.Length > 0 && !char.IsLetter(parameterName[0]) && parameterName[0] != '_') { parameterName = "_" + parameterName; } @@ -2215,7 +2215,7 @@ static string SanitizeCompilerGeneratedName(string s) { // Compiler-generated field names intentionally contain illegal characters, specifically angle brackets <>. // In cases where there's something within the angle brackets, that tends to be the original user-provided variable name - // (e.g. k__BackingField). If we see angle brackets, extract that out, or it the angle brackets contain no + // (e.g. k__BackingField). If we see angle brackets, extract that out, or if the angle brackets contain no // content, strip them out entirely and take what comes after. var closingBracket = s.IndexOf('>'); if (closingBracket == -1) diff --git a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs index e94d04d5639..20aa0e9e94a 100644 --- a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs +++ b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs @@ -58,6 +58,7 @@ private static readonly PropertyInfo QueryContextContextPropertyInfo private readonly Dictionary _parameters = new(); + /// /// 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 @@ -1082,11 +1083,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) + { + 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; switch (setters) diff --git a/test/EFCore.Relational.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesRelationalTestBase.cs index b3150985919..ed32133a032 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) { var exception = await Assert.ThrowsAsync(query); diff --git a/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs index 3c135a53aa8..892a5f04bc8 100644 --- a/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs @@ -1665,6 +1665,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 cc02a2d8d79..0c582d696af 100644 --- a/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs @@ -1571,6 +1571,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);