diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPostprocessor.cs b/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPostprocessor.cs index 0acc8a795..0bb5e96d6 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPostprocessor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryTranslationPostprocessor.cs @@ -35,7 +35,7 @@ public override Expression Process(Expression query) var result = base.Process(query); result = new NpgsqlUnnestPostprocessor().Visit(result); - result = new NpgsqlSetOperationTypeResolutionCompensatingExpressionVisitor().Visit(result); + result = new NpgsqlSetOperationTypingInjector().Visit(result); return result; } diff --git a/src/EFCore.PG/Query/Internal/NpgsqlSetOperationTypeResolutionCompensatingExpressionVisitor.cs b/src/EFCore.PG/Query/Internal/NpgsqlSetOperationTypeResolutionCompensatingExpressionVisitor.cs deleted file mode 100644 index 9b89c039c..000000000 --- a/src/EFCore.PG/Query/Internal/NpgsqlSetOperationTypeResolutionCompensatingExpressionVisitor.cs +++ /dev/null @@ -1,126 +0,0 @@ -namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; - -/// -/// 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 class NpgsqlSetOperationTypeResolutionCompensatingExpressionVisitor : ExpressionVisitor -{ - private State _state; - - /// - /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to - /// the same compatibility standards as public APIs. It may be changed or removed without notice in - /// any release. You should only use it directly in your code with extreme caution and knowing that - /// doing so can result in application failures when updating to a new Entity Framework Core release. - /// - protected override Expression VisitExtension(Expression extensionExpression) - => extensionExpression switch - { - ShapedQueryExpression shapedQueryExpression - => shapedQueryExpression.Update( - Visit(shapedQueryExpression.QueryExpression), - Visit(shapedQueryExpression.ShaperExpression)), - SetOperationBase setOperationExpression => VisitSetOperation(setOperationExpression), - SelectExpression selectExpression => VisitSelect(selectExpression), - _ => base.VisitExtension(extensionExpression) - }; - - private Expression VisitSetOperation(SetOperationBase setOperationExpression) - { - switch (_state) - { - case State.Nothing: - _state = State.InSingleSetOperation; - var visited = base.VisitExtension(setOperationExpression); - _state = State.Nothing; - return visited; - - case State.InSingleSetOperation: - _state = State.InNestedSetOperation; - visited = base.VisitExtension(setOperationExpression); - _state = State.InSingleSetOperation; - return visited; - - default: - return base.VisitExtension(setOperationExpression); - } - } - - private Expression VisitSelect(SelectExpression selectExpression) - { - var changed = false; - - var tables = new List(); - foreach (var table in selectExpression.Tables) - { - var newTable = (TableExpressionBase)Visit(table); - changed |= newTable != table; - tables.Add(newTable); - } - - // Above we visited the tables, which may contain nested set operations - so we retained our state. - // When visiting the below elements, reset to state to properly handle nested unions inside e.g. the predicate. - var parentState = _state; - _state = State.Nothing; - - var projections = new List(); - foreach (var item in selectExpression.Projection) - { - // Inject an explicit cast node around null literals - var updatedProjection = parentState == State.InNestedSetOperation && item.Expression is SqlConstantExpression { Value : null } - ? item.Update( - new SqlUnaryExpression(ExpressionType.Convert, item.Expression, item.Expression.Type, item.Expression.TypeMapping)) - : (ProjectionExpression)Visit(item); - - projections.Add(updatedProjection); - changed |= updatedProjection != item; - } - - var predicate = (SqlExpression?)Visit(selectExpression.Predicate); - changed |= predicate != selectExpression.Predicate; - - var groupBy = new List(); - foreach (var groupingKey in selectExpression.GroupBy) - { - var newGroupingKey = (SqlExpression)Visit(groupingKey); - changed |= newGroupingKey != groupingKey; - groupBy.Add(newGroupingKey); - } - - var havingExpression = (SqlExpression?)Visit(selectExpression.Having); - changed |= havingExpression != selectExpression.Having; - - var orderings = new List(); - foreach (var ordering in selectExpression.Orderings) - { - var orderingExpression = (SqlExpression)Visit(ordering.Expression); - changed |= orderingExpression != ordering.Expression; - orderings.Add(ordering.Update(orderingExpression)); - } - - var offset = (SqlExpression?)Visit(selectExpression.Offset); - changed |= offset != selectExpression.Offset; - - var limit = (SqlExpression?)Visit(selectExpression.Limit); - changed |= limit != selectExpression.Limit; - - // If we were in the InNestedSetOperation state, we've applied all explicit type mappings when visiting the ProjectionExpressions - // above; change the state to prevent unnecessarily continuing to compensate - _state = parentState == State.InNestedSetOperation ? State.AlreadyCompensated : parentState; - - return changed - ? selectExpression.Update(tables, predicate, groupBy, havingExpression, projections, orderings, offset, limit) - : selectExpression; - } - - private enum State - { - Nothing, - InSingleSetOperation, - InNestedSetOperation, - AlreadyCompensated - } -} diff --git a/src/EFCore.PG/Query/Internal/NpgsqlSetOperationTypingInjector.cs b/src/EFCore.PG/Query/Internal/NpgsqlSetOperationTypingInjector.cs new file mode 100644 index 000000000..6086bf436 --- /dev/null +++ b/src/EFCore.PG/Query/Internal/NpgsqlSetOperationTypingInjector.cs @@ -0,0 +1,80 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; + +/// +/// A visitor that injects explicit typing on null projections in set operations, to ensure PostgreSQL gets the typing right. +/// +/// +/// +/// See the +/// PostgreSQL docs on type conversion and set operations. +/// +/// +/// 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 class NpgsqlSetOperationTypingInjector : ExpressionVisitor +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitExtension(Expression extensionExpression) + => extensionExpression switch + { + ShapedQueryExpression shapedQueryExpression + => shapedQueryExpression.Update( + Visit(shapedQueryExpression.QueryExpression), + Visit(shapedQueryExpression.ShaperExpression)), + + SetOperationBase setOperationExpression => VisitSetOperation(setOperationExpression), + + _ => base.VisitExtension(extensionExpression) + }; + + private Expression VisitSetOperation(SetOperationBase setOperation) + { + var select1 = (SelectExpression)Visit(setOperation.Source1); + var select2 = (SelectExpression)Visit(setOperation.Source2); + + List? rewrittenProjections = null; + + for (var i = 0; i < select1.Projection.Count; i++) + { + var projection = select1.Projection[i]; + var visitedProjection = projection.Expression is SqlConstantExpression { Value : null } + && select2.Projection[i].Expression is SqlConstantExpression { Value : null } + ? projection.Update( + new SqlUnaryExpression( + ExpressionType.Convert, projection.Expression, projection.Expression.Type, projection.Expression.TypeMapping)) + : (ProjectionExpression)Visit(projection); + + if (visitedProjection != projection && rewrittenProjections is null) + { + rewrittenProjections = new List(select1.Projection.Count); + rewrittenProjections.AddRange(select1.Projection.Take(i)); + } + + rewrittenProjections?.Add(visitedProjection); + } + + if (rewrittenProjections is not null) + { + select1 = select1.Update( + select1.Tables, + select1.Predicate, + select1.GroupBy, + select1.Having, + rewrittenProjections, + select1.Orderings, + select1.Offset, + select1.Limit); + } + + return setOperation.Update(select1, select2); + } +} diff --git a/test/EFCore.PG.FunctionalTests/Query/EntitySplittingQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/EntitySplittingQueryNpgsqlTest.cs index aff617fd7..012aed426 100644 --- a/test/EFCore.PG.FunctionalTests/Query/EntitySplittingQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/EntitySplittingQueryNpgsqlTest.cs @@ -449,7 +449,7 @@ public override async Task Tpc_entity_owning_a_split_reference_on_leaf_with_tabl """ SELECT u."Id", u."BaseValue", u."MiddleValue", u."SiblingValue", u."LeafValue", u."Discriminator", l0."Id", l0."OwnedReference_Id", l0."OwnedReference_OwnedIntValue1", l0."OwnedReference_OwnedIntValue2", o0."OwnedIntValue3", o."OwnedIntValue4", l0."OwnedReference_OwnedStringValue1", l0."OwnedReference_OwnedStringValue2", o0."OwnedStringValue3", o."OwnedStringValue4" FROM ( - SELECT b."Id", b."BaseValue", NULL::int AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" + SELECT b."Id", b."BaseValue", NULL AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" FROM "BaseEntity" AS b UNION ALL SELECT m."Id", m."BaseValue", m."MiddleValue", NULL AS "SiblingValue", NULL AS "LeafValue", 'MiddleEntity' AS "Discriminator" @@ -578,7 +578,7 @@ public override async Task Tpc_entity_owning_a_split_reference_on_base_without_t """ SELECT u."Id", u."BaseValue", u."MiddleValue", u."SiblingValue", u."LeafValue", u."Discriminator", o."BaseEntityId", o."Id", o."OwnedIntValue1", o."OwnedIntValue2", o1."OwnedIntValue3", o0."OwnedIntValue4", o."OwnedStringValue1", o."OwnedStringValue2", o1."OwnedStringValue3", o0."OwnedStringValue4" FROM ( - SELECT b."Id", b."BaseValue", NULL::int AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" + SELECT b."Id", b."BaseValue", NULL AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" FROM "BaseEntity" AS b UNION ALL SELECT m."Id", m."BaseValue", m."MiddleValue", NULL AS "SiblingValue", NULL AS "LeafValue", 'MiddleEntity' AS "Discriminator" @@ -620,7 +620,7 @@ public override async Task Tpc_entity_owning_a_split_reference_on_middle_without """ SELECT u."Id", u."BaseValue", u."MiddleValue", u."SiblingValue", u."LeafValue", u."Discriminator", o."MiddleEntityId", o."Id", o."OwnedIntValue1", o."OwnedIntValue2", o1."OwnedIntValue3", o0."OwnedIntValue4", o."OwnedStringValue1", o."OwnedStringValue2", o1."OwnedStringValue3", o0."OwnedStringValue4" FROM ( - SELECT b."Id", b."BaseValue", NULL::int AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" + SELECT b."Id", b."BaseValue", NULL AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" FROM "BaseEntity" AS b UNION ALL SELECT m."Id", m."BaseValue", m."MiddleValue", NULL AS "SiblingValue", NULL AS "LeafValue", 'MiddleEntity' AS "Discriminator" @@ -686,7 +686,7 @@ public override async Task Tpc_entity_owning_a_split_collection_on_base(bool asy """ SELECT u."Id", u."BaseValue", u."MiddleValue", u."SiblingValue", u."LeafValue", u."Discriminator", s0."BaseEntityId", s0."Id", s0."OwnedIntValue1", s0."OwnedIntValue2", s0."OwnedIntValue3", s0."OwnedIntValue4", s0."OwnedStringValue1", s0."OwnedStringValue2", s0."OwnedStringValue3", s0."OwnedStringValue4" FROM ( - SELECT b."Id", b."BaseValue", NULL::int AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" + SELECT b."Id", b."BaseValue", NULL AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" FROM "BaseEntity" AS b UNION ALL SELECT m."Id", m."BaseValue", m."MiddleValue", NULL AS "SiblingValue", NULL AS "LeafValue", 'MiddleEntity' AS "Discriminator" @@ -732,7 +732,7 @@ public override async Task Tpc_entity_owning_a_split_collection_on_middle(bool a """ SELECT u."Id", u."BaseValue", u."MiddleValue", u."SiblingValue", u."LeafValue", u."Discriminator", s0."MiddleEntityId", s0."Id", s0."OwnedIntValue1", s0."OwnedIntValue2", s0."OwnedIntValue3", s0."OwnedIntValue4", s0."OwnedStringValue1", s0."OwnedStringValue2", s0."OwnedStringValue3", s0."OwnedStringValue4" FROM ( - SELECT b."Id", b."BaseValue", NULL::int AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" + SELECT b."Id", b."BaseValue", NULL AS "MiddleValue", NULL::int AS "SiblingValue", NULL::int AS "LeafValue", 'BaseEntity' AS "Discriminator" FROM "BaseEntity" AS b UNION ALL SELECT m."Id", m."BaseValue", m."MiddleValue", NULL AS "SiblingValue", NULL AS "LeafValue", 'MiddleEntity' AS "Discriminator"