Skip to content

Commit a868959

Browse files
committed
Implement EF.Functions.JsonExists
Closes #31136
1 parent 3673560 commit a868959

File tree

7 files changed

+412
-13
lines changed

7 files changed

+412
-13
lines changed

src/EFCore.Relational/Extensions/RelationalDbFunctionsExtensions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,13 @@ public static T Greatest<T>(
5656
this DbFunctions _,
5757
[NotParameterized] params T[] values)
5858
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Greatest)));
59+
60+
/// <summary>
61+
/// Returns a value indicating whether a given JSON path exists within the specified JSON.
62+
/// </summary>
63+
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
64+
/// <param name="json">The JSON value to check.</param>
65+
/// <param name="path">The JSON path to look for.</param>
66+
public static bool JsonExists(this DbFunctions _, object json, string path)
67+
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(JsonExists)));
5968
}

src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,45 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
240240
_typeMappingSource.FindMapping(isUnicode ? "nvarchar(max)" : "varchar(max)"));
241241
}
242242

243+
// We translate EF.Functions.JsonExists here and not in a method translator since we need to support JsonExists over a complex
244+
// property, which requires special handling.
245+
case nameof(RelationalDbFunctionsExtensions.JsonExists)
246+
when declaringType == typeof(RelationalDbFunctionsExtensions)
247+
&& @object is null
248+
&& arguments is [_, var json, var path]:
249+
{
250+
if (Translate(path) is not SqlExpression translatedPath)
251+
{
252+
return QueryCompilationContext.NotTranslatedExpression;
253+
}
254+
255+
#pragma warning disable EF1001 // TranslateProjection() is pubternal
256+
var translatedJson = TranslateProjection(json) switch
257+
{
258+
// The JSON argument is a scalar string property
259+
SqlExpression scalar => scalar,
260+
261+
// The JSON argument is a complex JSON property
262+
RelationalStructuralTypeShaperExpression { ValueBufferExpression: JsonQueryExpression { JsonColumn: var c } } => c,
263+
_ => null
264+
};
265+
#pragma warning restore EF1001
266+
267+
return translatedJson is null
268+
? QueryCompilationContext.NotTranslatedExpression
269+
: _sqlExpressionFactory.Equal(
270+
_sqlExpressionFactory.Function(
271+
"JSON_PATH_EXISTS",
272+
[translatedJson, translatedPath],
273+
nullable: true,
274+
// Note that json_type() does propagate nullability; however, our query pipeline assumes that if arguments
275+
// propagate nullability, that's the *only* reason for the function to return null; this means that if the
276+
// arguments are non-nullable, the IS NOT NULL wrapping check can be optimized away.
277+
argumentsPropagateNullability: [false, false],
278+
typeof(int)),
279+
_sqlExpressionFactory.Constant(1));
280+
}
281+
243282
default:
244283
return QueryCompilationContext.NotTranslatedExpression;
245284
}

src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -214,21 +214,65 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
214214
}
215215

216216
var method = methodCallExpression.Method;
217+
var declaringType = method.DeclaringType;
218+
var @object = methodCallExpression.Object;
219+
var arguments = methodCallExpression.Arguments;
217220

218-
// https://learn.microsoft.com/dotnet/api/system.string.startswith#system-string-startswith(system-string)
219-
// https://learn.microsoft.com/dotnet/api/system.string.startswith#system-string-startswith(system-char)
220-
// https://learn.microsoft.com/dotnet/api/system.string.endswith#system-string-endswith(system-string)
221-
// https://learn.microsoft.com/dotnet/api/system.string.endswith#system-string-endswith(system-char)
222-
if (method.Name is nameof(string.StartsWith) or nameof(string.EndsWith)
223-
&& methodCallExpression.Object is not null
224-
&& method.DeclaringType == typeof(string)
225-
&& methodCallExpression.Arguments is [Expression value]
226-
&& (value.Type == typeof(string) || value.Type == typeof(char)))
221+
switch (method.Name)
227222
{
228-
return TranslateStartsEndsWith(
229-
methodCallExpression.Object,
230-
value,
231-
method.Name is nameof(string.StartsWith));
223+
// https://learn.microsoft.com/dotnet/api/system.string.startswith#system-string-startswith(system-string)
224+
// https://learn.microsoft.com/dotnet/api/system.string.startswith#system-string-startswith(system-char)
225+
// https://learn.microsoft.com/dotnet/api/system.string.endswith#system-string-endswith(system-string)
226+
// https://learn.microsoft.com/dotnet/api/system.string.endswith#system-string-endswith(system-char)
227+
case nameof(string.StartsWith) or nameof(string.EndsWith)
228+
when methodCallExpression.Object is not null
229+
&& declaringType == typeof(string)
230+
&& arguments is [Expression value]
231+
&& (value.Type == typeof(string) || value.Type == typeof(char)):
232+
{
233+
return TranslateStartsEndsWith(
234+
methodCallExpression.Object,
235+
value,
236+
method.Name is nameof(string.StartsWith));
237+
}
238+
239+
// We translate EF.Functions.JsonExists here and not in a method translator since we need to support JsonExists over a complex
240+
// property, which requires special handling.
241+
case nameof(RelationalDbFunctionsExtensions.JsonExists)
242+
when declaringType == typeof(RelationalDbFunctionsExtensions)
243+
&& @object is null
244+
&& arguments is [_, var json, var path]:
245+
{
246+
if (Translate(path) is not SqlExpression translatedPath)
247+
{
248+
return QueryCompilationContext.NotTranslatedExpression;
249+
}
250+
251+
#pragma warning disable EF1001 // TranslateProjection() is pubternal
252+
var translatedJson = TranslateProjection(json) switch
253+
{
254+
// The JSON argument is a scalar string property
255+
SqlExpression scalar => scalar,
256+
257+
// The JSON argument is a complex JSON property
258+
RelationalStructuralTypeShaperExpression { ValueBufferExpression: JsonQueryExpression { JsonColumn: var c } } => c,
259+
_ => null
260+
};
261+
#pragma warning restore EF1001
262+
263+
return translatedJson is null
264+
? QueryCompilationContext.NotTranslatedExpression
265+
: _sqlExpressionFactory.IsNotNull(
266+
_sqlExpressionFactory.Function(
267+
"json_type",
268+
[translatedJson, translatedPath],
269+
nullable: true,
270+
// Note that json_type() does propagate nullability; however, our query pipeline assumes that if arguments
271+
// propagate nullability, that's the *only* reason for the function to return null; this means that if the
272+
// arguments are non-nullable, the IS NOT NULL wrapping check can be optimized away.
273+
argumentsPropagateNullability: [false, false],
274+
typeof(int)));
275+
}
232276
}
233277

234278
return QueryCompilationContext.NotTranslatedExpression;
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.ComponentModel.DataAnnotations.Schema;
5+
using System.Text.Json.Nodes;
6+
7+
namespace Microsoft.EntityFrameworkCore.Query.Translations;
8+
9+
// This test suite covers translations of JSON functions on EF.Functions (e.g. EF.Functions.JsonExists).
10+
// It does not cover general, built-in JSON support via complex type mapping, etc.
11+
public abstract class JsonTranslationsRelatinalTestBase<TFixture>(TFixture fixture) : QueryTestBase<TFixture>(fixture)
12+
where TFixture : JsonTranslationsRelatinalTestBase<TFixture>.JsonTranslationsQueryFixtureBase, new()
13+
{
14+
[ConditionalFact]
15+
public virtual Task JsonExists_on_scalar_string_column()
16+
=> AssertQuery(
17+
ss => ss.Set<JsonTranslationsEntity>()
18+
.Where(b => EF.Functions.JsonExists(b.JsonString, "$.OptionalInt")),
19+
ss => ss.Set<JsonTranslationsEntity>()
20+
.Where(b => ((IDictionary<string, JsonNode>)JsonNode.Parse(b.JsonString)!).ContainsKey("OptionalInt")));
21+
22+
[ConditionalFact]
23+
public virtual Task JsonExists_on_complex_property()
24+
=> AssertQuery(
25+
ss => ss.Set<JsonTranslationsEntity>()
26+
.Where(b => EF.Functions.JsonExists(b.JsonComplexType, "$.OptionalInt")),
27+
ss => ss.Set<JsonTranslationsEntity>()
28+
.Where(b => ((IDictionary<string, JsonNode>)JsonNode.Parse(b.JsonString)!).ContainsKey("OptionalInt")));
29+
30+
public class JsonTranslationsEntity
31+
{
32+
[DatabaseGenerated(DatabaseGeneratedOption.None)]
33+
public int Id { get; set; }
34+
35+
public required string JsonString { get; set; }
36+
37+
public required JsonComplexType JsonComplexType { get; set; }
38+
}
39+
40+
public class JsonComplexType
41+
{
42+
public required int RequiredInt { get; set; }
43+
public int? OptionalInt { get; set; }
44+
}
45+
46+
public class JsonTranslationsQueryContext(DbContextOptions options) : PoolableDbContext(options)
47+
{
48+
public DbSet<JsonTranslationsEntity> JsonEntities { get; set; } = null!;
49+
50+
protected override void OnModelCreating(ModelBuilder modelBuilder)
51+
=> modelBuilder.Entity<JsonTranslationsEntity>().ComplexProperty(j => j.JsonComplexType, j => j.ToJson());
52+
}
53+
54+
// The translation tests usually use BasicTypesQueryFixtureBase, which manages a single database with all the data needed for the tests.
55+
// However, here in the JSON translation tests we use a separate fixture and database, since not all providers necessary implement full
56+
// JSON support, and we don't want to make life difficult for them with the basic translation tests.
57+
public abstract class JsonTranslationsQueryFixtureBase : SharedStoreFixtureBase<JsonTranslationsQueryContext>, IQueryFixtureBase, ITestSqlLoggerFactory
58+
{
59+
private JsonTranslationsData? _expectedData;
60+
61+
protected override string StoreName
62+
=> "JsonTranslationsQueryTest";
63+
64+
protected override async Task SeedAsync(JsonTranslationsQueryContext context)
65+
{
66+
var data = new JsonTranslationsData();
67+
context.AddRange(data.JsonTranslationsEntities);
68+
await context.SaveChangesAsync();
69+
70+
var entityType = context.Model.FindEntityType(typeof(JsonTranslationsEntity))!;
71+
var sqlGenerationHelper = context.GetService<ISqlGenerationHelper>();
72+
var table = sqlGenerationHelper.DelimitIdentifier(entityType.GetTableName()!);
73+
var idColumn = sqlGenerationHelper.DelimitIdentifier(
74+
entityType.FindProperty(nameof(JsonTranslationsEntity.Id))!.GetColumnName());
75+
var complexTypeColumn = sqlGenerationHelper.DelimitIdentifier(
76+
entityType.FindComplexProperty(nameof(JsonTranslationsEntity.JsonComplexType))!.ComplexType.GetContainerColumnName()!);
77+
78+
await context.Database.ExecuteSqlRawAsync(
79+
$$"""UPDATE {{table}} SET {{complexTypeColumn}} = {{RemoveJsonProperty(complexTypeColumn, "$.OptionalInt")}} WHERE {{idColumn}} = 4""");
80+
}
81+
82+
protected abstract string RemoveJsonProperty(string column, string jsonPath);
83+
84+
public virtual ISetSource GetExpectedData()
85+
=> _expectedData ??= new JsonTranslationsData();
86+
87+
public IReadOnlyDictionary<Type, object> EntitySorters { get; } = new Dictionary<Type, Func<object?, object?>>
88+
{
89+
{ typeof(JsonTranslationsEntity), e => ((JsonTranslationsEntity?)e)?.Id },
90+
}.ToDictionary(e => e.Key, e => (object)e.Value);
91+
92+
public IReadOnlyDictionary<Type, object> EntityAsserters { get; } = new Dictionary<Type, Action<object?, object?>>
93+
{
94+
{
95+
typeof(JsonTranslationsEntity), (e, a) =>
96+
{
97+
Assert.Equal(e == null, a == null);
98+
99+
if (a != null)
100+
{
101+
var ee = (JsonTranslationsEntity)e!;
102+
var aa = (JsonTranslationsEntity)a;
103+
104+
Assert.Equal(ee.Id, aa.Id);
105+
106+
Assert.Equal(ee.JsonString, aa.JsonString);
107+
}
108+
}
109+
}
110+
}.ToDictionary(e => e.Key, e => (object)e.Value);
111+
112+
public Func<DbContext> GetContextCreator()
113+
=> CreateContext;
114+
115+
public TestSqlLoggerFactory TestSqlLoggerFactory
116+
=> (TestSqlLoggerFactory)ListLoggerFactory;
117+
}
118+
119+
public class JsonTranslationsData : ISetSource
120+
{
121+
public IReadOnlyList<JsonTranslationsEntity> JsonTranslationsEntities { get; } = CreateJsonTranslationsEntities();
122+
123+
public IQueryable<TEntity> Set<TEntity>()
124+
where TEntity : class
125+
=> typeof(TEntity) == typeof(JsonTranslationsEntity)
126+
? (IQueryable<TEntity>)JsonTranslationsEntities.AsQueryable()
127+
: throw new InvalidOperationException("Invalid entity type: " + typeof(TEntity));
128+
129+
public static IReadOnlyList<JsonTranslationsEntity> CreateJsonTranslationsEntities() =>
130+
[
131+
// In the following, JsonString should correspond exactly to JsonComplexType; we don't currently support mapping both
132+
// a string scalar property and a complex JSON property to the same column in the database.
133+
134+
new()
135+
{
136+
Id = 1,
137+
JsonString = """{ "RequiredInt": 8, "OptionalInt": 8 }""",
138+
JsonComplexType = new()
139+
{
140+
RequiredInt = 8,
141+
OptionalInt = 8
142+
}
143+
},
144+
// Different values
145+
new()
146+
{
147+
Id = 2,
148+
JsonString = """{ "RequiredInt": 9, "OptionalInt": 9 }""",
149+
JsonComplexType = new()
150+
{
151+
RequiredInt = 9,
152+
OptionalInt = 9
153+
}
154+
},
155+
// OptionalInt is null.
156+
new()
157+
{
158+
Id = 3,
159+
JsonString = """{ "RequiredInt": 10, "OptionalInt": null }""",
160+
JsonComplexType = new()
161+
{
162+
RequiredInt = 10,
163+
OptionalInt = null
164+
}
165+
},
166+
// OptionalInt is missing (not null).
167+
// Note that this requires a manual SQL update since EF's complex type support always writes out the property (with null);
168+
// any change here requires updating JsonTranslationsQueryContext.SeedAsync as well.
169+
new()
170+
{
171+
Id = 4,
172+
JsonString = """{ "RequiredInt": 10 }""",
173+
JsonComplexType = new()
174+
{
175+
RequiredInt = 10,
176+
OptionalInt = null // This will be replaced by a missing property
177+
}
178+
}
179+
];
180+
}
181+
182+
protected JsonTranslationsQueryContext CreateContext()
183+
=> Fixture.CreateContext();
184+
}

test/EFCore.SqlServer.FunctionalTests/Query/Associations/ComplexJson/ComplexJsonSqlServerFixture.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ protected override ITestStoreFactory TestStoreFactory
1212
public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
1313
{
1414
var options = base.AddOptions(builder);
15+
1516
return TestEnvironment.SqlServerMajorVersion < 17
1617
? options
1718
: options.UseSqlServerCompatibilityLevel(170);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.EntityFrameworkCore.Query.Translations;
5+
6+
public class JsonTranslationsSqlServerTest : JsonTranslationsRelatinalTestBase<JsonTranslationsSqlServerTest.JsonTranslationsQuerySqlServerFixture>
7+
{
8+
public JsonTranslationsSqlServerTest(JsonTranslationsQuerySqlServerFixture fixture, ITestOutputHelper testOutputHelper)
9+
: base(fixture)
10+
{
11+
Fixture.TestSqlLoggerFactory.Clear();
12+
Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper);
13+
}
14+
15+
[ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)]
16+
public override async Task JsonExists_on_scalar_string_column()
17+
{
18+
await base.JsonExists_on_scalar_string_column();
19+
20+
AssertSql(
21+
"""
22+
SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType]
23+
FROM [JsonEntities] AS [j]
24+
WHERE JSON_PATH_EXISTS([j].[JsonString], N'$.OptionalInt') = 1
25+
""");
26+
}
27+
28+
[ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)]
29+
public override async Task JsonExists_on_complex_property()
30+
{
31+
await base.JsonExists_on_complex_property();
32+
33+
AssertSql(
34+
"""
35+
SELECT [j].[Id], [j].[JsonString], [j].[JsonComplexType]
36+
FROM [JsonEntities] AS [j]
37+
WHERE JSON_PATH_EXISTS([j].[JsonComplexType], N'$.OptionalInt') = 1
38+
""");
39+
}
40+
41+
public class JsonTranslationsQuerySqlServerFixture : JsonTranslationsQueryFixtureBase, ITestSqlLoggerFactory
42+
{
43+
protected override ITestStoreFactory TestStoreFactory
44+
=> SqlServerTestStoreFactory.Instance;
45+
46+
// When testing against SQL Server 2025 or later, set the compatibility level to 170 to use the json type instead of nvarchar(max).
47+
public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder)
48+
{
49+
var options = base.AddOptions(builder);
50+
51+
return TestEnvironment.SqlServerMajorVersion < 17
52+
? options
53+
: options.UseSqlServerCompatibilityLevel(170);
54+
}
55+
56+
protected override string RemoveJsonProperty(string column, string jsonPath)
57+
=> $"JSON_MODIFY({column}, '{jsonPath}', NULL)";
58+
}
59+
60+
[ConditionalFact]
61+
public virtual void Check_all_tests_overridden()
62+
=> TestHelpers.AssertAllMethodsOverridden(GetType());
63+
64+
private void AssertSql(params string[] expected)
65+
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
66+
}

0 commit comments

Comments
 (0)