Repository navigation
Description
Problem
docs/ranges.md#sort-ranges documents a deliberate difference: "PostgreSQL puts NULL last in an ascending ORDER BY. In memory, null sorts first. On a nullable column, the two orders differ only in where the nulls go." The page doesn't say that the same holds for an ordering by a bound, where the NULLs come from non-null ranges and so appear on a required column too. The README and docs/efcore.md show exactly that query as their first ordering example: bookings.OrderBy(b => b.Period.LowerBound()); // ORDER BY lower(b."Period").
Measured 2026-10-24 on PostgreSQL 16, over a required Int32Range column. The rows by id are 1 [1,20], 2 [1,1], 3 [20,20], 4 (,1], 5 (,20], 6 [1,), 7 [20,), 8 (,) and 9 empty:
| Query | PostgreSQL | Memory |
|---|---|---|
OrderBy(r => r.Seats.LowerBound()).ThenBy(r => r.Id) |
1,2,6,3,7,4,5,8,9 | 4,5,8,9,1,2,6,3,7 |
OrderByDescending(r => r.Seats.UpperBound()).ThenBy(r => r.Id) |
6,7,8,9,1,3,5,2,4 | 1,3,5,2,4,6,7,8,9 |
So a grid sorted by "from" shows the open-start and empty rows at the bottom when it sorts in the database, and at the top in memory. Ordering by the range itself (OrderBy(r => r.Seats)) uses PostgreSQL's range order, where a missing lower bound sorts first, and agrees (RangeOrderParityTests).
Change
Follow the documented decision and say where it applies:
- Extend the "Good to know" bullet in
docs/ranges.md#sort-ranges, or add one underdocs/efcore.md#read-bounds-merge-ranges-and-aggregate. An ordering byLowerBound()orUpperBound()puts the empty rows and the rows without that bound where PostgreSQL puts NULLs, also on a required column. To sort them as memory does, order byIsUnboundedStart()/IsEmpty()first, or by the range. - Say so beside the README and
docs/efcore.mdexamples that order byLowerBound().
Rewriting the ordering in the query interceptor, so a bound's NULL sorts as in memory, would depart from that decision for bounds only. That is the maintainer's call, not this issue's.
Done when
The sentence is in the docs and quoted by a claim. The claim is held by a test that orders a column of every shape by each bound in both directions and asserts the documented placement.
Activity
[-]OrderBy(LowerBound()) and OrderByDescending(UpperBound()) put open-ended and empty rows at the other end than memory[/-][+]Docs: ordering by LowerBound() or UpperBound() puts open-ended and empty rows where PostgreSQL puts NULLs, also on a required column[/+]docs: ordering by a bound puts the rows without it where PostgreSQL p…
Merge pull request #374 from CaffeinatedCoder/docs/364-bound-ordering…