diff --git a/global.json b/global.json index 60f97b22080..702df4e94db 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "11.0.100-preview.5.26227.104", + "version": "11.0.100-preview.5.26279.113", "allowPrerelease": true, "rollForward": "latestMajor", "paths": [ @@ -13,7 +13,7 @@ "runner": "Microsoft.Testing.Platform" }, "tools": { - "dotnet": "11.0.100-preview.5.26227.104", + "dotnet": "11.0.100-preview.5.26279.113", "runtimes": { "dotnet": [ "$(MicrosoftNETCorePlatformsVersion)" diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Cosmos/Query/Internal/CosmosQueryableMethodTranslatingExpressionVisitor.cs index 9f1ce8d1c79..83981854c74 100644 --- a/src/EFCore.Cosmos/Query/Internal/CosmosQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Cosmos/Query/Internal/CosmosQueryableMethodTranslatingExpressionVisitor.cs @@ -868,6 +868,23 @@ protected override ShapedQueryExpression TranslateCast(ShapedQueryExpression sou return null; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override ShapedQueryExpression? TranslateFullJoin( + ShapedQueryExpression outer, + ShapedQueryExpression inner, + LambdaExpression outerKeySelector, + LambdaExpression innerKeySelector, + LambdaExpression resultSelector) + { + AddTranslationErrorDetails(CosmosStrings.CrossDocumentJoinNotSupported); + return null; + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs index 8a5af0166fb..9fad146681b 100644 --- a/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.InMemory/Query/Internal/InMemoryQueryableMethodTranslatingExpressionVisitor.cs @@ -847,6 +847,20 @@ static bool IsConvertedToNullable(Expression outer, Expression inner) LambdaExpression resultSelector) => null; + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override ShapedQueryExpression? TranslateFullJoin( + ShapedQueryExpression outer, + ShapedQueryExpression inner, + LambdaExpression outerKeySelector, + LambdaExpression innerKeySelector, + LambdaExpression resultSelector) + => null; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.Relational/EFCore.Relational.baseline.json b/src/EFCore.Relational/EFCore.Relational.baseline.json index dee6b2f7287..1d7dd76b751 100644 --- a/src/EFCore.Relational/EFCore.Relational.baseline.json +++ b/src/EFCore.Relational/EFCore.Relational.baseline.json @@ -3039,6 +3039,35 @@ } ] }, + { + "Type": "class Microsoft.EntityFrameworkCore.Query.SqlExpressions.FullJoinExpression : Microsoft.EntityFrameworkCore.Query.SqlExpressions.PredicateJoinExpressionBase", + "Methods": [ + { + "Member": "FullJoinExpression(Microsoft.EntityFrameworkCore.Query.SqlExpressions.TableExpressionBase table, Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression joinPredicate, bool prunable = false);" + }, + { + "Member": "override bool Equals(object? obj);" + }, + { + "Member": "override int GetHashCode();" + }, + { + "Member": "override void Print(Microsoft.EntityFrameworkCore.Query.ExpressionPrinter expressionPrinter);" + }, + { + "Member": "override System.Linq.Expressions.Expression Quote();" + }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Query.SqlExpressions.FullJoinExpression Update(Microsoft.EntityFrameworkCore.Query.SqlExpressions.TableExpressionBase table, Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression joinPredicate);" + }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Query.SqlExpressions.FullJoinExpression Update(Microsoft.EntityFrameworkCore.Query.SqlExpressions.TableExpressionBase table);" + }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Query.SqlExpressions.FullJoinExpression WithAnnotations(System.Collections.Generic.IReadOnlyDictionary annotations);" + } + ] + }, { "Type": "class Microsoft.EntityFrameworkCore.Storage.GuidTypeMapping : Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping", "Methods": [ @@ -9456,6 +9485,9 @@ { "Member": "virtual System.Linq.Expressions.Expression VisitFromSql(Microsoft.EntityFrameworkCore.Query.SqlExpressions.FromSqlExpression fromSqlExpression);" }, + { + "Member": "virtual System.Linq.Expressions.Expression VisitFullJoin(Microsoft.EntityFrameworkCore.Query.SqlExpressions.FullJoinExpression fullJoinExpression);" + }, { "Member": "virtual System.Linq.Expressions.Expression VisitInnerJoin(Microsoft.EntityFrameworkCore.Query.SqlExpressions.InnerJoinExpression innerJoinExpression);" }, @@ -15291,6 +15323,9 @@ { "Member": "override Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression? TranslateFirstOrDefault(Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression source, System.Linq.Expressions.LambdaExpression? predicate, System.Type returnType, bool returnDefault);" }, + { + "Member": "override Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression? TranslateFullJoin(Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression outer, Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression inner, System.Linq.Expressions.LambdaExpression outerKeySelector, System.Linq.Expressions.LambdaExpression innerKeySelector, System.Linq.Expressions.LambdaExpression resultSelector);" + }, { "Member": "override Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression? TranslateGroupBy(Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression source, System.Linq.Expressions.LambdaExpression keySelector, System.Linq.Expressions.LambdaExpression? elementSelector, System.Linq.Expressions.LambdaExpression? resultSelector);" }, @@ -18326,6 +18361,12 @@ { "Member": "System.Linq.Expressions.Expression AddCrossJoin(Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression innerSource, System.Linq.Expressions.Expression outerShaper);" }, + { + "Member": "void AddFullJoin(Microsoft.EntityFrameworkCore.Query.SqlExpressions.SelectExpression innerSelectExpression, Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression joinPredicate);" + }, + { + "Member": "System.Linq.Expressions.Expression AddFullJoin(Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression innerSource, Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression joinPredicate, System.Linq.Expressions.Expression outerShaper);" + }, { "Member": "void AddInnerJoin(Microsoft.EntityFrameworkCore.Query.SqlExpressions.SelectExpression innerSelectExpression, Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression joinPredicate);" }, diff --git a/src/EFCore.Relational/Query/QuerySqlGenerator.cs b/src/EFCore.Relational/Query/QuerySqlGenerator.cs index 45c4d5197f6..c24b718d3cb 100644 --- a/src/EFCore.Relational/Query/QuerySqlGenerator.cs +++ b/src/EFCore.Relational/Query/QuerySqlGenerator.cs @@ -137,6 +137,7 @@ protected override Expression VisitExtension(Expression expression) ProjectionExpression e => VisitProjection(e), TableValuedFunctionExpression e => VisitTableValuedFunction(e), RightJoinExpression e => VisitRightJoin(e), + FullJoinExpression e => VisitFullJoin(e), RowNumberExpression e => VisitRowNumber(e), RowValueExpression e => VisitRowValue(e), ScalarSubqueryExpression e => VisitScalarSubquery(e), @@ -1299,6 +1300,20 @@ protected virtual Expression VisitRightJoin(RightJoinExpression rightJoinExpress return rightJoinExpression; } + /// + /// Generates SQL for a full join. + /// + /// The for which to generate SQL. + protected virtual Expression VisitFullJoin(FullJoinExpression fullJoinExpression) + { + _relationalCommandBuilder.Append("FULL JOIN "); + Visit(fullJoinExpression.Table); + _relationalCommandBuilder.Append(" ON "); + Visit(fullJoinExpression.JoinPredicate); + + return fullJoinExpression; + } + /// /// Generates SQL for a scalar subquery. /// diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs index a6120445123..754039eef22 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs @@ -1011,6 +1011,27 @@ static bool HasMatchingRequiredForeignKey( return null; } + /// + protected override ShapedQueryExpression? TranslateFullJoin( + ShapedQueryExpression outer, + ShapedQueryExpression inner, + LambdaExpression outerKeySelector, + LambdaExpression innerKeySelector, + LambdaExpression resultSelector) + { + var joinPredicate = CreateJoinPredicate(outer, outerKeySelector, inner, innerKeySelector); + if (joinPredicate != null) + { + var outerSelectExpression = (SelectExpression)outer.QueryExpression; + var outerShaperExpression = outerSelectExpression.AddFullJoin(inner, joinPredicate, outer.ShaperExpression); + outer = outer.UpdateShaperExpression(outerShaperExpression); + + return TranslateTwoParameterSelector(outer, resultSelector); + } + + return null; + } + private SqlExpression CreateJoinPredicate( ShapedQueryExpression outer, LambdaExpression outerKeySelector, diff --git a/src/EFCore.Relational/Query/SqlExpressions/FullJoinExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/FullJoinExpression.cs new file mode 100644 index 00000000000..b58ae04e38b --- /dev/null +++ b/src/EFCore.Relational/Query/SqlExpressions/FullJoinExpression.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +/// +/// +/// An expression that represents a FULL JOIN in a SQL tree. +/// +/// +/// This type is typically used by database providers (and other extensions). It is generally +/// not used in application code. +/// +/// +public class FullJoinExpression : PredicateJoinExpressionBase +{ + private static ConstructorInfo? _quotingConstructor; + + /// + /// Creates a new instance of the class. + /// + /// A table source to FULL JOIN with. + /// A predicate to use for the join. + /// Whether this join expression may be pruned if nothing references a column on it. + public FullJoinExpression(TableExpressionBase table, SqlExpression joinPredicate, bool prunable = false) + : this(table, joinPredicate, prunable, annotations: null) + { + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + [EntityFrameworkInternal] // For precompiled queries + public FullJoinExpression( + TableExpressionBase table, + SqlExpression joinPredicate, + bool prunable, + IReadOnlyDictionary? annotations = null) + : base(table, joinPredicate, prunable, annotations) + { + } + + /// + /// Creates a new expression that is like this one, but using the supplied children. If all of the children are the same, it will + /// return this expression. + /// + /// The property of the result. + /// The property of the result. + /// This expression if no children changed, or an expression with the updated children. + public override FullJoinExpression Update(TableExpressionBase table, SqlExpression joinPredicate) + => table != Table || joinPredicate != JoinPredicate + ? new FullJoinExpression(table, joinPredicate, IsPrunable, Annotations) + : this; + + /// + /// Creates a new expression that is like this one, but using the supplied children. If all of the children are the same, it will + /// return this expression. + /// + /// The property of the result. + /// This expression if no children changed, or an expression with the updated children. + public override FullJoinExpression Update(TableExpressionBase table) + => table != Table + ? new FullJoinExpression(table, JoinPredicate, IsPrunable, Annotations) + : this; + + /// + protected override FullJoinExpression WithAnnotations(IReadOnlyDictionary annotations) + => new(Table, JoinPredicate, IsPrunable, annotations); + + /// + public override Expression Quote() + => New( + _quotingConstructor ??= typeof(FullJoinExpression).GetConstructor( + [typeof(TableExpressionBase), typeof(SqlExpression), typeof(bool), typeof(IReadOnlyDictionary)])!, + Table.Quote(), + JoinPredicate.Quote(), + Constant(IsPrunable), + RelationalExpressionQuotingUtilities.QuoteAnnotations(Annotations)); + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Append("FULL JOIN "); + expressionPrinter.Visit(Table); + expressionPrinter.Append(" ON "); + expressionPrinter.Visit(JoinPredicate); + PrintAnnotations(expressionPrinter); + } + + /// + public override bool Equals(object? obj) + => obj != null + && (ReferenceEquals(this, obj) + || obj is FullJoinExpression fullJoinExpression + && Equals(fullJoinExpression)); + + private bool Equals(FullJoinExpression fullJoinExpression) + => base.Equals(fullJoinExpression); + + /// + public override int GetHashCode() + => base.GetHashCode(); +} diff --git a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs index 9f98878e2b7..9dd4057ec7d 100644 --- a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs +++ b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs @@ -2898,6 +2898,7 @@ private enum JoinType InnerJoin, LeftJoin, RightJoin, + FullJoin, CrossJoin, CrossApply, OuterApply @@ -2919,8 +2920,8 @@ private Expression AddJoin( var innerMemberInfo = transparentIdentifierType.GetTypeInfo().GetDeclaredField("Inner")!; var outerClientEval = _clientProjections.Count > 0; var innerClientEval = innerSelect._clientProjections.Count > 0; - var outerNullable = joinType is JoinType.RightJoin; - var innerNullable = joinType is JoinType.LeftJoin or JoinType.OuterApply; + var outerNullable = joinType is JoinType.RightJoin or JoinType.FullJoin; + var innerNullable = joinType is JoinType.LeftJoin or JoinType.OuterApply or JoinType.FullJoin; if (outerClientEval) { @@ -3150,7 +3151,10 @@ private void AddJoin( if (Limit != null || Offset != null || IsDistinct - || GroupBy.Count > 0) + || GroupBy.Count > 0 + // When the outer becomes nullable (RIGHT/FULL JOIN), an outer predicate must be applied before the join; otherwise it + // would be rendered as a post-join WHERE that incorrectly filters out the unmatched inner rows (where the outer is null). + || (joinType is JoinType.RightJoin or JoinType.FullJoin && Predicate != null)) { var sqlRemappingVisitor = PushdownIntoSubqueryInternal(); innerSelect = sqlRemappingVisitor.Remap(innerSelect); @@ -3176,10 +3180,10 @@ private void AddJoin( _identifier.Clear(); innerSelect._identifier.Clear(); } - else if (!isToOneJoin || joinType is JoinType.RightJoin) + else if (!isToOneJoin || joinType is JoinType.RightJoin or JoinType.FullJoin) { // This is cardinality-increasing join - add identifiers from the inner. - // Note that we do the same for right joins, since these make the outer identifiers nullable; that could mean that + // Note that we do the same for right/full joins, since these make the outer identifiers nullable; that could mean that // multiple inner rows get the same NULL identifier. switch (joinType) { @@ -3198,9 +3202,23 @@ private void AddJoin( _identifier.AddRange(innerSelect._identifier); break; - default: + case JoinType.FullJoin: + // Both sides may be unmatched, so make the outer identifiers nullable and add the inner identifiers made nullable. + for (var i = 0; i < _identifier.Count; i++) + { + var identifier = _identifier[i]; + _identifier[i] = (identifier.Column.MakeNullable(), identifier.Comparer); + } + + _identifier.AddRange(innerSelect._identifier.Select(e => (e.Column.MakeNullable(), e.Comparer))); + break; + + case JoinType.InnerJoin or JoinType.CrossJoin or JoinType.CrossApply: _identifier.AddRange(innerSelect._identifier); break; + + default: + throw new UnreachableException(); } } @@ -3210,6 +3228,7 @@ private void AddJoin( JoinType.InnerJoin => new InnerJoinExpression(innerTable, joinPredicate!, isPrunableJoin), JoinType.LeftJoin => new LeftJoinExpression(innerTable, joinPredicate!, isPrunableJoin), JoinType.RightJoin => new RightJoinExpression(innerTable, joinPredicate!), + JoinType.FullJoin => new FullJoinExpression(innerTable, joinPredicate!), JoinType.CrossJoin => new CrossJoinExpression(innerTable), JoinType.CrossApply => new CrossApplyExpression(innerTable), JoinType.OuterApply => (TableExpressionBase)new OuterApplyExpression(innerTable), @@ -3544,6 +3563,14 @@ public void AddInnerJoin(SelectExpression innerSelectExpression, SqlExpression j public void AddLeftJoin(SelectExpression innerSelectExpression, SqlExpression joinPredicate) => AddJoin(JoinType.LeftJoin, ref innerSelectExpression, out _, joinPredicate); + /// + /// Adds the given to table sources using FULL JOIN. + /// + /// A to join with. + /// A predicate to use for the join. + public void AddFullJoin(SelectExpression innerSelectExpression, SqlExpression joinPredicate) + => AddJoin(JoinType.FullJoin, ref innerSelectExpression, out _, joinPredicate); + /// /// Adds the given to table sources using CROSS JOIN. /// @@ -3659,6 +3686,20 @@ public Expression AddRightJoin( => AddJoin( JoinType.RightJoin, (SelectExpression)innerSource.QueryExpression, outerShaper, innerSource.ShaperExpression, joinPredicate); + /// + /// Adds the query expression of the given to table sources using FULL JOIN and combine shapers. + /// + /// A to join with. + /// A predicate to use for the join. + /// An expression for outer shaper. + /// An expression which shapes the result of this join. + public Expression AddFullJoin( + ShapedQueryExpression innerSource, + SqlExpression joinPredicate, + Expression outerShaper) + => AddJoin( + JoinType.FullJoin, (SelectExpression)innerSource.QueryExpression, outerShaper, innerSource.ShaperExpression, joinPredicate); + /// /// Adds the query expression of the given to table sources using CROSS JOIN and combine shapers. /// diff --git a/src/EFCore/EFCore.baseline.json b/src/EFCore/EFCore.baseline.json index 523bc1d7eaa..f37917c59c2 100644 --- a/src/EFCore/EFCore.baseline.json +++ b/src/EFCore/EFCore.baseline.json @@ -20922,6 +20922,9 @@ { "Member": "static System.Reflection.MethodInfo FirstWithPredicate { get; }" }, + { + "Member": "static System.Reflection.MethodInfo FullJoin { get; }" + }, { "Member": "static System.Reflection.MethodInfo GroupByWithKeyElementResultSelector { get; }" }, @@ -21113,6 +21116,9 @@ { "Member": "abstract Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression? TranslateFirstOrDefault(Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression source, System.Linq.Expressions.LambdaExpression? predicate, System.Type returnType, bool returnDefault);" }, + { + "Member": "abstract Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression? TranslateFullJoin(Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression outer, Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression inner, System.Linq.Expressions.LambdaExpression outerKeySelector, System.Linq.Expressions.LambdaExpression innerKeySelector, System.Linq.Expressions.LambdaExpression resultSelector);" + }, { "Member": "abstract Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression? TranslateGroupBy(Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression source, System.Linq.Expressions.LambdaExpression keySelector, System.Linq.Expressions.LambdaExpression? elementSelector, System.Linq.Expressions.LambdaExpression? resultSelector);" }, diff --git a/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs b/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs index a294c4d1596..ccc07dde58f 100644 --- a/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs +++ b/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs @@ -2278,7 +2278,12 @@ private bool IsGenerallyEvaluatable(Expression expression) private bool IsParameterParameterizable(MethodInfo method, ParameterInfo parameter) => parameter.GetCustomAttribute() is null - && !_model.IsIndexerMethod(method); + && !_model.IsIndexerMethod(method) + // An equality comparer is structural rather than a value: it affects the query's translatability, so it must never be + // parameterized (we always evaluate it as a constant, like a [NotParameterized]-annotated argument). This matters for operators + // with a single overload taking an optional comparer (e.g. Queryable.FullJoin), where the compiler-supplied default null would + // otherwise be parameterized, hiding from the translator whether a (non-translatable) custom comparer was supplied. + && !(parameter.ParameterType.IsGenericType && parameter.ParameterType.GetGenericTypeDefinition() == typeof(IEqualityComparer<>)); private enum StateType { diff --git a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs index 20aa0e9e94a..6544d7fb5f6 100644 --- a/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs +++ b/src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs @@ -521,6 +521,26 @@ when QueryableMethods.IsSumWithSelector(method): goto default; } + case nameof(Queryable.FullJoin) + when genericMethod == QueryableMethods.FullJoin + && methodCallExpression.Arguments[5] is ConstantExpression { Value: null }: + { + var secondArgument = Visit(methodCallExpression.Arguments[1]); + secondArgument = UnwrapCollectionMaterialization(secondArgument); + if (secondArgument is NavigationExpansionExpression innerSource) + { + return ProcessJoin( + source, + innerSource, + methodCallExpression.Arguments[2].UnwrapLambdaFromQuote(), + methodCallExpression.Arguments[3].UnwrapLambdaFromQuote(), + methodCallExpression.Arguments[4].UnwrapLambdaFromQuote(), + QueryableMethods.FullJoin); + } + + goto default; + } + case nameof(Queryable.SelectMany) when genericMethod == QueryableMethods.SelectManyWithoutCollectionSelector: return ProcessSelectMany( @@ -1341,7 +1361,8 @@ private NavigationExpansionExpression ProcessJoin( MethodInfo joinMethod) { Check.DebugAssert( - joinMethod == QueryableMethods.Join || joinMethod == QueryableMethods.LeftJoin || joinMethod == QueryableMethods.RightJoin, + joinMethod == QueryableMethods.Join || joinMethod == QueryableMethods.LeftJoin || joinMethod == QueryableMethods.RightJoin + || joinMethod == QueryableMethods.FullJoin, "Join method required"); if (innerSource.PendingOrderings.Any()) @@ -1366,24 +1387,37 @@ private NavigationExpansionExpression ProcessJoin( outerSource.CurrentParameter, innerSource.CurrentParameter); - var source = Expression.Call( - joinMethod.MakeGenericMethod( - outerSource.SourceElementType, innerSource.SourceElementType, outerKeySelector.ReturnType, - newResultSelector.ReturnType), - outerSource.Source, - innerSource.Source, - Expression.Quote(outerKeySelector), - Expression.Quote(innerKeySelector), - Expression.Quote(newResultSelector)); + var genericJoinMethod = joinMethod.MakeGenericMethod( + outerSource.SourceElementType, innerSource.SourceElementType, outerKeySelector.ReturnType, + newResultSelector.ReturnType); + + // Unlike Join/LeftJoin/RightJoin, Queryable.FullJoin only exposes a single overload taking an + // (optional) IEqualityComparer, so the rebuilt call must supply that trailing argument. + var source = joinMethod == QueryableMethods.FullJoin + ? Expression.Call( + genericJoinMethod, + outerSource.Source, + innerSource.Source, + Expression.Quote(outerKeySelector), + Expression.Quote(innerKeySelector), + Expression.Quote(newResultSelector), + Expression.Constant(null, typeof(IEqualityComparer<>).MakeGenericType(outerKeySelector.ReturnType))) + : Expression.Call( + genericJoinMethod, + outerSource.Source, + innerSource.Source, + Expression.Quote(outerKeySelector), + Expression.Quote(innerKeySelector), + Expression.Quote(newResultSelector)); var outerPendingSelector = outerSource.PendingSelector; - if (joinMethod == QueryableMethods.RightJoin) + if (joinMethod == QueryableMethods.RightJoin || joinMethod == QueryableMethods.FullJoin) { outerPendingSelector = _entityReferenceOptionalMarkingExpressionVisitor.Visit(outerPendingSelector); } var innerPendingSelector = innerSource.PendingSelector; - if (joinMethod == QueryableMethods.LeftJoin) + if (joinMethod == QueryableMethods.LeftJoin || joinMethod == QueryableMethods.FullJoin) { innerPendingSelector = _entityReferenceOptionalMarkingExpressionVisitor.Visit(innerPendingSelector); } diff --git a/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs index 672878459f9..89498d0209b 100644 --- a/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs @@ -422,6 +422,21 @@ when QueryableMethods.IsAverageWithSelector(method): break; } + case nameof(Queryable.FullJoin) + when genericMethod == QueryableMethods.FullJoin + && methodCallExpression.Arguments[5] is ConstantExpression { Value: null }: + { + if (Visit(methodCallExpression.Arguments[1]) is ShapedQueryExpression innerShapedQueryExpression) + { + return CheckTranslated( + TranslateFullJoin( + shapedQueryExpression, innerShapedQueryExpression, GetLambdaExpressionFromArgument(2), + GetLambdaExpressionFromArgument(3), GetLambdaExpressionFromArgument(4))); + } + + break; + } + case nameof(Queryable.Last) when genericMethod == QueryableMethods.LastWithoutPredicate: shapedQueryExpression = shapedQueryExpression.UpdateResultCardinality(ResultCardinality.Single); @@ -898,6 +913,22 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) LambdaExpression innerKeySelector, LambdaExpression resultSelector); + /// + /// Translates FullJoin over the given source. + /// + /// The shaped query on which the operator is applied. + /// The inner shaped query to perform join with. + /// The key selector for the outer source. + /// The key selector for the inner source. + /// The result selector supplied in the call. + /// The shaped query after translation. + protected abstract ShapedQueryExpression? TranslateFullJoin( + ShapedQueryExpression outer, + ShapedQueryExpression inner, + LambdaExpression outerKeySelector, + LambdaExpression innerKeySelector, + LambdaExpression resultSelector); + /// /// Translates method or /// and their other overloads over the given source. diff --git a/src/EFCore/Query/QueryableMethods.cs b/src/EFCore/Query/QueryableMethods.cs index c09ace496e5..84bff8a43d8 100644 --- a/src/EFCore/Query/QueryableMethods.cs +++ b/src/EFCore/Query/QueryableMethods.cs @@ -210,6 +210,13 @@ public static class QueryableMethods /// public static MethodInfo LeftJoin { get; } + /// + /// The for + /// + /// + public static MethodInfo FullJoin { get; } + /// /// The for /// @@ -767,6 +774,18 @@ static QueryableMethods() typeof(Expression<>).MakeGenericType(typeof(Func<,,>).MakeGenericType(types[0], types[1], types[3])) ]); + FullJoin = GetMethod( + nameof(Queryable.FullJoin), 4, + types => + [ + typeof(IQueryable<>).MakeGenericType(types[0]), + typeof(IEnumerable<>).MakeGenericType(types[1]), + typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(types[0], types[2])), + typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(types[1], types[2])), + typeof(Expression<>).MakeGenericType(typeof(Func<,,>).MakeGenericType(types[0], types[1], types[3])), + typeof(IEqualityComparer<>).MakeGenericType(types[2]) + ]); + Select = GetMethod( nameof(Queryable.Select), 2, types => diff --git a/test/EFCore.InMemory.FunctionalTests/Query/NorthwindJoinQueryInMemoryTest.cs b/test/EFCore.InMemory.FunctionalTests/Query/NorthwindJoinQueryInMemoryTest.cs index bea493f6d02..06f998c8f22 100644 --- a/test/EFCore.InMemory.FunctionalTests/Query/NorthwindJoinQueryInMemoryTest.cs +++ b/test/EFCore.InMemory.FunctionalTests/Query/NorthwindJoinQueryInMemoryTest.cs @@ -28,6 +28,18 @@ public override Task SelectMany_with_client_eval_with_constructor(bool async) public override Task RightJoin(bool async) => AssertTranslationFailed(() => base.RightJoin(async)); + // Right join not supported in InMemory + public override Task RightJoin_with_filtered_outer(bool async) + => AssertTranslationFailed(() => base.RightJoin_with_filtered_outer(async)); + + // Full join not supported in InMemory + public override Task FullJoin(bool async) + => AssertTranslationFailed(() => base.FullJoin(async)); + + // Full join not supported in InMemory + public override Task FullJoin_with_unmatched_rows_on_both_sides(bool async) + => AssertTranslationFailed(() => base.FullJoin_with_unmatched_rows_on_both_sides(async)); + public override async Task Join_local_collection_int_closure_is_cached_correctly(bool async) { var ids = new uint[] { 1, 2 }; diff --git a/test/EFCore.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesTestBase.cs b/test/EFCore.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesTestBase.cs index 894248ebe6a..3a678c10214 100644 --- a/test/EFCore.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesTestBase.cs +++ b/test/EFCore.Specification.Tests/BulkUpdates/NorthwindBulkUpdatesTestBase.cs @@ -826,7 +826,17 @@ public virtual Task Update_with_RightJoin(bool async) e => e.Order, s => s.SetProperty(t => t.Order.OrderDate, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc)), rowsAffectedCount: 2, - (b, a) => Assert.All(a, o => Assert.Equal(new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), o.OrderDate))); + // The RIGHT JOIN returns all F-prefixed customers; those without a matching order (OrderID < 10300) yield a null outer + // Order, which is left untouched. Only the matched (non-null) orders are updated. + (b, a) => + { + Assert.Equal(b.Count, a.Count); + Assert.Contains(null, a); + Assert.Equal(2, a.Count(o => o is not null)); + Assert.All( + a.Where(o => o is not null), + o => Assert.Equal(new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), o!.OrderDate)); + }); [Theory, MemberData(nameof(IsAsyncData))] public virtual Task Update_with_cross_join_set_constant(bool async) diff --git a/test/EFCore.Specification.Tests/Query/NorthwindJoinQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/NorthwindJoinQueryTestBase.cs index 23013d2afad..af4ac0c46bf 100644 --- a/test/EFCore.Specification.Tests/Query/NorthwindJoinQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/NorthwindJoinQueryTestBase.cs @@ -297,6 +297,56 @@ public virtual Task RightJoin(bool async) (c, o) => new { c, o }), e => (e.c.CustomerID, e.o?.OrderID)); + [Theory, MemberData(nameof(IsAsyncData))] + public virtual Task RightJoin_with_filtered_outer(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(c => c.CustomerID.StartsWith("A")) + .RightJoin( + ss.Set(), + c => c.CustomerID, + o => o.CustomerID, + (c, o) => new { c, o }), + e => (e.c?.CustomerID, e.o.OrderID)); + + [Theory, MemberData(nameof(IsAsyncData))] + public virtual Task FullJoin(bool async) + => AssertQuery( + async, + ss => ss.Set() + .FullJoin( + ss.Set(), + c => c.CustomerID, + o => o.CustomerID, + (c, o) => new { c, o }), + e => (e.c?.CustomerID, e.o?.OrderID)); + + [Theory, MemberData(nameof(IsAsyncData))] + public virtual Task FullJoin_with_unmatched_rows_on_both_sides(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(c => c.CustomerID.StartsWith("A")) + .FullJoin( + ss.Set().Where(o => o.CustomerID.StartsWith("B")), + c => c.CustomerID, + o => o.CustomerID, + (c, o) => new { c, o }), + e => (e.c?.CustomerID, e.o?.OrderID)); + + [Theory, MemberData(nameof(IsAsyncData))] + public virtual Task FullJoin_with_custom_comparer_does_not_translate(bool async) + => AssertTranslationFailed( + () => AssertQuery( + async, + ss => ss.Set() + .FullJoin( + ss.Set(), + c => c.CustomerID, + o => o.CustomerID, + (c, o) => new { c, o }, + StringComparer.Ordinal), + e => (e.c?.CustomerID, e.o?.OrderID))); + [Theory, MemberData(nameof(IsAsyncData))] public virtual Task GroupJoin_customers_employees_shadow(bool async) => AssertQuery( diff --git a/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs index e8f4862c436..5c5dd2409f9 100644 --- a/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqlServerTest.cs @@ -618,14 +618,21 @@ public override async Task Delete_with_RightJoin(bool async) DELETE FROM [o] FROM [Order Details] AS [o] -RIGHT JOIN ( - SELECT [o0].[OrderID] - FROM [Orders] AS [o0] - WHERE [o0].[OrderID] < 10300 - ORDER BY [o0].[OrderID] - OFFSET @p ROWS FETCH NEXT @p1 ROWS ONLY -) AS [o1] ON [o].[OrderID] = [o1].[OrderID] -WHERE [o].[OrderID] < 10276 +WHERE EXISTS ( + SELECT 1 + FROM ( + SELECT [o1].[OrderID], [o1].[ProductID] + FROM [Order Details] AS [o1] + WHERE [o1].[OrderID] < 10276 + ) AS [o0] + RIGHT JOIN ( + SELECT [o3].[OrderID] + FROM [Orders] AS [o3] + WHERE [o3].[OrderID] < 10300 + ORDER BY [o3].[OrderID] + OFFSET @p ROWS FETCH NEXT @p1 ROWS ONLY + ) AS [o2] ON [o0].[OrderID] = [o2].[OrderID] + WHERE [o0].[OrderID] = [o].[OrderID] AND [o0].[ProductID] = [o].[ProductID]) """); } @@ -1384,15 +1391,22 @@ public override async Task Update_with_RightJoin(bool async) """ @p='2020-01-01T00:00:00.0000000Z' (Nullable = true) (DbType = DateTime) -UPDATE [o] -SET [o].[OrderDate] = @p -FROM [Orders] AS [o] -RIGHT JOIN ( - SELECT [c].[CustomerID] - FROM [Customers] AS [c] - WHERE [c].[CustomerID] LIKE N'F%' -) AS [c0] ON [o].[CustomerID] = [c0].[CustomerID] -WHERE [o].[OrderID] < 10300 +UPDATE [o1] +SET [o1].[OrderDate] = @p +FROM [Orders] AS [o1] +INNER JOIN ( + SELECT [o0].[OrderID] + FROM ( + SELECT [o].[OrderID], [o].[CustomerID] + FROM [Orders] AS [o] + WHERE [o].[OrderID] < 10300 + ) AS [o0] + RIGHT JOIN ( + SELECT [c].[CustomerID] + FROM [Customers] AS [c] + WHERE [c].[CustomerID] LIKE N'F%' + ) AS [c0] ON [o0].[CustomerID] = [c0].[CustomerID] +) AS [s] ON [o1].[OrderID] = [s].[OrderID] """); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindJoinQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindJoinQuerySqlServerTest.cs index d615a646e81..eb6f59ef015 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindJoinQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindJoinQuerySqlServerTest.cs @@ -259,6 +259,61 @@ FROM [Customers] AS [c] """); } + public override async Task RightJoin_with_filtered_outer(bool async) + { + await base.RightJoin_with_filtered_outer(async); + + AssertSql( + """ +SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region], [o].[OrderID], [o].[CustomerID], [o].[EmployeeID], [o].[OrderDate] +FROM ( + SELECT [c].[CustomerID], [c].[Address], [c].[City], [c].[CompanyName], [c].[ContactName], [c].[ContactTitle], [c].[Country], [c].[Fax], [c].[Phone], [c].[PostalCode], [c].[Region] + FROM [Customers] AS [c] + WHERE [c].[CustomerID] LIKE N'A%' +) AS [c0] +RIGHT JOIN [Orders] AS [o] ON [c0].[CustomerID] = [o].[CustomerID] +"""); + } + + public override async Task FullJoin(bool async) + { + await base.FullJoin(async); + + AssertSql( + """ +SELECT [c].[CustomerID], [c].[Address], [c].[City], [c].[CompanyName], [c].[ContactName], [c].[ContactTitle], [c].[Country], [c].[Fax], [c].[Phone], [c].[PostalCode], [c].[Region], [o].[OrderID], [o].[CustomerID], [o].[EmployeeID], [o].[OrderDate] +FROM [Customers] AS [c] +FULL JOIN [Orders] AS [o] ON [c].[CustomerID] = [o].[CustomerID] +"""); + } + + public override async Task FullJoin_with_unmatched_rows_on_both_sides(bool async) + { + await base.FullJoin_with_unmatched_rows_on_both_sides(async); + + AssertSql( + """ +SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region], [o0].[OrderID], [o0].[CustomerID], [o0].[EmployeeID], [o0].[OrderDate] +FROM ( + SELECT [c].[CustomerID], [c].[Address], [c].[City], [c].[CompanyName], [c].[ContactName], [c].[ContactTitle], [c].[Country], [c].[Fax], [c].[Phone], [c].[PostalCode], [c].[Region] + FROM [Customers] AS [c] + WHERE [c].[CustomerID] LIKE N'A%' +) AS [c0] +FULL JOIN ( + SELECT [o].[OrderID], [o].[CustomerID], [o].[EmployeeID], [o].[OrderDate] + FROM [Orders] AS [o] + WHERE [o].[CustomerID] LIKE N'B%' +) AS [o0] ON [c0].[CustomerID] = [o0].[CustomerID] +"""); + } + + public override async Task FullJoin_with_custom_comparer_does_not_translate(bool async) + { + await base.FullJoin_with_custom_comparer_does_not_translate(async); + + AssertSql(); + } + public override async Task GroupJoin_simple(bool async) { await base.GroupJoin_simple(async); diff --git a/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs index 76a2b4c9c1d..c6aa9536405 100644 --- a/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/BulkUpdates/NorthwindBulkUpdatesSqliteTest.cs @@ -600,15 +600,19 @@ public override async Task Delete_with_RightJoin(bool async) DELETE FROM "Order Details" AS "o" WHERE EXISTS ( SELECT 1 - FROM "Order Details" AS "o0" + FROM ( + SELECT "o1"."OrderID", "o1"."ProductID" + FROM "Order Details" AS "o1" + WHERE "o1"."OrderID" < 10276 + ) AS "o0" RIGHT JOIN ( - SELECT "o2"."OrderID" - FROM "Orders" AS "o2" - WHERE "o2"."OrderID" < 10300 - ORDER BY "o2"."OrderID" + SELECT "o3"."OrderID" + FROM "Orders" AS "o3" + WHERE "o3"."OrderID" < 10300 + ORDER BY "o3"."OrderID" LIMIT @p1 OFFSET @p - ) AS "o1" ON "o0"."OrderID" = "o1"."OrderID" - WHERE "o0"."OrderID" < 10276 AND "o0"."OrderID" = "o"."OrderID" AND "o0"."ProductID" = "o"."ProductID") + ) AS "o2" ON "o0"."OrderID" = "o2"."OrderID" + WHERE "o0"."OrderID" = "o"."OrderID" AND "o0"."ProductID" = "o"."ProductID") """); } @@ -1364,19 +1368,22 @@ public override async Task Update_with_RightJoin(bool async) """ @p='2020-01-01T00:00:00.0000000Z' (Nullable = true) (DbType = DateTime) -UPDATE "Orders" AS "o0" +UPDATE "Orders" AS "o1" SET "OrderDate" = @p FROM ( - SELECT "o"."OrderID" - FROM "Orders" AS "o" + SELECT "o0"."OrderID" + FROM ( + SELECT "o"."OrderID", "o"."CustomerID" + FROM "Orders" AS "o" + WHERE "o"."OrderID" < 10300 + ) AS "o0" RIGHT JOIN ( SELECT "c"."CustomerID" FROM "Customers" AS "c" WHERE "c"."CustomerID" LIKE 'F%' - ) AS "c0" ON "o"."CustomerID" = "c0"."CustomerID" - WHERE "o"."OrderID" < 10300 + ) AS "c0" ON "o0"."CustomerID" = "c0"."CustomerID" ) AS "s" -WHERE "o0"."OrderID" = "s"."OrderID" +WHERE "o1"."OrderID" = "s"."OrderID" """); }