diff --git a/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs
index 45add4be613..54f11b9a170 100644
--- a/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs
+++ b/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs
@@ -3,6 +3,7 @@
// ReSharper disable once CheckNamespace
+using System.Diagnostics.CodeAnalysis;
using Microsoft.Data.SqlTypes;
namespace Microsoft.EntityFrameworkCore;
@@ -94,6 +95,33 @@ public static bool Contains(
#endregion Full-text search
+ #region JSON functions
+
+ ///
+ /// A DbFunction method stub that can be used in LINQ queries to target the SQL Server JSON_CONTAINS function.
+ ///
+ ///
+ /// See Database functions, and
+ /// Accessing SQL Server and Azure SQL databases with EF Core
+ /// for more information and examples.
+ ///
+ /// The instance.
+ /// The JSON value to search.
+ /// The JSON value to search for.
+ /// The JSON path to search within.
+ /// The search mode.
+ /// 1 if the JSON value contains the given value; otherwise 0.
+ [Experimental(EFDiagnostics.JsonContainsExperimental)]
+ public static int JsonContains(
+ this DbFunctions _,
+ object json,
+ object searchValue,
+ string? path = null,
+ int? searchMode = null)
+ => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(JsonContains)));
+
+ #endregion JSON functions
+
#region DateDiffYear
///
diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs
index d87a3490c31..8ea95da5e4a 100644
--- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs
+++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs
@@ -642,9 +642,11 @@ IComplexType complexType
&& TranslateExpression(item, applyDefaultTypeMapping: false) is { } translatedItem
// Literal untyped NULL not supported as item by JSON_CONTAINS().
// For any other nullable item, SqlServerNullabilityProcessor will add a null check around the JSON_CONTAINS call.
+ // TODO: reverify this once JSON_CONTAINS comes out of preview, #37715
&& translatedItem is not SqlConstantExpression { Value: null }
// Note: JSON_CONTAINS doesn't allow searching for null items within a JSON collection (returns 0)
// As a result, we only translate to JSON_CONTAINS when we know that either the item is non-nullable or the collection's elements are.
+ // TODO: reverify this once JSON_CONTAINS comes out of preview, #37715
&& (
translatedItem is ColumnExpression { IsNullable: false } or SqlConstantExpression { Value: not null }
|| !translatedItem.Type.IsNullableType()
@@ -656,7 +658,7 @@ IComplexType complexType
"JSON_CONTAINS",
[json, translatedItem],
nullable: true,
- argumentsPropagateNullability: [false, true],
+ argumentsPropagateNullability: [true, false],
typeof(int)),
_sqlExpressionFactory.Constant(1));
diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs
index 0af9d673f26..c049b4c8bfe 100644
--- a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs
+++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs
@@ -240,6 +240,81 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
_typeMappingSource.FindMapping(isUnicode ? "nvarchar(max)" : "varchar(max)"));
}
+ // We translate EF.Functions.JsonContains here and not in a method translator since we need to support JsonContains over
+ // complex and owned JSON properties, which requires special handling.
+ case nameof(SqlServerDbFunctionsExtensions.JsonContains)
+ when declaringType == typeof(SqlServerDbFunctionsExtensions)
+ && @object is null
+ && arguments is [_, var json, var searchValue, var path, var searchMode]:
+ {
+ if (Translate(searchValue) is not SqlExpression translatedSearchValue)
+ {
+ return QueryCompilationContext.NotTranslatedExpression;
+ }
+
+ SqlExpression? translatedPath = null;
+ if (path is not ConstantExpression { Value: null })
+ {
+ if (Translate(path) is not SqlExpression pathExpression)
+ {
+ return QueryCompilationContext.NotTranslatedExpression;
+ }
+
+ translatedPath = pathExpression;
+ }
+
+ SqlExpression? translatedSearchMode = null;
+ if (searchMode is not ConstantExpression { Value: null })
+ {
+ if (Translate(searchMode) is not SqlExpression searchModeExpression)
+ {
+ return QueryCompilationContext.NotTranslatedExpression;
+ }
+
+ translatedSearchMode = searchModeExpression;
+ }
+
+#pragma warning disable EF1001 // TranslateProjection() is pubternal
+ var translatedJson = TranslateProjection(json) switch
+ {
+ // The JSON argument is a scalar string property
+ SqlExpression scalar => scalar,
+
+ // The JSON argument is a complex or owned JSON property
+ RelationalStructuralTypeShaperExpression { ValueBufferExpression: JsonQueryExpression { JsonColumn: var c } } => c,
+
+ _ => null
+ };
+#pragma warning restore EF1001
+
+ if (translatedJson is null)
+ {
+ return QueryCompilationContext.NotTranslatedExpression;
+ }
+
+ List functionArguments = [translatedJson, translatedSearchValue];
+ List argumentsPropagateNullability = [true, false];
+
+ if (translatedPath is not null)
+ {
+ functionArguments.Add(translatedPath);
+ argumentsPropagateNullability.Add(true);
+ }
+
+ if (translatedSearchMode is not null)
+ {
+ functionArguments.Add(translatedSearchMode);
+ argumentsPropagateNullability.Add(true);
+ }
+
+ return _sqlExpressionFactory.Function(
+ "JSON_CONTAINS",
+ functionArguments,
+ nullable: true,
+ argumentsPropagateNullability: argumentsPropagateNullability,
+ typeof(int));
+ }
+
// We translate EF.Functions.JsonExists here and not in a method translator since we need to support JsonExists over
// complex and owned JSON properties, which requires special handling.
case nameof(RelationalDbFunctionsExtensions.JsonExists)
diff --git a/src/Shared/EFDiagnostics.cs b/src/Shared/EFDiagnostics.cs
index fab6f198a3a..4c8267fdd09 100644
--- a/src/Shared/EFDiagnostics.cs
+++ b/src/Shared/EFDiagnostics.cs
@@ -26,4 +26,5 @@ internal static class EFDiagnostics
internal const string CosmosVectorSearchExperimental = "EF9103"; // No longer experimental
internal const string CosmosFullTextSearchExperimental = "EF9104"; // No longer experimental
internal const string SqlServerVectorSearch = "EF9105";
+ internal const string JsonContainsExperimental = "EF9106";
}
diff --git a/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs
index 9b8d7d2b3e0..dfcc4c898e8 100644
--- a/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs
+++ b/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.ComponentModel.DataAnnotations.Schema;
+using System.Text.Json;
using System.Text.Json.Nodes;
namespace Microsoft.EntityFrameworkCore.Query.Translations;
@@ -131,7 +132,11 @@ public virtual ISetSource GetExpectedData()
Assert.Equal(ee.Id, aa.Id);
- Assert.Equal(ee.JsonString, aa.JsonString);
+ // The database may normalize the JSON representation, e.g. removing whitespace and the like.
+ // Compare JSON DOM representations to ignore such differences.
+ using var expectedJson = JsonDocument.Parse(ee.JsonString);
+ using var actualJson = JsonDocument.Parse(aa.JsonString);
+ Assert.True(JsonElement.DeepEquals(expectedJson.RootElement, actualJson.RootElement));
}
}
}
diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs
index 19a28d8c267..e594bda2113 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Text.Json.Nodes;
+
namespace Microsoft.EntityFrameworkCore.Query.Translations;
public class JsonTranslationsSqlServerTest : JsonTranslationsRelationalTestBase
@@ -51,6 +53,61 @@ WHERE JSON_PATH_EXISTS([j].[JsonOwnedType], N'$.OptionalInt') = 1
""");
}
+#pragma warning disable EF9106 // JsonContains is experimental
+ [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsJsonType)]
+ public async Task JsonContains_on_scalar_string_column()
+ {
+ await AssertQuery(
+ ss => ss.Set()
+ .Where(b => EF.Functions.JsonContains(b.JsonString, 8, "$.OptionalInt") == 1),
+ ss => ss.Set()
+ .Where(b => JsonNode.Parse(b.JsonString ?? "{}")!.AsObject().ContainsKey("OptionalInt")
+ && JsonNode.Parse(b.JsonString ?? "{}")!.AsObject()["OptionalInt"] != null
+ && ((JsonValue)JsonNode.Parse(b.JsonString ?? "{}")!.AsObject()["OptionalInt"]!).GetValue() == 8));
+
+ AssertSql(
+ """
+SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType], [j].[JsonOwnedType]
+FROM [JsonEntities] AS [j]
+WHERE JSON_CONTAINS([j].[JsonString], 8, N'$.OptionalInt') = 1
+""");
+ }
+
+ [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsJsonType)]
+ public async Task JsonContains_on_complex_property()
+ {
+ await AssertQuery(
+ ss => ss.Set()
+ .Where(b => EF.Functions.JsonContains(b.JsonComplexType, 8, "$.OptionalInt") == 1),
+ ss => ss.Set()
+ .Where(b => b.JsonComplexType.OptionalInt == 8));
+
+ AssertSql(
+ """
+SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType], [j].[JsonOwnedType]
+FROM [JsonEntities] AS [j]
+WHERE JSON_CONTAINS([j].[JsonComplexType], 8, N'$.OptionalInt') = 1
+""");
+ }
+
+ [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsJsonType)]
+ public async Task JsonContains_on_owned_entity()
+ {
+ await AssertQuery(
+ ss => ss.Set()
+ .Where(b => EF.Functions.JsonContains(b.JsonOwnedType, 8, "$.OptionalInt") == 1),
+ ss => ss.Set()
+ .Where(b => b.JsonOwnedType.OptionalInt == 8));
+
+ AssertSql(
+ """
+SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType], [j].[JsonOwnedType]
+FROM [JsonEntities] AS [j]
+WHERE JSON_CONTAINS([j].[JsonOwnedType], 8, N'$.OptionalInt') = 1
+""");
+ }
+#pragma warning restore EF9106 // JsonContains is experimental
+
public class JsonTranslationsQuerySqlServerFixture : JsonTranslationsQueryFixtureBase, ITestSqlLoggerFactory
{
protected override ITestStoreFactory TestStoreFactory
@@ -66,6 +123,16 @@ public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder build
: options.UseSqlServerCompatibilityLevel(170);
}
+ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context)
+ {
+ base.OnModelCreating(modelBuilder, context);
+
+ if (TestEnvironment.IsJsonTypeSupported)
+ {
+ modelBuilder.Entity().Property(e => e.JsonString).HasColumnType("json");
+ }
+ }
+
protected override string RemoveJsonProperty(string column, string jsonPath)
=> $"JSON_MODIFY({column}, '{jsonPath}', NULL)";
}