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