Skip to content

Commit f7e01ea

Browse files
committed
Make indexing work over complex JSON collections
Part of #36296
1 parent bd1e532 commit f7e01ea

16 files changed

+80
-74
lines changed

src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,6 +1551,7 @@ private sealed class SharedTypeEntityExpandingExpressionVisitor(
15511551
{
15521552
private readonly SqlAliasManager _sqlAliasManager = queryableTranslator._sqlAliasManager;
15531553
private SelectExpression _selectExpression = null!;
1554+
private bool _bindComplexProperties;
15541555

15551556
public Expression Expand(SelectExpression selectExpression, Expression lambdaBody)
15561557
{
@@ -1564,6 +1565,7 @@ protected override Expression VisitMember(MemberExpression memberExpression)
15641565
var innerExpression = Visit(memberExpression.Expression);
15651566

15661567
return TryExpand(innerExpression, MemberIdentity.Create(memberExpression.Member))
1568+
?? TryBindPropertyAccess(innerExpression, memberExpression.Member.Name)
15671569
?? memberExpression.Update(innerExpression);
15681570
}
15691571

@@ -1574,10 +1576,17 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
15741576
source = Visit(source);
15751577

15761578
return TryExpand(source, MemberIdentity.Create(navigationName))
1577-
?? TryBindPrimitiveCollection(source, navigationName)
1578-
?? methodCallExpression.Update(null!, new[] { source, methodCallExpression.Arguments[1] });
1579+
?? TryBindPropertyAccess(source, navigationName)
1580+
?? methodCallExpression.Update(null!, [source, methodCallExpression.Arguments[1]]);
15791581
}
15801582

1583+
// The following is a hack.
1584+
// In preprocessing, SubqueryMemberPushdownExpressionVisitor rewrites ElementAt(0).Int to Select(s => s.Int).ElementAt(0),
1585+
// as that's apparently needed for NavigationExpandingExpressionVisitor to work correctly;
1586+
// see https://github.com/dotnet/efcore/issues/30386#issuecomment-1452643142 for more context.
1587+
// Here, we pattern match the resulting Select(s => s.Int).ElementAt(0) over JsonQueryExpression to properly identify the
1588+
// indexing operation and translate it.
1589+
// Note that this happens for both owned and complex JSON-mapped types.
15811590
if (methodCallExpression.Method.IsGenericMethod
15821591
&& (methodCallExpression.Method.GetGenericMethodDefinition() == QueryableMethods.ElementAt
15831592
|| methodCallExpression.Method.GetGenericMethodDefinition() == QueryableMethods.ElementAtOrDefault))
@@ -1600,7 +1609,16 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
16001609
source = maybeAsQueryableMethodCall.Arguments[0];
16011610
}
16021611

1612+
// This special visitor normally only handles owned access, and not complex properties; all complex property access
1613+
// is handled in RelationalSqlTranslatingExpressionVisitor as usual.
1614+
// However, indexing over complex JSON-mapped collection is mangled in preprocessing just like it is for owned
1615+
// entities (see comment above). So we very specifically handle complex properties as part of the pattern matching
1616+
// hack here, to make sure indexing works properly.
1617+
// #36335 tracks removing the preprocessing mangling, at which point this should no longer be needed
1618+
var parentBindComplexProperties = _bindComplexProperties;
1619+
_bindComplexProperties = true;
16031620
source = Visit(source);
1621+
_bindComplexProperties = parentBindComplexProperties;
16041622

16051623
if (source is JsonQueryExpression jsonQueryExpression)
16061624
{
@@ -1666,15 +1684,15 @@ static Expression PrepareFailedTranslationResult(
16661684
var result = source;
16671685
if (asQueryable != null)
16681686
{
1669-
result = asQueryable.Update(null, new[] { result });
1687+
result = asQueryable.Update(null, [result]);
16701688
}
16711689

16721690
if (select != null)
16731691
{
1674-
result = select.Update(null, new[] { result, select.Arguments[1] });
1692+
result = select.Update(null, [result, select.Arguments[1]]);
16751693
}
16761694

1677-
return elementAt.Update(null, new[] { result, elementAt.Arguments[1] });
1695+
return elementAt.Update(null, [result, elementAt.Arguments[1]]);
16781696
}
16791697

16801698
static bool IsValidSelectorForJsonArrayElementAccess(Expression expression, JsonQueryExpression baselineJsonQuery)
@@ -1907,7 +1925,7 @@ static TableExpressionBase FindRootTableExpressionForColumn(SelectExpression sel
19071925
}
19081926
}
19091927

1910-
private Expression? TryBindPrimitiveCollection(Expression? source, string memberName)
1928+
private Expression? TryBindPropertyAccess(Expression? source, string memberName)
19111929
{
19121930
while (source is IncludeExpression includeExpression)
19131931
{
@@ -1937,12 +1955,27 @@ static TableExpressionBase FindRootTableExpressionForColumn(SelectExpression sel
19371955
}
19381956

19391957
var property = type.FindProperty(memberName);
1940-
if (property?.IsPrimitiveCollection != true)
1958+
if (property?.IsPrimitiveCollection is true)
19411959
{
1942-
return null;
1960+
return source.CreateEFPropertyExpression(property);
1961+
}
1962+
1963+
// See comments on indexing-related hacks in VisitMethodCall above
1964+
if (_bindComplexProperties
1965+
&& type.FindComplexProperty(memberName) is IComplexProperty { IsCollection: true } complexProperty)
1966+
{
1967+
Check.DebugAssert(complexProperty.ComplexType.IsMappedToJson());
1968+
1969+
if (queryableTranslator._sqlTranslator.TryBindMember(
1970+
queryableTranslator._sqlTranslator.Visit(source), MemberIdentity.Create(memberName),
1971+
out var translatedExpression, out _)
1972+
&& translatedExpression is CollectionResultExpression { QueryExpression: JsonQueryExpression jsonQuery })
1973+
{
1974+
return jsonQuery;
1975+
}
19431976
}
19441977

1945-
return source.CreateEFPropertyExpression(property);
1978+
return null;
19461979
}
19471980

19481981
private sealed class AnnotationApplyingExpressionVisitor(IReadOnlyList<IAnnotation> annotations) : ExpressionVisitor

test/EFCore.SqlServer.FunctionalTests/Query/Relationships/ComplexJson/ComplexJsonCollectionSqlServerTest.cs

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -120,45 +120,39 @@ public override async Task Distinct_over_projected_filtered_nested_collection()
120120

121121
public override async Task Index_constant()
122122
{
123-
// Complex collection indexing currently fails because SubqueryMemberPushdownExpressionVisitor moves the Int member access to before the
124-
// ElementAt (making a Select()), this interferes with our translation. See #36335.
125-
await Assert.ThrowsAsync<EqualException>(() => base.Index_constant());
123+
await base.Index_constant();
126124

127125
AssertSql(
128126
"""
129127
SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated]
130128
FROM [RootEntity] AS [r]
131-
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[0]') AS int) = 8
129+
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[0].Int') AS int) = 8
132130
""");
133131
}
134132

135133
public override async Task Index_parameter()
136134
{
137-
// Complex collection indexing currently fails because SubqueryMemberPushdownExpressionVisitor moves the Int member access to before the
138-
// ElementAt (making a Select()), this interferes with our translation. See #36335.
139-
await Assert.ThrowsAsync<EqualException>(() => base.Index_parameter());
135+
await base.Index_parameter();
140136

141137
AssertSql(
142138
"""
143139
@i='?' (DbType = Int32)
144140
145141
SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated]
146142
FROM [RootEntity] AS [r]
147-
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[' + CAST(@i AS nvarchar(max)) + ']') AS int) = 8
143+
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[' + CAST(@i AS nvarchar(max)) + '].Int') AS int) = 8
148144
""");
149145
}
150146

151147
public override async Task Index_column()
152148
{
153-
// Complex collection indexing currently fails because SubqueryMemberPushdownExpressionVisitor moves the Int member access to before the
154-
// ElementAt (making a Select()), this interferes with our translation. See #36335.
155-
await Assert.ThrowsAsync<EqualException>(() => base.Index_column());
149+
await base.Index_column();
156150

157151
AssertSql(
158152
"""
159153
SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated]
160154
FROM [RootEntity] AS [r]
161-
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[' + CAST([r].[Id] - 1 AS nvarchar(max)) + ']') AS int) = 8
155+
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[' + CAST([r].[Id] - 1 AS nvarchar(max)) + '].Int') AS int) = 8
162156
""");
163157
}
164158

@@ -170,7 +164,7 @@ public override async Task Index_out_of_bounds()
170164
"""
171165
SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated]
172166
FROM [RootEntity] AS [r]
173-
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[9999]') AS int) = 8
167+
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[9999].Int') AS int) = 8
174168
""");
175169
}
176170

test/EFCore.SqlServer.FunctionalTests/Query/Relationships/Navigations/NavigationsCollectionSqlServerTest.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ public override async Task Distinct_over_projected_filtered_nested_collection()
105105

106106
#endregion Distinct
107107

108+
#region Index
109+
108110
public override async Task Index_constant()
109111
{
110112
await base.Index_constant();
@@ -133,6 +135,8 @@ public override async Task Index_out_of_bounds()
133135
AssertSql();
134136
}
135137

138+
#endregion Index
139+
136140
public override async Task Select_within_Select_within_Select_with_aggregates()
137141
{
138142
await base.Select_within_Select_within_Select_with_aggregates();

test/EFCore.SqlServer.FunctionalTests/Query/Relationships/OwnedJson/OwnedJsonCollectionSqlServerTest.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,6 @@ [String] nvarchar(max) '$.String'
4040
""");
4141
}
4242

43-
public override async Task Index_constant()
44-
{
45-
await base.Index_constant();
46-
47-
AssertSql(
48-
"""
49-
SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated]
50-
FROM [RootEntity] AS [r]
51-
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[0].Int') AS int) = 8
52-
""");
53-
}
54-
5543
public override async Task OrderBy_ElementAt()
5644
{
5745
await base.OrderBy_ElementAt();
@@ -146,6 +134,18 @@ public override async Task Distinct_over_projected_filtered_nested_collection()
146134

147135
#region Index
148136

137+
public override async Task Index_constant()
138+
{
139+
await base.Index_constant();
140+
141+
AssertSql(
142+
"""
143+
SELECT [r].[Id], [r].[Name], [r].[OptionalRelated], [r].[RelatedCollection], [r].[RequiredRelated]
144+
FROM [RootEntity] AS [r]
145+
WHERE CAST(JSON_VALUE([r].[RelatedCollection], '$[0].Int') AS int) = 8
146+
""");
147+
}
148+
149149
public override async Task Index_parameter()
150150
{
151151
await base.Index_parameter();

test/EFCore.SqlServer.FunctionalTests/Query/Relationships/OwnedNavigations/OwnedNavigationsCollectionSqlServerTest.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ ORDER BY [r0].[Id]
101101
""");
102102
}
103103

104-
105104
#region Distinct
106105

107106
public override async Task Distinct()
@@ -181,6 +180,8 @@ public override async Task Distinct_over_projected_filtered_nested_collection()
181180

182181
#endregion Distinct
183182

183+
#region Index
184+
184185
public override async Task Index_constant()
185186
{
186187
await base.Index_constant();
@@ -315,6 +316,8 @@ ORDER BY (SELECT 1)
315316
""");
316317
}
317318

319+
#endregion Index
320+
318321
public override async Task Select_within_Select_within_Select_with_aggregates()
319322
{
320323
await base.Select_within_Select_within_Select_with_aggregates();
Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
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 Microsoft.Data.Sqlite;
5-
64
namespace Microsoft.EntityFrameworkCore.Query.Relationships.ComplexJson;
75

86
public class ComplexJsonCollectionSqliteTest(ComplexJsonSqliteFixture fixture, ITestOutputHelper testOutputHelper)
9-
: ComplexJsonCollectionRelationalTestBase<ComplexJsonSqliteFixture>(fixture, testOutputHelper)
10-
{
11-
// TODO: #36296
12-
public override Task Index_column()
13-
=> Assert.ThrowsAsync<SqliteException>(() => base.Index_column());
14-
}
7+
: ComplexJsonCollectionRelationalTestBase<ComplexJsonSqliteFixture>(fixture, testOutputHelper);

test/EFCore.Sqlite.FunctionalTests/Query/Relationships/ComplexJson/ComplexJsonMiscellaneousSqliteTest.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,4 @@
44
namespace Microsoft.EntityFrameworkCore.Query.Relationships.ComplexJson;
55

66
public class ComplexJsonMiscellaneousSqliteTest(ComplexJsonSqliteFixture fixture, ITestOutputHelper testOutputHelper)
7-
: ComplexJsonMiscellaneousRelationalTestBase<ComplexJsonSqliteFixture>(fixture, testOutputHelper)
8-
{
9-
}
7+
: ComplexJsonMiscellaneousRelationalTestBase<ComplexJsonSqliteFixture>(fixture, testOutputHelper);

test/EFCore.Sqlite.FunctionalTests/Query/Relationships/ComplexJson/ComplexJsonSetOperationsSqliteTest.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,4 @@
44
namespace Microsoft.EntityFrameworkCore.Query.Relationships.ComplexJson;
55

66
public class ComplexJsonSetOperationsSqliteTest(ComplexJsonSqliteFixture fixture, ITestOutputHelper testOutputHelper)
7-
: ComplexJsonSetOperationsRelationalTestBase<ComplexJsonSqliteFixture>(fixture, testOutputHelper)
8-
{
9-
10-
}
7+
: ComplexJsonSetOperationsRelationalTestBase<ComplexJsonSqliteFixture>(fixture, testOutputHelper);

test/EFCore.Sqlite.FunctionalTests/Query/Relationships/ComplexTableSplitting/ComplexTableSplittingMiscellaneousSqliteTest.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,4 @@ namespace Microsoft.EntityFrameworkCore.Query.Relationships.ComplexTableSplittin
66
public class ComplexTableSplittingMiscellaneousSqliteTest(
77
ComplexTableSplittingSqliteFixture fixture,
88
ITestOutputHelper testOutputHelper)
9-
: ComplexTableSplittingMiscellaneousRelationalTestBase<ComplexTableSplittingSqliteFixture>(fixture, testOutputHelper)
10-
{
11-
}
9+
: ComplexTableSplittingMiscellaneousRelationalTestBase<ComplexTableSplittingSqliteFixture>(fixture, testOutputHelper);

test/EFCore.Sqlite.FunctionalTests/Query/Relationships/Navigations/NavigationsCollectionSqliteTest.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,4 @@
44
namespace Microsoft.EntityFrameworkCore.Query.Relationships.Navigations;
55

66
public class NavigationsCollectionSqliteTest(NavigationsSqliteFixture fixture, ITestOutputHelper testOutputHelper)
7-
: NavigationsCollectionRelationalTestBase<NavigationsSqliteFixture>(fixture, testOutputHelper)
8-
{
9-
}
7+
: NavigationsCollectionRelationalTestBase<NavigationsSqliteFixture>(fixture, testOutputHelper);

0 commit comments

Comments
 (0)