Skip to content

Opt-in strict finite factory: refuse start > end as written, translated to the bare PostgreSQL constructor #429

Open

Description

@CaffeinatedCoder

Summary

CreateFinite returns the empty range when start > end. That's right for computed bounds: "from the later start to the earlier end" should be empty when the inputs don't overlap. But it's silent for swapped arguments or a bad row (To < From), and the empty range then flows into Contains and Overlaps as a plausible false. The literal parser has refused inverted bounds since 10.0.0 (#114), as PostgreSQL's range_in does. The factory has no opt-in way to do the same.

Add an opt-in strict finite factory that refuses start > end, and leave CreateFinite as it is.

Proposal

  • The rule is the parser's (RangeFormat.Finite): compare the bounds as written, before a discrete type closes them. Only start > end is refused. (1,2) over integers and [x,x) stay a legitimate Empty. That keeps "inverted" (probably a bug) apart from "empty once the bounds are closed" (a correct answer).

  • Shape of the API, still to decide:

    • a throwing factory (name open, e.g. CreateFiniteStrict or CreateFiniteOrThrow) that throws ArgumentException naming both bounds; and/or
    • TryCreateFinite(start, end, startInclusive, endInclusive, out TRange range), returning false only for inverted bounds.

    Only the throwing one can be translated: an out parameter can't appear in an expression tree, so TryCreateFinite would be client-only.

  • EF translation: the throwing factory becomes PostgreSQL's bare range constructor, daterange(lower, upper, '[]'), without the CASE WHEN lower <= upper guard that GuardedFiniteRangeConstructor adds for CreateFinite. PostgreSQL then raises range lower bound must be less than or equal to range upper bound, so memory and the server both refuse. Rule (c) still applies: NULL over a NULL element (NullOverNullElement).

  • Every range type gets it: the eight core types and the four NodaTime ones. YearMonthRange (a month-aligned daterange) and the custom store types (timerange, float8range, float4range) need their constructor behaviour measured, not assumed.

Open questions

  • Does it go on IRangeFactory<TRange,T>? CreateFinite is abstract static there. A new abstract static member breaks anyone implementing the interface outside the library. A static virtual member with a default implementation built on CreateFinite plus the comparison doesn't, and gives generic code the strict path too.
  • Which exception: ArgumentException (it's an argument check), or ArgumentOutOfRangeException on end?
  • The float types already throw ArgumentException for NaN or an infinity as a bound. The strict factory keeps that check.

Done when

  • Each range type has the strict factory, and a test reverting the check shows the test fails (AGENTS.md, workflow step 4).
  • A live integration test on PostgreSQL 16 and 18 shows the translated factory raising the error for inverted column values, matching memory, and building the same range as CreateFinite otherwise. Covers every element type, NULL elements and the domain extremes.
  • The new public members have a claim in test/oracles/claims.json (OracleSurfaceCoverageTests).
  • docs/ranges.md and docs/efcore.md say when to use which factory, with an example, and the changelogs have their rows.
  • Lands on main first, then cherry-picked with -x into release/10.x as a 10.x minor (it's an addition, not breaking).

Not in scope: changing CreateFinite's own answer. That would turn a value into an exception (breaking) and penalise the computed-bounds idiom it exists for.

Activity

  1. added
    enhancementNew feature or request
    area/rangesRange unions, variants and the shape engines
    package/nodatimeAffects CodoMetis.ValueRanges.NodaTime
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    package/coreAffects CodoMetis.ValueRanges
    on Oct 25, 2026
  2. CaffeinatedCoder commented on Oct 25, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Decision (2026-10-25): no Option/Result in the core

    CreateFinite keeps its return type (TRange).

    • Inverted bounds give Empty, which is a real value, so there's no failure to report. Option<TRange> would give two ways of saying "nothing": None and Some(Empty).
    • CreateFinite translates to PostgreSQL's range constructor inside queries (DateRange.CreateFinite(b.From, b.To).Contains(day), docs/efcore.md). A call returning Result needs Match or TryGetValue to get the range back, and EF can't translate that.
    • IRangeFactory<TRange,T>.CreateFinite is abstract static and returns TRange. The engines, the literal parser and the EF registry all build ranges through it.

    No reference to CodoMetis.TypeKit from the core packages, on 10.x or 11.x.

    • TypeKit's core package passes its analyzers on to anyone who references it (PrivateAssets="none"), so every ValueRanges consumer would get CMTK rules in their build without choosing TypeKit.
    • With Option/Result in ValueRanges' public API, every TypeKit major would become a ValueRanges major. TypeKit versions by API and plans 3.0.0 (C# 15 unions, dropping net10.0) around .NET 12, while ValueRanges' lines follow .NET.
    • An application on another TypeKit major would get a version conflict it can't resolve itself.
    • The core package has no package references today, and should stay that way.
    • C# 15 unions ship no standard Option/Result, so .NET 11 doesn't change this.

    A Result-returning strict factory, if anyone wants one, goes in an optional satellite (CodoMetis.ValueRanges.TypeKit, like the NodaTime one). C# 14 static extension members let it add the factory to the range types without the core knowing about it:

    extension(DateRange)
    {
        public static Result<DateRange, Fault<RangeFault>> CreateFiniteChecked(DateOnly start, DateOnly end) => …;
    }

    It applies the rule this issue defines for the strict factory and is client-only, like TryCreateFinite. Build it only once a consumer asks for it. Out of scope for this issue.

    What this issue still covers: the throwing variant, which translates to the bare constructor, and possibly TryCreateFinite. Both are BCL-only.

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 enginesenhancementNew feature or requestpackage/coreAffects CodoMetis.ValueRangespackage/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLpackage/nodatimeAffects CodoMetis.ValueRanges.NodaTime

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions