Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

// ReSharper disable once CheckNamespace

using System.Diagnostics.CodeAnalysis;
using Microsoft.Data.SqlTypes;

namespace Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -94,6 +95,33 @@ public static bool Contains(

#endregion Full-text search

#region JSON functions

/// <summary>
/// A DbFunction method stub that can be used in LINQ queries to target the SQL Server <c>JSON_CONTAINS</c> function.
/// </summary>
/// <remarks>
/// See <see href="https://aka.ms/efcore-docs-database-functions">Database functions</see>, and
/// <see href="https://aka.ms/efcore-docs-sqlserver">Accessing SQL Server and Azure SQL databases with EF Core</see>
/// for more information and examples.
/// </remarks>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="json">The JSON value to search.</param>
/// <param name="searchValue">The JSON value to search for.</param>
/// <param name="path">The JSON path to search within.</param>
/// <param name="searchMode">The search mode.</param>
/// <returns>1 if the JSON value contains the given value; otherwise 0.</returns>
[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

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -656,7 +658,7 @@ IComplexType complexType
"JSON_CONTAINS",
[json, translatedItem],
nullable: true,
argumentsPropagateNullability: [false, true],
argumentsPropagateNullability: [true, false],
typeof(int)),
_sqlExpressionFactory.Constant(1));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SqlExpression> functionArguments = [translatedJson, translatedSearchValue];
List<bool> 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)
Expand Down
1 change: 1 addition & 0 deletions src/Shared/EFDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<JsonTranslationsSqlServerTest.JsonTranslationsQuerySqlServerFixture>
Expand Down Expand Up @@ -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<JsonTranslationsEntity>()
.Where(b => EF.Functions.JsonContains(b.JsonString, 8, "$.OptionalInt") == 1),
ss => ss.Set<JsonTranslationsEntity>()
.Where(b => JsonNode.Parse(b.JsonString ?? "{}")!.AsObject().ContainsKey("OptionalInt")
&& JsonNode.Parse(b.JsonString ?? "{}")!.AsObject()["OptionalInt"] != null
&& ((JsonValue)JsonNode.Parse(b.JsonString ?? "{}")!.AsObject()["OptionalInt"]!).GetValue<int?>() == 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<JsonTranslationsEntity>()
.Where(b => EF.Functions.JsonContains(b.JsonComplexType, 8, "$.OptionalInt") == 1),
ss => ss.Set<JsonTranslationsEntity>()
.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<JsonTranslationsEntity>()
.Where(b => EF.Functions.JsonContains(b.JsonOwnedType, 8, "$.OptionalInt") == 1),
ss => ss.Set<JsonTranslationsEntity>()
.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
Expand All @@ -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<JsonTranslationsEntity>().Property(e => e.JsonString).HasColumnType("json");
}
}

protected override string RemoveJsonProperty(string column, string jsonPath)
=> $"JSON_MODIFY({column}, '{jsonPath}', NULL)";
}
Expand Down
Loading