Skip to content

Commit d63b16a

Browse files
committed
Implement JSON_CONTAINS() for primitive collections
Closes #36656 Fixes #37561
1 parent 461b9a3 commit d63b16a

18 files changed

+330
-129
lines changed

src/EFCore.Relational/Storage/JsonTypeMapping.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,24 @@ namespace Microsoft.EntityFrameworkCore.Storage;
1818
/// See <see href="https://aka.ms/efcore-docs-providers">Implementation of database providers and extensions</see>
1919
/// for more information and examples.
2020
/// </remarks>
21-
public abstract class JsonTypeMapping : RelationalTypeMapping
21+
public abstract class StructuralJsonTypeMapping : RelationalTypeMapping
2222
{
2323
/// <summary>
24-
/// Initializes a new instance of the <see cref="JsonTypeMapping" /> class.
24+
/// Initializes a new instance of the <see cref="StructuralJsonTypeMapping" /> class.
2525
/// </summary>
2626
/// <param name="storeType">The name of the database type.</param>
2727
/// <param name="clrType">The .NET type.</param>
2828
/// <param name="dbType">The <see cref="DbType" /> to be used.</param>
29-
protected JsonTypeMapping(string storeType, Type clrType, DbType? dbType)
29+
protected StructuralJsonTypeMapping(string storeType, Type clrType, DbType? dbType)
3030
: base(storeType, clrType, dbType)
3131
{
3232
}
3333

3434
/// <summary>
35-
/// Initializes a new instance of the <see cref="JsonTypeMapping" /> class.
35+
/// Initializes a new instance of the <see cref="StructuralJsonTypeMapping" /> class.
3636
/// </summary>
3737
/// <param name="parameters">Parameter object for <see cref="RelationalTypeMapping" />.</param>
38-
protected JsonTypeMapping(RelationalTypeMappingParameters parameters)
38+
protected StructuralJsonTypeMapping(RelationalTypeMappingParameters parameters)
3939
: base(parameters)
4040
{
4141
}
@@ -45,3 +45,9 @@ protected override string GenerateNonNullSqlLiteral(object value)
4545
=> throw new InvalidOperationException(
4646
RelationalStrings.MethodNeedsToBeImplementedInTheProvider);
4747
}
48+
49+
/// <summary>
50+
/// Use StructuralJsonTypeMapping instead for type mappings representing JSON structural types as opposed to JSON strings.
51+
/// </summary>
52+
[Obsolete("Use StructuralJsonTypeMapping instead for type mappings representing JSON structural types as opposed to JSON strings.")]
53+
public class JsonTypeMapping;

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

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Diagnostics.CodeAnalysis;
54
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
65
using Microsoft.EntityFrameworkCore.SqlServer.Internal;
76
using Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal;
@@ -55,8 +54,7 @@ public Expression Process(Expression expression)
5554
/// any release. You should only use it directly in your code with extreme caution and knowing that
5655
/// doing so can result in application failures when updating to a new Entity Framework Core release.
5756
/// </summary>
58-
[return: NotNullIfNotNull(nameof(expression))]
59-
public override Expression? Visit(Expression? expression)
57+
protected override Expression VisitExtension(Expression expression)
6058
{
6159
switch (expression)
6260
{
@@ -128,7 +126,7 @@ public Expression Process(Expression expression)
128126
{
129127
Check.DebugAssert(newTables is null, "newTables must be null if columnsToRewrite is null");
130128

131-
result = (SelectExpression)base.Visit(result);
129+
result = (SelectExpression)base.VisitExtension(result);
132130
}
133131
else
134132
{
@@ -154,7 +152,7 @@ public Expression Process(Expression expression)
154152

155153
// Record the OPENJSON expression and its projected column(s), along with the store type we just removed from the WITH
156154
// clause. Then visit the select expression, adding a cast around the matching ColumnExpressions.
157-
result = (SelectExpression)base.Visit(result);
155+
result = (SelectExpression)base.VisitExtension(result);
158156

159157
foreach (var columnsToRewriteKey in columnsToRewrite.Keys)
160158
{
@@ -270,19 +268,33 @@ when _columnsToRewrite.TryGetValue((columnExpression.TableAlias, columnExpressio
270268
&& left is not SqlConstantExpression { Value: null }
271269
&& right is not SqlConstantExpression { Value: null }:
272270
{
273-
return comparison.Update(
274-
sqlExpressionFactory.Convert(
275-
left,
276-
typeof(string),
277-
typeMappingSource.FindMapping(typeof(string))),
278-
sqlExpressionFactory.Convert(
279-
right,
280-
typeof(string),
281-
typeMappingSource.FindMapping(typeof(string))));
271+
var stringTypeMapping = typeMappingSource.FindMapping(typeof(string));
272+
273+
return comparison.Update(ConvertToString(left), ConvertToString(right));
274+
275+
SqlExpression ConvertToString(SqlExpression expression)
276+
=> expression switch
277+
{
278+
// If the expression happens to be a json literal (CAST('...' AS json)), we can just extract the string inside,
279+
// instead of applying an additional CAST
280+
SqlConstantExpression { TypeMapping.StoreType: "json", Value: var value }
281+
// => new SqlConstantExpression(value, typeof(string), stringTypeMapping),
282+
=> new SqlConstantExpression(
283+
value,
284+
typeof(string),
285+
(RelationalTypeMapping)new SqlServerStringTypeMapping("nvarchar(max)").WithComposedConverter(expression.TypeMapping.Converter)),
286+
// expression.TypeMapping.WithStoreTypeAndSize("nvarchar(max)", size: null)),
287+
// typeMappingSource.FindMapping(typeof(string), model, elementMapping: expression.TypeMapping.ElementTypeMapping)),
288+
289+
{ TypeMapping.StoreType: "json" }
290+
=> sqlExpressionFactory.Convert(expression, typeof(string), stringTypeMapping),
291+
292+
_ => expression
293+
};
282294
}
283295

284296
default:
285-
return base.Visit(expression);
297+
return base.VisitExtension(expression);
286298
}
287299

288300
static bool IsKeyColumn(SqlExpression sqlExpression, string openJsonTableAlias)

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

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,19 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;
99
/// any release. You should only use it directly in your code with extreme caution and knowing that
1010
/// doing so can result in application failures when updating to a new Entity Framework Core release.
1111
/// </summary>
12-
public class SqlServerQueryTranslationPostprocessor : RelationalQueryTranslationPostprocessor
12+
public class SqlServerQueryTranslationPostprocessor(
13+
QueryTranslationPostprocessorDependencies dependencies,
14+
RelationalQueryTranslationPostprocessorDependencies relationalDependencies,
15+
SqlServerQueryCompilationContext queryCompilationContext)
16+
: RelationalQueryTranslationPostprocessor(dependencies, relationalDependencies, queryCompilationContext)
1317
{
14-
private readonly SqlServerJsonPostprocessor _jsonPostprocessor;
15-
private readonly SqlServerAggregateOverSubqueryPostprocessor _aggregatePostprocessor;
16-
private readonly SqlServerSqlTreePruner _pruner = new();
18+
private readonly SqlServerJsonPostprocessor _jsonPostprocessor = new(
19+
relationalDependencies.TypeMappingSource,
20+
relationalDependencies.SqlExpressionFactory,
21+
queryCompilationContext.SqlAliasManager);
1722

18-
/// <summary>
19-
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
20-
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
21-
/// any release. You should only use it directly in your code with extreme caution and knowing that
22-
/// doing so can result in application failures when updating to a new Entity Framework Core release.
23-
/// </summary>
24-
public SqlServerQueryTranslationPostprocessor(
25-
QueryTranslationPostprocessorDependencies dependencies,
26-
RelationalQueryTranslationPostprocessorDependencies relationalDependencies,
27-
SqlServerQueryCompilationContext queryCompilationContext)
28-
: base(dependencies, relationalDependencies, queryCompilationContext)
29-
{
30-
_jsonPostprocessor = new SqlServerJsonPostprocessor(
31-
relationalDependencies.TypeMappingSource, relationalDependencies.SqlExpressionFactory, queryCompilationContext.SqlAliasManager);
32-
_aggregatePostprocessor = new SqlServerAggregateOverSubqueryPostprocessor(queryCompilationContext.SqlAliasManager);
33-
}
23+
private readonly SqlServerAggregateOverSubqueryPostprocessor _aggregatePostprocessor = new(queryCompilationContext.SqlAliasManager);
24+
private readonly SqlServerSqlTreePruner _pruner = new();
3425

3526
/// <summary>
3627
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to

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

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,71 @@ IComplexType complexType
345345
false));
346346
}
347347

348+
/// <summary>
349+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
350+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
351+
/// any release. You should only use it directly in your code with extreme caution and knowing that
352+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
353+
/// </summary>
354+
protected override ShapedQueryExpression? TranslateContains(ShapedQueryExpression source, Expression item)
355+
{
356+
// Attempt to translate to JSON_CONTAINS for SQL Server 2025+ (compatibility level 170+).
357+
// JSON_CONTAINS is more efficient than IN (SELECT ... FROM OPENJSON(...)) for primitive collections.
358+
if (_sqlServerSingletonOptions.SupportsJsonType
359+
&& source.QueryExpression is SelectExpression
360+
{
361+
// Primitive collection over OPENJSON (e.g. [p].[Ints])
362+
Tables:
363+
[
364+
SqlServerOpenJsonExpression
365+
{
366+
// JSON_CONTAINS() is only supported over json, not nvarchar
367+
JsonExpression: { TypeMapping: SqlServerJsonTypeMapping } json,
368+
Path: null,
369+
ColumnInfos: [{ Name: "value" }]
370+
} openJsonExpression
371+
],
372+
Predicate: null,
373+
GroupBy: [],
374+
Having: null,
375+
IsDistinct: false,
376+
Limit: null,
377+
Offset: null
378+
}
379+
&& TranslateExpression(item, applyDefaultTypeMapping: false) is { } translatedItem
380+
// Literal untyped NULL not supported as item by JSON_CONTAINS().
381+
// For any other nullable item, SqlServerNullabilityProcessor will add a null check around the JSON_CONTAINS call.
382+
&& translatedItem is not SqlConstantExpression { Value: null }
383+
// Note: JSON_CONTAINS doesn't allow searching for null items within a JSON collection (returns 0)
384+
// 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.
385+
&& (
386+
translatedItem is ColumnExpression { IsNullable: false } or SqlConstantExpression { Value: not null }
387+
|| !translatedItem.Type.IsNullableType()
388+
|| json.Type.GetSequenceType() is var elementClrType && !elementClrType.IsNullableType()))
389+
{
390+
// JSON_CONTAINS returns 1 if found, 0 if not found. It's a search condition expression.
391+
var jsonContains = _sqlExpressionFactory.Equal(
392+
_sqlExpressionFactory.Function(
393+
"JSON_CONTAINS",
394+
[json, translatedItem],
395+
nullable: true,
396+
argumentsPropagateNullability: [false, true],
397+
typeof(int)),
398+
_sqlExpressionFactory.Constant(1));
399+
400+
#pragma warning disable EF1001 // Internal EF Core API usage.
401+
var selectExpression = new SelectExpression(jsonContains, _queryCompilationContext.SqlAliasManager);
402+
return source.Update(
403+
selectExpression,
404+
Expression.Convert(
405+
new ProjectionBindingExpression(selectExpression, new ProjectionMember(), typeof(bool?)),
406+
typeof(bool)));
407+
#pragma warning restore EF1001 // Internal EF Core API usage.
408+
}
409+
410+
return base.TranslateContains(source, item);
411+
}
412+
348413
/// <summary>
349414
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
350415
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
@@ -360,8 +425,8 @@ IComplexType complexType
360425
{
361426
switch (source.QueryExpression)
362427
{
363-
// index on parameter using a column
364-
// translate via JSON because it is a better translation
428+
// Index on parameter using a column
429+
// Translate via JSON_VALUE() instead of via a VALUES subquery
365430
case SelectExpression
366431
{
367432
Tables: [ValuesExpression { ValuesParameter: { } valuesParameter }],
@@ -675,15 +740,7 @@ protected override bool TrySerializeScalarToJson(
675740
// IReadOnlyList<PathSegment> (just like Json{Scalar,Query}Expression), but instead we do the slight hack of packaging it
676741
// as a constant argument; it will be unpacked and handled in SQL generation.
677742
_sqlExpressionFactory.Constant(path, RelationalTypeMapping.NullMapping),
678-
679-
// If an inline JSON object (complex type) is being assigned, it would be rendered here as a simple string:
680-
// [column].modify('$.foo', '{ "x": 8 }')
681-
// Since it's untyped, modify would treat is as a string rather than a JSON object, and insert it as such into
682-
// the enclosing object, escaping all the special JSON characters - that's not what we want.
683-
// We add a cast to JSON to have it interpreted as a JSON object.
684-
value is SqlConstantExpression { TypeMapping.StoreType: "json" }
685-
? _sqlExpressionFactory.Convert(value, value.Type, _typeMappingSource.FindMapping("json")!)
686-
: value
743+
value
687744
],
688745
nullable: true,
689746
instancePropagatesNullability: true,

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,48 @@ protected virtual SqlExpression VisitSqlServerAggregateFunction(
146146
: aggregateFunctionExpression;
147147
}
148148

149+
/// <summary>
150+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
151+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
152+
/// any release. You should only use it directly in your code with extreme caution and knowing that
153+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
154+
/// </summary>
155+
protected override SqlExpression VisitSqlFunction(
156+
SqlFunctionExpression sqlFunctionExpression,
157+
bool allowOptimizedExpansion,
158+
out bool nullable)
159+
{
160+
if (sqlFunctionExpression is { Name: "JSON_CONTAINS", Arguments: [var collection, var item] } jsonContains)
161+
{
162+
// JSON_CONTAINS() does not allow searching for NULL within a JSON collection (always returns zero when the item is NULL).
163+
// As a result, we do not translate to JSON_CONTAINS() in SqlServerQueryableMethodTranslatingExpressionVisitor unless we know that
164+
// either the item or the collection's elements are non-nullable.
165+
// When the item argument is nullable, we add a null check around JSON_CONTAINS():
166+
// CASE WHEN @item IS NULL THEN NULL ELSE JSON_CONTAINS(collection, @item) END
167+
item = Visit(item, out var itemNullable);
168+
collection = Visit(collection, out var collectionNullable);
169+
170+
sqlFunctionExpression = jsonContains.Update(instance: null, arguments: [collection, item]);
171+
172+
if (itemNullable && !UseRelationalNulls)
173+
{
174+
nullable = true;
175+
return Dependencies.SqlExpressionFactory.Case(
176+
[
177+
new CaseWhenClause(
178+
Dependencies.SqlExpressionFactory.IsNull(item),
179+
Dependencies.SqlExpressionFactory.Constant(null, typeof(bool?), jsonContains.TypeMapping))
180+
],
181+
jsonContains);
182+
}
183+
184+
nullable = itemNullable || collectionNullable;
185+
return sqlFunctionExpression;
186+
}
187+
188+
return base.VisitSqlFunction(sqlFunctionExpression, allowOptimizedExpansion, out nullable);
189+
}
190+
149191
/// <summary>
150192
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
151193
/// the same compatibility standards as public APIs. It may be changed or removed without notice in

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,15 @@ protected virtual SqlServerOpenJsonExpression ApplyTypeMappingsOnOpenJsonExpress
101101
elementTypeMapping = e;
102102
}
103103

104-
if (parameterTypeMapping is not SqlServerStringTypeMapping { ElementTypeMapping: not null })
104+
if (parameterTypeMapping is not SqlServerStringTypeMapping { ElementTypeMapping: not null }
105+
and not SqlServerJsonTypeMapping { ElementTypeMapping: not null })
105106
{
106-
throw new UnreachableException("A SqlServerStringTypeMapping collection type mapping could not be found");
107+
throw new UnreachableException("A string/JSON collection type mapping was not found");
107108
}
108109

109110
return openJsonExpression.Update(
110111
parameterExpression.ApplyTypeMapping(parameterTypeMapping),
111112
path: null,
112-
[new SqlServerOpenJsonExpression.ColumnInfo("value", elementTypeMapping, [])]);
113+
[new SqlServerOpenJsonExpression.ColumnInfo("value", elementTypeMapping, Path: [])]);
113114
}
114115
}

0 commit comments

Comments
 (0)