Skip to content

Commit 4185501

Browse files
authored
[release/9.0-staging] Fix to #35393 - GroupJoin in EF Core 9 Returns Null for Joined Entities
Fixes #35393 Description In EF9 we refactored some optimizations we performed on generated SQL - we are now applying them earlier (as part of SqlExpressionFactory. However, one of those optimizations is only correct for non-nullable arguments, and at the time we apply them we don't have full nullability information. Fix is to only apply this optimization when we know the argument is non-nullable. Customer impact Data corruption. Queries comparing nullable boolean property to a constant (true/false) produced incorrect results (data corruption). Workaround is to rewrite the query to add missing null checks, but this is not intuitive at all. How found Customer report on 9.0.0 Regression Yes, from 8.0. Testing Added tests for the scenarios affected as well as test that brute-forces all combinations of comparisons between nullable arguments in variety of scenarios. Risk Low, we are just removing the optimization that was incorrect. After removal, the queries produced are the same as in EF8. Quirk added, just in case.
1 parent cc16006 commit 4185501

File tree

13 files changed

+1860
-282
lines changed

13 files changed

+1860
-282
lines changed

src/EFCore.Relational/Query/SqlExpressionFactory.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ namespace Microsoft.EntityFrameworkCore.Query;
99
/// <inheritdoc />
1010
public class SqlExpressionFactory : ISqlExpressionFactory
1111
{
12+
private static readonly bool UseOldBehavior35393 =
13+
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue35393", out var enabled35393) && enabled35393;
14+
1215
private readonly IRelationalTypeMappingSource _typeMappingSource;
1316
private readonly RelationalTypeMapping _boolTypeMapping;
1417

@@ -660,20 +663,45 @@ private SqlExpression Not(SqlExpression operand, SqlExpression? existingExpressi
660663
SqlBinaryExpression { OperatorType: ExpressionType.OrElse } binary
661664
=> AndAlso(Not(binary.Left), Not(binary.Right)),
662665

663-
// use equality where possible
666+
// use equality where possible - we can only do this when we know a is not null
667+
// at this point we are limited to constants, parameters and columns
668+
// see issue #35393
664669
// !(a == true) -> a == false
665670
// !(a == false) -> a == true
666671
SqlBinaryExpression { OperatorType: ExpressionType.Equal, Right: SqlConstantExpression { Value: bool } } binary
672+
when UseOldBehavior35393
673+
=> Equal(binary.Left, Not(binary.Right)),
674+
675+
SqlBinaryExpression
676+
{
677+
OperatorType: ExpressionType.Equal,
678+
Right: SqlConstantExpression { Value: bool },
679+
Left: SqlConstantExpression { Value: bool }
680+
or SqlParameterExpression { IsNullable: false }
681+
or ColumnExpression { IsNullable: false }
682+
} binary
667683
=> Equal(binary.Left, Not(binary.Right)),
668684

669685
// !(true == a) -> false == a
670686
// !(false == a) -> true == a
671687
SqlBinaryExpression { OperatorType: ExpressionType.Equal, Left: SqlConstantExpression { Value: bool } } binary
688+
when UseOldBehavior35393
689+
=> Equal(Not(binary.Left), binary.Right),
690+
691+
SqlBinaryExpression
692+
{
693+
OperatorType: ExpressionType.Equal,
694+
Left: SqlConstantExpression { Value: bool },
695+
Right: SqlConstantExpression { Value: bool }
696+
or SqlParameterExpression { IsNullable: false }
697+
or ColumnExpression { IsNullable: false }
698+
} binary
672699
=> Equal(Not(binary.Left), binary.Right),
673700

674701
// !(a == b) -> a != b
675702
SqlBinaryExpression { OperatorType: ExpressionType.Equal } sqlBinaryOperand => NotEqual(
676703
sqlBinaryOperand.Left, sqlBinaryOperand.Right),
704+
677705
// !(a != b) -> a == b
678706
SqlBinaryExpression { OperatorType: ExpressionType.NotEqual } sqlBinaryOperand => Equal(
679707
sqlBinaryOperand.Left, sqlBinaryOperand.Right),

src/EFCore.Relational/Query/SqlNullabilityProcessor.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ namespace Microsoft.EntityFrameworkCore.Query;
1919
/// </summary>
2020
public class SqlNullabilityProcessor
2121
{
22+
private static readonly bool UseOldBehavior35393 =
23+
AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue35393", out var enabled35393) && enabled35393;
24+
2225
private readonly List<ColumnExpression> _nonNullableColumns;
2326
private readonly List<ColumnExpression> _nullValueColumns;
2427
private readonly ISqlExpressionFactory _sqlExpressionFactory;
@@ -1343,7 +1346,7 @@ protected virtual SqlExpression VisitSqlBinary(
13431346
// we assume that NullSemantics rewrite is only needed (on the current level)
13441347
// if the optimization didn't make any changes.
13451348
// Reason is that optimization can/will change the nullability of the resulting expression
1346-
// and that inforation is not tracked/stored anywhere
1349+
// and that information is not tracked/stored anywhere
13471350
// so we can no longer rely on nullabilities that we computed earlier (leftNullable, rightNullable)
13481351
// when performing null semantics rewrite.
13491352
// It should be fine because current optimizations *radically* change the expression

test/EFCore.Relational.Specification.Tests/Query/NullSemanticsQueryTestBase.cs

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -92,22 +92,26 @@ public virtual async Task Rewrite_compare_int_with_int(bool async)
9292
public virtual async Task Rewrite_compare_bool_with_bool(bool async)
9393
{
9494
var bools = new[] { false, true };
95+
var onetwothree = new[] { 1, 2, 3 };
9596

9697
foreach (var neq in bools)
9798
{
9899
foreach (var negated in bools)
99100
{
100101
foreach (var negateB in bools)
101102
{
102-
foreach (var nullableA in bools)
103+
foreach (var nullableA in onetwothree)
103104
{
104105
foreach (var negateA in bools)
105106
{
106-
foreach (var nullableB in bools)
107+
foreach (var nullableB in onetwothree)
107108
{
109+
// filter out tests comparing two constants
110+
if (nullableA == 3 && nullableB == 3) continue;
111+
108112
var queryBuilder = (ISetSource ss) =>
109113
{
110-
var data = nullableA
114+
var data = nullableA == 2
111115
? ss.Set<NullSemanticsEntity1>().Select(
112116
e => new
113117
{
@@ -116,30 +120,47 @@ public virtual async Task Rewrite_compare_bool_with_bool(bool async)
116120
e.BoolB,
117121
e.NullableBoolB
118122
})
119-
: ss.Set<NullSemanticsEntity1>().Select(
120-
e => new
121-
{
122-
e.Id,
123-
A = (bool?)e.BoolA,
124-
e.BoolB,
125-
e.NullableBoolB
126-
});
127-
128-
var query = nullableB
123+
: nullableA == 1
124+
? ss.Set<NullSemanticsEntity1>().Select(
125+
e => new
126+
{
127+
e.Id,
128+
A = (bool?)e.BoolA,
129+
e.BoolB,
130+
e.NullableBoolB
131+
})
132+
: ss.Set<NullSemanticsEntity1>().Select(
133+
e => new
134+
{
135+
e.Id,
136+
A = (bool?)true,
137+
e.BoolB,
138+
e.NullableBoolB
139+
});
140+
141+
var query = nullableB == 2
129142
? data.Select(
130143
e => new
131144
{
132145
e.Id,
133146
e.A,
134147
B = e.NullableBoolB
135148
})
136-
: data.Select(
137-
e => new
138-
{
139-
e.Id,
140-
e.A,
141-
B = (bool?)e.BoolB
142-
});
149+
: nullableB == 1
150+
? data.Select(
151+
e => new
152+
{
153+
e.Id,
154+
e.A,
155+
B = (bool?)e.BoolB
156+
})
157+
: data.Select(
158+
e => new
159+
{
160+
e.Id,
161+
e.A,
162+
B = (bool?)true
163+
});
143164

144165
query = negateA
145166
? query.Select(
@@ -2462,6 +2483,18 @@ await AssertQueryScalar(
24622483
ss => ss.Set<NullSemanticsEntity1>().Where(e => true).Select(e => e.Id));
24632484
}
24642485

2486+
[ConditionalTheory]
2487+
[MemberData(nameof(IsAsyncData))]
2488+
public virtual async Task Compare_constant_true_to_expression_which_evaluates_to_null(bool async)
2489+
{
2490+
var prm = default(bool?);
2491+
2492+
await AssertQueryScalar(
2493+
async,
2494+
ss => ss.Set<NullSemanticsEntity1>().Where(x => x.NullableBoolA != null
2495+
&& !object.Equals(true, x.NullableBoolA == null ? null : prm)).Select(x => x.Id));
2496+
}
2497+
24652498
// We can't client-evaluate Like (for the expected results).
24662499
// However, since the test data has no LIKE wildcards, it effectively functions like equality - except that 'null like null' returns
24672500
// false instead of true. So we have this "lite" implementation which doesn't support wildcards.

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,24 @@ join o in ss.Set<Order>().OrderBy(o => o.OrderID).Take(100) on c.CustomerID equa
654654
from o in lo.Where(x => x.CustomerID.StartsWith("A"))
655655
select new { c.CustomerID, o.OrderID });
656656

657+
[ConditionalTheory]
658+
[MemberData(nameof(IsAsyncData))]
659+
public virtual Task GroupJoin_on_true_equal_true(bool async)
660+
=> AssertQuery(
661+
async,
662+
ss => ss.Set<Customer>().GroupJoin(
663+
ss.Set<Order>(),
664+
x => true,
665+
x => true,
666+
(c, g) => new { c, g })
667+
.Select(x => new { x.c.CustomerID, Orders = x.g }),
668+
elementSorter: e => e.CustomerID,
669+
elementAsserter: (e, a) =>
670+
{
671+
Assert.Equal(e.CustomerID, a.CustomerID);
672+
AssertCollection(e.Orders, a.Orders, elementSorter: ee => ee.OrderID);
673+
});
674+
657675
[ConditionalTheory]
658676
[MemberData(nameof(IsAsyncData))]
659677
public virtual Task Inner_join_with_tautology_predicate_converts_to_cross_join(bool async)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5021,7 +5021,7 @@ INNER JOIN (
50215021
FROM [Factions] AS [f]
50225022
WHERE [f].[Name] = N'Swarm'
50235023
) AS [f0] ON [l].[Name] = [f0].[CommanderName]
5024-
WHERE [f0].[Eradicated] = CAST(0 AS bit) OR [f0].[Eradicated] IS NULL
5024+
WHERE [f0].[Eradicated] <> CAST(1 AS bit) OR [f0].[Eradicated] IS NULL
50255025
""");
50265026
}
50275027

@@ -5038,7 +5038,7 @@ LEFT JOIN (
50385038
FROM [Factions] AS [f]
50395039
WHERE [f].[Name] = N'Swarm'
50405040
) AS [f0] ON [l].[Name] = [f0].[CommanderName]
5041-
WHERE [f0].[Eradicated] = CAST(0 AS bit) OR [f0].[Eradicated] IS NULL
5041+
WHERE [f0].[Eradicated] <> CAST(1 AS bit) OR [f0].[Eradicated] IS NULL
50425042
""");
50435043
}
50445044

@@ -7592,7 +7592,7 @@ FROM [LocustLeaders] AS [l]
75927592
INNER JOIN [Factions] AS [f] ON [l].[Name] = [f].[CommanderName]
75937593
WHERE CASE
75947594
WHEN [f].[Name] = N'Locust' THEN CAST(1 AS bit)
7595-
END = CAST(0 AS bit) OR CASE
7595+
END <> CAST(1 AS bit) OR CASE
75967596
WHEN [f].[Name] = N'Locust' THEN CAST(1 AS bit)
75977597
END IS NULL
75987598
""");

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,22 @@ ORDER BY [o].[OrderID]
992992
""");
993993
}
994994

995+
public override async Task GroupJoin_on_true_equal_true(bool async)
996+
{
997+
await base.GroupJoin_on_true_equal_true(async);
998+
999+
AssertSql(
1000+
"""
1001+
SELECT [c].[CustomerID], [o0].[OrderID], [o0].[CustomerID], [o0].[EmployeeID], [o0].[OrderDate]
1002+
FROM [Customers] AS [c]
1003+
OUTER APPLY (
1004+
SELECT [o].[OrderID], [o].[CustomerID], [o].[EmployeeID], [o].[OrderDate]
1005+
FROM [Orders] AS [o]
1006+
) AS [o0]
1007+
ORDER BY [c].[CustomerID]
1008+
""");
1009+
}
1010+
9951011
private void AssertSql(params string[] expected)
9961012
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
9971013

0 commit comments

Comments
 (0)