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(); }