diff --git a/src/EFCore.PG/Extensions/Internal/NpgsqlShapedQueryExpressionExtensions.cs b/src/EFCore.PG/Extensions/Internal/NpgsqlShapedQueryExpressionExtensions.cs
new file mode 100644
index 000000000..e234ac48c
--- /dev/null
+++ b/src/EFCore.PG/Extensions/Internal/NpgsqlShapedQueryExpressionExtensions.cs
@@ -0,0 +1,158 @@
+using System.Diagnostics.CodeAnalysis;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping;
+
+namespace Npgsql.EntityFrameworkCore.PostgreSQL.Extensions.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 static class NpgsqlShapedQueryExpressionExtensions
+{
+ ///
+ /// If the given wraps an array-returning expression without any additional clauses (e.g. filter,
+ /// ordering...), returns that expression.
+ ///
+ ///
+ /// 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 bool TryExtractArray(
+ this ShapedQueryExpression source,
+ [NotNullWhen(true)] out SqlExpression? array,
+ bool ignoreOrderings = false,
+ bool ignorePredicate = false)
+ => TryExtractArray(source, out array, out _, ignoreOrderings, ignorePredicate);
+
+ ///
+ /// If the given wraps an array-returning expression without any additional clauses (e.g. filter,
+ /// ordering...), returns that expression.
+ ///
+ ///
+ /// 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 bool TryExtractArray(
+ this ShapedQueryExpression source,
+ [NotNullWhen(true)] out SqlExpression? array,
+ [NotNullWhen(true)] out ColumnExpression? projectedColumn,
+ bool ignoreOrderings = false,
+ bool ignorePredicate = false)
+ {
+ if (source.QueryExpression is SelectExpression
+ {
+ Tables: [PgUnnestExpression { Array: var a } unnest],
+ GroupBy: [],
+ Having: null,
+ IsDistinct: false,
+ Limit: null,
+ Offset: null
+ } select
+ && (ignorePredicate || select.Predicate is null)
+ // We can only apply the indexing if the JSON array is ordered by its natural ordered, i.e. by the "ordinality" column that
+ // we created in TranslatePrimitiveCollection. For example, if another ordering has been applied (e.g. by the array elements
+ // themselves), we can no longer simply index into the original array.
+ && (ignoreOrderings
+ || select.Orderings is []
+ || (select.Orderings is [{ Expression: ColumnExpression { Name: "ordinality", TableAlias: var orderingTableAlias } }]
+ && orderingTableAlias == unnest.Alias))
+ && IsPostgresArray(a)
+ && TryGetProjectedColumn(source, out var column))
+ {
+ array = a;
+ projectedColumn = column;
+ return true;
+ }
+
+ array = null;
+ projectedColumn = null;
+ return false;
+ }
+
+ ///
+ /// If the given wraps a without any additional clauses (e.g. filter,
+ /// ordering...), converts that to a and returns that.
+ ///
+ ///
+ /// 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 bool TryConvertValuesToArray(
+ this ShapedQueryExpression source,
+ [NotNullWhen(true)] out SqlExpression? array,
+ bool ignoreOrderings = false,
+ bool ignorePredicate = false)
+ {
+ if (source.QueryExpression is SelectExpression
+ {
+ Tables: [ValuesExpression { ColumnNames: ["_ord", "Value"], RowValues.Count: > 0 } valuesExpression],
+ GroupBy: [],
+ Having: null,
+ IsDistinct: false,
+ Limit: null,
+ Offset: null
+ } select
+ && (ignorePredicate || select.Predicate is null)
+ && (ignoreOrderings || select.Orderings is []))
+ {
+ var elements = new SqlExpression[valuesExpression.RowValues.Count];
+
+ for (var i = 0; i < elements.Length; i++)
+ {
+ // Skip the first column (_ord) and copy the second (Value)
+ elements[i] = valuesExpression.RowValues[i].Values[1];
+ }
+
+ array = new PgNewArrayExpression(elements, valuesExpression.RowValues[0].Values[1].Type.MakeArrayType(), typeMapping: null);
+ return true;
+ }
+
+ array = null;
+ return false;
+ }
+
+ ///
+ /// Checks whether the given expression maps to a PostgreSQL array, as opposed to a multirange type.
+ ///
+ private static bool IsPostgresArray(SqlExpression expression)
+ => expression switch
+ {
+ { TypeMapping: NpgsqlArrayTypeMapping } => true,
+ { TypeMapping: NpgsqlMultirangeTypeMapping } => false,
+ { Type: var type } when type.IsMultirange() => false,
+ _ => true
+ };
+
+ private static bool TryGetProjectedColumn(
+ ShapedQueryExpression shapedQueryExpression,
+ [NotNullWhen(true)] out ColumnExpression? projectedColumn)
+ {
+ var shaperExpression = shapedQueryExpression.ShaperExpression;
+ if (shaperExpression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression
+ && unaryExpression.Operand.Type.IsNullableType()
+ && unaryExpression.Operand.Type.UnwrapNullableType() == unaryExpression.Type)
+ {
+ shaperExpression = unaryExpression.Operand;
+ }
+
+ if (shaperExpression is ProjectionBindingExpression projectionBindingExpression
+ && shapedQueryExpression.QueryExpression is SelectExpression selectExpression
+ && selectExpression.GetProjection(projectionBindingExpression) is ColumnExpression c)
+ {
+ projectedColumn = c;
+ return true;
+ }
+
+ projectedColumn = null;
+ return false;
+ }
+}
diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs
index aa93bc326..2224b09a2 100644
--- a/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs
+++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Extensions.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions;
using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal;
@@ -137,9 +138,11 @@ [new TableValuedFunctionExpression(tableAlias, "ST_Dump", new[] { sqlExpression
// (f above); since the table alias may get uniquified by EF, this would break queries.
// TODO: When we have metadata to determine if the element is nullable, pass that here to SelectExpression
- // Note also that with PostgreSQL unnest, the output ordering is guaranteed to be the same as the input array, so we don't need
- // to add ordering like in most other providers (https://www.postgresql.org/docs/current/functions-array.html)
- // We also don't need to apply any casts or typing, since PG arrays are fully typed (unlike e.g. a JSON string).
+
+ // Note also that with PostgreSQL unnest, the output ordering is guaranteed to be the same as the input array. However, we still
+ // need to add an explicit ordering on the ordinality column, since once the unnest is joined into a select, its "natural"
+ // orderings is lost and an explicit ordering is needed again (see #3207).
+ var (ordinalityColumn, ordinalityComparer) = GenerateOrdinalityIdentifier(tableAlias);
selectExpression = new SelectExpression(
[new PgUnnestExpression(tableAlias, sqlExpression, "value")],
new ColumnExpression(
@@ -148,8 +151,10 @@ [new PgUnnestExpression(tableAlias, sqlExpression, "value")],
elementClrType.UnwrapNullableType(),
elementTypeMapping,
isElementNullable),
- identifier: [GenerateOrdinalityIdentifier(tableAlias)],
+ identifier: [(ordinalityColumn, ordinalityComparer)],
_queryCompilationContext.SqlAliasManager);
+
+ selectExpression.AppendOrdering(new OrderingExpression(ordinalityColumn, ascending: true));
}
#pragma warning restore EF1001
@@ -274,17 +279,9 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
///
protected override ShapedQueryExpression? TranslateAll(ShapedQueryExpression source, LambdaExpression predicate)
{
- if (source.QueryExpression is SelectExpression
- {
- Tables: [var sourceTable],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Limit: null,
- Offset: null
- }
- && TryGetArray(sourceTable, out var array)
+ if ((source.TryExtractArray(out var array, ignoreOrderings: true)
+ || source.TryConvertValuesToArray(out array, ignoreOrderings: true))
+ && source.QueryExpression is SelectExpression { Tables: [{ Alias: var tableAlias }] }
&& TranslateLambdaExpression(source, predicate) is { } translatedPredicate)
{
switch (translatedPredicate)
@@ -297,7 +294,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Pattern: ColumnExpression pattern,
EscapeChar: SqlConstantExpression { Value: "" }
}
- when pattern.TableAlias == sourceTable.Alias:
+ when pattern.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source,
@@ -312,7 +309,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Pattern: ColumnExpression pattern,
EscapeChar: SqlConstantExpression { Value: "" }
}
- when pattern.TableAlias == sourceTable.Alias:
+ when pattern.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source,
@@ -326,7 +323,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Item: ColumnExpression sourceColumn,
Array: var otherArray
}
- when sourceColumn.TableAlias == sourceTable.Alias:
+ when sourceColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(source, _sqlExpressionFactory.ContainedBy(array, otherArray));
}
@@ -339,7 +336,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Left: var otherArray,
Right: PgNewArrayExpression { Expressions: [ColumnExpression sourceColumn] }
}
- when sourceColumn.TableAlias == sourceTable.Alias:
+ when sourceColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(source, _sqlExpressionFactory.ContainedBy(array, otherArray));
}
@@ -357,17 +354,9 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
///
protected override ShapedQueryExpression? TranslateAny(ShapedQueryExpression source, LambdaExpression? predicate)
{
- if (source.QueryExpression is SelectExpression
- {
- Tables: [var sourceTable],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Limit: null,
- Offset: null
- }
- && TryGetArray(sourceTable, out var array))
+ if ((source.TryExtractArray(out var array, ignoreOrderings: true)
+ || source.TryConvertValuesToArray(out array, ignoreOrderings: true))
+ && source.QueryExpression is SelectExpression { Tables: [{ Alias: var tableAlias }] })
{
// Pattern match: x.Array.Any()
// Translation: cardinality(x.array) > 0 instead of EXISTS (SELECT 1 FROM FROM unnest(x.Array))
@@ -400,7 +389,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Pattern: ColumnExpression pattern,
EscapeChar: SqlConstantExpression { Value: "" }
}
- when pattern.TableAlias == sourceTable.Alias:
+ when pattern.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source, _sqlExpressionFactory.Any(match, array, PgAnyOperatorType.Like));
@@ -414,7 +403,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Pattern: ColumnExpression pattern,
EscapeChar: SqlConstantExpression { Value: "" }
}
- when pattern.TableAlias == sourceTable.Alias:
+ when pattern.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source, _sqlExpressionFactory.Any(match, array, PgAnyOperatorType.ILike));
@@ -428,7 +417,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Item: ColumnExpression sourceColumn,
Array: var otherArray
}
- when sourceColumn.TableAlias == sourceTable.Alias:
+ when sourceColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(source, _sqlExpressionFactory.Overlaps(array, otherArray));
}
@@ -442,7 +431,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Left: var otherArray,
Right: PgNewArrayExpression { Expressions: [ColumnExpression sourceColumn] }
}
- when sourceColumn.TableAlias == sourceTable.Alias:
+ when sourceColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(source, _sqlExpressionFactory.Overlaps(array, otherArray));
}
@@ -457,7 +446,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Left: var ltree,
Right: SqlUnaryExpression { OperatorType: ExpressionType.Convert, Operand: ColumnExpression lqueryColumn }
}
- when lqueryColumn.TableAlias == sourceTable.Alias:
+ when lqueryColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source,
@@ -480,7 +469,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
// Contains/ContainedBy can happen for non-LTree types too, so check that
Right: { TypeMapping: NpgsqlLTreeTypeMapping } ltree
}
- when ltreeColumn.TableAlias == sourceTable.Alias:
+ when ltreeColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source,
@@ -502,7 +491,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Left: ColumnExpression ltreeColumn,
Right: var lquery
}
- when ltreeColumn.TableAlias == sourceTable.Alias:
+ when ltreeColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source,
@@ -523,7 +512,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
Left: ColumnExpression ltreeColumn,
Right: var lqueries
}
- when ltreeColumn.TableAlias == sourceTable.Alias:
+ when ltreeColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source,
@@ -593,16 +582,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
{
// Note that most other simplifications convert ValuesExpression to unnest over array constructor, but we avoid doing that
// here for Contains, since the relational translation for ValuesExpression is better.
- if (source.QueryExpression is SelectExpression
- {
- Tables: [PgUnnestExpression { Array: var array }],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Limit: null,
- Offset: null
- }
+ if (source.TryExtractArray(out var array, ignoreOrderings: true)
&& TranslateExpression(item, applyDefaultTypeMapping: false) is SqlExpression translatedItem)
{
(translatedItem, array) = _sqlExpressionFactory.ApplyTypeMappingsOnItemAndArray(translatedItem, array);
@@ -674,17 +654,7 @@ static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityT
protected override ShapedQueryExpression? TranslateCount(ShapedQueryExpression source, LambdaExpression? predicate)
{
// Simplify x.Array.Count() => cardinality(x.Array) instead of SELECT COUNT(*) FROM unnest(x.Array)
- if (predicate is null
- && source.QueryExpression is SelectExpression
- {
- Tables: [PgUnnestExpression { Array: var array }],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Limit: null,
- Offset: null
- })
+ if (predicate is null && source.TryExtractArray(out var array, ignoreOrderings: true))
{
var translation = _sqlExpressionFactory.Function(
"cardinality",
@@ -715,30 +685,8 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s
{
// Simplify x.Array.Concat(y.Array) => x.Array || y.Array instead of:
// SELECT u.value FROM unnest(x.Array) UNION ALL SELECT u.value FROM unnest(y.Array)
- if (source1.QueryExpression is SelectExpression
- {
- Tables: [PgUnnestExpression { Array: var array1 } unnestExpression1],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Limit: null,
- Offset: null,
- Orderings: []
- }
- && source2.QueryExpression is SelectExpression
- {
- Tables: [PgUnnestExpression { Array: var array2 }],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Limit: null,
- Offset: null,
- Orderings: []
- }
- && TryGetProjectedColumn(source1, out var projectedColumn1)
- && TryGetProjectedColumn(source2, out var projectedColumn2))
+ if (source1.TryExtractArray(out var array1, out var projectedColumn1)
+ && source2.TryExtractArray(out var array2, out var projectedColumn2))
{
Check.DebugAssert(projectedColumn1.Type == projectedColumn2.Type, "projectedColumn1.Type == projectedColumn2.Type");
Check.DebugAssert(
@@ -749,7 +697,7 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s
var inferredTypeMapping = projectedColumn1.TypeMapping ?? projectedColumn2.TypeMapping;
#pragma warning disable EF1001 // SelectExpression constructors are currently internal
- var tableAlias = unnestExpression1.Alias;
+ var tableAlias = ((SelectExpression)source1.QueryExpression).Tables.Single().Alias!;
var selectExpression = new SelectExpression(
[new PgUnnestExpression(tableAlias, _sqlExpressionFactory.Add(array1, array2), "value")],
new ColumnExpression("value", tableAlias, projectedColumn1.Type, inferredTypeMapping, projectedColumn1.IsNullable || projectedColumn2.IsNullable),
@@ -790,19 +738,7 @@ [new PgUnnestExpression(tableAlias, _sqlExpressionFactory.Add(array1, array2), "
// Simplify x.Array[1] => x.Array[1] (using the PG array subscript operator) instead of a subquery with LIMIT/OFFSET
// Note that we have unnest over multiranges, not just arrays - but multiranges don't support subscripting/slicing.
if (!returnDefault
- && source.QueryExpression is SelectExpression
- {
- Tables: [PgUnnestExpression { Array: var array }],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Orderings: [],
- Limit: null,
- Offset: null
- }
- && IsPostgresArray(array)
- && TryGetProjectedColumn(source, out var projectedColumn)
+ && source.TryExtractArray(out var array, out var projectedColumn)
&& TranslateExpression(index) is { } translatedIndex)
{
// Note that PostgreSQL arrays are 1-based, so adjust the index.
@@ -834,18 +770,9 @@ [new PgUnnestExpression(tableAlias, _sqlExpressionFactory.Add(array1, array2), "
// Some LTree translations (see LTreeQueryTest)
// Note that preprocessing normalizes FirstOrDefault(predicate) to Where(predicate).FirstOrDefault(), so the source's
// select expression should already contain our predicate.
- if (source.QueryExpression is SelectExpression
- {
- Tables: [var sourceTable],
- Predicate: var translatedPredicate,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Limit: null,
- Offset: null,
- Orderings: []
- }
- && TryGetArray(sourceTable, out var array)
+ if ((source.TryExtractArray(out var array, ignorePredicate: true)
+ || source.TryConvertValuesToArray(out array, ignorePredicate: true))
+ && source.QueryExpression is SelectExpression { Tables: [{ Alias: var tableAlias }], Predicate: var translatedPredicate }
&& translatedPredicate is null ^ predicate is null)
{
if (translatedPredicate is null)
@@ -870,7 +797,7 @@ [new PgUnnestExpression(tableAlias, _sqlExpressionFactory.Add(array1, array2), "
// Contains/ContainedBy can happen for non-LTree types too, so check that
Right: { TypeMapping: NpgsqlLTreeTypeMapping } ltree
}
- when ltreeColumn.TableAlias == sourceTable.Alias:
+ when ltreeColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source,
@@ -894,7 +821,7 @@ [new PgUnnestExpression(tableAlias, _sqlExpressionFactory.Add(array1, array2), "
Left: ColumnExpression ltreeColumn,
Right: var lquery
}
- when ltreeColumn.TableAlias == sourceTable.Alias:
+ when ltreeColumn.TableAlias == tableAlias:
{
return BuildSimplifiedShapedQuery(
source,
@@ -921,23 +848,11 @@ [new PgUnnestExpression(tableAlias, _sqlExpressionFactory.Add(array1, array2), "
{
// Translate Skip over array to the PostgreSQL slice operator (array.Skip(2) -> array[3,])
// Note that we have unnest over multiranges, not just arrays - but multiranges don't support subscripting/slicing.
- if (source.QueryExpression is SelectExpression
- {
- Tables: [PgUnnestExpression { Array: var array } unnestExpression],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Orderings: [],
- Limit: null,
- Offset: null
- }
- && IsPostgresArray(array)
- && TryGetProjectedColumn(source, out var projectedColumn)
+ if (source.TryExtractArray(out var array, out var projectedColumn)
&& TranslateExpression(count) is { } translatedCount)
{
#pragma warning disable EF1001 // SelectExpression constructors are currently internal
- var tableAlias = unnestExpression.Alias;
+ var tableAlias = ((SelectExpression)source.QueryExpression).Tables[0].Alias!;
var selectExpression = new SelectExpression(
[
new PgUnnestExpression(
@@ -984,26 +899,9 @@ [new PgUnnestExpression(tableAlias, _sqlExpressionFactory.Add(array1, array2), "
{
// Translate Take over array to the PostgreSQL slice operator (array.Take(2) -> array[,2])
// Note that we have unnest over multiranges, not just arrays - but multiranges don't support subscripting/slicing.
- if (source.QueryExpression is SelectExpression
- {
- Tables: [PgUnnestExpression { Array: var array } unnestExpression],
- Predicate: null,
- GroupBy: [],
- Having: null,
- IsDistinct: false,
- Orderings: [],
- Limit: null,
- Offset: null
- }
- && IsPostgresArray(array)
- && TryGetProjectedColumn(source, out var projectedColumn))
+ if (source.TryExtractArray(out var array, out var projectedColumn)
+ && TranslateExpression(count) is { } translatedCount)
{
- var translatedCount = TranslateExpression(count);
- if (translatedCount == null)
- {
- return base.TranslateTake(source, count);
- }
-
PgArraySliceExpression sliceExpression;
// If Skip has been called before, an array slice expression is already there; try to integrate this Take into it.
@@ -1043,10 +941,11 @@ [new PgUnnestExpression(tableAlias, _sqlExpressionFactory.Add(array1, array2), "
}
#pragma warning disable EF1001 // SelectExpression constructors are currently internal
+ var tableAlias = ((SelectExpression)source.QueryExpression).Tables[0].Alias!;
var selectExpression = new SelectExpression(
- [new PgUnnestExpression(unnestExpression.Alias, sliceExpression, "value")],
- new ColumnExpression("value", unnestExpression.Alias, projectedColumn.Type, projectedColumn.TypeMapping, projectedColumn.IsNullable),
- [GenerateOrdinalityIdentifier(unnestExpression.Alias)],
+ [new PgUnnestExpression(tableAlias, sliceExpression, "value")],
+ new ColumnExpression("value", tableAlias, projectedColumn.Type, projectedColumn.TypeMapping, projectedColumn.IsNullable),
+ [GenerateOrdinalityIdentifier(tableAlias)],
_queryCompilationContext.SqlAliasManager);
#pragma warning restore EF1001 // Internal EF Core API usage.
@@ -1069,6 +968,19 @@ [new PgUnnestExpression(unnestExpression.Alias, sliceExpression, "value")],
return base.TranslateTake(source, count);
}
+ ///
+ /// 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 bool IsNaturallyOrdered(SelectExpression selectExpression)
+ => selectExpression is { Tables: [PgUnnestExpression unnest, ..] }
+ && (selectExpression.Orderings is []
+ || selectExpression.Orderings is
+ [{ Expression: ColumnExpression { Name: "ordinality", TableAlias: var orderingTableAlias } }]
+ && orderingTableAlias == unnest.Alias);
+
///
/// 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
@@ -1178,42 +1090,6 @@ protected override bool IsOrdered(SelectExpression selectExpression)
|| selectExpression.Tables is
[PgTableValuedFunctionExpression { Name: "unnest" or "jsonb_to_recordset" or "json_to_recordset" }];
- ///
- /// Checks whether the given expression maps to a PostgreSQL array, as opposed to a multirange type.
- ///
- private static bool IsPostgresArray(SqlExpression expression)
- => expression switch
- {
- { TypeMapping: NpgsqlArrayTypeMapping } => true,
- { TypeMapping: NpgsqlMultirangeTypeMapping } => false,
- { Type: var type } when type.IsMultirange() => false,
- _ => true
- };
-
- private bool TryGetProjectedColumn(
- ShapedQueryExpression shapedQueryExpression,
- [NotNullWhen(true)] out ColumnExpression? projectedColumn)
- {
- var shaperExpression = shapedQueryExpression.ShaperExpression;
- if (shaperExpression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression
- && unaryExpression.Operand.Type.IsNullableType()
- && unaryExpression.Operand.Type.UnwrapNullableType() == unaryExpression.Type)
- {
- shaperExpression = unaryExpression.Operand;
- }
-
- if (shaperExpression is ProjectionBindingExpression projectionBindingExpression
- && shapedQueryExpression.QueryExpression is SelectExpression selectExpression
- && selectExpression.GetProjection(projectionBindingExpression) is ColumnExpression c)
- {
- projectedColumn = c;
- return true;
- }
-
- projectedColumn = null;
- return false;
- }
-
private (ColumnExpression, ValueComparer) GenerateOrdinalityIdentifier(string tableAlias)
{
_ordinalityTypeMapping ??= _typeMappingSource.FindMapping("int")!;
@@ -1238,43 +1114,6 @@ private ShapedQueryExpression BuildSimplifiedShapedQuery(ShapedQueryExpression s
new ProjectionBindingExpression(translation, new ProjectionMember(), typeof(bool?)), typeof(bool)));
#pragma warning restore EF1001
- ///
- /// Extracts the out of .
- /// If a is given, converts its literal values into a .
- ///
- private bool TryGetArray(TableExpressionBase tableExpression, [NotNullWhen(true)] out SqlExpression? array)
- {
- switch (tableExpression)
- {
- case PgUnnestExpression unnest:
- array = unnest.Array;
- return true;
-
- // TODO: We currently don't have information type information on empty ValuesExpression, so we can't transform that into an
- // array.
- case ValuesExpression { ColumnNames: ["_ord", "Value"], RowValues.Count: > 0 } valuesExpression:
- {
- // The source table was a constant collection, so translated by default to ValuesExpression. Convert it to an unnest over
- // an array constructor.
- var elements = new SqlExpression[valuesExpression.RowValues.Count];
-
- for (var i = 0; i < elements.Length; i++)
- {
- // Skip the first column (_ord) and copy the second (Value)
- elements[i] = valuesExpression.RowValues[i].Values[1];
- }
-
- array = new PgNewArrayExpression(
- elements, valuesExpression.RowValues[0].Values[1].Type.MakeArrayType(), typeMapping: null);
- return true;
- }
-
- default:
- array = null;
- return false;
- }
- }
-
private sealed class OuterReferenceFindingExpressionVisitor(TableExpression mainTable) : ExpressionVisitor
{
private bool _containsReference;
diff --git a/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs b/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs
index 1fc98797b..4b1a5d38d 100644
--- a/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs
+++ b/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs
@@ -33,19 +33,21 @@ public class NpgsqlUnnestPostprocessor : ExpressionVisitor
{
TableExpressionBase[]? newTables = null;
+ var orderings = selectExpression.Orderings;
+
for (var i = 0; i < selectExpression.Tables.Count; i++)
{
var table = selectExpression.Tables[i];
var unwrappedTable = table.UnwrapJoin();
// Find any unnest table which does not have any references to its ordinality column in the projection or orderings
- // (this is where they may appear when a column is an identifier).
+ // (this is where they may appear); if found, remove the ordinality column from the unnest call.
+ // Note that if the ordinality column is the first ordering, we can still remove it, since unnest already returns
+ // ordered results.
if (unwrappedTable is PgUnnestExpression unnest
- && !selectExpression.Orderings.Select(o => o.Expression)
+ && !selectExpression.Orderings.Skip(1).Select(o => o.Expression)
.Concat(selectExpression.Projection.Select(p => p.Expression))
- .Any(
- p => p is ColumnExpression { Name: "ordinality" } ordinalityColumn
- && ordinalityColumn.TableAlias == unwrappedTable.Alias))
+ .Any(IsOrdinalityColumn))
{
if (newTables is null)
{
@@ -65,7 +67,16 @@ public class NpgsqlUnnestPostprocessor : ExpressionVisitor
PgUnnestExpression => newUnnest,
_ => throw new UnreachableException()
};
+
+ if (orderings.Count > 0 && IsOrdinalityColumn(orderings[0].Expression))
+ {
+ orderings = orderings.Skip(1).ToList();
+ }
}
+
+ bool IsOrdinalityColumn(SqlExpression expression)
+ => expression is ColumnExpression { Name: "ordinality" } ordinalityColumn
+ && ordinalityColumn.TableAlias == unwrappedTable.Alias;
}
return base.Visit(
@@ -77,7 +88,7 @@ newTables is null
selectExpression.Predicate,
selectExpression.GroupBy,
selectExpression.Having,
- selectExpression.Orderings,
+ orderings,
selectExpression.Limit,
selectExpression.Offset));
}
diff --git a/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs
index 3f73b78cf..7d34b3711 100644
--- a/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs
+++ b/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs
@@ -1254,7 +1254,7 @@ LEFT JOIN LATERAL (
FROM unnest(p."DateTimes") WITH ORDINALITY AS d(value)
WHERE date_part('day', d.value AT TIME ZONE 'UTC')::int <> 1 OR d.value AT TIME ZONE 'UTC' IS NULL
) AS d0 ON TRUE
-ORDER BY p."Id" NULLS FIRST
+ORDER BY p."Id" NULLS FIRST, d0.ordinality NULLS FIRST
""");
}
@@ -1339,7 +1339,7 @@ ORDER BY p."Id" NULLS FIRST
LIMIT 1
) AS p0
LEFT JOIN LATERAL unnest(p0."Ints") WITH ORDINALITY AS i(value) ON TRUE
-ORDER BY p0."Id" NULLS FIRST
+ORDER BY p0."Id" NULLS FIRST, i.ordinality NULLS FIRST
""");
}
@@ -1361,7 +1361,7 @@ LEFT JOIN LATERAL (
FROM unnest(p."NullableInts") WITH ORDINALITY AS n0(value)
WHERE n0.value IS NULL
) AS n2 ON TRUE
-ORDER BY p."Id" NULLS FIRST, n1.ordinality NULLS FIRST
+ORDER BY p."Id" NULLS FIRST, n1.ordinality NULLS FIRST, n2.ordinality NULLS FIRST
""");
}
@@ -1403,7 +1403,7 @@ LEFT JOIN LATERAL (
FROM unnest(p."DateTimes") WITH ORDINALITY AS d0(value)
WHERE d0.value > TIMESTAMPTZ '2000-01-01T00:00:00Z'
) AS d2 ON TRUE
-ORDER BY p."Id" NULLS FIRST, i.ordinality NULLS FIRST, i0.value DESC NULLS LAST, i0.ordinality NULLS FIRST, d1.ordinality NULLS FIRST
+ORDER BY p."Id" NULLS FIRST, i.ordinality NULLS FIRST, i0.value DESC NULLS LAST, i0.ordinality NULLS FIRST, d1.ordinality NULLS FIRST, d2.ordinality NULLS FIRST
""");
}