From 3dbd954034d8f740047010e0577630f3c3f12100 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sat, 10 Jan 2026 09:53:23 +0100 Subject: [PATCH 1/2] Implement EF.Functions.JsonExists Closes #31136 --- .../RelationalDbFunctionsExtensions.cs | 22 ++- ...qlServerSqlTranslatingExpressionVisitor.cs | 39 ++++ .../SqliteSqlTranslatingExpressionVisitor.cs | 70 +++++-- .../JsonTranslationsRelationalTestBase.cs | 184 ++++++++++++++++++ .../ComplexJsonSqlServerFixture.cs | 1 + .../JsonTranslationsSqlServerTest.cs | 66 +++++++ .../JsonTranslationsSqliteTest.cs | 56 ++++++ 7 files changed, 415 insertions(+), 23 deletions(-) create mode 100644 test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs create mode 100644 test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs create mode 100644 test/EFCore.Sqlite.FunctionalTests/Query/Translations/JsonTranslationsSqliteTest.cs diff --git a/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs b/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs index f0f6a0e1ea8..3eb33258d9d 100644 --- a/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs @@ -31,10 +31,7 @@ public static class RelationalDbFunctionsExtensions /// The instance. /// The operand to which to apply the collation. /// The name of the collation. - public static TProperty Collate( - this DbFunctions _, - TProperty operand, - [NotParameterized] string collation) + public static TProperty Collate(this DbFunctions _, TProperty operand, [NotParameterized] string collation) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Collate))); /// @@ -42,9 +39,7 @@ public static TProperty Collate( /// /// The instance. /// The list of values from which return the smallest value. - public static T Least( - this DbFunctions _, - [NotParameterized] params T[] values) + public static T Least(this DbFunctions _, [NotParameterized] params T[] values) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Least))); /// @@ -52,8 +47,15 @@ public static T Least( /// /// The instance. /// The list of values from which return the greatest value. - public static T Greatest( - this DbFunctions _, - [NotParameterized] params T[] values) + 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); +} From cb246a8e10adb9b3df8842af3a6989f97f6c31c1 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Thu, 22 Jan 2026 08:13:58 +0100 Subject: [PATCH 2/2] Add support for owned JSON as well --- ...qlServerSqlTranslatingExpressionVisitor.cs | 7 ++- .../SqliteSqlTranslatingExpressionVisitor.cs | 6 +- .../JsonTranslationsRelationalTestBase.cs | 59 +++++++++++++++++-- .../JsonTranslationsSqlServerTest.cs | 17 +++++- .../JsonTranslationsSqliteTest.cs | 17 +++++- 5 files changed, 91 insertions(+), 15 deletions(-) diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs index eeabf08d59b..0af9d673f26 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs @@ -240,8 +240,8 @@ 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. + // 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) when declaringType == typeof(RelationalDbFunctionsExtensions) && @object is null @@ -258,8 +258,9 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp // The JSON argument is a scalar string property SqlExpression scalar => scalar, - // The JSON argument is a complex JSON property + // The JSON argument is a complex or owned JSON property RelationalStructuralTypeShaperExpression { ValueBufferExpression: JsonQueryExpression { JsonColumn: var c } } => c, + _ => null }; #pragma warning restore EF1001 diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs index 1062ca09e2f..2001be61a61 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs @@ -236,8 +236,8 @@ when methodCallExpression.Object is not null 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. + // 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) when declaringType == typeof(RelationalDbFunctionsExtensions) && @object is null @@ -254,7 +254,7 @@ when methodCallExpression.Object is not null // The JSON argument is a scalar string property SqlExpression scalar => scalar, - // The JSON argument is a complex JSON property + // The JSON argument is a complex or owned JSON property RelationalStructuralTypeShaperExpression { ValueBufferExpression: JsonQueryExpression { JsonColumn: var c } } => c, _ => null }; diff --git a/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs index bcd464b0aa0..9b8d7d2b3e0 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/Translations/JsonTranslationsRelationalTestBase.cs @@ -27,6 +27,14 @@ public virtual Task JsonExists_on_complex_property() ss => ss.Set() .Where(b => ((IDictionary)JsonNode.Parse(b.JsonString)!).ContainsKey("OptionalInt"))); + [ConditionalFact] + public virtual Task JsonExists_on_owned_entity() + => AssertQuery( + ss => ss.Set() + .Where(b => EF.Functions.JsonExists(b.JsonOwnedType, "$.OptionalInt")), + ss => ss.Set() + .Where(b => ((IDictionary)JsonNode.Parse(b.JsonString)!).ContainsKey("OptionalInt"))); + public class JsonTranslationsEntity { [DatabaseGenerated(DatabaseGeneratedOption.None)] @@ -35,6 +43,7 @@ public class JsonTranslationsEntity public required string JsonString { get; set; } public required JsonComplexType JsonComplexType { get; set; } + public required JsonOwnedType JsonOwnedType { get; set; } } public class JsonComplexType @@ -43,12 +52,26 @@ public class JsonComplexType public int? OptionalInt { get; set; } } + public class JsonOwnedType + { + 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()); + { + modelBuilder.Entity(b => + { + b.ComplexProperty(j => j.JsonComplexType, j => j.ToJson()); +#pragma warning disable EF8001 // ToJson() on owned entities is obsolete + b.OwnsOne(j => j.JsonOwnedType, j => j.ToJson()); +#pragma warning restore EF8001 + }); + } } // The translation tests usually use BasicTypesQueryFixtureBase, which manages a single database with all the data needed for the tests. @@ -74,9 +97,14 @@ protected override async Task SeedAsync(JsonTranslationsQueryContext context) entityType.FindProperty(nameof(JsonTranslationsEntity.Id))!.GetColumnName()); var complexTypeColumn = sqlGenerationHelper.DelimitIdentifier( entityType.FindComplexProperty(nameof(JsonTranslationsEntity.JsonComplexType))!.ComplexType.GetContainerColumnName()!); + var ownedColumn = sqlGenerationHelper.DelimitIdentifier( + entityType.FindNavigation(nameof(JsonTranslationsEntity.JsonOwnedType))!.TargetEntityType.GetContainerColumnName()!); await context.Database.ExecuteSqlRawAsync( - $$"""UPDATE {{table}} SET {{complexTypeColumn}} = {{RemoveJsonProperty(complexTypeColumn, "$.OptionalInt")}} WHERE {{idColumn}} = 4"""); + $$""" + UPDATE {{table}} SET {{complexTypeColumn}} = {{RemoveJsonProperty(complexTypeColumn, "$.OptionalInt")}} WHERE {{idColumn}} = 4; + UPDATE {{table}} SET {{ownedColumn}} = {{RemoveJsonProperty(ownedColumn, "$.OptionalInt")}} WHERE {{idColumn}} = 4; + """); } protected abstract string RemoveJsonProperty(string column, string jsonPath); @@ -128,14 +156,20 @@ public IQueryable Set() 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. + // In the following, JsonString should correspond exactly to JsonComplexType and JsonOwnedType; + // we don't currently support mapping both a string scalar property and a complex/owned JSON property + // to the same column in the database. new() { Id = 1, JsonString = """{ "RequiredInt": 8, "OptionalInt": 8 }""", JsonComplexType = new() + { + RequiredInt = 8, + OptionalInt = 8 + }, + JsonOwnedType = new() { RequiredInt = 8, OptionalInt = 8 @@ -147,6 +181,11 @@ public static IReadOnlyList CreateJsonTranslationsEntiti Id = 2, JsonString = """{ "RequiredInt": 9, "OptionalInt": 9 }""", JsonComplexType = new() + { + RequiredInt = 9, + OptionalInt = 9 + }, + JsonOwnedType = new() { RequiredInt = 9, OptionalInt = 9 @@ -158,19 +197,29 @@ public static IReadOnlyList CreateJsonTranslationsEntiti Id = 3, JsonString = """{ "RequiredInt": 10, "OptionalInt": null }""", JsonComplexType = new() + { + RequiredInt = 10, + OptionalInt = null + }, + JsonOwnedType = 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); + // Note that this requires a manual SQL update since EF's complex/owned 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 + }, + JsonOwnedType = new() { RequiredInt = 10, OptionalInt = null // This will be replaced by a missing property diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs index 156f8f410ab..19a28d8c267 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/JsonTranslationsSqlServerTest.cs @@ -19,7 +19,7 @@ public override async Task JsonExists_on_scalar_string_column() AssertSql( """ -SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType] +SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType], [j].[JsonOwnedType] FROM [JsonEntities] AS [j] WHERE JSON_PATH_EXISTS([j].[JsonString], N'$.OptionalInt') = 1 """); @@ -32,12 +32,25 @@ public override async Task JsonExists_on_complex_property() AssertSql( """ -SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType] +SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType], [j].[JsonOwnedType] FROM [JsonEntities] AS [j] WHERE JSON_PATH_EXISTS([j].[JsonComplexType], N'$.OptionalInt') = 1 """); } + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public override async Task JsonExists_on_owned_entity() + { + await base.JsonExists_on_owned_entity(); + + AssertSql( + """ +SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType], [j].[JsonOwnedType] +FROM [JsonEntities] AS [j] +WHERE JSON_PATH_EXISTS([j].[JsonOwnedType], N'$.OptionalInt') = 1 +"""); + } + public class JsonTranslationsQuerySqlServerFixture : JsonTranslationsQueryFixtureBase, ITestSqlLoggerFactory { protected override ITestStoreFactory TestStoreFactory diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Translations/JsonTranslationsSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/JsonTranslationsSqliteTest.cs index 0ea236607ff..f1c82ae2a8e 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/Translations/JsonTranslationsSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/JsonTranslationsSqliteTest.cs @@ -19,7 +19,7 @@ public override async Task JsonExists_on_scalar_string_column() AssertSql( """ -SELECT "j"."Id", "j"."JsonString", "j"."JsonComplexType" +SELECT "j"."Id", "j"."JsonString", "j"."JsonComplexType", "j"."JsonOwnedType" FROM "JsonEntities" AS "j" WHERE json_type("j"."JsonString", '$.OptionalInt') IS NOT NULL """); @@ -32,12 +32,25 @@ public override async Task JsonExists_on_complex_property() AssertSql( """ -SELECT "j"."Id", "j"."JsonString", "j"."JsonComplexType" +SELECT "j"."Id", "j"."JsonString", "j"."JsonComplexType", "j"."JsonOwnedType" FROM "JsonEntities" AS "j" WHERE json_type("j"."JsonComplexType", '$.OptionalInt') IS NOT NULL """); } + [ConditionalFact] + public override async Task JsonExists_on_owned_entity() + { + await base.JsonExists_on_owned_entity(); + + AssertSql( + """ +SELECT "j"."Id", "j"."JsonString", "j"."JsonComplexType", "j"."JsonOwnedType" +FROM "JsonEntities" AS "j" +WHERE json_type("j"."JsonOwnedType", '$.OptionalInt') IS NOT NULL +"""); + } + public class JsonTranslationsQuerySqliteFixture : JsonTranslationsQueryFixtureBase, ITestSqlLoggerFactory { protected override ITestStoreFactory TestStoreFactory