diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs
index 08a1687a84e..b591b6bbef7 100644
--- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs
+++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs
@@ -951,6 +951,20 @@ private SqlExpression CreateJoinPredicate(
: _sqlExpressionFactory.AndAlso(result, joinPredicate);
}
+ // In LINQ equijoins, null is not equal null, just like in SQL
+ // (https://learn.microsoft.com/dotnet/csharp/language-reference/keywords/join-clause#the-equals-operator)
+ // As a result, in SqlNullabilityProcessor.ProcessJoinPredicate(), we have special handling for an equality
+ // immediately inside a join predicate - we bypass null compensation for that, to make sure the SQL behavior
+ // matches the LINQ behavior.
+ // However, when two anonymous types are being compared, the LINQ behavior *does* treat nulls as equal; as a result, in
+ // SqlNullabilityProcessor.ProcessJoinPredicate() we differentiate between a single top-level comparison
+ // and multiple comparisons with ANDs.
+ // Unfortunately, when we have a an anonymous type with a single property (on new { Foo = x } equals new { Foo = y }),
+ // we produce the same predicate as the single comparison case (without an anonymous type), bypassing the null
+ // compensation and generating incorrect results.
+ // To work around this, we add an always-true predicate here, and the AND will cause
+ // SqlNullabilityProcessor.ProcessJoinPredicate() to go into the multiple-property anonymous type logic,
+ // and not bypass null compensation.
if (outerNew.Arguments.Count == 1)
{
result = _sqlExpressionFactory.AndAlso(
@@ -958,11 +972,11 @@ private SqlExpression CreateJoinPredicate(
CreateJoinPredicate(Expression.Constant(true), Expression.Constant(true)));
}
- return result!;
- }
+ return result ?? _sqlExpressionFactory.Constant(true);
- private SqlExpression CreateJoinPredicate(Expression outerKey, Expression innerKey)
- => TranslateExpression(Infrastructure.ExpressionExtensions.CreateEqualsExpression(outerKey, innerKey))!;
+ SqlExpression CreateJoinPredicate(Expression outerKey, Expression innerKey)
+ => TranslateExpression(Infrastructure.ExpressionExtensions.CreateEqualsExpression(outerKey, innerKey))!;
+ }
///
protected override ShapedQueryExpression? TranslateLastOrDefault(
diff --git a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs
index 909aee3a42f..93b12e4c483 100644
--- a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs
+++ b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs
@@ -222,6 +222,13 @@ protected override Expression VisitExtension(Expression node)
SqlExpression ProcessJoinPredicate(SqlExpression predicate)
{
+ // In LINQ equijoins, null is not equal null, just like in SQL
+ // (https://learn.microsoft.com/dotnet/csharp/language-reference/keywords/join-clause#the-equals-operator).
+ // As a result, we handle top-level join predicate equality in a special way, not using the generic Visit logic
+ // (which would add null compensation).
+ // However, when two anonymous types are being compared, the LINQ behavior *does* treat nulls as equal.
+ // As a result, we differentiate between a single top-level comparison and multiple comparisons with ANDs.
+ // See additional notes in RelationalQueryableMethodTranslatingExpressionVisitor.CreateJoinPredicate().
switch (predicate)
{
case SqlBinaryExpression { OperatorType: ExpressionType.Equal } binary:
@@ -250,6 +257,9 @@ or ExpressionType.LessThanOrEqual
} binary:
return Visit(binary, allowOptimizedExpansion: true, out _);
+ case SqlConstantExpression { Value: bool }:
+ return predicate;
+
default:
throw new InvalidOperationException(
RelationalStrings.UnhandledExpressionInVisitor(predicate, predicate.GetType(), nameof(SqlNullabilityProcessor)));
diff --git a/test/EFCore.Specification.Tests/Query/ComplexNavigationsQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/ComplexNavigationsQueryTestBase.cs
index d7ad4b9ca7c..94f4664b8ff 100644
--- a/test/EFCore.Specification.Tests/Query/ComplexNavigationsQueryTestBase.cs
+++ b/test/EFCore.Specification.Tests/Query/ComplexNavigationsQueryTestBase.cs
@@ -2006,8 +2006,8 @@ public virtual Task Where_on_multilevel_reference_in_subquery_with_outer_project
.Take(10)
.Select(l3 => l3.Name));
- [ConditionalTheory, MemberData(nameof(IsAsyncData))]
- public virtual Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(bool async)
+ [ConditionalTheory, MemberData(nameof(IsAsyncData))] // #18808
+ public virtual Task Join_on_anonymous_type_with_single_property(bool async)
=> AssertQuery(
async,
ss => from l1 in ss.Set()
@@ -2017,7 +2017,7 @@ join l2 in ss.Set()
select l1);
[ConditionalTheory, MemberData(nameof(IsAsyncData))]
- public virtual Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(bool async)
+ public virtual Task Join_on_anonymous_type_with_multiple_properties(bool async)
=> AssertQuery(
async,
ss =>
@@ -2030,7 +2030,8 @@ join l2 in ss.Set()
}
equals new
{
- A = EF.Property(l2, "Level1_Optional_Id"), B = EF.Property(l2, "OneToMany_Optional_Self_Inverse2Id")
+ A = EF.Property(l2, "Level1_Optional_Id"),
+ B = EF.Property(l2, "OneToMany_Optional_Self_Inverse2Id")
}
select l1);
diff --git a/test/EFCore.Specification.Tests/Query/ComplexNavigationsSharedTypeQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/ComplexNavigationsSharedTypeQueryTestBase.cs
index 569d5b79e5f..fd9d6e2aab2 100644
--- a/test/EFCore.Specification.Tests/Query/ComplexNavigationsSharedTypeQueryTestBase.cs
+++ b/test/EFCore.Specification.Tests/Query/ComplexNavigationsSharedTypeQueryTestBase.cs
@@ -10,13 +10,13 @@ public abstract class ComplexNavigationsSharedTypeQueryTestBase(TFixtu
public override Task Join_navigation_self_ref(bool async)
=> AssertTranslationFailed(() => base.Join_navigation_self_ref(async));
- public override Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(bool async)
+ public override Task Join_on_anonymous_type_with_multiple_properties(bool async)
=> AssertUnableToTranslateEFProperty(()
- => base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(async));
+ => base.Join_on_anonymous_type_with_multiple_properties(async));
- public override Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(bool async)
+ public override Task Join_on_anonymous_type_with_single_property(bool async)
=> AssertUnableToTranslateEFProperty(()
- => base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(async));
+ => base.Join_on_anonymous_type_with_single_property(async));
public override Task Multiple_SelectMany_with_nested_navigations_and_explicit_DefaultIfEmpty_joined_together(bool async)
=> AssertTranslationFailed(()
diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsQuerySqlServer160Test.cs b/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsQuerySqlServer160Test.cs
index 740aa4b91b8..4def73b16a3 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsQuerySqlServer160Test.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsQuerySqlServer160Test.cs
@@ -2645,9 +2645,9 @@ OFFSET @p ROWS FETCH NEXT @p1 ROWS ONLY
""");
}
- public override async Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(bool async)
+ public override async Task Join_on_anonymous_type_with_single_property(bool async)
{
- await base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(async);
+ await base.Join_on_anonymous_type_with_single_property(async);
AssertSql(
"""
@@ -2657,9 +2657,9 @@ FROM [LevelOne] AS [l]
""");
}
- public override async Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(bool async)
+ public override async Task Join_on_anonymous_type_with_multiple_properties(bool async)
{
- await base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(async);
+ await base.Join_on_anonymous_type_with_multiple_properties(async);
AssertSql(
"""
diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsQuerySqlServerTest.cs
index 155df48818a..7429e1f9e2f 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsQuerySqlServerTest.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsQuerySqlServerTest.cs
@@ -2645,9 +2645,9 @@ OFFSET @p ROWS FETCH NEXT @p1 ROWS ONLY
""");
}
- public override async Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(bool async)
+ public override async Task Join_on_anonymous_type_with_single_property(bool async)
{
- await base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(async);
+ await base.Join_on_anonymous_type_with_single_property(async);
AssertSql(
"""
@@ -2657,9 +2657,9 @@ FROM [LevelOne] AS [l]
""");
}
- public override async Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(bool async)
+ public override async Task Join_on_anonymous_type_with_multiple_properties(bool async)
{
- await base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(async);
+ await base.Join_on_anonymous_type_with_multiple_properties(async);
AssertSql(
"""
diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsSharedTypeQuerySqlServer160Test.cs b/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsSharedTypeQuerySqlServer160Test.cs
index a630facf9a6..15a49e03b72 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsSharedTypeQuerySqlServer160Test.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsSharedTypeQuerySqlServer160Test.cs
@@ -1406,16 +1406,16 @@ public override async Task Join_navigation_self_ref(bool async)
AssertSql();
}
- public override async Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(bool async)
+ public override async Task Join_on_anonymous_type_with_multiple_properties(bool async)
{
- await base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(async);
+ await base.Join_on_anonymous_type_with_multiple_properties(async);
AssertSql();
}
- public override async Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(bool async)
+ public override async Task Join_on_anonymous_type_with_single_property(bool async)
{
- await base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(async);
+ await base.Join_on_anonymous_type_with_single_property(async);
AssertSql();
}
diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsSharedTypeQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsSharedTypeQuerySqlServerTest.cs
index 94f3bf220dd..c5d550d4cd3 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsSharedTypeQuerySqlServerTest.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Query/ComplexNavigationsSharedTypeQuerySqlServerTest.cs
@@ -1408,16 +1408,16 @@ public override async Task Join_navigation_self_ref(bool async)
AssertSql();
}
- public override async Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(bool async)
+ public override async Task Join_on_anonymous_type_with_multiple_properties(bool async)
{
- await base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_multiple_properties(async);
+ await base.Join_on_anonymous_type_with_multiple_properties(async);
AssertSql();
}
- public override async Task Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(bool async)
+ public override async Task Join_on_anonymous_type_with_single_property(bool async)
{
- await base.Join_condition_optimizations_applied_correctly_when_anonymous_type_with_single_property(async);
+ await base.Join_on_anonymous_type_with_single_property(async);
AssertSql();
}