From c55c350f7f362420ebf74d44df64570348096da3 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Mon, 27 Nov 2023 21:30:32 +0100 Subject: [PATCH] Implement EF.Constant (#32412) Closes #31552 (cherry picked from commit 484a3ed98685d897756fb558c01fc202cfd76f64) --- src/EFCore/EF.cs | 11 ++++ src/EFCore/Properties/CoreStrings.Designer.cs | 12 +++++ src/EFCore/Properties/CoreStrings.resx | 6 +++ .../ParameterExtractingExpressionVisitor.cs | 40 ++++++++++++++ .../Query/NorthwindWhereQueryCosmosTest.cs | 43 +++++++++++++++ .../Query/NorthwindWhereQueryTestBase.cs | 53 ++++++++++++++++++- .../PrimitiveCollectionsQueryTestBase.cs | 12 +++++ .../Query/NorthwindWhereQuerySqlServerTest.cs | 43 +++++++++++++++ ...imitiveCollectionsQueryOldSqlServerTest.cs | 12 +++++ .../PrimitiveCollectionsQuerySqlServerTest.cs | 12 +++++ .../PrimitiveCollectionsQuerySqliteTest.cs | 12 +++++ 11 files changed, 255 insertions(+), 1 deletion(-) diff --git a/src/EFCore/EF.cs b/src/EFCore/EF.cs index 8812db8a0bb..46513effd3d 100644 --- a/src/EFCore/EF.cs +++ b/src/EFCore/EF.cs @@ -63,6 +63,17 @@ public static TProperty Property( [NotParameterized] string propertyName) => throw new InvalidOperationException(CoreStrings.PropertyMethodInvoked); + /// + /// Within the context of an EF LINQ query, forces its argument to be inserted into the query as a constant expression. This can be + /// used to e.g. integrate a value as a constant inside an EF query, instead of as a parameter, for query performance reasons. + /// + /// Note that this is a static method accessed through the top-level static type. + /// The type of the expression to be integrated as a constant into the query. + /// The expression to be integrated as a constant into the query. + /// The same value for further use in the query. + public static T Constant(T argument) + => throw new InvalidOperationException(CoreStrings.EFConstantInvoked); + /// /// Provides CLR methods that get translated to database functions when used in LINQ to Entities queries. /// Calling these methods in other contexts (e.g. LINQ to Objects) will throw a . diff --git a/src/EFCore/Properties/CoreStrings.Designer.cs b/src/EFCore/Properties/CoreStrings.Designer.cs index 124451fa54d..9546ff18313 100644 --- a/src/EFCore/Properties/CoreStrings.Designer.cs +++ b/src/EFCore/Properties/CoreStrings.Designer.cs @@ -964,6 +964,18 @@ public static string DuplicateTrigger(object? trigger, object? entityType, objec GetString("DuplicateTrigger", nameof(trigger), nameof(entityType), nameof(conflictingEntityType)), trigger, entityType, conflictingEntityType); + /// + /// The EF.Constant<T> method may only be used within Entity Framework LINQ queries. + /// + public static string EFConstantInvoked + => GetString("EFConstantInvoked"); + + /// + /// The EF.Constant<T> method may only be used with an argument that can be evaluated client-side and does not contain any reference to database-side entities. + /// + public static string EFConstantWithNonEvaluableArgument + => GetString("EFConstantWithNonEvaluableArgument"); + /// /// Complex type '{complexType}' has no properties defines. Configure at least one property or don't include this type in the model. /// diff --git a/src/EFCore/Properties/CoreStrings.resx b/src/EFCore/Properties/CoreStrings.resx index 22fcdb99bc3..d5fc18b31e8 100644 --- a/src/EFCore/Properties/CoreStrings.resx +++ b/src/EFCore/Properties/CoreStrings.resx @@ -474,6 +474,12 @@ The trigger '{trigger}' cannot be added to the entity type '{entityType}' because another trigger with the same name already exists on entity type '{conflictingEntityType}'. + + The EF.Constant<T> method may only be used within Entity Framework LINQ queries. + + + The EF.Constant<T> method may only be used with an argument that can be evaluated client-side and does not contain any reference to database-side entities. + Complex type '{complexType}' has no properties defines. Configure at least one property or don't include this type in the model. diff --git a/src/EFCore/Query/Internal/ParameterExtractingExpressionVisitor.cs b/src/EFCore/Query/Internal/ParameterExtractingExpressionVisitor.cs index aedb1b2c2dc..558104b7d40 100644 --- a/src/EFCore/Query/Internal/ParameterExtractingExpressionVisitor.cs +++ b/src/EFCore/Query/Internal/ParameterExtractingExpressionVisitor.cs @@ -28,6 +28,9 @@ public class ParameterExtractingExpressionVisitor : ExpressionVisitor private IDictionary _evaluatableExpressions; private IQueryProvider? _currentQueryProvider; + private static readonly bool UseOldBehavior31552 = + AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31552", out var enabled31552) && enabled31552; + /// /// 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 @@ -170,6 +173,33 @@ protected override Expression VisitConditional(ConditionalExpression conditional Visit(conditionalExpression.IfFalse)); } + /// + /// 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 + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) + { + if (!UseOldBehavior31552 + && methodCallExpression.Method.DeclaringType == typeof(EF) + && methodCallExpression.Method.Name == nameof(EF.Constant)) + { + // If this is a call to EF.Constant(), then examine its operand. If the operand isn't evaluatable (i.e. contains a reference + // to a database table), throw immediately. + // Otherwise, evaluate the operand as a constant and return that. + var operand = methodCallExpression.Arguments[0]; + if (!_evaluatableExpressions.TryGetValue(operand, out _)) + { + throw new InvalidOperationException(CoreStrings.EFConstantWithNonEvaluableArgument); + } + + return Evaluate(operand, generateParameter: false); + } + + return base.VisitMethodCall(methodCallExpression); + } + /// /// 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 @@ -655,6 +685,16 @@ private static bool IsEvaluatableNodeType(Expression expression, out bool prefer preferNoEvaluation = false; return expression.CanReduce && IsEvaluatableNodeType(expression.ReduceAndCheck(), out preferNoEvaluation); + // Identify a call to EF.Constant(), and flag that as non-evaluable. + // This is important to prevent a larger subtree containing EF.Constant from being evaluated, i.e. to make sure that + // the EF.Function argument is present in the tree as its own, constant node. + case ExpressionType.Call + when !UseOldBehavior31552 && expression is MethodCallExpression { Method: var method } + && method.DeclaringType == typeof(EF) + && method.Name == nameof(EF.Constant): + preferNoEvaluation = true; + return false; + default: preferNoEvaluation = false; return true; diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindWhereQueryCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindWhereQueryCosmosTest.cs index c5a4a84062b..5880cf2df9a 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindWhereQueryCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindWhereQueryCosmosTest.cs @@ -2907,6 +2907,49 @@ FROM root c """); } + public override async Task EF_Constant(bool async) + { + await base.EF_Constant(async); + + AssertSql( + """ +SELECT c +FROM root c +WHERE ((c["Discriminator"] = "Customer") AND (c["CustomerID"] = "ALFKI")) +"""); + } + + public override async Task EF_Constant_with_subtree(bool async) + { + await base.EF_Constant_with_subtree(async); + + AssertSql( + """ +SELECT c +FROM root c +WHERE ((c["Discriminator"] = "Customer") AND (c["CustomerID"] = "ALFKI")) +"""); + } + + public override async Task EF_Constant_does_not_parameterized_as_part_of_bigger_subtree(bool async) + { + await base.EF_Constant_does_not_parameterized_as_part_of_bigger_subtree(async); + + AssertSql( + """ +SELECT c +FROM root c +WHERE ((c["Discriminator"] = "Customer") AND (c["CustomerID"] = ("ALF" || "KI"))) +"""); + } + + public override async Task EF_Constant_with_non_evaluatable_argument_throws(bool async) + { + await base.EF_Constant_with_non_evaluatable_argument_throws(async); + + AssertSql(); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); diff --git a/test/EFCore.Specification.Tests/Query/NorthwindWhereQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/NorthwindWhereQueryTestBase.cs index 256223a0cf0..fd68f8a0375 100644 --- a/test/EFCore.Specification.Tests/Query/NorthwindWhereQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/NorthwindWhereQueryTestBase.cs @@ -3,9 +3,11 @@ using Microsoft.EntityFrameworkCore.TestModels.Northwind; +namespace Microsoft.EntityFrameworkCore.Query; + +// ReSharper disable ConvertToConstant.Local // ReSharper disable RedundantBoolCompare // ReSharper disable InconsistentNaming -namespace Microsoft.EntityFrameworkCore.Query; public abstract class NorthwindWhereQueryTestBase : QueryTestBase where TFixture : NorthwindQueryFixtureBase, new() @@ -2342,4 +2344,53 @@ public virtual Task Case_block_simplification_works_correctly(bool async) async, ss => ss.Set().Where(c => (c.Region == null ? "OR" : c.Region) == "OR")); #pragma warning restore IDE0029 // Use coalesce expression + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task EF_Constant(bool async) + { + var id = "ALFKI"; + + return AssertQuery( + async, + ss => ss.Set().Where(c => c.CustomerID == EF.Constant(id)), + ss => ss.Set().Where(c => c.CustomerID == "ALFKI")); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task EF_Constant_with_subtree(bool async) + { + var i = "ALF"; + var j = "KI"; + + return AssertQuery( + async, + ss => ss.Set().Where(c => c.CustomerID == EF.Constant(i + j)), + ss => ss.Set().Where(c => c.CustomerID == "ALFKI")); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task EF_Constant_does_not_parameterized_as_part_of_bigger_subtree(bool async) + { + var id = "ALF"; + + return AssertQuery( + async, + ss => ss.Set().Where(c => c.CustomerID == EF.Constant(id) + "KI"), + ss => ss.Set().Where(c => c.CustomerID == "ALF" + "KI")); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task EF_Constant_with_non_evaluatable_argument_throws(bool async) + { + var exception = await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Where(c => c.CustomerID == EF.Constant(c.CustomerID)))); + + Assert.Equal(CoreStrings.EFConstantWithNonEvaluableArgument, exception.Message); + } } diff --git a/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs index 0e302e2becf..3fe9be34e1b 100644 --- a/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs @@ -92,6 +92,18 @@ public virtual Task Inline_collection_Contains_with_three_values(bool async) async, ss => ss.Set().Where(c => new[] { 2, 999, 1000 }.Contains(c.Id))); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Inline_collection_Contains_with_EF_Constant(bool async) + { + var ids = new[] { 2, 999, 1000 }; + + return AssertQuery( + async, + ss => ss.Set().Where(c => EF.Constant(ids).Contains(c.Id)), + ss => ss.Set().Where(c => new[] { 2, 99, 1000 }.Contains(c.Id))); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Inline_collection_Contains_with_all_parameters(bool async) diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindWhereQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindWhereQuerySqlServerTest.cs index 2a924a06727..156c8bc3c39 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindWhereQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindWhereQuerySqlServerTest.cs @@ -3243,6 +3243,49 @@ public override async Task Where_client_deep_inside_predicate_and_server_top_lev AssertSql(); } + public override async Task EF_Constant(bool async) + { + await base.EF_Constant(async); + + AssertSql( + """ +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] = N'ALFKI' +"""); + } + + public override async Task EF_Constant_with_subtree(bool async) + { + await base.EF_Constant_with_subtree(async); + + AssertSql( + """ +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] = N'ALFKI' +"""); + } + + public override async Task EF_Constant_does_not_parameterized_as_part_of_bigger_subtree(bool async) + { + await base.EF_Constant_does_not_parameterized_as_part_of_bigger_subtree(async); + + AssertSql( + """ +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] = N'ALF' + N'KI' +"""); + } + + public override async Task EF_Constant_with_non_evaluatable_argument_throws(bool async) + { + await base.EF_Constant_with_non_evaluatable_argument_throws(async); + + AssertSql(); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs index 2117994b0e9..9c20a1d9b49 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs @@ -150,6 +150,18 @@ WHERE [p].[Id] IN (2, 999, 1000) """); } + public override async Task Inline_collection_Contains_with_EF_Constant(bool async) + { + await base.Inline_collection_Contains_with_EF_Constant(async); + + AssertSql( + """ +SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[NullableString], [p].[NullableStrings], [p].[String], [p].[Strings] +FROM [PrimitiveCollectionsEntity] AS [p] +WHERE [p].[Id] IN (2, 999, 1000) +"""); + } + public override async Task Inline_collection_Contains_with_all_parameters(bool async) { await base.Inline_collection_Contains_with_all_parameters(async); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs index 970e6cbdbc9..573e3fe521b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs @@ -142,6 +142,18 @@ WHERE [p].[Id] IN (2, 999, 1000) """); } + public override async Task Inline_collection_Contains_with_EF_Constant(bool async) + { + await base.Inline_collection_Contains_with_EF_Constant(async); + + AssertSql( + """ +SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[NullableString], [p].[NullableStrings], [p].[String], [p].[Strings] +FROM [PrimitiveCollectionsEntity] AS [p] +WHERE [p].[Id] IN (2, 999, 1000) +"""); + } + public override async Task Inline_collection_Contains_with_all_parameters(bool async) { await base.Inline_collection_Contains_with_all_parameters(async); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs index bf094e27c63..540e3693bad 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs @@ -143,6 +143,18 @@ public override async Task Inline_collection_Contains_with_three_values(bool asy """); } + public override async Task Inline_collection_Contains_with_EF_Constant(bool async) + { + await base.Inline_collection_Contains_with_EF_Constant(async); + + AssertSql( + """ +SELECT "p"."Id", "p"."Bool", "p"."Bools", "p"."DateTime", "p"."DateTimes", "p"."Enum", "p"."Enums", "p"."Int", "p"."Ints", "p"."NullableInt", "p"."NullableInts", "p"."NullableString", "p"."NullableStrings", "p"."String", "p"."Strings" +FROM "PrimitiveCollectionsEntity" AS "p" +WHERE "p"."Id" IN (2, 999, 1000) +"""); + } + public override async Task Inline_collection_Contains_with_all_parameters(bool async) { await base.Inline_collection_Contains_with_all_parameters(async);