diff --git a/src/EFCore/EF.cs b/src/EFCore/EF.cs index 8812db8a0bb..21824e50c6b 100644 --- a/src/EFCore/EF.cs +++ b/src/EFCore/EF.cs @@ -63,6 +63,24 @@ 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. + /// + /// + /// See Using EF.Property in EF Core queries for more information and examples. + /// + /// + /// 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 0440c95b0f0..957537deb5f 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 b35d6c07149..0db53ec29e7 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..afc8cd415b6 100644 --- a/src/EFCore/Query/Internal/ParameterExtractingExpressionVisitor.cs +++ b/src/EFCore/Query/Internal/ParameterExtractingExpressionVisitor.cs @@ -170,6 +170,31 @@ 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 (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 +680,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 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 10ce709579e..2d1cf3837f1 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindWhereQueryCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/NorthwindWhereQueryCosmosTest.cs @@ -2903,6 +2903,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 7891d0ae0b2..9cbd80c879d 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 9a78d37f910..3681bac7ee6 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 b7016e96076..8491e9de820 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 83d46064923..ed091b924fa 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);