From 427f6753ce6cd235a5dbeb878f7897ee13b7b2d9 Mon Sep 17 00:00:00 2001 From: kimjaejung96 Date: Fri, 9 Jan 2026 11:51:19 +0900 Subject: [PATCH 1/2] Implement EF.Functions.JsonExists translation for SQL Server (#31136) --- .../RelationalDbFunctionsExtensions.cs | 13 +++++ .../SqlServerMethodCallTranslatorProvider.cs | 1 + .../SqlServerJsonFunctionTranslator.cs | 57 +++++++++++++++++++ .../RelationalDbFunctionsTest.cs | 34 +++++++++++ .../NorthwindDbFunctionsQuerySqlServerTest.cs | 24 ++++++++ 5 files changed, 129 insertions(+) create mode 100644 src/EFCore.SqlServer/Query/Internal/Translators/SqlServerJsonFunctionTranslator.cs create mode 100644 test/EFCore.Relational.Tests/RelationalDbFunctionsTest.cs diff --git a/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs b/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs index f0f6a0e1ea8..a08b5166923 100644 --- a/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs @@ -56,4 +56,17 @@ public static T Greatest( this DbFunctions _, [NotParameterized] params T[] values) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Greatest))); + + /// + /// Returns a value indicating whether a given JSON path exists within the specified JSON. + /// Usually corresponds to the JSON_PATH_EXISTS SQL function. + /// + /// The instance. + /// The JSON value to check. + /// The JSON path to look for. + public static bool JsonExists( + this DbFunctions _, + object json, + [NotParameterized] string path) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(JsonExists))); } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerMethodCallTranslatorProvider.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerMethodCallTranslatorProvider.cs index d9952444e5a..c8140c4518d 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerMethodCallTranslatorProvider.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerMethodCallTranslatorProvider.cs @@ -38,6 +38,7 @@ public SqlServerMethodCallTranslatorProvider( new SqlServerFullTextSearchFunctionsTranslator(sqlExpressionFactory), new SqlServerIsDateFunctionTranslator(sqlExpressionFactory), new SqlServerIsNumericFunctionTranslator(sqlExpressionFactory), + new SqlServerJsonFunctionTranslator(sqlExpressionFactory), new SqlServerMathTranslator(sqlExpressionFactory), new SqlServerNewGuidTranslator(sqlExpressionFactory), new SqlServerObjectToStringTranslator(sqlExpressionFactory, typeMappingSource), diff --git a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerJsonFunctionTranslator.cs b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerJsonFunctionTranslator.cs new file mode 100644 index 00000000000..47aa2a0bc42 --- /dev/null +++ b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerJsonFunctionTranslator.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; + +/// +/// 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. +/// +public class SqlServerJsonFunctionTranslator : IMethodCallTranslator +{ + private readonly ISqlExpressionFactory _sqlExpressionFactory; + + private static readonly MethodInfo JsonExistsMethodInfo = typeof(RelationalDbFunctionsExtensions) + .GetRuntimeMethod(nameof(RelationalDbFunctionsExtensions.JsonExists), [typeof(DbFunctions), typeof(object), typeof(string)])!; + + /// + /// 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. + /// + public SqlServerJsonFunctionTranslator(ISqlExpressionFactory sqlExpressionFactory) + => _sqlExpressionFactory = sqlExpressionFactory; + + /// + /// 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. + /// + public virtual SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + if (JsonExistsMethodInfo.Equals(method)) + { + return _sqlExpressionFactory.Equal( + _sqlExpressionFactory.Function( + "JSON_PATH_EXISTS", + [arguments[1], arguments[2]], + nullable: true, + argumentsPropagateNullability: [true, false], + typeof(int)), + _sqlExpressionFactory.Constant(1)); + } + + return null; + } +} diff --git a/test/EFCore.Relational.Tests/RelationalDbFunctionsTest.cs b/test/EFCore.Relational.Tests/RelationalDbFunctionsTest.cs new file mode 100644 index 00000000000..b0fd0b246aa --- /dev/null +++ b/test/EFCore.Relational.Tests/RelationalDbFunctionsTest.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// ReSharper disable ArgumentsStyleStringLiteral +// ReSharper disable InconsistentNaming + +namespace Microsoft.EntityFrameworkCore; + +public class RelationalDbFunctionsTest +{ + [ConditionalFact] + public void Collate_on_client_throws() + => Assert.Equal( + CoreStrings.FunctionOnClient(nameof(RelationalDbFunctionsExtensions.Collate)), + Assert.Throws(() => EF.Functions.Collate("abc", "Latin1_General_CI_AS")).Message); + + [ConditionalFact] + public void Least_on_client_throws() + => Assert.Equal( + CoreStrings.FunctionOnClient(nameof(RelationalDbFunctionsExtensions.Least)), + Assert.Throws(() => EF.Functions.Least(1, 2, 3)).Message); + + [ConditionalFact] + public void Greatest_on_client_throws() + => Assert.Equal( + CoreStrings.FunctionOnClient(nameof(RelationalDbFunctionsExtensions.Greatest)), + Assert.Throws(() => EF.Functions.Greatest(1, 2, 3)).Message); + + [ConditionalFact] + public void JsonExists_on_client_throws() + => Assert.Equal( + CoreStrings.FunctionOnClient(nameof(RelationalDbFunctionsExtensions.JsonExists)), + Assert.Throws(() => EF.Functions.JsonExists("{\"key\": 1}", "$.key")).Message); +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs index 51f37ee2e49..33f9eb3ca4c 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs @@ -1348,6 +1348,30 @@ WHERE CAST(DATALENGTH(N'foo') AS int) = 3 } } + [ConditionalFact] + public void JsonExists_client_eval_throws() + { + Assert.Throws(() => EF.Functions.JsonExists("{\"key\": 1}", "$.key")); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public async Task JsonExists_on_json_string_literal() + { + await using var context = CreateContext(); + var result = await context.Customers + .Where(c => EF.Functions.JsonExists("{\"name\": \"test\"}", "$.name")) + .CountAsync(); + + Assert.Equal(91, result); + + AssertSql( + """ +SELECT COUNT(*) +FROM [Customers] AS [c] +WHERE JSON_PATH_EXISTS(N'{"name": "test"}', N'$.name') = 1 +"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } From b50c201ead1035860ba20a31f0f9a8e0e1a172a8 Mon Sep 17 00:00:00 2001 From: kimjaejung96 Date: Sat, 10 Jan 2026 11:36:26 +0900 Subject: [PATCH 2/2] Address PR review feedback - Use primary constructor in SqlServerJsonFunctionTranslator - Use expression-bodied method with ternary operator - Use trimming-friendly method matching (name + declaring type instead of reflection) - Remove [NotParameterized] from path parameter - Remove RelationalDbFunctionsTest.cs (throw tests not useful) - Replace literal-only test with column-based test (JsonExists_with_column) --- .../RelationalDbFunctionsExtensions.cs | 2 +- .../SqlServerJsonFunctionTranslator.cs | 42 ++++++------------- .../RelationalDbFunctionsTest.cs | 34 --------------- .../NorthwindDbFunctionsQuerySqlServerTest.cs | 21 ++++------ 4 files changed, 21 insertions(+), 78 deletions(-) delete mode 100644 test/EFCore.Relational.Tests/RelationalDbFunctionsTest.cs diff --git a/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs b/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs index a08b5166923..e5a95a82700 100644 --- a/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs @@ -67,6 +67,6 @@ public static T Greatest( public static bool JsonExists( this DbFunctions _, object json, - [NotParameterized] string path) + string path) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(JsonExists))); } diff --git a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerJsonFunctionTranslator.cs b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerJsonFunctionTranslator.cs index 47aa2a0bc42..948145b1164 100644 --- a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerJsonFunctionTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerJsonFunctionTranslator.cs @@ -12,22 +12,8 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; /// 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. /// -public class SqlServerJsonFunctionTranslator : IMethodCallTranslator +public class SqlServerJsonFunctionTranslator(ISqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslator { - private readonly ISqlExpressionFactory _sqlExpressionFactory; - - private static readonly MethodInfo JsonExistsMethodInfo = typeof(RelationalDbFunctionsExtensions) - .GetRuntimeMethod(nameof(RelationalDbFunctionsExtensions.JsonExists), [typeof(DbFunctions), typeof(object), typeof(string)])!; - - /// - /// 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. - /// - public SqlServerJsonFunctionTranslator(ISqlExpressionFactory sqlExpressionFactory) - => _sqlExpressionFactory = sqlExpressionFactory; - /// /// 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 @@ -39,19 +25,15 @@ public SqlServerJsonFunctionTranslator(ISqlExpressionFactory sqlExpressionFactor MethodInfo method, IReadOnlyList arguments, IDiagnosticsLogger logger) - { - if (JsonExistsMethodInfo.Equals(method)) - { - return _sqlExpressionFactory.Equal( - _sqlExpressionFactory.Function( - "JSON_PATH_EXISTS", - [arguments[1], arguments[2]], - nullable: true, - argumentsPropagateNullability: [true, false], - typeof(int)), - _sqlExpressionFactory.Constant(1)); - } - - return null; - } + => method.Name == nameof(RelationalDbFunctionsExtensions.JsonExists) + && method.DeclaringType == typeof(RelationalDbFunctionsExtensions) + ? sqlExpressionFactory.Equal( + sqlExpressionFactory.Function( + "JSON_PATH_EXISTS", + [arguments[1], arguments[2]], + nullable: true, + argumentsPropagateNullability: [true, false], + typeof(int)), + sqlExpressionFactory.Constant(1)) + : null; } diff --git a/test/EFCore.Relational.Tests/RelationalDbFunctionsTest.cs b/test/EFCore.Relational.Tests/RelationalDbFunctionsTest.cs deleted file mode 100644 index b0fd0b246aa..00000000000 --- a/test/EFCore.Relational.Tests/RelationalDbFunctionsTest.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -// ReSharper disable ArgumentsStyleStringLiteral -// ReSharper disable InconsistentNaming - -namespace Microsoft.EntityFrameworkCore; - -public class RelationalDbFunctionsTest -{ - [ConditionalFact] - public void Collate_on_client_throws() - => Assert.Equal( - CoreStrings.FunctionOnClient(nameof(RelationalDbFunctionsExtensions.Collate)), - Assert.Throws(() => EF.Functions.Collate("abc", "Latin1_General_CI_AS")).Message); - - [ConditionalFact] - public void Least_on_client_throws() - => Assert.Equal( - CoreStrings.FunctionOnClient(nameof(RelationalDbFunctionsExtensions.Least)), - Assert.Throws(() => EF.Functions.Least(1, 2, 3)).Message); - - [ConditionalFact] - public void Greatest_on_client_throws() - => Assert.Equal( - CoreStrings.FunctionOnClient(nameof(RelationalDbFunctionsExtensions.Greatest)), - Assert.Throws(() => EF.Functions.Greatest(1, 2, 3)).Message); - - [ConditionalFact] - public void JsonExists_on_client_throws() - => Assert.Equal( - CoreStrings.FunctionOnClient(nameof(RelationalDbFunctionsExtensions.JsonExists)), - Assert.Throws(() => EF.Functions.JsonExists("{\"key\": 1}", "$.key")).Message); -} diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs index 33f9eb3ca4c..51ce2f363ca 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs @@ -1348,27 +1348,22 @@ WHERE CAST(DATALENGTH(N'foo') AS int) = 3 } } - [ConditionalFact] - public void JsonExists_client_eval_throws() - { - Assert.Throws(() => EF.Functions.JsonExists("{\"key\": 1}", "$.key")); - } - [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] - public async Task JsonExists_on_json_string_literal() + public virtual async Task JsonExists_with_column() { await using var context = CreateContext(); - var result = await context.Customers - .Where(c => EF.Functions.JsonExists("{\"name\": \"test\"}", "$.name")) - .CountAsync(); - Assert.Equal(91, result); + // Note: Address is not valid JSON, so this will return no results, + // but the purpose is to verify the SQL translation is correct + var result = await context.Customers + .Where(c => EF.Functions.JsonExists(c.Address!, "$.city")) + .ToListAsync(); AssertSql( """ -SELECT COUNT(*) +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 JSON_PATH_EXISTS(N'{"name": "test"}', N'$.name') = 1 +WHERE JSON_PATH_EXISTS([c].[Address], N'$.city') = 1 """); }