From 32c2baac5209aaebade01d5ef287db3d9fb0a2fa Mon Sep 17 00:00:00 2001 From: Sanjay Date: Tue, 29 Sep 2026 20:54:02 -0400 Subject: [PATCH] Preserve descending ordinality so Last() on a PostgreSQL array returns the last element. The unnest cleanup treated that sort as redundant and collapsed Last() to LIMIT 1. Co-authored-by: Cursor --- .../Internal/NpgsqlUnnestPostprocessor.cs | 15 ++++++-- .../PrimitiveCollectionsQueryNpgsqlTest.cs | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs b/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs index b4a1d578c3..aa975afb0b 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlUnnestPostprocessor.cs @@ -44,12 +44,17 @@ public class NpgsqlUnnestPostprocessor : ExpressionVisitor // 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); 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. + // Note that if the ordinality column is the first ordering and it is ascending, we can still remove it, since + // unnest already returns ordered results in ascending ordinality order. Descending ordinality ordering must be + // kept (along with WITH ORDINALITY), otherwise Last()/LastOrDefault() incorrectly returns the first element + // (see https://github.com/npgsql/efcore.pg/issues/3909). if (unwrappedTable is PgUnnestExpression unnest && !selectExpression.Orderings.Skip(1).Select(o => o.Expression) .Concat(selectExpression.Projection.Select(p => p.Expression)) - .Any(IsOrdinalityColumn)) + .Any(IsOrdinalityColumn) + && (orderings.Count == 0 + || !IsOrdinalityColumn(orderings[0].Expression) + || orderings[0].IsAscending)) { if (newTables is null) { @@ -75,6 +80,10 @@ public class NpgsqlUnnestPostprocessor : ExpressionVisitor orderings = orderings.Skip(1).ToList(); } } + else if (newTables is not null) + { + newTables[i] = table; + } bool IsOrdinalityColumn(SqlExpression expression) => expression is ColumnExpression { Name: "ordinality" } ordinalityColumn diff --git a/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs index bf7b1ce344..e408711f9b 100644 --- a/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/PrimitiveCollectionsQueryNpgsqlTest.cs @@ -1489,6 +1489,25 @@ FROM unnest(p."Ints") AS i(value) """); } + [ConditionalFact] + public virtual async Task Column_collection_Last() + { + await AssertQuery( + ss => ss.Set().Where(c => c.Ints.Last() == 10), + ss => ss.Set().Where(c => (c.Ints.Length >= 1 ? c.Ints.Last() : -1) == 10)); + + AssertSql( + """ +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."NullableString", p."NullableStrings", p."NullableWrappedId", p."NullableWrappedIdWithNullableComparer", p."String", p."Strings", p."WrappedId" +FROM "PrimitiveCollectionsEntity" AS p +WHERE ( + SELECT i.value + FROM unnest(p."Ints") WITH ORDINALITY AS i(value) + ORDER BY i.ordinality DESC NULLS LAST + LIMIT 1) = 10 +"""); + } + public override async Task Column_collection_FirstOrDefault() { await base.Column_collection_FirstOrDefault(); @@ -1504,6 +1523,24 @@ FROM unnest(p."Ints") AS i(value) """); } + [ConditionalFact] + public virtual async Task Column_collection_LastOrDefault() + { + await AssertQuery( + ss => ss.Set().Where(c => c.Ints.LastOrDefault() == 10)); + + AssertSql( + """ +SELECT p."Id", p."Bool", p."Bools", p."DateTime", p."DateTimes", p."Enum", p."Enums", p."Int", p."Ints", p."NullableInt", p."NullableInts", p."NullableString", p."NullableStrings", p."NullableWrappedId", p."NullableWrappedIdWithNullableComparer", p."String", p."Strings", p."WrappedId" +FROM "PrimitiveCollectionsEntity" AS p +WHERE COALESCE(( + SELECT i.value + FROM unnest(p."Ints") WITH ORDINALITY AS i(value) + ORDER BY i.ordinality DESC NULLS LAST + LIMIT 1), 0) = 10 +"""); + } + public override async Task Column_collection_Single() { await base.Column_collection_Single();