Skip to content

IsUnboundedStart/IsUnboundedEnd on the infinite range answer false in memory and true through EF Core (lower_inf) #109

Closed

Description

@CaffeinatedCoder

IsUnboundedStart() and IsUnboundedEnd() on a single range answer false for the infinite range (,). EF Core translates them to lower_inf and upper_inf, which answer true for it. A query and the same predicate in memory therefore disagree on every row holding (,), for every range type, without an error.

Int32Range.Infinite.IsUnboundedStart();   // false
Int32Range.Infinite.IsUnboundedEnd();     // false

// The same value as a one-element set answers the other way:
RangeSet<Int32Range, int>.From(Int32Range.Infinite).IsUnboundedStart();   // true

// Through EF Core, a row whose Seats column holds (,) matches:
db.Reservations.Where(r => r.Seats.IsUnboundedStart());   // lower_inf("Seats") → true
SELECT lower_inf('(,)'::int4range), upper_inf('(,)'::int4range);   -- t, t

Confirmed against PostgreSQL 17 (postgres:17-alpine) on main (7f4dc47).

Where. src/CodoMetis.ValueRanges/RangeExtensions.cs: IsUnboundedStart() => range is IUnboundedStartRange<T> (and IsUnboundedEnd likewise) is a test for the variant. RangeSet.IsUnboundedStart() tests IUnboundedStartRange<T> or IInfinityRange<T>, which is what lower_inf means. The documents promise both: the XML doc says "false for all other shapes", while docs/efcore.md says "IsUnboundedStart() becomes lower_inf" and docs/ranges.md says the state checks "match PostgreSQL's isempty, lower_inf and upper_inf".

Scope. All 11 range types (Int32Range, Int64Range, DecimalRange, DateRange, DateTimeRange, DateTimeOffsetRange, TimeRange, LocalDateRange, LocalDateTimeRange, InstantRange, YearMonthRange), through a column, a constant and a parameter. RangeSet is unaffected.

Fix (10.0.0). Pick one meaning and make memory and SQL agree. Either the range methods answer true for (,), as lower_inf and RangeSet do, or the translation becomes lower_inf(x) AND NOT upper_inf(x). Both change an answer one side gives today, so this is breaking. Remove the DifferentialFuzzParityTests candidates for this family in the fix's PR.

Found by the differential fuzzing sweep (#104), on 12 to 22 of the 200 default rows per type (every row whose left operand is (,)).

Activity

  1. added this to the 10.0.0 milestone on Oct 17, 2026
  2. added
    bugSomething isn't working
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    breakingChanges an answer or a signature — needs a major
    found-by/oracleFound by the generated-oracle machinery (claims × API surfaces)
    area/rangesRange unions, variants and the shape engines
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    package/coreAffects CodoMetis.ValueRanges
    severity/mediumInconsistency or failure in a narrow scenario
    on Oct 17, 2026
  3. CaffeinatedCoder commented on Oct 17, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Oracle check (main at 3ecfa10): would catch. DifferentialFuzzParityTests (Ranges_AgreeWithPostgres) fails on rows IsUnboundedStart Infinite× and IsUnboundedEnd Infinite×, e.g. YearMonthRange IsUnboundedStart Infinite×Finite [column]: (,) … → memory=false, EF=true, PostgreSQL=true.
    Verified by a run on 3ecfa10. At the default seed, 354 lines report SKIPPED [known candidate, #109] across every range type. It was found by this sweep.

  4. CaffeinatedCoder commented on Oct 18, 2026

    @CaffeinatedCoder
    OwnerAuthor

    RangeSetLiftParityTests (#139) finds this one again in memory alone: IsUnboundedStart()/IsUnboundedEnd() on (,) answer false, and the one-element RangeSet holding (,) answers true (it reads lower_inf/upper_inf from its first and last element). That happens on all 11 range types. The rows are registered in test/oracles/candidates.json under this issue (<Type> (,) IsUnboundedStart: / IsUnboundedEnd:), so the fix's PR removes them along with the DifferentialFuzzParityTests rows.

  5. CaffeinatedCoder commented on Oct 18, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Fixed in #168 (fix/infinite-range-is-unbounded, stacked on #167, 10.0.0). Decision: PostgreSQL is the authority, so memory moved. What is now true:

    • IsUnboundedStart() and IsUnboundedEnd() on a range are true for (,), as lower_inf/upper_inf, the EF Core translation and RangeSet already were. The XML docs say so and point at IUnboundedStartRange<T>/IUnboundedEndRange<T> for the exact shape test. IsInfinity() and IsFinite() are unchanged and follow from the three primitives in memory as in SQL.
    • The translation stays lower_inf/upper_inf; QueryTranslationTests asserts it does not narrow to exclude (,). ExecutedQueryTests.RangeShapePredicates_MatchInMemory runs the five checks over a row of each shape against the server and pins the infinite row to true.
    • The 24 candidates (2 fuzz, 22 RangeSetLiftParityTests) are removed; with the fix reverted both sweeps report them as unregistered rows again. Changelog Breaking row and docs/migration.md section added.
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/rangesRange unions, variants and the shape enginesbreakingChanges an answer or a signature — needs a majorbugSomething isn't workingfound-by/oracleFound by the generated-oracle machinery (claims × API surfaces)package/coreAffects CodoMetis.ValueRangespackage/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLseverity/mediumInconsistency or failure in a narrow scenariosilentAnswers wrongly without throwing — fixes go in the next major, never deferred

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions