Skip to content

Commit 419975b

Browse files
committed
Fix DefaultIfEmpty and nullability within SelectMany selector
Fixes #35950
1 parent 35cd115 commit 419975b

File tree

5 files changed

+118
-2
lines changed

5 files changed

+118
-2
lines changed

src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +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 System;
45
using System.Diagnostics.CodeAnalysis;
56
using Microsoft.EntityFrameworkCore.Internal;
67
using Microsoft.EntityFrameworkCore.Query.Internal;
@@ -231,6 +232,16 @@ [new FromSqlExpression(alias, sqlQueryRootExpression.Sql, sqlQueryRootExpression
231232
protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression)
232233
{
233234
var method = methodCallExpression.Method;
235+
236+
if (method.DeclaringType == typeof(RelationalQueryableMethodTranslatingExpressionVisitor)
237+
&& method.IsGenericMethod
238+
&& method.GetGenericMethodDefinition() == _fakeDefaultIfEmptyMethodInfo.Value
239+
&& Visit(methodCallExpression.Arguments[0]) is ShapedQueryExpression source)
240+
{
241+
((SelectExpression)source.QueryExpression).MakeProjectionNullable(_sqlExpressionFactory);
242+
return source.UpdateShaperExpression(MarkShaperNullable(source.ShaperExpression));
243+
}
244+
234245
var translated = base.VisitMethodCall(methodCallExpression);
235246

236247
// For Contains over a collection parameter, if the provider hasn't implemented TranslateCollection (e.g. OPENJSON on SQL
@@ -1196,7 +1207,10 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
11961207
&& methodCallExpression.Method.GetGenericMethodDefinition() == QueryableMethods.DefaultIfEmptyWithoutArgument)
11971208
{
11981209
_defaultIfEmpty = true;
1199-
return Visit(methodCallExpression.Arguments[0]);
1210+
1211+
return Expression.Call(
1212+
_fakeDefaultIfEmptyMethodInfo.Value.MakeGenericMethod(methodCallExpression.Method.GetGenericArguments()[0]),
1213+
Visit(methodCallExpression.Arguments[0]));
12001214
}
12011215

12021216
if (!SupportsLiftingDefaultIfEmpty(methodCallExpression.Method))
@@ -2273,6 +2287,13 @@ private ShapedQueryExpression CreateShapedQueryExpressionForValuesExpression(
22732287
return new ShapedQueryExpression(selectExpression, shaperExpression);
22742288
}
22752289

2290+
private static IQueryable<TSource?> FakeDefaultIfEmpty<TSource>(IQueryable<TSource> source)
2291+
=> throw new UnreachableException();
2292+
2293+
private static readonly Lazy<MethodInfo> _fakeDefaultIfEmptyMethodInfo = new(
2294+
() => typeof(RelationalQueryableMethodTranslatingExpressionVisitor)
2295+
.GetMethod(nameof(FakeDefaultIfEmpty), BindingFlags.NonPublic | BindingFlags.Static)!);
2296+
22762297
/// <summary>
22772298
/// This visitor has been obsoleted; Extend RelationalTypeMappingPostprocessor instead, and invoke it from
22782299
/// <see cref="RelationalQueryTranslationPostprocessor.ProcessTypeMappings" />.

src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2399,7 +2399,20 @@ [new ProjectionExpression(nullSqlExpression, "empty")],
23992399
_tables.Add(dummySelectExpression);
24002400
_tables.Add(joinTable);
24012401

2402+
MakeProjectionNullable(sqlExpressionFactory);
2403+
}
2404+
2405+
/// <summary>
2406+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
2407+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
2408+
/// any release. You should only use it directly in your code with extreme caution and knowing that
2409+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
2410+
/// </summary>
2411+
[EntityFrameworkInternal]
2412+
public void MakeProjectionNullable(ISqlExpressionFactory sqlExpressionFactory)
2413+
{
24022414
// Go over all projected columns and make them nullable; for non-nullable value types, add a SQL COALESCE as well.
2415+
24032416
var projectionMapping = new Dictionary<ProjectionMember, Expression>();
24042417
foreach (var (projectionMember, projection) in _projectionMapping)
24052418
{

test/EFCore.Cosmos.FunctionalTests/Query/NorthwindMiscellaneousQueryCosmosTest.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3398,7 +3398,7 @@ public override async Task SelectMany_primitive_select_subquery(bool async)
33983398
// Cosmos client evaluation. Issue #17246.
33993399
Assert.Equal(
34003400
CoreStrings.ExpressionParameterizationExceptionSensitive(
3401-
"value(Microsoft.EntityFrameworkCore.Query.NorthwindMiscellaneousQueryTestBase`1+<>c__DisplayClass175_0[Microsoft.EntityFrameworkCore.Query.NorthwindQueryCosmosFixture`1[Microsoft.EntityFrameworkCore.TestUtilities.NoopModelCustomizer]]).ss.Set().Any()"),
3401+
"value(Microsoft.EntityFrameworkCore.Query.NorthwindMiscellaneousQueryTestBase`1+<>c__DisplayClass177_0[Microsoft.EntityFrameworkCore.Query.NorthwindQueryCosmosFixture`1[Microsoft.EntityFrameworkCore.TestUtilities.NoopModelCustomizer]]).ss.Set().Any()"),
34023402
(await Assert.ThrowsAsync<InvalidOperationException>(
34033403
() => base.SelectMany_primitive_select_subquery(async))).Message);
34043404

@@ -3550,6 +3550,22 @@ public override async Task SelectMany_correlated_subquery_simple(bool async)
35503550
AssertSql();
35513551
}
35523552

3553+
public override async Task SelectMany_correlated_with_DefaultIfEmpty_and_Select_value_type_in_selector_throws(bool async)
3554+
{
3555+
// The test "passes" since the base implementation expects InvalidOperation (but for a different reason).
3556+
await base.SelectMany_correlated_with_DefaultIfEmpty_and_Select_value_type_in_selector_throws(async);
3557+
3558+
AssertSql();
3559+
}
3560+
3561+
public override async Task SelectMany_correlated_with_Select_value_type_and_DefaultIfEmpty_in_selector(bool async)
3562+
{
3563+
// Cosmos client evaluation. Issue #17246.
3564+
await AssertTranslationFailed(() => base.SelectMany_correlated_with_Select_value_type_and_DefaultIfEmpty_in_selector(async));
3565+
3566+
AssertSql();
3567+
}
3568+
35533569
public override async Task SelectMany_correlated_subquery_hard(bool async)
35543570
{
35553571
// Cosmos client evaluation. Issue #17246.

test/EFCore.Specification.Tests/Query/NorthwindMiscellaneousQueryTestBase.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,6 +2036,40 @@ from e in ss.Set<Employee>().Where(e => e.City == c.City)
20362036
select new { c, e },
20372037
assertOrder: true);
20382038

2039+
// DefaultIfEmpty() over empty set followed by Select() to a non-nullable type - should throw "Nullable object must have a value".
2040+
// (same happens for DefaultIfEmpty().Select() at the toplevel without SelectMany)
2041+
[ConditionalTheory] // #35950
2042+
[MemberData(nameof(IsAsyncData))]
2043+
public virtual Task SelectMany_correlated_with_DefaultIfEmpty_and_Select_value_type_in_selector_throws(bool async)
2044+
=> Assert.ThrowsAsync<InvalidOperationException>(
2045+
() => AssertQuery(
2046+
async,
2047+
ss =>
2048+
from c in ss.Set<Customer>()
2049+
from o in c.Orders
2050+
.Where(x => x.CustomerID == "NONEXISTENT") // Produce empty set for DefaultIfEmpty
2051+
.DefaultIfEmpty()
2052+
.Select(x => x.OrderID)
2053+
select o));
2054+
2055+
// DefaultIfEmpty() after Select() to a non-nullable type - should add a COALESCE to the CLR default (0 here).
2056+
// Note that within the SelectMany selector, DIE is lifted out (and the INNER JOIN/CROSS APPLY is converted to
2057+
// LEFT JOIN/OUTER APPLY). But the COALESCE must still be applied.
2058+
[ConditionalTheory] // #35950
2059+
[MemberData(nameof(IsAsyncData))]
2060+
public virtual Task SelectMany_correlated_with_Select_value_type_and_DefaultIfEmpty_in_selector(bool async)
2061+
=> AssertQuery(
2062+
async,
2063+
ss =>
2064+
from c in ss.Set<Customer>()
2065+
from o in c.Orders
2066+
.Where(x => x.CustomerID == "NONEXISTENT") // Produce empty set for DefaultIfEmpty
2067+
.Take(2)
2068+
.OrderBy(x => true)
2069+
.Select(x => x.OrderID)
2070+
.DefaultIfEmpty()
2071+
select o);
2072+
20392073
[ConditionalTheory]
20402074
[MemberData(nameof(IsAsyncData))]
20412075
public virtual Task SelectMany_correlated_subquery_hard(bool async)

test/EFCore.SqlServer.FunctionalTests/Query/NorthwindMiscellaneousQuerySqlServerTest.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6338,6 +6338,38 @@ FROM [Customers] AS [c]
63386338
""");
63396339
}
63406340

6341+
public override async Task SelectMany_correlated_with_DefaultIfEmpty_and_Select_value_type_in_selector_throws(bool async)
6342+
{
6343+
await base.SelectMany_correlated_with_DefaultIfEmpty_and_Select_value_type_in_selector_throws(async);
6344+
6345+
AssertSql(
6346+
"""
6347+
SELECT [o0].[OrderID]
6348+
FROM [Customers] AS [c]
6349+
LEFT JOIN (
6350+
SELECT [o].[OrderID], [o].[CustomerID]
6351+
FROM [Orders] AS [o]
6352+
WHERE [o].[CustomerID] = N'NONEXISTENT'
6353+
) AS [o0] ON [c].[CustomerID] = [o0].[CustomerID]
6354+
""");
6355+
}
6356+
6357+
public override async Task SelectMany_correlated_with_Select_value_type_and_DefaultIfEmpty_in_selector(bool async)
6358+
{
6359+
await base.SelectMany_correlated_with_Select_value_type_and_DefaultIfEmpty_in_selector(async);
6360+
6361+
AssertSql(
6362+
"""
6363+
SELECT COALESCE([o0].[OrderID], 0)
6364+
FROM [Customers] AS [c]
6365+
OUTER APPLY (
6366+
SELECT TOP(2) [o].[OrderID]
6367+
FROM [Orders] AS [o]
6368+
WHERE [c].[CustomerID] = [o].[CustomerID] AND [o].[CustomerID] = N'NONEXISTENT'
6369+
) AS [o0]
6370+
""");
6371+
}
6372+
63416373
public override async Task Select_Property_when_shadow_unconstrained_generic_method(bool async)
63426374
{
63436375
await base.Select_Property_when_shadow_unconstrained_generic_method(async);

0 commit comments

Comments
 (0)