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)"; }