diff --git a/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs b/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs index f0f6a0e1ea8..cb063c00d65 100644 --- a/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs @@ -56,4 +56,13 @@ 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. + /// + /// The instance. + /// The JSON value to check. + /// The JSON path to look for. + public static bool JsonExists(this DbFunctions _, object json, string path) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(JsonExists))); } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs index ea2aa94c58b..eeabf08d59b 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs @@ -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; } diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs index 9e13d5e0bfd..1062ca09e2f 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs @@ -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; diff --git a/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs new file mode 100644 index 00000000000..bcd464b0aa0 --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs @@ -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 fixture) : QueryTestBase(fixture) + where TFixture : JsonTranslationsRelationalTestBase.JsonTranslationsQueryFixtureBase, new() +{ + [ConditionalFact] + public virtual Task JsonExists_on_scalar_string_column() + => AssertQuery( + ss => ss.Set() + .Where(b => EF.Functions.JsonExists(b.JsonString, "$.OptionalInt")), + ss => ss.Set() + .Where(b => ((IDictionary)JsonNode.Parse(b.JsonString)!).ContainsKey("OptionalInt"))); + + [ConditionalFact] + public virtual Task JsonExists_on_complex_property() + => AssertQuery( + ss => ss.Set() + .Where(b => EF.Functions.JsonExists(b.JsonComplexType, "$.OptionalInt")), + ss => ss.Set() + .Where(b => ((IDictionary)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 JsonEntities { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().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, 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(); + 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 EntitySorters { get; } = new Dictionary> + { + { typeof(JsonTranslationsEntity), e => ((JsonTranslationsEntity?)e)?.Id }, + }.ToDictionary(e => e.Key, e => (object)e.Value); + + public IReadOnlyDictionary EntityAsserters { get; } = new Dictionary> + { + { + 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 GetContextCreator() + => CreateContext; + + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; + } + + public class JsonTranslationsData : ISetSource + { + public IReadOnlyList JsonTranslationsEntities { get; } = CreateJsonTranslationsEntities(); + + public IQueryable Set() + where TEntity : class + => typeof(TEntity) == typeof(JsonTranslationsEntity) + ? (IQueryable)JsonTranslationsEntities.AsQueryable() + : throw new InvalidOperationException("Invalid entity type: " + typeof(TEntity)); + + public static IReadOnlyList 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(); +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonSqlServerFixture.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonSqlServerFixture.cs index 3584e53423e..300689eb1e9 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonSqlServerFixture.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonSqlServerFixture.cs @@ -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); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs new file mode 100644 index 00000000000..156f8f410ab --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs @@ -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 +{ + 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); +} diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Translations/JsonTranslationsSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/JsonTranslationsSqliteTest.cs new file mode 100644 index 00000000000..0ea236607ff --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/JsonTranslationsSqliteTest.cs @@ -0,0 +1,56 @@ +// 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 JsonTranslationsSqliteTest : JsonTranslationsRelationalTestBase +{ + public JsonTranslationsSqliteTest(JsonTranslationsQuerySqliteFixture fixture, ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + [ConditionalFact] + 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_type("j"."JsonString", '$.OptionalInt') IS NOT NULL +"""); + } + + [ConditionalFact] + 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_type("j"."JsonComplexType", '$.OptionalInt') IS NOT NULL +"""); + } + + public class JsonTranslationsQuerySqliteFixture : JsonTranslationsQueryFixtureBase, ITestSqlLoggerFactory + { + protected override ITestStoreFactory TestStoreFactory + => SqliteTestStoreFactory.Instance; + + protected override string RemoveJsonProperty(string column, string jsonPath) + => $"json_remove({column}, '{jsonPath}')"; + } + + [ConditionalFact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); + + private void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); +}