From ada1ac22c8d39561d422a0185d45ebd4a4fa5ca3 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Fri, 21 Jun 2019 00:44:54 +0200 Subject: [PATCH] Implement set operations (Union, Intersect...). Navigation/include support not included. Fixes #6812 Fixes #12549 --- .../Properties/RelationalStrings.Designer.cs | 6 + .../Properties/RelationalStrings.resx | 5 +- .../Query/Pipeline/QuerySqlGenerator.cs | 128 +++++++-- ...yableMethodTranslatingExpressionVisitor.cs | 33 ++- .../SqlExpressions/SelectExpression.cs | 266 +++++++++++++++++- .../Query/Pipeline/SqliteQuerySqlGenerator.cs | 18 ++ src/EFCore/Extensions/EntityTypeExtensions.cs | 41 ++- .../Metadata/Internal/EntityTypeExtensions.cs | 21 +- .../Query/Pipeline/ShapedQueryExpression.cs | 1 - .../Query/InheritanceTestBase.cs | 63 ++--- .../SimpleQueryTestBase.SetOperations.cs | 166 +++++++++++ .../Query/InheritanceSqlServerTest.cs | 56 +++- .../Query/QueryBugsTest.cs | 54 ++++ .../SimpleQuerySqlServerTest.SetOperations.cs | 244 ++++++++++++++++ .../Query/SimpleQuerySqliteTest.cs | 3 + 15 files changed, 1010 insertions(+), 95 deletions(-) create mode 100644 test/EFCore.Specification.Tests/Query/SimpleQueryTestBase.SetOperations.cs create mode 100644 test/EFCore.SqlServer.FunctionalTests/Query/SimpleQuerySqlServerTest.SetOperations.cs diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index 37e3f48e2bb..602bcae5745 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -586,6 +586,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 c7fe462a9f4..81c96afd259 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 ec86119e76d..92c0ea9b360 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,65 @@ 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)); + _relationalCommandBuilder + .AppendLine() + .AppendLine(GenerateSetOperationType(setOperationExpression.SetOperationType)); + + GenerateSetOperationOperand(setOperationExpression, (SelectExpression)setOperationExpression.Tables[1]); + + GenerateOrderings(setOperationExpression); + GenerateLimitOffset(setOperationExpression); + } + + 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) + { + var parensOpened = false; + IDisposable indent = null; + if (operandExpression.IsSetOperation) + { + // 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.SetOperationType != setOperationExpression.SetOperationType) + { + _relationalCommandBuilder.AppendLine("("); + parensOpened = true; + indent = _relationalCommandBuilder.Indent(); } } - else if (selectExpression.Offset != null) + else { - _relationalCommandBuilder.AppendLine().Append("ORDER BY (SELECT 1)"); + indent = _relationalCommandBuilder.Indent(); } - GenerateLimitOffset(selectExpression); + Visit(operandExpression); - if (selectExpression.Alias != null) + indent?.Dispose(); + if (parensOpened) { - subQueryIndent.Dispose(); - - _relationalCommandBuilder.AppendLine() - .Append(") AS " + _sqlGenerationHelper.DelimitIdentifier(selectExpression.Alias)); + _relationalCommandBuilder.AppendLine().Append(")"); } - - return selectExpression; } protected override Expression VisitProjection(ProjectionExpression projectionExpression) @@ -198,9 +246,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; @@ -538,6 +590,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/SelectExpression.cs b/src/EFCore.Relational/Query/Pipeline/SqlExpressions/SelectExpression.cs index 552a4a400f6..2de96e001ed 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, @@ -159,6 +175,9 @@ public Expression GetMappedProjection(ProjectionMember projectionMember) return _projectionMapping[projectionMember]; } + public bool TryGetMappedProjection(ProjectionMember projectionMember, out Expression mappedProjection) + => _projectionMapping.TryGetValue(projectionMember, out mappedProjection); + public int AddToProjection(SqlExpression sqlExpression) { return AddToProjection(sqlExpression, null); @@ -206,9 +225,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 +239,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 +330,7 @@ public void ReverseOrderings() public void ApplyDistinct() { - if (Limit != null - || Offset != null) + if (Limit != null || Offset != null || IsSetOperation) { PushdownIntoSubquery(); } @@ -329,6 +344,204 @@ 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(); + + // Keys don't across union operations, as we're always performing them within the same entity type hierarchy. + select1._identifyingProjection.AddRange(_identifyingProjection); + _identifyingProjection.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 +549,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 +635,7 @@ public IDictionary PushdownIntoSubquery() Limit = null; IsDistinct = false; Predicate = null; + SetOperationType = SetOperationType.None; _tables.Clear(); _tables.Add(subquery); @@ -847,7 +1062,8 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) Predicate = predicate, Offset = offset, Limit = limit, - IsDistinct = IsDistinct + IsDistinct = IsDistinct, + SetOperationType = SetOperationType }; return newSelectExpression; @@ -1066,4 +1282,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 399ea35e68a..9f57626fb21 100644 --- a/src/EFCore/Query/Pipeline/ShapedQueryExpression.cs +++ b/src/EFCore/Query/Pipeline/ShapedQueryExpression.cs @@ -61,5 +61,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 8a8eb193f22..884bfd3d8ac 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,49 @@ 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() - { - using (var context = CreateContext()) - { - var kiwis = context.Set(); - - var eagles = context.Set(); - - var concat = kiwis.Cast().Except(eagles).ToList(); - - Assert.Equal(1, concat.Count); - } - } - - [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] + public virtual void OfType_Union_subquery() { 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()) + .Where(o => o.FoundOn == Island.North) + .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..b97548d9ed9 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 214282d409a..77a1544ebc7 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/QueryBugsTest.cs @@ -5471,6 +5471,60 @@ public class Child12518 #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..c6d79ff9e9c --- /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;