diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
index d471c519e94..459576424e7 100644
--- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
+++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
@@ -583,6 +583,12 @@ public static string MissingConcurrencyColumn([CanBeNull] object entityType, [Ca
public static string PendingAmbientTransaction
=> GetString("PendingAmbientTransaction");
+ ///
+ /// Set operations (Union, Concat, Intersect, Except) are only supported over entity types within the same type hierarchy.
+ ///
+ public static string SetOperationNotWithinEntityTypeHierarchy
+ => GetString("SetOperationNotWithinEntityTypeHierarchy");
+
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);
diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx
index 813a2d54ded..895fb37119c 100644
--- a/src/EFCore.Relational/Properties/RelationalStrings.resx
+++ b/src/EFCore.Relational/Properties/RelationalStrings.resx
@@ -481,4 +481,7 @@
This connection was used with an ambient transaction. The original ambient transaction needs to be completed before this connection can be used outside of it.
-
\ No newline at end of file
+
+ Set operations (Union, Concat, Intersect, Except) are only supported over entity types within the same type hierarchy.
+
+
diff --git a/src/EFCore.Relational/Query/Pipeline/QuerySqlGenerator.cs b/src/EFCore.Relational/Query/Pipeline/QuerySqlGenerator.cs
index 853d484a892..972cc60d3f5 100644
--- a/src/EFCore.Relational/Query/Pipeline/QuerySqlGenerator.cs
+++ b/src/EFCore.Relational/Query/Pipeline/QuerySqlGenerator.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore.Relational.Query.Pipeline.SqlExpressions;
@@ -79,6 +80,28 @@ protected override Expression VisitSelect(SelectExpression selectExpression)
subQueryIndent = _relationalCommandBuilder.Indent();
}
+ if (selectExpression.IsSetOperation)
+ {
+ GenerateSetOperation(selectExpression);
+ }
+ else
+ {
+ GenerateSelect(selectExpression);
+ }
+
+ if (selectExpression.Alias != null)
+ {
+ subQueryIndent.Dispose();
+
+ _relationalCommandBuilder.AppendLine()
+ .Append(") AS " + _sqlGenerationHelper.DelimitIdentifier(selectExpression.Alias));
+ }
+
+ return selectExpression;
+ }
+
+ protected virtual void GenerateSelect(SelectExpression selectExpression)
+ {
_relationalCommandBuilder.Append("SELECT ");
if (selectExpression.IsDistinct)
@@ -111,40 +134,55 @@ protected override Expression VisitSelect(SelectExpression selectExpression)
Visit(selectExpression.Predicate);
}
- if (selectExpression.Orderings.Any())
- {
- var orderings = selectExpression.Orderings.ToList();
+ GenerateOrderings(selectExpression);
+ GenerateLimitOffset(selectExpression);
+ }
- if (selectExpression.Limit == null
- && selectExpression.Offset == null)
- {
- orderings.RemoveAll(oe => oe.Expression is SqlConstantExpression || oe.Expression is SqlParameterExpression);
- }
+ protected virtual void GenerateSetOperation(SelectExpression setOperationExpression)
+ {
+ Debug.Assert(setOperationExpression.Tables.Count == 2,
+ $"{nameof(SelectExpression)} with {setOperationExpression.Tables.Count} tables, must be 2");
- if (orderings.Count > 0)
- {
- _relationalCommandBuilder.AppendLine()
- .Append("ORDER BY ");
+ GenerateSetOperationOperand(setOperationExpression, (SelectExpression)setOperationExpression.Tables[0]);
- GenerateList(orderings, e => Visit(e));
- }
- }
- else if (selectExpression.Offset != null)
- {
- _relationalCommandBuilder.AppendLine().Append("ORDER BY (SELECT 1)");
- }
+ _relationalCommandBuilder
+ .AppendLine()
+ .AppendLine(GenerateSetOperationType(setOperationExpression.SetOperationType));
- GenerateLimitOffset(selectExpression);
+ GenerateSetOperationOperand(setOperationExpression, (SelectExpression)setOperationExpression.Tables[1]);
- if (selectExpression.Alias != null)
- {
- subQueryIndent.Dispose();
+ GenerateOrderings(setOperationExpression);
+ GenerateLimitOffset(setOperationExpression);
+ }
- _relationalCommandBuilder.AppendLine()
- .Append(") AS " + _sqlGenerationHelper.DelimitIdentifier(selectExpression.Alias));
+ private static string GenerateSetOperationType(SetOperationType setOperationType)
+ => setOperationType switch {
+ SetOperationType.Union => "UNION",
+ SetOperationType.UnionAll => "UNION ALL",
+ SetOperationType.Intersect => "INTERSECT",
+ SetOperationType.Except => "EXCEPT",
+ _ => throw new NotSupportedException($"Invalid {nameof(SetOperationType)}: {setOperationType}")
+ };
+
+ protected virtual void GenerateSetOperationOperand(
+ SelectExpression setOperationExpression,
+ SelectExpression operandExpression)
+ {
+ // INTERSECT has higher precedence over UNION and EXCEPT, but otherwise evaluation is left-to-right.
+ // To preserve meaning, add parentheses whenever a set operation is nested within a different set operation.
+ if (operandExpression.IsSetOperation
+ && operandExpression.SetOperationType != setOperationExpression.SetOperationType)
+ {
+ _relationalCommandBuilder.AppendLine("(");
+ using (_relationalCommandBuilder.Indent())
+ {
+ Visit(operandExpression);
+ }
+ _relationalCommandBuilder.AppendLine().Append(")");
+ return;
}
- return selectExpression;
+ Visit(operandExpression);
}
protected override Expression VisitProjection(ProjectionExpression projectionExpression)
@@ -193,9 +231,13 @@ protected override Expression VisitSqlFunction(SqlFunctionExpression sqlFunction
protected override Expression VisitColumn(ColumnExpression columnExpression)
{
+ if (columnExpression.Table.Alias != null)
+ {
+ _relationalCommandBuilder
+ .Append(_sqlGenerationHelper.DelimitIdentifier(columnExpression.Table.Alias))
+ .Append(".");
+ }
_relationalCommandBuilder
- .Append(_sqlGenerationHelper.DelimitIdentifier(columnExpression.Table.Alias))
- .Append(".")
.Append(_sqlGenerationHelper.DelimitIdentifier(columnExpression.Name));
return columnExpression;
@@ -533,6 +575,32 @@ protected virtual void GenerateTop(SelectExpression selectExpression)
{
}
+ protected virtual void GenerateOrderings(SelectExpression selectExpression)
+ {
+ if (selectExpression.Orderings.Any())
+ {
+ var orderings = selectExpression.Orderings.ToList();
+
+ if (selectExpression.Limit == null
+ && selectExpression.Offset == null)
+ {
+ orderings.RemoveAll(oe => oe.Expression is SqlConstantExpression || oe.Expression is SqlParameterExpression);
+ }
+
+ if (orderings.Count > 0)
+ {
+ _relationalCommandBuilder.AppendLine()
+ .Append("ORDER BY ");
+
+ GenerateList(orderings, e => Visit(e));
+ }
+ }
+ else if (selectExpression.Offset != null)
+ {
+ _relationalCommandBuilder.AppendLine().Append("ORDER BY (SELECT 1)");
+ }
+ }
+
protected virtual void GenerateLimitOffset(SelectExpression selectExpression)
{
// The below implements ISO SQL:2008
diff --git a/src/EFCore.Relational/Query/Pipeline/RelationalQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/Pipeline/RelationalQueryableMethodTranslatingExpressionVisitor.cs
index 2b7f03553bb..e8bb64a372d 100644
--- a/src/EFCore.Relational/Query/Pipeline/RelationalQueryableMethodTranslatingExpressionVisitor.cs
+++ b/src/EFCore.Relational/Query/Pipeline/RelationalQueryableMethodTranslatingExpressionVisitor.cs
@@ -13,6 +13,7 @@
using Microsoft.EntityFrameworkCore.Query.Pipeline;
using Microsoft.EntityFrameworkCore.Relational.Query.Pipeline.SqlExpressions;
using System.Diagnostics;
+using Microsoft.EntityFrameworkCore.Storage;
namespace Microsoft.EntityFrameworkCore.Relational.Query.Pipeline
{
@@ -150,7 +151,13 @@ protected override ShapedQueryExpression TranslateCast(ShapedQueryExpression sou
return source;
}
- protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression source1, ShapedQueryExpression source2) => throw new NotImplementedException();
+ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression source1, ShapedQueryExpression source2)
+ {
+ var operand1 = (SelectExpression)source1.QueryExpression;
+ var operand2 = (SelectExpression)source2.QueryExpression;
+ source1.ShaperExpression = operand1.ApplySetOperation(SetOperationType.UnionAll, operand2, source1.ShaperExpression);
+ return source1;
+ }
protected override ShapedQueryExpression TranslateContains(ShapedQueryExpression source, Expression item)
{
@@ -212,7 +219,13 @@ protected override ShapedQueryExpression TranslateDistinct(ShapedQueryExpression
protected override ShapedQueryExpression TranslateElementAtOrDefault(ShapedQueryExpression source, Expression index, bool returnDefault) => throw new NotImplementedException();
- protected override ShapedQueryExpression TranslateExcept(ShapedQueryExpression source1, ShapedQueryExpression source2) => throw new NotImplementedException();
+ protected override ShapedQueryExpression TranslateExcept(ShapedQueryExpression source1, ShapedQueryExpression source2)
+ {
+ var operand1 = (SelectExpression)source1.QueryExpression;
+ var operand2 = (SelectExpression)source2.QueryExpression;
+ source1.ShaperExpression = operand1.ApplySetOperation(SetOperationType.Except, operand2, source1.ShaperExpression);
+ return source1;
+ }
protected override ShapedQueryExpression TranslateFirstOrDefault(ShapedQueryExpression source, LambdaExpression predicate, Type returnType, bool returnDefault)
{
@@ -279,7 +292,13 @@ protected override ShapedQueryExpression TranslateGroupJoin(ShapedQueryExpressio
throw new NotImplementedException();
}
- protected override ShapedQueryExpression TranslateIntersect(ShapedQueryExpression source1, ShapedQueryExpression source2) => throw new NotImplementedException();
+ protected override ShapedQueryExpression TranslateIntersect(ShapedQueryExpression source1, ShapedQueryExpression source2)
+ {
+ var operand1 = (SelectExpression)source1.QueryExpression;
+ var operand2 = (SelectExpression)source2.QueryExpression;
+ source1.ShaperExpression = operand1.ApplySetOperation(SetOperationType.Intersect, operand2, source1.ShaperExpression);
+ return source1;
+ }
protected override ShapedQueryExpression TranslateJoin(
ShapedQueryExpression outer,
@@ -730,7 +749,13 @@ protected override ShapedQueryExpression TranslateThenBy(ShapedQueryExpression s
throw new InvalidOperationException();
}
- protected override ShapedQueryExpression TranslateUnion(ShapedQueryExpression source1, ShapedQueryExpression source2) => throw new NotImplementedException();
+ protected override ShapedQueryExpression TranslateUnion(ShapedQueryExpression source1, ShapedQueryExpression source2)
+ {
+ var operand1 = (SelectExpression)source1.QueryExpression;
+ var operand2 = (SelectExpression)source2.QueryExpression;
+ source1.ShaperExpression = operand1.ApplySetOperation(SetOperationType.Union, operand2, source1.ShaperExpression);
+ return source1;
+ }
protected override ShapedQueryExpression TranslateWhere(ShapedQueryExpression source, LambdaExpression predicate)
{
diff --git a/src/EFCore.Relational/Query/Pipeline/SqlExpressions/ColumnExpression.cs b/src/EFCore.Relational/Query/Pipeline/SqlExpressions/ColumnExpression.cs
index fb0aa90549c..401c7bbb4dc 100644
--- a/src/EFCore.Relational/Query/Pipeline/SqlExpressions/ColumnExpression.cs
+++ b/src/EFCore.Relational/Query/Pipeline/SqlExpressions/ColumnExpression.cs
@@ -48,9 +48,14 @@ protected override Expression VisitChildren(ExpressionVisitor visitor)
public ColumnExpression MakeNullable()
=> new ColumnExpression(Name, Table, Type.MakeNullable(), TypeMapping, true);
-
public override void Print(ExpressionPrinter expressionPrinter)
- => expressionPrinter.StringBuilder.Append(Table.Alias).Append(".").Append(Name);
+ {
+ if (Table.Alias != null)
+ {
+ expressionPrinter.StringBuilder.Append(Table.Alias).Append(".");
+ }
+ expressionPrinter.StringBuilder.Append(Name);
+ }
public override bool Equals(object obj)
=> obj != null
@@ -66,6 +71,7 @@ private bool Equals(ColumnExpression columnExpression)
public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), Name, Table, Nullable);
- private string DebuggerDisplay() => $"{Table.Alias}.{Name}";
+ private string DebuggerDisplay()
+ => Table.Alias == null ? Name : $"{Table.Alias}.{Name}";
}
}
diff --git a/src/EFCore.Relational/Query/Pipeline/SqlExpressions/SelectExpression.cs b/src/EFCore.Relational/Query/Pipeline/SqlExpressions/SelectExpression.cs
index 552a4a400f6..d75dd3d3027 100644
--- a/src/EFCore.Relational/Query/Pipeline/SqlExpressions/SelectExpression.cs
+++ b/src/EFCore.Relational/Query/Pipeline/SqlExpressions/SelectExpression.cs
@@ -4,13 +4,18 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
+using JetBrains.Annotations;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.EntityFrameworkCore.Query.Internal;
using Microsoft.EntityFrameworkCore.Query.Pipeline;
+using Microsoft.EntityFrameworkCore.Storage;
namespace Microsoft.EntityFrameworkCore.Relational.Query.Pipeline.SqlExpressions
{
@@ -36,6 +41,17 @@ private readonly IDictionary
+ /// Marks this as representing an SQL set operation, such as a UNION.
+ /// For regular SQL SELECT expressions, contains None.
+ ///
+ public SetOperationType SetOperationType { get; private set; }
+
+ ///
+ /// Returns whether this represents an SQL set operation, such as a UNION.
+ ///
+ public bool IsSetOperation => SetOperationType != SetOperationType.None;
+
internal SelectExpression(
string alias,
List projections,
@@ -206,9 +222,7 @@ public IDictionary AddToProjection(EntityProjectionExpression en
public void PrepareForAggregate()
{
- if (IsDistinct
- || Limit != null
- || Offset != null)
+ if (IsDistinct || Limit != null || Offset != null || IsSetOperation)
{
PushdownIntoSubquery();
}
@@ -222,8 +236,7 @@ public void ApplyPredicate(SqlExpression expression)
return;
}
- if (Limit != null
- || Offset != null)
+ if (Limit != null || Offset != null || IsSetOperation)
{
var mappings = PushdownIntoSubquery();
expression = new SqlRemappingVisitor(mappings).Remap(expression);
@@ -314,8 +327,7 @@ public void ReverseOrderings()
public void ApplyDistinct()
{
- if (Limit != null
- || Offset != null)
+ if (Limit != null || Offset != null || IsSetOperation)
{
PushdownIntoSubquery();
}
@@ -329,6 +341,200 @@ public void ClearOrdering()
_orderings.Clear();
}
+
+ ///
+ /// Applies a set operation (e.g. Union, Intersect) on this query, pushing it down and
+ /// down to be the set operands.
+ ///
+ /// The type of set operation to be applied.
+ /// The other expression to participate as an operate in the operation (along with this one).
+ /// The shaper expression currently in use.
+ ///
+ /// A shaper expression to be used. This will be the same as , unless the set operation
+ /// modified the return type (i.e. upcast to common ancestor).
+ ///
+ public Expression ApplySetOperation(
+ SetOperationType setOperationType,
+ SelectExpression otherSelectExpression,
+ Expression shaperExpression)
+ {
+ var select1 = new SelectExpression(null, new List(), _tables.ToList(), _orderings.ToList())
+ {
+ IsDistinct = IsDistinct,
+ Predicate = Predicate,
+ Offset = Offset,
+ Limit = Limit,
+ SetOperationType = SetOperationType
+ };
+
+ select1._projectionMapping = new Dictionary(_projectionMapping);
+ _projectionMapping.Clear();
+
+ var select2 = otherSelectExpression;
+
+ if (_projection.Any())
+ {
+ throw new NotImplementedException("Set operation on SelectExpression with populated _projection");
+ }
+ else
+ {
+ if (select1._projectionMapping.Count != select2._projectionMapping.Count)
+ {
+ // Should not be possible after compiler checks
+ throw new Exception("Different projection mapping count in set operation");
+ }
+
+ foreach (var joinedMapping in select1._projectionMapping.Join(
+ select2._projectionMapping,
+ kv => kv.Key,
+ kv => kv.Key,
+ (kv1, kv2) => (kv1.Key, Value1: kv1.Value, Value2: kv2.Value)))
+ {
+
+ if (joinedMapping.Value1 is EntityProjectionExpression entityProjection1
+ && joinedMapping.Value2 is EntityProjectionExpression entityProjection2)
+ {
+ var propertyExpressions = new Dictionary();
+
+ if (entityProjection1.EntityType == entityProjection2.EntityType)
+ {
+ foreach (var property in GetAllPropertiesInHierarchy(entityProjection1.EntityType))
+ {
+ propertyExpressions[property] = AddSetOperationColumnProjections(
+ property,
+ select1, entityProjection1.GetProperty(property),
+ select2, entityProjection2.GetProperty(property));
+ }
+
+ _projectionMapping[joinedMapping.Key] = new EntityProjectionExpression(entityProjection1.EntityType, propertyExpressions);
+ continue;
+ }
+
+ // We're doing a set operation over two different entity types (within the same hierarchy).
+ // Since both sides of the set operations must produce the same result shape, find the
+ // closest common ancestor and load all the columns for that, adding null projections where
+ // necessary. Note this means we add null projections for properties which neither sibling
+ // actually needs, since the shaper doesn't know that only those sibling types will be coming
+ // back.
+ var commonParentEntityType = entityProjection1.EntityType.GetClosestCommonParent(entityProjection2.EntityType);
+
+ if (commonParentEntityType == null)
+ {
+ throw new NotSupportedException(RelationalStrings.SetOperationNotWithinEntityTypeHierarchy);
+ }
+
+ var properties1 = GetAllPropertiesInHierarchy(entityProjection1.EntityType).ToArray();
+ var properties2 = GetAllPropertiesInHierarchy(entityProjection2.EntityType).ToArray();
+
+ foreach (var property in properties1.Intersect(properties2))
+ {
+ propertyExpressions[property] = AddSetOperationColumnProjections(
+ property,
+ select1, entityProjection1.GetProperty(property),
+ select2, entityProjection2.GetProperty(property));
+ }
+
+ foreach (var property in properties1.Except(properties2))
+ {
+ propertyExpressions[property] = AddSetOperationColumnProjections(
+ property,
+ select1,entityProjection1.GetProperty(property),
+ select2, null);
+ }
+
+ foreach (var property in properties2.Except(properties1))
+ {
+ propertyExpressions[property] = AddSetOperationColumnProjections(
+ property,
+ select1, null,
+ select2, entityProjection2.GetProperty(property));
+ }
+
+ foreach (var property in GetAllPropertiesInHierarchy(commonParentEntityType)
+ .Except(properties1).Except(properties2))
+ {
+ propertyExpressions[property] = AddSetOperationColumnProjections(
+ property,
+ select1, null,
+ select2, null);
+ }
+
+ _projectionMapping[joinedMapping.Key] = new EntityProjectionExpression(commonParentEntityType, propertyExpressions);
+
+ if (commonParentEntityType != entityProjection1.EntityType)
+ {
+ if (!(shaperExpression.RemoveConvert() is EntityShaperExpression entityShaperExpression))
+ {
+ throw new Exception("Non-entity shaper expression while handling set operation over siblings.");
+ }
+
+ shaperExpression = new EntityShaperExpression(
+ commonParentEntityType, entityShaperExpression.ValueBufferExpression, entityShaperExpression.Nullable);
+ }
+
+ continue;
+ }
+
+ if (joinedMapping.Value1 is ColumnExpression innerColumn1
+ && joinedMapping.Value2 is ColumnExpression innerColumn2)
+ {
+ // The actual columns may actually be different, but we don't care as long as the type and alias
+ // coming out of the two operands are the same
+ var alias = joinedMapping.Key.LastMember?.Name;
+ var index = select1.AddToProjection(innerColumn1, alias);
+ var projectionExpression1 = select1._projection[index];
+ select2.AddToProjection(innerColumn2, alias);
+ var outerColumn = new ColumnExpression(projectionExpression1, select1, IsNullableProjection(projectionExpression1));
+ _projectionMapping[joinedMapping.Key] = outerColumn;
+ continue;
+ }
+
+ throw new NotSupportedException("Non-matching or unknown projection mapping type in set operation");
+ }
+ }
+
+ Offset = null;
+ Limit = null;
+ IsDistinct = false;
+ Predicate = null;
+ _orderings.Clear();
+ _tables.Clear();
+ _tables.Add(select1);
+ _tables.Add(otherSelectExpression);
+ SetOperationType = setOperationType;
+ return shaperExpression;
+
+ static ColumnExpression AddSetOperationColumnProjections(
+ IProperty property,
+ SelectExpression select1, ColumnExpression column1,
+ SelectExpression select2, ColumnExpression column2)
+ {
+ var columnName = column1?.Name ?? column2?.Name ?? property.Name;
+ var baseColumnName = columnName;
+ var counter = 0;
+ while (select1._projection.Any(pe => string.Equals(pe.Alias, columnName, StringComparison.OrdinalIgnoreCase)))
+ {
+ columnName = $"{baseColumnName}{counter++}";
+ }
+
+ var typeMapping = column1?.TypeMapping ?? column2?.TypeMapping ?? property.FindRelationalMapping();
+
+ select1._projection.Add(new ProjectionExpression(column1 != null
+ ? (SqlExpression)column1
+ : new SqlConstantExpression(Constant(null), typeMapping),
+ columnName));
+
+ select2._projection.Add(new ProjectionExpression(column2 != null
+ ? (SqlExpression)column2
+ : new SqlConstantExpression(Constant(null), typeMapping),
+ columnName));
+
+ var projectionExpression = select1._projection[select1._projection.Count - 1];
+ var outerColumn = new ColumnExpression(projectionExpression, select1, IsNullableProjection(projectionExpression));
+ return outerColumn;
+ }
+ }
+
public IDictionary PushdownIntoSubquery()
{
var subquery = new SelectExpression("t", new List(), _tables.ToList(), _orderings.ToList())
@@ -336,7 +542,8 @@ public IDictionary PushdownIntoSubquery()
IsDistinct = IsDistinct,
Predicate = Predicate,
Offset = Offset,
- Limit = Limit
+ Limit = Limit,
+ SetOperationType = SetOperationType
};
if (subquery.Limit == null && subquery.Offset == null)
@@ -421,6 +628,7 @@ public IDictionary PushdownIntoSubquery()
Limit = null;
IsDistinct = false;
Predicate = null;
+ SetOperationType = SetOperationType.None;
_tables.Clear();
_tables.Add(subquery);
@@ -847,7 +1055,8 @@ protected override Expression VisitChildren(ExpressionVisitor visitor)
Predicate = predicate,
Offset = offset,
Limit = limit,
- IsDistinct = IsDistinct
+ IsDistinct = IsDistinct,
+ SetOperationType = SetOperationType
};
return newSelectExpression;
@@ -1066,4 +1275,36 @@ public override void Print(ExpressionPrinter expressionPrinter)
}
}
}
+
+ ///
+ /// Marks a as representing an SQL set operation, such as a UNION.
+ ///
+ public enum SetOperationType
+ {
+ ///
+ /// Represents a regular SQL SELECT expression that isn't a set operation.
+ ///
+ None = 0,
+
+ ///
+ /// Represents an SQL UNION set operation.
+ ///
+ Union = 1,
+
+ ///
+ /// Represents an SQL UNION ALL set operation.
+ ///
+ UnionAll = 2,
+
+ ///
+ /// Represents an SQL INTERSECT set operation.
+ ///
+ Intersect = 3,
+
+ ///
+ /// Represents an SQL EXCEPT set operation.
+ ///
+ Except = 4
+ }
}
+
diff --git a/src/EFCore.Sqlite.Core/Query/Pipeline/SqliteQuerySqlGenerator.cs b/src/EFCore.Sqlite.Core/Query/Pipeline/SqliteQuerySqlGenerator.cs
index f92b25c3c8a..de5cc6d9c27 100644
--- a/src/EFCore.Sqlite.Core/Query/Pipeline/SqliteQuerySqlGenerator.cs
+++ b/src/EFCore.Sqlite.Core/Query/Pipeline/SqliteQuerySqlGenerator.cs
@@ -1,6 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+using System;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore.Relational.Query.Pipeline;
using Microsoft.EntityFrameworkCore.Relational.Query.Pipeline.SqlExpressions;
@@ -45,5 +46,22 @@ protected override void GenerateLimitOffset(SelectExpression selectExpression)
}
}
}
+
+ protected override void GenerateSetOperationOperand(
+ SelectExpression setOperationExpression,
+ SelectExpression operandExpression)
+ {
+ // Sqlite doesn't support parentheses around set operation operands
+
+ IDisposable indent = null;
+ if (!operandExpression.IsSetOperation)
+ {
+ indent = Sql.Indent();
+ }
+
+ Visit(operandExpression);
+
+ indent?.Dispose();
+ }
}
}
diff --git a/src/EFCore/Extensions/EntityTypeExtensions.cs b/src/EFCore/Extensions/EntityTypeExtensions.cs
index 18431119b0e..05643d2d0db 100644
--- a/src/EFCore/Extensions/EntityTypeExtensions.cs
+++ b/src/EFCore/Extensions/EntityTypeExtensions.cs
@@ -97,6 +97,26 @@ public static bool IsAssignableFrom([NotNull] this IEntityType entityType, [NotN
return false;
}
+ ///
+ /// Returns the closest entity type that is a parent of both given entity types. If one of the given entities is
+ /// a parent of the other, that parent is returned. Returns null if the two entity types aren't in the same hierarchy.
+ ///
+ /// An entity type.
+ /// Another entity type.
+ ///
+ /// The closest common parent of and ,
+ /// or null if they have not common parent.
+ ///
+ public static IEntityType GetClosestCommonParent([NotNull] this IEntityType entityType1, [NotNull] IEntityType entityType2)
+ {
+ Check.NotNull(entityType1, nameof(entityType1));
+ Check.NotNull(entityType2, nameof(entityType2));
+
+ return entityType1
+ .GetAllBaseTypesInclusiveAscending()
+ .FirstOrDefault(i => entityType2.GetAllBaseTypesInclusiveAscending().Any(j => j == i));
+ }
+
///
/// Determines if an entity type derives from (but is not the same as) a given entity type.
///
@@ -141,15 +161,28 @@ public static IEntityType LeastDerivedType([NotNull] this IEntityType entityType
}
///
- /// Returns all base types of the given , including the type itself.
+ /// Returns all base types of the given , including the type itself, top to bottom.
///
/// The entity type.
/// Base types.
public static IEnumerable GetAllBaseTypesInclusive([NotNull] this IEntityType entityType)
- => new List(entityType.GetAllBaseTypes())
+ => GetAllBaseTypesInclusiveAscending(entityType).Reverse();
+
+ ///
+ /// Returns all base types of the given , including the type itself, bottom to top.
+ ///
+ /// The entity type.
+ /// Base types.
+ public static IEnumerable GetAllBaseTypesInclusiveAscending([NotNull] this IEntityType entityType)
+ {
+ Check.NotNull(entityType, nameof(entityType));
+
+ while (entityType != null)
{
- entityType
- };
+ yield return entityType;
+ entityType = entityType.BaseType;
+ }
+ }
///
///
diff --git a/src/EFCore/Metadata/Internal/EntityTypeExtensions.cs b/src/EFCore/Metadata/Internal/EntityTypeExtensions.cs
index d16567f4aed..91ee13d739f 100644
--- a/src/EFCore/Metadata/Internal/EntityTypeExtensions.cs
+++ b/src/EFCore/Metadata/Internal/EntityTypeExtensions.cs
@@ -55,19 +55,16 @@ public static MemberInfo GetNavigationMemberInfo(
/// doing so can result in application failures when updating to a new Entity Framework Core release.
///
public static IEnumerable GetAllBaseTypes([NotNull] this IEntityType entityType)
- {
- var baseTypes = new List();
- var currentEntityType = entityType;
- while (currentEntityType.BaseType != null)
- {
- currentEntityType = currentEntityType.BaseType;
- baseTypes.Add(currentEntityType);
- }
+ => entityType.GetAllBaseTypesAscending().Reverse();
- baseTypes.Reverse();
-
- return baseTypes;
- }
+ ///
+ /// 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.
+ ///
+ public static IEnumerable GetAllBaseTypesAscending([NotNull] this IEntityType entityType)
+ => entityType.GetAllBaseTypesInclusiveAscending().Skip(1);
///
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
diff --git a/src/EFCore/Query/Pipeline/ShapedQueryExpression.cs b/src/EFCore/Query/Pipeline/ShapedQueryExpression.cs
index 06dda77a975..5c4fdc35759 100644
--- a/src/EFCore/Query/Pipeline/ShapedQueryExpression.cs
+++ b/src/EFCore/Query/Pipeline/ShapedQueryExpression.cs
@@ -65,5 +65,4 @@ public enum ResultType
SingleWithDefault
#pragma warning restore SA1602 // Enumeration items should be documented
}
-
}
diff --git a/test/EFCore.Specification.Tests/Query/InheritanceTestBase.cs b/test/EFCore.Specification.Tests/Query/InheritanceTestBase.cs
index 061be652011..45cc674cba3 100644
--- a/test/EFCore.Specification.Tests/Query/InheritanceTestBase.cs
+++ b/test/EFCore.Specification.Tests/Query/InheritanceTestBase.cs
@@ -3,6 +3,7 @@
using System.Linq;
using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Internal;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.TestModels.Inheritance;
using Microsoft.EntityFrameworkCore.TestUtilities;
@@ -477,63 +478,62 @@ protected virtual void UseTransaction(DatabaseFacade facade, IDbContextTransacti
{
}
- [ConditionalFact(Skip = "Issue #14935. Cannot eval 'Concat({value(Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryable`1[Microsoft.EntityFrameworkCore.TestModels.Inheritance.Eagle])})'")]
- public virtual void Can_concat_kiwis_and_eagles_as_birds()
+ [ConditionalFact]
+ public virtual void Union_siblings_with_duplicate_property_in_subquery()
{
+ // Coke and Tea both have CaffeineGrams, which both need to be projected out on each side and so
+ // requiring alias uniquification. They also have a different number of properties.
using (var context = CreateContext())
{
- var kiwis = context.Set();
+ var cokes = context.Set();
- var eagles = context.Set();
+ var teas = context.Set();
- var concat = kiwis.Cast().Concat(eagles).ToList();
+ var concat = cokes.Cast()
+ .Union(teas)
+ .Where(d => d.Id > 0)
+ .ToList();
Assert.Equal(2, concat.Count);
}
}
- [ConditionalFact(Skip = "Issue #14935. Cannot eval 'Except({value(Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryable`1[Microsoft.EntityFrameworkCore.TestModels.Inheritance.Eagle])})'")]
- public virtual void Can_except_kiwis_and_eagles_as_birds()
+ [ConditionalFact]
+ public virtual void OfType_Union_subquery()
{
using (var context = CreateContext())
{
- var kiwis = context.Set();
-
- var eagles = context.Set();
-
- var concat = kiwis.Cast().Except(eagles).ToList();
-
- Assert.Equal(1, concat.Count);
+ context.Set()
+ .OfType()
+ .Union(context.Set()
+ .OfType())
+ .Where(o => o.FoundOn == Island.North)
+ .ToList();
}
}
- [ConditionalFact(Skip = "Issue #14935. Cannot eval 'Intersect({value(Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryable`1[Microsoft.EntityFrameworkCore.TestModels.Inheritance.Eagle])})'")]
- public virtual void Can_intersect_kiwis_and_eagles_as_birds()
+ [ConditionalFact(Skip = "#16217")]
+ public virtual void OfType_Union_OfType()
{
using (var context = CreateContext())
{
- var kiwis = context.Set();
-
- var eagles = context.Set();
-
- var concat = kiwis.Cast().Intersect(eagles).ToList();
-
- Assert.Equal(0, concat.Count);
+ context.Set()
+ .OfType()
+ .Union(context.Set())
+ .OfType()
+ .ToList();
}
}
- [ConditionalFact(Skip = "Issue #14935. Cannot eval 'Union({value(Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryable`1[Microsoft.EntityFrameworkCore.TestModels.Inheritance.Eagle])})'")]
- public virtual void Can_union_kiwis_and_eagles_as_birds()
+ [ConditionalFact]
+ public virtual void Union_entity_equality()
{
using (var context = CreateContext())
{
- var kiwis = context.Set();
-
- var eagles = context.Set();
-
- var concat = kiwis.Cast().Union(eagles).ToList();
-
- Assert.Equal(2, concat.Count);
+ context.Set()
+ .Union(context.Set().Cast())
+ .Where(b => b == null)
+ .ToList();
}
}
diff --git a/test/EFCore.Specification.Tests/Query/SimpleQueryTestBase.SetOperations.cs b/test/EFCore.Specification.Tests/Query/SimpleQueryTestBase.SetOperations.cs
new file mode 100644
index 00000000000..bba21626c8e
--- /dev/null
+++ b/test/EFCore.Specification.Tests/Query/SimpleQueryTestBase.SetOperations.cs
@@ -0,0 +1,166 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore.Internal;
+using Microsoft.EntityFrameworkCore.TestModels.Northwind;
+using Xunit;
+
+// ReSharper disable InconsistentNaming
+
+namespace Microsoft.EntityFrameworkCore.Query
+{
+ public abstract partial class SimpleQueryTestBase
+ {
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Union(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Union(cs.Where(c => c.City == "London")),
+ entryCount: 7);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Concat(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Concat(cs.Where(c => c.City == "London")),
+ entryCount: 7);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Intersect(bool isAsync)
+ {
+ return AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "London")
+ .Intersect(cs.Where(c => c.ContactName.Contains("Thomas"))),
+ entryCount: 1);
+ }
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Except(bool isAsync)
+ {
+ return AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "London")
+ .Except(cs.Where(c => c.ContactName.Contains("Thomas"))),
+ entryCount: 5);
+ }
+
+ // OrderBy, Skip and Take are typically supported on the set operation itself (no need for query pushdown)
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Union_OrderBy_Skip_Take(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Union(cs.Where(c => c.City == "London"))
+ .OrderBy(c => c.ContactName)
+ .Skip(1)
+ .Take(1),
+ entryCount: 1,
+ assertOrder: true);
+
+ // Should cause pushdown into a subquery
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Union_Where(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Union(cs.Where(c => c.City == "London"))
+ .Where(c => c.ContactName.Contains("Thomas")), // pushdown
+ entryCount: 1);
+
+ // Should cause pushdown into a subquery, keeping the ordering, offset and limit inside the subquery
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Union_Skip_Take_OrderBy_ThenBy_Where(bool isAsync)
+ => AssertQuery(
+ isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Union(cs.Where(c => c.City == "London"))
+ .OrderBy(c => c.Region)
+ .ThenBy(c => c.City)
+ .Skip(0) // prevent pushdown from removing OrderBy
+ .Where(c => c.ContactName.Contains("Thomas")), // pushdown
+ entryCount: 1);
+
+ // Nested set operation with same operation type - no parentheses are needed.
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Union_Union(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Union(cs.Where(c => c.City == "London"))
+ .Union(cs.Where(c => c.City == "Mannheim")),
+ entryCount: 8);
+
+ // Nested set operation but with different operation type. On SqlServer and PostgreSQL INTERSECT binds
+ // more tightly than UNION/EXCEPT, so parentheses are needed.
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Union_Intersect(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Union(cs.Where(c => c.City == "London"))
+ .Intersect(cs.Where(c => c.ContactName.Contains("Thomas"))),
+ entryCount: 1);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Union_Take_Union_Take(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Union(cs.Where(c => c.City == "London"))
+ .Take(1)
+ .Union(cs.Where(c => c.City == "Mannheim"))
+ .Take(1),
+ entryCount: 666);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Select_Union(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Select(c => c.Address)
+ .Union(cs
+ .Where(c => c.City == "London")
+ .Select(c => c.Address)));
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Union_Select(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Union(cs.Where(c => c.City == "London"))
+ .Select(c => c.Address)
+ .Where(a => a.Contains("Hanover")));
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Select_Union_unrelated(bool isAsync)
+ => AssertQuery(isAsync, (cs, pd) => cs
+ .Select(c => c.ContactName)
+ .Union(pd.Select(p => p.ProductName))
+ .Where(x => x.StartsWith("C"))
+ .OrderBy(x => x),
+ assertOrder: true);
+
+ [ConditionalTheory]
+ [MemberData(nameof(IsAsyncData))]
+ public virtual Task Select_Union_different_fields_in_anonymous_with_subquery(bool isAsync)
+ => AssertQuery(isAsync, cs => cs
+ .Where(c => c.City == "Berlin")
+ .Select(c => new { Foo = c.City, Customer = c }) // Foo is City
+ .Union(cs
+ .Where(c => c.City == "London")
+ .Select(c => new { Foo = c.Region, Customer = c })) // Foo is Region
+ .OrderBy(x => x.Foo)
+ .Skip(1)
+ .Take(10)
+ .Where(x => x.Foo == "Berlin"),
+ entryCount: 1);
+ }
+}
diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceSqlServerTest.cs
index 59afb6daadd..8101daeb1d2 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceSqlServerTest.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceSqlServerTest.cs
@@ -18,7 +18,7 @@ public InheritanceSqlServerTest(InheritanceSqlServerFixture fixture, ITestOutput
: base(fixture)
{
Fixture.TestSqlLoggerFactory.Clear();
- //Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper);
+ Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper);
}
[ConditionalFact]
@@ -430,6 +430,60 @@ FROM [Animal] AS [a]
WHERE [a].[Discriminator] = N'Kiwi'");
}
+ public override void Union_siblings_with_duplicate_property_in_subquery()
+ {
+ base.Union_siblings_with_duplicate_property_in_subquery();
+
+ AssertSql(
+ @"SELECT [t].[Id], [t].[Discriminator], [t].[CaffeineGrams], [t].[CokeCO2], [t].[SugarGrams], [t].[Carbination], [t].[SugarGrams0], [t].[CaffeineGrams0], [t].[HasMilk]
+FROM (
+ SELECT [d].[Id], [d].[Discriminator], [d].[CaffeineGrams], [d].[CokeCO2], [d].[SugarGrams], NULL AS [CaffeineGrams0], NULL AS [HasMilk], NULL AS [Carbination], NULL AS [SugarGrams0]
+ FROM [Drink] AS [d]
+ WHERE [d].[Discriminator] = N'Coke'
+ UNION
+ SELECT [d0].[Id], [d0].[Discriminator], NULL AS [CaffeineGrams], NULL AS [CokeCO2], NULL AS [SugarGrams], [d0].[CaffeineGrams] AS [CaffeineGrams0], [d0].[HasMilk], NULL AS [Carbination], NULL AS [SugarGrams0]
+ FROM [Drink] AS [d0]
+ WHERE [d0].[Discriminator] = N'Tea'
+) AS [t]
+WHERE [t].[Id] > 0");
+ }
+
+ public override void OfType_Union_subquery()
+ {
+ base.OfType_Union_subquery();
+
+ AssertSql(
+ @"SELECT [t].[Species], [t].[CountryId], [t].[Discriminator], [t].[Name], [t].[EagleId], [t].[IsFlightless], [t].[FoundOn]
+FROM (
+ SELECT [a].[Species], [a].[CountryId], [a].[Discriminator], [a].[Name], [a].[EagleId], [a].[IsFlightless], [a].[FoundOn]
+ FROM [Animal] AS [a]
+ WHERE [a].[Discriminator] IN (N'Eagle', N'Kiwi') AND ([a].[Discriminator] = N'Kiwi')
+ UNION
+ SELECT [a0].[Species], [a0].[CountryId], [a0].[Discriminator], [a0].[Name], [a0].[EagleId], [a0].[IsFlightless], [a0].[FoundOn]
+ FROM [Animal] AS [a0]
+ WHERE [a0].[Discriminator] IN (N'Eagle', N'Kiwi') AND ([a0].[Discriminator] = N'Kiwi')
+) AS [t]
+WHERE ([t].[FoundOn] = CAST(0 AS tinyint)) AND [t].[FoundOn] IS NOT NULL");
+ }
+
+ public override void Union_entity_equality()
+ {
+ base.Union_entity_equality();
+
+ AssertSql(
+ @"SELECT [t].[Species], [t].[CountryId], [t].[Discriminator], [t].[Name], [t].[EagleId], [t].[IsFlightless], [t].[Group], [t].[FoundOn]
+FROM (
+ SELECT [a].[Species], [a].[CountryId], [a].[Discriminator], [a].[Name], [a].[EagleId], [a].[IsFlightless], [a].[FoundOn], NULL AS [Group]
+ FROM [Animal] AS [a]
+ WHERE [a].[Discriminator] = N'Kiwi'
+ UNION
+ SELECT [a0].[Species], [a0].[CountryId], [a0].[Discriminator], [a0].[Name], [a0].[EagleId], [a0].[IsFlightless], NULL AS [FoundOn], [a0].[Group]
+ FROM [Animal] AS [a0]
+ WHERE [a0].[Discriminator] = N'Eagle'
+) AS [t]
+WHERE CAST(0 AS bit) = CAST(1 AS bit)");
+ }
+
protected override void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction)
=> facade.UseTransaction(transaction.GetDbTransaction());
diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs
index 05f4169da37..2e4ee32a7b7 100644
--- a/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs
+++ b/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs
@@ -5394,6 +5394,60 @@ public class InventoryPool13587
#endregion
+ #region Bug12549
+
+ [ConditionalFact]
+ public virtual void Union_and_insert_12549()
+ {
+ using (CreateDatabase12549())
+ {
+ using (var context = new MyContext12549(_options))
+ {
+ var id1 = 1;
+ var id2 = 2;
+
+ var ids1 = context.Set()
+ .Where(x => x.Id == id1)
+ .Select(x => x.Id);
+
+ var ids2 = context.Set()
+ .Where(x => x.Id == id2)
+ .Select(x => x.Id);
+
+ var results = ids1.Union(ids2).ToList();
+
+ context.AddRange(new Table1_12549(), new Table2_12549(), new Table1_12549(), new Table2_12549());
+ context.SaveChanges();
+ }
+ }
+ }
+
+ private SqlServerTestStore CreateDatabase12549()
+ => CreateTestStore(() => new MyContext12549(_options), context => {});
+
+ public class MyContext12549 : DbContext
+ {
+ public DbSet Table1 { get; set; }
+ public DbSet Table2 { get; set; }
+
+ public MyContext12549(DbContextOptions options)
+ : base(options)
+ {
+ }
+ }
+
+ public class Table1_12549
+ {
+ public int Id { get; set; }
+ }
+
+ public class Table2_12549
+ {
+ public int Id { get; set; }
+ }
+
+ #endregion
+
private DbContextOptions _options;
private SqlServerTestStore CreateTestStore(
diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/SimpleQuerySqlServerTest.SetOperations.cs b/test/EFCore.SqlServer.FunctionalTests/Query/SimpleQuerySqlServerTest.SetOperations.cs
new file mode 100644
index 00000000000..7505ce3d234
--- /dev/null
+++ b/test/EFCore.SqlServer.FunctionalTests/Query/SimpleQuerySqlServerTest.SetOperations.cs
@@ -0,0 +1,244 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.EntityFrameworkCore.Query
+{
+ public partial class SimpleQuerySqlServerTest
+ {
+ public override async Task Union(bool isAsync)
+ {
+ await base.Union(isAsync);
+
+ AssertSql(
+ @"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].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+UNION
+SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+FROM [Customers] AS [c0]
+WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL");
+ }
+
+ public override async Task Concat(bool isAsync)
+ {
+ await base.Concat(isAsync);
+
+ AssertSql(
+ @"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].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+UNION ALL
+SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+FROM [Customers] AS [c0]
+WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL");
+ }
+
+ public override async Task Intersect(bool isAsync)
+ {
+ await base.Intersect(isAsync);
+
+ AssertSql(
+ @"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].[City] = N'London') AND [c].[City] IS NOT NULL
+INTERSECT
+SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+FROM [Customers] AS [c0]
+WHERE CHARINDEX(N'Thomas', [c0].[ContactName]) > 0");
+ }
+
+ public override async Task Except(bool isAsync)
+ {
+ await base.Except(isAsync);
+
+ AssertSql(
+ @"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].[City] = N'London') AND [c].[City] IS NOT NULL
+EXCEPT
+SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+FROM [Customers] AS [c0]
+WHERE CHARINDEX(N'Thomas', [c0].[ContactName]) > 0"); }
+
+ public override async Task Union_OrderBy_Skip_Take(bool isAsync)
+ {
+ await base.Union_OrderBy_Skip_Take(isAsync);
+
+ AssertSql(
+ @"@__p_0='1'
+
+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].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+UNION
+SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+FROM [Customers] AS [c0]
+WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL
+ORDER BY [ContactName]
+OFFSET @__p_0 ROWS FETCH NEXT @__p_0 ROWS ONLY");
+ }
+
+ public override async Task Union_Where(bool isAsync)
+ {
+ await base.Union_Where(isAsync);
+
+ AssertSql(
+ @"SELECT [t].[CustomerID], [t].[Address], [t].[City], [t].[CompanyName], [t].[ContactName], [t].[ContactTitle], [t].[Country], [t].[Fax], [t].[Phone], [t].[PostalCode], [t].[Region]
+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].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+ UNION
+ SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+ FROM [Customers] AS [c0]
+ WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL
+) AS [t]
+WHERE CHARINDEX(N'Thomas', [t].[ContactName]) > 0");
+ }
+
+ public override async Task Union_Skip_Take_OrderBy_ThenBy_Where(bool isAsync)
+ {
+ await base.Union_Skip_Take_OrderBy_ThenBy_Where(isAsync);
+
+ AssertSql(
+ @"@__p_0='0'
+
+SELECT [t].[CustomerID], [t].[Address], [t].[City], [t].[CompanyName], [t].[ContactName], [t].[ContactTitle], [t].[Country], [t].[Fax], [t].[Phone], [t].[PostalCode], [t].[Region]
+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].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+ UNION
+ SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+ FROM [Customers] AS [c0]
+ WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL
+ ORDER BY [Region], [City]
+ OFFSET @__p_0 ROWS
+) AS [t]
+WHERE CHARINDEX(N'Thomas', [t].[ContactName]) > 0
+ORDER BY [t].[Region], [t].[City]");
+ }
+
+ public override async Task Union_Union(bool isAsync)
+ {
+ await base.Union_Union(isAsync);
+
+ AssertSql(
+ @"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].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+UNION
+SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+FROM [Customers] AS [c0]
+WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL
+UNION
+SELECT [c1].[CustomerID], [c1].[Address], [c1].[City], [c1].[CompanyName], [c1].[ContactName], [c1].[ContactTitle], [c1].[Country], [c1].[Fax], [c1].[Phone], [c1].[PostalCode], [c1].[Region]
+FROM [Customers] AS [c1]
+WHERE ([c1].[City] = N'Mannheim') AND [c1].[City] IS NOT NULL");
+ }
+
+ public override async Task Union_Intersect(bool isAsync)
+ {
+ await base.Union_Intersect(isAsync);
+
+ AssertSql(
+ @"(
+ 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].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+ UNION
+ SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+ FROM [Customers] AS [c0]
+ WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL
+)
+INTERSECT
+SELECT [c1].[CustomerID], [c1].[Address], [c1].[City], [c1].[CompanyName], [c1].[ContactName], [c1].[ContactTitle], [c1].[Country], [c1].[Fax], [c1].[Phone], [c1].[PostalCode], [c1].[Region]
+FROM [Customers] AS [c1]
+WHERE CHARINDEX(N'Thomas', [c1].[ContactName]) > 0");
+ }
+
+ [ConditionalTheory(Skip = "Need to push down set operation on take without orderby+skip on SQL Server, waiting on design")]
+ public override async Task Union_Take_Union_Take(bool isAsync)
+ {
+ await base.Union_Take_Union_Take(isAsync);
+
+ throw new NotImplementedException("Take is being ignored");
+ //AssertSql(@"");
+ }
+
+ public override async Task Select_Union(bool isAsync)
+ {
+ await base.Select_Union(isAsync);
+
+ AssertSql(@"SELECT [c].[Address]
+FROM [Customers] AS [c]
+WHERE ([c].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+UNION
+SELECT [c0].[Address]
+FROM [Customers] AS [c0]
+WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL");
+ }
+
+ public override async Task Union_Select(bool isAsync)
+ {
+ await base.Union_Select(isAsync);
+
+ AssertSql(@"SELECT [t].[Address]
+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].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+ UNION
+ SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+ FROM [Customers] AS [c0]
+ WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL
+) AS [t]
+WHERE CHARINDEX(N'Hanover', [t].[Address]) > 0");
+ }
+
+ public override async Task Select_Union_unrelated(bool isAsync)
+ {
+ await base.Select_Union_unrelated(isAsync);
+
+ AssertSql(
+ @"SELECT [t].[ContactName]
+FROM (
+ SELECT [c].[ContactName]
+ FROM [Customers] AS [c]
+ UNION
+ SELECT [p].[ProductName]
+ FROM [Products] AS [p]
+) AS [t]
+WHERE [t].[ContactName] IS NOT NULL AND ([t].[ContactName] LIKE N'C%')
+ORDER BY [t].[ContactName]");
+ }
+
+ public override async Task Select_Union_different_fields_in_anonymous_with_subquery(bool isAsync)
+ {
+ await base.Select_Union_different_fields_in_anonymous_with_subquery(isAsync);
+
+ AssertSql(
+ @"@__p_0='1'
+@__p_1='10'
+
+SELECT [t].[Foo], [t].[CustomerID], [t].[Address], [t].[City], [t].[CompanyName], [t].[ContactName], [t].[ContactTitle], [t].[Country], [t].[Fax], [t].[Phone], [t].[PostalCode], [t].[Region]
+FROM (
+ SELECT [c].[City] AS [Foo], [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].[City] = N'Berlin') AND [c].[City] IS NOT NULL
+ UNION
+ SELECT [c0].[Region] AS [Foo], [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region]
+ FROM [Customers] AS [c0]
+ WHERE ([c0].[City] = N'London') AND [c0].[City] IS NOT NULL
+ ORDER BY [Foo]
+ OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
+) AS [t]
+WHERE ([t].[Foo] = N'Berlin') AND [t].[Foo] IS NOT NULL
+ORDER BY [t].[Foo]");
+ }
+ }
+}
diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/SimpleQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/SimpleQuerySqliteTest.cs
index 76f740c9fae..d5a3863090e 100644
--- a/test/EFCore.Sqlite.FunctionalTests/Query/SimpleQuerySqliteTest.cs
+++ b/test/EFCore.Sqlite.FunctionalTests/Query/SimpleQuerySqliteTest.cs
@@ -93,6 +93,9 @@ public SimpleQuerySqliteTest(NorthwindQuerySqliteFixture fi
// Skip for SQLite. Issue #14935. Cannot eval 'Sum()'
public override Task Sum_with_division_on_decimal_no_significant_digits(bool isAsync) => null;
+ // Sqlite does not support LIMIT on set operation operands, nor subqueries, so this is untranslatable.
+ public override Task Union_Take_Union_Take(bool isAsync) => Task.CompletedTask;
+
// Skip for SQLite. Issue #14935. Cannot eval 'where (Convert([o].OrderDate, Nullable`1) == Convert(DateTimeOffset.Now, Nullable`1))'
public override Task Where_datetimeoffset_now_component(bool isAsync) => null;