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"