Skip to content
Open
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 @@ -56,4 +56,13 @@ public static T Greatest<T>(
this DbFunctions _,
[NotParameterized] params T[] values)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Greatest)));

/// <summary>
/// Returns a value indicating whether a given JSON path exists within the specified JSON.
/// </summary>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="json">The JSON value to check.</param>
/// <param name="path">The JSON path to look for.</param>
public static bool JsonExists(this DbFunctions _, object json, string path)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(JsonExists)));
}
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,45 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
_typeMappingSource.FindMapping(isUnicode ? "nvarchar(max)" : "varchar(max)"));
}

// We translate EF.Functions.JsonExists here and not in a method translator since we need to support JsonExists over a complex
// property, which requires special handling.
case nameof(RelationalDbFunctionsExtensions.JsonExists)
when declaringType == typeof(RelationalDbFunctionsExtensions)
&& @object is null
&& arguments is [_, var json, var path]:
{
if (Translate(path) is not SqlExpression translatedPath)
{
return QueryCompilationContext.NotTranslatedExpression;
}

#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 JSON property
RelationalStructuralTypeShaperExpression { ValueBufferExpression: JsonQueryExpression { JsonColumn: var c } } => c,
_ => null
};
#pragma warning restore EF1001

return translatedJson is null
? QueryCompilationContext.NotTranslatedExpression
: _sqlExpressionFactory.Equal(
_sqlExpressionFactory.Function(
"JSON_PATH_EXISTS",
[translatedJson, translatedPath],
nullable: true,
// Note that JSON_PATH_EXISTS() does propagate nullability; however, our query pipeline assumes that if
// arguments propagate nullability, that's the *only* reason for the function to return null; this means that
// if the arguments are non-nullable, the IS NOT NULL wrapping check can be optimized away.
argumentsPropagateNullability: [false, false],
typeof(int)),
_sqlExpressionFactory.Constant(1));
}

default:
return QueryCompilationContext.NotTranslatedExpression;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,21 +214,65 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
}

var method = methodCallExpression.Method;
var declaringType = method.DeclaringType;
var @object = methodCallExpression.Object;
var arguments = methodCallExpression.Arguments;

// https://learn.microsoft.com/dotnet/api/system.string.startswith#system-string-startswith(system-string)
// https://learn.microsoft.com/dotnet/api/system.string.startswith#system-string-startswith(system-char)
// https://learn.microsoft.com/dotnet/api/system.string.endswith#system-string-endswith(system-string)
// https://learn.microsoft.com/dotnet/api/system.string.endswith#system-string-endswith(system-char)
if (method.Name is nameof(string.StartsWith) or nameof(string.EndsWith)
&& methodCallExpression.Object is not null
&& method.DeclaringType == typeof(string)
&& methodCallExpression.Arguments is [Expression value]
&& (value.Type == typeof(string) || value.Type == typeof(char)))
switch (method.Name)
{
return TranslateStartsEndsWith(
methodCallExpression.Object,
value,
method.Name is nameof(string.StartsWith));
// https://learn.microsoft.com/dotnet/api/system.string.startswith#system-string-startswith(system-string)
// https://learn.microsoft.com/dotnet/api/system.string.startswith#system-string-startswith(system-char)
// https://learn.microsoft.com/dotnet/api/system.string.endswith#system-string-endswith(system-string)
// https://learn.microsoft.com/dotnet/api/system.string.endswith#system-string-endswith(system-char)
case nameof(string.StartsWith) or nameof(string.EndsWith)
when methodCallExpression.Object is not null
&& declaringType == typeof(string)
&& arguments is [Expression value]
&& (value.Type == typeof(string) || value.Type == typeof(char)):
{
return TranslateStartsEndsWith(
methodCallExpression.Object,
value,
method.Name is nameof(string.StartsWith));
}

// We translate EF.Functions.JsonExists here and not in a method translator since we need to support JsonExists over a complex
// property, which requires special handling.
case nameof(RelationalDbFunctionsExtensions.JsonExists)
when declaringType == typeof(RelationalDbFunctionsExtensions)
&& @object is null
&& arguments is [_, var json, var path]:
{
if (Translate(path) is not SqlExpression translatedPath)
{
return QueryCompilationContext.NotTranslatedExpression;
}

#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 JSON property
RelationalStructuralTypeShaperExpression { ValueBufferExpression: JsonQueryExpression { JsonColumn: var c } } => c,
_ => null
};
#pragma warning restore EF1001

return translatedJson is null
? QueryCompilationContext.NotTranslatedExpression
: _sqlExpressionFactory.IsNotNull(
_sqlExpressionFactory.Function(
"json_type",
[translatedJson, translatedPath],
nullable: true,
// Note that json_type() does propagate nullability; however, our query pipeline assumes that if arguments
// propagate nullability, that's the *only* reason for the function to return null; this means that if the
// arguments are non-nullable, the IS NOT NULL wrapping check can be optimized away.
argumentsPropagateNullability: [false, false],
typeof(int)));
}
}

return QueryCompilationContext.NotTranslatedExpression;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Nodes;

namespace Microsoft.EntityFrameworkCore.Query.Translations;

// This test suite covers translations of JSON functions on EF.Functions (e.g. EF.Functions.JsonExists).
// It does not cover general, built-in JSON support via complex type mapping, etc.
public abstract class JsonTranslationsRelationalTestBase<TFixture>(TFixture fixture) : QueryTestBase<TFixture>(fixture)
where TFixture : JsonTranslationsRelationalTestBase<TFixture>.JsonTranslationsQueryFixtureBase, new()
{
[ConditionalFact]
public virtual Task JsonExists_on_scalar_string_column()
=> AssertQuery(
ss => ss.Set<JsonTranslationsEntity>()
.Where(b => EF.Functions.JsonExists(b.JsonString, "$.OptionalInt")),
ss => ss.Set<JsonTranslationsEntity>()
.Where(b => ((IDictionary<string, JsonNode>)JsonNode.Parse(b.JsonString)!).ContainsKey("OptionalInt")));

[ConditionalFact]
public virtual Task JsonExists_on_complex_property()
=> AssertQuery(
ss => ss.Set<JsonTranslationsEntity>()
.Where(b => EF.Functions.JsonExists(b.JsonComplexType, "$.OptionalInt")),
ss => ss.Set<JsonTranslationsEntity>()
.Where(b => ((IDictionary<string, JsonNode>)JsonNode.Parse(b.JsonString)!).ContainsKey("OptionalInt")));

public class JsonTranslationsEntity
{
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public int Id { get; set; }

public required string JsonString { get; set; }

public required JsonComplexType JsonComplexType { get; set; }
}

public class JsonComplexType
{
public required int RequiredInt { get; set; }
public int? OptionalInt { get; set; }
}

public class JsonTranslationsQueryContext(DbContextOptions options) : PoolableDbContext(options)
{
public DbSet<JsonTranslationsEntity> JsonEntities { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<JsonTranslationsEntity>().ComplexProperty(j => j.JsonComplexType, j => j.ToJson());
}

// The translation tests usually use BasicTypesQueryFixtureBase, which manages a single database with all the data needed for the tests.
// However, here in the JSON translation tests we use a separate fixture and database, since not all providers necessary implement full
// JSON support, and we don't want to make life difficult for them with the basic translation tests.
public abstract class JsonTranslationsQueryFixtureBase : SharedStoreFixtureBase<JsonTranslationsQueryContext>, IQueryFixtureBase, ITestSqlLoggerFactory
{
private JsonTranslationsData? _expectedData;

protected override string StoreName
=> "JsonTranslationsQueryTest";

protected override async Task SeedAsync(JsonTranslationsQueryContext context)
{
var data = new JsonTranslationsData();
context.AddRange(data.JsonTranslationsEntities);
await context.SaveChangesAsync();

var entityType = context.Model.FindEntityType(typeof(JsonTranslationsEntity))!;
var sqlGenerationHelper = context.GetService<ISqlGenerationHelper>();
var table = sqlGenerationHelper.DelimitIdentifier(entityType.GetTableName()!);
var idColumn = sqlGenerationHelper.DelimitIdentifier(
entityType.FindProperty(nameof(JsonTranslationsEntity.Id))!.GetColumnName());
var complexTypeColumn = sqlGenerationHelper.DelimitIdentifier(
entityType.FindComplexProperty(nameof(JsonTranslationsEntity.JsonComplexType))!.ComplexType.GetContainerColumnName()!);

await context.Database.ExecuteSqlRawAsync(
$$"""UPDATE {{table}} SET {{complexTypeColumn}} = {{RemoveJsonProperty(complexTypeColumn, "$.OptionalInt")}} WHERE {{idColumn}} = 4""");
}

protected abstract string RemoveJsonProperty(string column, string jsonPath);

public virtual ISetSource GetExpectedData()
=> _expectedData ??= new JsonTranslationsData();

public IReadOnlyDictionary<Type, object> EntitySorters { get; } = new Dictionary<Type, Func<object?, object?>>
{
{ typeof(JsonTranslationsEntity), e => ((JsonTranslationsEntity?)e)?.Id },
}.ToDictionary(e => e.Key, e => (object)e.Value);

public IReadOnlyDictionary<Type, object> EntityAsserters { get; } = new Dictionary<Type, Action<object?, object?>>
{
{
typeof(JsonTranslationsEntity), (e, a) =>
{
Assert.Equal(e == null, a == null);

if (a != null)
{
var ee = (JsonTranslationsEntity)e!;
var aa = (JsonTranslationsEntity)a;

Assert.Equal(ee.Id, aa.Id);

Assert.Equal(ee.JsonString, aa.JsonString);
}
}
}
}.ToDictionary(e => e.Key, e => (object)e.Value);

public Func<DbContext> GetContextCreator()
=> CreateContext;

public TestSqlLoggerFactory TestSqlLoggerFactory
=> (TestSqlLoggerFactory)ListLoggerFactory;
}

public class JsonTranslationsData : ISetSource
{
public IReadOnlyList<JsonTranslationsEntity> JsonTranslationsEntities { get; } = CreateJsonTranslationsEntities();

public IQueryable<TEntity> Set<TEntity>()
where TEntity : class
=> typeof(TEntity) == typeof(JsonTranslationsEntity)
? (IQueryable<TEntity>)JsonTranslationsEntities.AsQueryable()
: throw new InvalidOperationException("Invalid entity type: " + typeof(TEntity));

public static IReadOnlyList<JsonTranslationsEntity> CreateJsonTranslationsEntities() =>
[
// In the following, JsonString should correspond exactly to JsonComplexType; we don't currently support mapping both
// a string scalar property and a complex JSON property to the same column in the database.

new()
{
Id = 1,
JsonString = """{ "RequiredInt": 8, "OptionalInt": 8 }""",
JsonComplexType = new()
{
RequiredInt = 8,
OptionalInt = 8
}
},
// Different values
new()
{
Id = 2,
JsonString = """{ "RequiredInt": 9, "OptionalInt": 9 }""",
JsonComplexType = new()
{
RequiredInt = 9,
OptionalInt = 9
}
},
// OptionalInt is null.
new()
{
Id = 3,
JsonString = """{ "RequiredInt": 10, "OptionalInt": null }""",
JsonComplexType = new()
{
RequiredInt = 10,
OptionalInt = null
}
},
// OptionalInt is missing (not null).
// Note that this requires a manual SQL update since EF's complex type support always writes out the property (with null);
// any change here requires updating JsonTranslationsQueryContext.SeedAsync as well.
new()
{
Id = 4,
JsonString = """{ "RequiredInt": 10 }""",
JsonComplexType = new()
{
RequiredInt = 10,
OptionalInt = null // This will be replaced by a missing property
}
}
];
}

protected JsonTranslationsQueryContext CreateContext()
=> Fixture.CreateContext();
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ protected override ITestStoreFactory TestStoreFactory
public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
{
var options = base.AddOptions(builder);

return TestEnvironment.SqlServerMajorVersion < 17
? options
: options.UseSqlServerCompatibilityLevel(170);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.EntityFrameworkCore.Query.Translations;

public class JsonTranslationsSqlServerTest : JsonTranslationsRelationalTestBase<JsonTranslationsSqlServerTest.JsonTranslationsQuerySqlServerFixture>
{
public JsonTranslationsSqlServerTest(JsonTranslationsQuerySqlServerFixture fixture, ITestOutputHelper testOutputHelper)
: base(fixture)
{
Fixture.TestSqlLoggerFactory.Clear();
Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper);
}

[ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)]
public override async Task JsonExists_on_scalar_string_column()
{
await base.JsonExists_on_scalar_string_column();

AssertSql(
"""
SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType]
FROM [JsonEntities] AS [j]
WHERE JSON_PATH_EXISTS([j].[JsonString], N'$.OptionalInt') = 1
""");
}

[ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)]
public override async Task JsonExists_on_complex_property()
{
await base.JsonExists_on_complex_property();

AssertSql(
"""
SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType]
FROM [JsonEntities] AS [j]
WHERE JSON_PATH_EXISTS([j].[JsonComplexType], N'$.OptionalInt') = 1
""");
}

public class JsonTranslationsQuerySqlServerFixture : JsonTranslationsQueryFixtureBase, ITestSqlLoggerFactory
{
protected override ITestStoreFactory TestStoreFactory
=> SqlServerTestStoreFactory.Instance;

// When testing against SQL Server 2025 or later, set the compatibility level to 170 to use the json type instead of nvarchar(max).
public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
{
var options = base.AddOptions(builder);

return TestEnvironment.SqlServerMajorVersion < 17
? options
: options.UseSqlServerCompatibilityLevel(170);
}

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

[ConditionalFact]
public virtual void Check_all_tests_overridden()
=> TestHelpers.AssertAllMethodsOverridden(GetType());

private void AssertSql(params string[] expected)
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
}
Loading