From 8a4c25b3a55e075fca7c61fceb6addfeeacd9615 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Mon, 24 Jun 2024 00:22:47 +0200 Subject: [PATCH] Add ordering by ordinality column for primitive collections (#3209) Fixes #3207 (cherry picked from commit 933bc8df4461442bf39c08cda14818b153dbb2a1) --- .../NpgsqlShapedQueryExpressionExtensions.cs | 158 +++++++++++ ...yableMethodTranslatingExpressionVisitor.cs | 255 ++++-------------- .../Internal/NpgsqlUnnestPostprocessor.cs | 27 +- .../PrimitiveCollectionsQueryNpgsqlTest.cs | 6 +- 4 files changed, 230 insertions(+), 216 deletions(-) create mode 100644 src/EFCore.PG/Extensions/Internal/NpgsqlShapedQueryExpressionExtensions.cs 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 6b929e47a..4f72fd75d 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs @@ -1,4 +1,6 @@ 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; using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; @@ -119,18 +121,25 @@ protected override ShapedQueryExpression TranslatePrimitiveCollection( // (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 unnest = new PgUnnestExpression(tableAlias, sqlExpression, "value"); + var ordinalityTypeMapping = _typeMappingSource.FindMapping(typeof(int)); selectExpression = new SelectExpression( - new PgUnnestExpression(tableAlias, sqlExpression, "value"), + unnest, columnName: "value", columnType: elementClrType, columnTypeMapping: elementTypeMapping, isColumnNullable: isElementNullable, identifierColumnName: "ordinality", identifierColumnType: typeof(int), - identifierColumnTypeMapping: _typeMappingSource.FindMapping(typeof(int))); + identifierColumnTypeMapping: ordinalityTypeMapping); + + selectExpression.AppendOrdering( + new OrderingExpression( + selectExpression.CreateColumnExpression(unnest, "ordinality", typeof(int), ordinalityTypeMapping), ascending: true)); } #pragma warning restore EF1001 @@ -265,17 +274,9 @@ protected override Expression ApplyInferredTypeMappings( /// 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: [var sourceTable] } && TranslateLambdaExpression(source, predicate) is { } translatedPredicate) { switch (translatedPredicate) @@ -348,17 +349,9 @@ protected override Expression ApplyInferredTypeMappings( /// 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: [var sourceTable] }) { // Pattern match: x.Array.Any() // Translation: cardinality(x.array) > 0 instead of EXISTS (SELECT 1 FROM FROM unnest(x.Array)) @@ -584,16 +577,7 @@ protected override Expression ApplyInferredTypeMappings( { // 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); @@ -665,17 +649,7 @@ protected override Expression ApplyInferredTypeMappings( 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", @@ -704,30 +678,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( @@ -738,8 +690,9 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s var inferredTypeMapping = projectedColumn1.TypeMapping ?? projectedColumn2.TypeMapping; #pragma warning disable EF1001 // Internal EF Core API usage. + var tableAlias = ((SelectExpression)source1.QueryExpression).Tables.Single().Alias!; var selectExpression = new SelectExpression( - new PgUnnestExpression(unnestExpression1.Alias, _sqlExpressionFactory.Add(array1, array2), "value"), + new PgUnnestExpression(tableAlias, _sqlExpressionFactory.Add(array1, array2), "value"), columnName: "value", columnType: projectedColumn1.Type, columnTypeMapping: inferredTypeMapping, @@ -782,19 +735,7 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s // 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. @@ -823,18 +764,9 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s // 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: [var sourceTable], Predicate: var translatedPredicate } && translatedPredicate is null ^ predicate is null) { if (translatedPredicate is null) @@ -910,25 +842,14 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s { // 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 // Internal EF Core API usage. + var tableAlias = ((SelectExpression)source.QueryExpression).Tables[0].Alias!; var selectExpression = new SelectExpression( new PgUnnestExpression( - unnestExpression.Alias, + tableAlias, _sqlExpressionFactory.ArraySlice( array, lowerBound: GenerateOneBasedIndexExpression(translatedCount), @@ -974,26 +895,9 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s { // 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. @@ -1033,8 +937,9 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s } #pragma warning disable EF1001 // Internal EF Core API usage. + var tableAlias = ((SelectExpression)source.QueryExpression).Tables[0].Alias!; var selectExpression = new SelectExpression( - new PgUnnestExpression(unnestExpression.Alias, sliceExpression, "value"), + new PgUnnestExpression(tableAlias, sliceExpression, "value"), "value", projectedColumn.Type, projectedColumn.TypeMapping, @@ -1063,6 +968,19 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s 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 @@ -1176,42 +1094,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; - } - /// /// PostgreSQL array indexing is 1-based. If the index happens to be a constant, just increment it. Otherwise, append a +1 in the /// SQL. @@ -1227,43 +1109,6 @@ private ShapedQueryExpression BuildSimplifiedShapedQuery(ShapedQueryExpression s Expression.Convert( new ProjectionBindingExpression(translation, new ProjectionMember(), typeof(bool?)), typeof(bool))); - /// - /// 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 : ExpressionVisitor { private readonly TableExpression _mainTable; diff --git a/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs b/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs index 826a7e2aa..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). - var unnest = table as PgUnnestExpression ?? (table as JoinExpressionBase)?.Table as PgUnnestExpression; - if (unnest is not null - && !selectExpression.Orderings.Select(o => o.Expression) + // (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.Skip(1).Select(o => o.Expression) .Concat(selectExpression.Projection.Select(p => p.Expression)) - .Any( - p => p is ColumnExpression { Name: "ordinality", Table: var ordinalityTable } - && ordinalityTable == table)) + .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 7f7448a03..d15589b71 100644 --- a/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs @@ -1190,7 +1190,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 t ON TRUE -ORDER BY p."Id" NULLS FIRST +ORDER BY p."Id" NULLS FIRST, t.ordinality NULLS FIRST """); } @@ -1279,7 +1279,7 @@ LEFT JOIN LATERAL ( FROM unnest(p."NullableInts") WITH ORDINALITY AS n0(value) WHERE n0.value IS NULL ) AS t0 ON TRUE -ORDER BY p."Id" NULLS FIRST, t.ordinality NULLS FIRST +ORDER BY p."Id" NULLS FIRST, t.ordinality NULLS FIRST, t0.ordinality NULLS FIRST """); } @@ -1321,7 +1321,7 @@ LEFT JOIN LATERAL ( FROM unnest(p."DateTimes") WITH ORDINALITY AS d0(value) WHERE d0.value > TIMESTAMPTZ '2000-01-01T00:00:00Z' ) AS t0 ON TRUE -ORDER BY p."Id" NULLS FIRST, i.ordinality NULLS FIRST, i0.value DESC NULLS LAST, i0.ordinality NULLS FIRST, t.ordinality NULLS FIRST +ORDER BY p."Id" NULLS FIRST, i.ordinality NULLS FIRST, i0.value DESC NULLS LAST, i0.ordinality NULLS FIRST, t.ordinality NULLS FIRST, t0.ordinality NULLS FIRST """); }