Skip to content

Docs: ordering by LowerBound() or UpperBound() puts open-ended and empty rows where PostgreSQL puts NULLs, also on a required column #359

Closed

Description

@CaffeinatedCoder

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 under docs/efcore.md#read-bounds-merge-ranges-and-aggregate. An ordering by LowerBound() or UpperBound() 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 by IsUnboundedStart() / IsEmpty() first, or by the range.
  • Say so beside the README and docs/efcore.md examples that order by LowerBound().

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

  1. added this to the 11.0.0 milestone on Oct 24, 2026
  2. added
    bugSomething isn't working
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    area/rangesRange unions, variants and the shape engines
    area/range-setRangeSet and multirange algebra
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    package/efcore-nodatimeAffects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime
    severity/mediumInconsistency or failure in a narrow scenario
    on Oct 24, 2026
  3. removed
    bugSomething isn't working
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    severity/mediumInconsistency or failure in a narrow scenario
    on Oct 24, 2026
  4. changed the title [-]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[/+] on Oct 24, 2026
  5. added 2 commits that reference this issue on Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/range-setRangeSet and multirange algebraarea/rangesRange unions, variants and the shape enginesdocumentationImprovements or additions to documentationpackage/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLpackage/efcore-nodatimeAffects CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions