From 209f0884ef5e1431c93d54a8ed4cb07382f00930 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 07:52:11 +0100 Subject: [PATCH 1/2] [release/10.0] Fix invalid SQL parameter names for switch/case pattern-matched variables (#37805) Co-authored-by: Shay Rojansky --- .../Internal/ExpressionTreeFuncletizer.cs | 62 ++++++++++++++++--- .../NorthwindMiscellaneousQueryCosmosTest.cs | 12 ++++ .../NorthwindMiscellaneousQueryTestBase.cs | 17 +++++ ...orthwindMiscellaneousQuerySqlServerTest.cs | 14 +++++ 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs b/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs index 4998075d3a5..ebb401accb3 100644 --- a/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs +++ b/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs @@ -25,6 +25,9 @@ public class ExpressionTreeFuncletizer : ExpressionVisitor private static readonly bool UseOldBehavior37152 = AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37152", out var enabled) && enabled; + private static readonly bool UseOldBehavior37465 = + AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue37465", out var enabled37465) && enabled37465; + // The general algorithm here is the following. // 1. First, for each node type, visit that node's children and get their states (evaluatable, contains evaluatable, no evaluatable). // 2. Calculate the parent node's aggregate state from its children; a container node whose children are all evaluatable is itself @@ -2083,12 +2086,19 @@ bool PreserveConvertNode(Expression expression) if (evaluateAsParameter) { - parameterName = tempParameterName ?? "p"; + if (UseOldBehavior37465) + { + parameterName = tempParameterName ?? "p"; - var compilerPrefixIndex = parameterName.LastIndexOf('>'); - if (compilerPrefixIndex != -1) + var compilerPrefixIndex = parameterName.LastIndexOf('>'); + if (compilerPrefixIndex != -1) + { + parameterName = parameterName[(compilerPrefixIndex + 1)..]; + } + } + else { - parameterName = parameterName[(compilerPrefixIndex + 1)..]; + parameterName = string.IsNullOrWhiteSpace(tempParameterName) ? "p" : tempParameterName; } // The VB compiler prefixes closure member names with $VB$Local_, remove that (#33150) @@ -2097,6 +2107,17 @@ bool PreserveConvertNode(Expression expression) parameterName = parameterName.Substring("$VB$Local_".Length); } + if (!UseOldBehavior37465) + { + // 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 (parameterName.Length > 0 && !char.IsLetter(parameterName[0]) && parameterName[0] != '_') + { + parameterName = "_" + parameterName; + } + } + if (UseOldBehavior37152) { // Uniquify the parameter name @@ -2162,12 +2183,18 @@ static Expression RemoveConvert(Expression expression) switch (memberExpression.Member) { case FieldInfo fieldInfo: - parameterName = parameterName is null ? fieldInfo.Name : $"{parameterName}_{fieldInfo.Name}"; + { + var name = UseOldBehavior37465 ? fieldInfo.Name : SanitizeCompilerGeneratedName(fieldInfo.Name); + parameterName = parameterName is null ? name : $"{parameterName}_{name}"; return fieldInfo.GetValue(instanceValue); + } case PropertyInfo propertyInfo: - parameterName = parameterName is null ? propertyInfo.Name : $"{parameterName}_{propertyInfo.Name}"; + { + var name = UseOldBehavior37465 ? propertyInfo.Name : SanitizeCompilerGeneratedName(propertyInfo.Name); + parameterName = parameterName is null ? name : $"{parameterName}_{name}"; return propertyInfo.GetValue(instanceValue); + } } } catch @@ -2181,7 +2208,9 @@ static Expression RemoveConvert(Expression expression) return constantExpression.Value; case MethodCallExpression methodCallExpression: - parameterName = methodCallExpression.Method.Name; + parameterName = UseOldBehavior37465 + ? methodCallExpression.Method.Name + : SanitizeCompilerGeneratedName(methodCallExpression.Method.Name); break; case UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unaryExpression @@ -2205,6 +2234,25 @@ static Expression RemoveConvert(Expression expression) exception); } } + + 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 if the angle brackets contain no + // content, strip them out entirely and take what comes after. + var closingBracket = s.IndexOf('>'); + if (closingBracket == -1) + { + return s; + } + + var openingBracket = s.IndexOf('<'); + + return openingBracket != -1 && openingBracket < closingBracket - 1 + ? s[(openingBracket + 1)..closingBracket] + : s[(closingBracket + 1)..]; + } } private Expression ConvertIfNeeded(Expression expression, Type type) diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindMiscellaneousQueryCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindMiscellaneousQueryCosmosTest.cs index b2ff29176f3..64603f898b7 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindMiscellaneousQueryCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindMiscellaneousQueryCosmosTest.cs @@ -4494,6 +4494,18 @@ public override async Task Parameter_extraction_can_throw_exception_from_user_co AssertSql(); } + public override Task Captured_variable_from_switch_case_pattern_matching(bool async) + => Fixture.NoSyncTest( + async, async a => + { + await base.Captured_variable_from_switch_case_pattern_matching(a); + + AssertSql( + """ +ReadItem(None, ALFKI) +"""); + }); + public override async Task Where_query_composition5(bool async) { var exception = await Assert.ThrowsAsync(() => AssertQuery( diff --git a/test/EFCore.Specification.Tests/Query/NorthwindMiscellaneousQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/NorthwindMiscellaneousQueryTestBase.cs index 0b70b83c750..bbff0e844c1 100644 --- a/test/EFCore.Specification.Tests/Query/NorthwindMiscellaneousQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/NorthwindMiscellaneousQueryTestBase.cs @@ -2993,6 +2993,23 @@ public virtual Task Parameter_extraction_can_throw_exception_from_user_code_2(bo && o.OrderDate.Value.Year == dateFilter.Value.Year))); } + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public virtual async Task Captured_variable_from_switch_case_pattern_matching(bool async) + { + object customerIdAsObject = "ALFKI"; + + switch (customerIdAsObject) + { + case string customerId: + { + await AssertQuery( + async, + ss => ss.Set().Where(c => c.CustomerID == customerId)); + break; + } + } + } + [ConditionalTheory, MemberData(nameof(IsAsyncData))] public virtual Task Subquery_member_pushdown_does_not_change_original_subquery_model(bool async) => AssertQuery( diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindMiscellaneousQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindMiscellaneousQuerySqlServerTest.cs index 7612cc2ca39..8104a760aa1 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindMiscellaneousQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindMiscellaneousQuerySqlServerTest.cs @@ -3236,6 +3236,20 @@ FROM [Orders] AS [o] """); } + public override async Task Captured_variable_from_switch_case_pattern_matching(bool async) + { + await base.Captured_variable_from_switch_case_pattern_matching(async); + + AssertSql( + """ +@customerId='ALFKI' (Size = 5) (DbType = StringFixedLength) + +SELECT [c].[CustomerID], [c].[Address], [c].[City], [c].[CompanyName], [c].[ContactName], [c].[ContactTitle], [c].[Country], [c].[Fax], [c].[Phone], [c].[PostalCode], [c].[Region] +FROM [Customers] AS [c] +WHERE [c].[CustomerID] = @customerId +"""); + } + public override async Task Subquery_member_pushdown_does_not_change_original_subquery_model(bool async) { await base.Subquery_member_pushdown_does_not_change_original_subquery_model(async); From 39531c8c2f36f12abf468d1e16fc4a3340e01fb3 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Fri, 27 Feb 2026 08:16:47 +0100 Subject: [PATCH 2/2] Fix ExecuteUpdate over scalar projections (#37791) 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);