Skip to content

Releases: CaffeinatedCoder/CodoMetis.ValueRanges

v8.0.0

Choose a tag to compare

@github-actions github-actions released this 17 Aug 17:55
d1c1d66

8.0.0 — 2026-08-17

Six corrections and the machinery that found them. Nothing was removed or resignatured —
package validation passes against the 7.0.0 baseline — and no signature changed anywhere.

This is a major for the same reason 7.0.0 was: several corrections change what existing calls
answer, and a caller cannot discover that from a compile error. One goes further and changes a
query that compiled and ran into one that throws. Upgrading is a code change for anyone on the
affected paths, and a floating 7.0.* reference must not pick that up silently.

What can break you, in order of likelihood:

  1. ==/!=/Equals over a server-computed value set Union now fails translation. Those
    queries were returning wrong rows; they now throw with a message naming the alternatives.
  2. RangeSet.Except(TRange) with an infinity operand returned the whole domain and now returns
    the empty set, Complement() on the infinite set with it.
  3. YearMonthRange.NextValueAfter/PreviousValueBefore now reject a non-ISO calendar instead
    of stepping in it and returning a value the type's own constructors refuse.
  4. RangeSet.Contains(T) on the infinite set threw and now answers true; the NodaTime step
    functions
    threw at a Gregorian-spelled domain maximum and now answer null. Both were loud
    failures, so only code catching them is affected.

Everything else here is internal or additive. The engines now decide on the shape pair rather than
the receiver's shape, and a pair with no arm throws instead of returning something plausible — the
fourth instance of the trap 7.0.0 documented, and the structural change that makes a fifth loud. The
release also adds four exhaustive oracles, three convention tests and a hand triage of every discard
arm in the codebase; those found four of the six corrections and are listed under Added.

Fixed

  • ⚠️ Comparing a server-computed value set Union for equality is now refused instead of
    answering wrongly.
    Union translates to array_cat, which concatenates: the result carries
    duplicates and keeps each operand's ordering, while array equality is sensitive to both. Against
    a live server {a,c} ∪ {a,b} = {a,b,c} is false for the repeated a, and — the half that
    surprises — {a,c} ∪ {b} = {a,b,c} is false too, where nothing repeats and only the order
    differs. In memory both are true. Nothing threw; the row simply did not match.

    ==, != and Equals over a union, or over anything built on one that did not restore
    canonical form, now fail translation with an exception naming the alternatives. This matches how
    Count over a union has been refused since 6.2.0, and it was the last known-wrong translation in
    the package. The order- and multiplicity-insensitive operators are unaffected and still compose
    on a union — Contains, Overlaps, IsSubsetOf, IsSupersetOf, IsEmpty, and the proper
    subset/superset pair, which is written as <@ AND NOT @> precisely so it stays insensitive.

    The refusal covers only the contexts EF must translate in full — Where, Any, All, the
    predicate overloads, the ordering and grouping keys. In a projection it still works, because
    EF falls back to client evaluation there and computes against the materialized set. That scoping
    is what keeps equality in step with the Count refusal, which reaches the same outcome by a
    different route: returning null from a translator hands the decision to EF. Materializing with
    AsEnumerable() remains the way to ask for the in-memory answer in any context.

    Not "fixed" by canonicalizing in SQL, deliberately: PostgreSQL has no array_distinct, and sorting
    inside the query orders text by the database collation rather than ordinally — silently
    disagreeing with the client's canonical order, which is the same defect one layer down.
    If a query relied on this comparison it was already returning wrong rows; it now throws.

  • The NodaTime step functions missed a domain maximum spelled in another calendar.
    LocalDateRange.NextValueAfter compared against LocalDate.MaxIsoValue with ==, and NodaTime's
    equality includes the calendar system. Of its nineteen calendars only ISO and Gregorian can
    represent 9999-12-31 at all — Gregorian shares ISO's arithmetic but is a distinct
    CalendarSystem instance, so the Gregorian spelling of the domain maximum compared unequal, the
    guard was skipped and PlusDays(1) ran off the end of the domain. (gregorianMax, +∞) came back
    as a throw where it is the empty range. Each type is now fixed the way its own documented policy
    already says: LocalDateRange normalizes to ISO before comparing, YearMonthRange rejects
    non-ISO outright rather than stepping in the caller's calendar and returning a value its own
    constructors would refuse.

  • IRangeFactory.ToString formatted an unrecognised range as "empty". All five shapes are
    named above the fallback, so it is reachable only through an IRange<T> implementation that is
    none of them — which the sealed-variant rule forbids and the type system permits, the interface
    being public. "empty" was the worst available answer: that text is what Parse round-trips,
    what the EF literal sends to PostgreSQL and what the shape matrix compares against the server, so
    such a range would have been stored, queried and asserted as the empty range with nothing raised.
    It now throws. Found by triaging every discard arm outside Internals/ — 60 of them, recorded in
    docs/discard-triage.md, of which this was the only defect.

  • BridgedElementTypeMapping produced a string element's literal by accident. string is not
    IFormattable, so it missed every named arm and reached the fallback, where ToString() returned
    it unchanged — the right answer for the wrong reason, and the arm that would have silently handed
    PostgreSQL whatever ToString produced for a genuinely unknown element type. The string case is
    named and the fallback refuses.

  • RangeSet.Contains(T) threw on the infinite set.
    RangeSet<Int32Range, int>.Infinite.Contains(value) raised
    InvalidOperationException: Range shape 'Infinity' has no lower bound for every value, where the
    answer is true — the infinite set contains everything, and Int32Range.Infinite.Contains(value)
    has always said so. The set locates the candidate element by binary-searching the sorted lower
    bounds, and its single element has no lower bound to search on; every other query on the set
    short-circuits the infinite case first, and this one did not. Loud rather than silent, unlike the
    rest of this release, and found by the new oracle below on its first run.

  • RangeSet.Except(TRange) returned the whole domain when subtracting an infinity range.
    RangeSet<Int32Range, int>.Infinite.Except(Int32Range.Infinite) answered {(,)} where X \ (-∞, +∞) is the empty set for every X, and Complement() on the infinite set was wrong through the
    same path. The set-minus-set overload has always guarded its infinite operand and the single-range
    overload answers it through its Contains guard, so — as with the empty-range containment bug in
    7.0.0 — the three overloads were answering the same question two different ways. The engine's
    discard arm supplied ∞ for the one pair it was never given: (Infinity, Infinity).

Changed

  • The Intersect, Merge and Except engines dispatch on the shape pair. Each had three
    entry points typed by the receiver's shape, and each of those switched over the operand's shape
    with a discard that rebuilt the receiver or returned Empty — the structure all four bugs shared.
    They are now one entry point per engine taking IRange<T> on both sides, switching over
    (left, right) with one arm per accepted pair, so a pair nobody handled is a missing line
    rather than something a fallback absorbs. No public signature changed and no behaviour changed
    beyond the fix above; the 3,300-comparison shape matrix agrees with PostgreSQL as before.
  • A shape dispatch with no arm for its operands throws UnreachableException naming the pair.
    C# cannot prove a switch over interface patterns exhaustive, so the discard arm cannot be removed
    — but it can stop producing values. Every one of the four bugs was a fallback returning something
    well-formed: a wrong boolean, or a range of the right shape carrying the wrong values, which looks
    correct in a debugger and disagrees only with the database. These paths are unreachable behind the
    callers' existing guards; if a future change breaks one, the first test to reach it now says which
    pair is missing.

Added

  • EngineDispatchConventionTests, which parses the shipping sources and enforces both halves of
    the rule: a switch that dispatches on range shape must throw from its discard arm, and an engine's
    entry points must take IRange<T> on both sides rather than one operand's shape. Both are
    discovered by globbing src/, and both were verified by seeding the defect they claim to catch.

  • SmallModelOracleTests — a second oracle beside the PostgreSQL shape matrix, asking set
    theory instead of the database, and needing no Docker. Every representable range over a tiny
    universe is enumerated from its specification — around 110 per domain, all five shapes and all
    four inclusiveness combinations at every bound — the expected value set is derived arithmetically
    from that specification, and every one of the ~12,100 ordered pairs is checked for all eight
    binary predicates, the four value-producing operations, and the same questions asked again at the
    RangeSet arities. About 460,000 assertions per run over the discrete and continuous domains, in
    under 200 ms.

    It exists because hand-picked representatives are how the first version of the 7.0.0 Except
    sweep reported zero disagreements on the exact defect...

Read more

v7.0.0

Choose a tag to compare

@github-actions github-actions released this 17 Aug 10:29
5e9d6f7

7.0.0 — 2026-08-17

Two workstreams land together: the validated-wrapper arities now exist for every value set family
instead of four of them, and an audit of the range and multirange types corrected five defects.

Nothing was removed or resignatured — package validation passes against the 6.3.0 baseline — and
the value set surface only grew. What makes this a major is the range half: three of its five
corrections change what existing calls answer, silently, and a silent change of answer is the kind
a caller cannot discover from a compile error.

Those three shared one shape: the EF translation was correct and the in-memory implementation was
not, so the same expression gave one answer when it ran in PostgreSQL and another when it ran in
memory. Nothing threw. If you evaluate range operations only server-side, or only in memory, you saw
consistent (in these cases consistently wrong) results either way; the disagreement was visible only
to code that did both. The remaining two were loud rather than silent — a query PostgreSQL refused
to run, and a property that threw.

Two of the three are the same mistake, and it is the third time this repository has made it: an
operation that dispatches on the receiver's shape and handles the operand's shapes in an inner
switch, where the missing arm falls through to a default that answers false or returns the
receiver unchanged. IsAdjacentTo had it in 6.2.1; IsStrictlyLeftOf and Except have it here.

The audit's durable output is ShapeMatrixParityTests, which asks PostgreSQL for all eight binary
predicates and the four value-producing operations over every ordered pair of range shapes, and
requires the model to match — some 3,300 comparisons, no exclusions.

Added

  • Eleven new wrapper arities, completing the set: Int16Set<T>, DecimalSet<T>, DateSet<T>,
    TimeSet<T>, DateTimeSet<T> and DateTimeOffsetSet<T> in the core package, and
    LocalDateSet<T>, LocalDateTimeSet<T>, InstantSet<T>, LocalTimeSet<T> and
    YearMonthSet<T> in the NodaTime satellite. Every value set family now has one, so a
    domain type backed by any supported primitive can be stored as a native PostgreSQL array
    without the domain type referencing this package.

    As before, TElement is constrained only on BCL interfaces — struct, IEquatable<T>,
    IComparable<T>, IFormattable, IParsable<T> — which is what Vogen, Metalama,
    StronglyTypedId and hand-written wrappers already emit.

  • SetTypeRegistry.RegisterFamily, the seam the NodaTime satellite registers its arities
    through. A wrapper family cannot be registered as a closed definition, because its element type
    is whatever the consumer supplies.

Changed

  • The temporal arities ask their elements for a round-trip format rather than accepting the
    element's default text form. This is the one place the wrapper contract is stricter than for the
    existing four arities, and it is not cosmetic: TimeOnly renders as 09:30 with a null format,
    DateTime as 06/15/2024 10:30:00, so an arity built the way Int32Set<T> is built would have
    stored every timestamp truncated to the second, and every DateTimeKind lost — silently, on the
    way to the column.

    Concretely, the contract for these six families is that the element's ToString("O", …) (or
    "yyyy-MM-dd" for DateSet<T>, and the ISO pattern for the NodaTime arities) is exactly the
    backing primitive's. A wrapper that forwards its format argument — the generated shape —
    satisfies it with no extra work. One that swallows the argument is rejected at the persistence
    boundary with an error naming the type and the contract, rather than storing a truncated value.

  • DateTimeSet<T> and DateTimeOffsetSet<T> normalize at the provider boundary exactly as
    their closed siblings do: wall-clock DateTimeKind.Unspecified for timestamp, UTC for
    timestamptz.

Fixed

  • ⚠️ The numeric wrapper arities ignored JsonSerializerOptions.NumberHandling. Int16Set<T>,
    Int32Set<T>, Int64Set<T> and DecimalSet<T> write their elements' JSON tokens themselves, so
    System.Text.Json never gets to apply the setting on their behalf — and they did not consult it.
    Under JsonNumberHandling.WriteAsString an arity emitted a bare number where its primitive
    sibling emitted a string:

    Int64Set              ["9007199254740993"]
    Int64Set<OrderId>     [9007199254740993]     ← before
    

    WriteAsString is switched on almost exclusively because the consumer is JavaScript, where a
    bare number above 2^53 is rounded on arrival — 9007199254740993 arrives as 9007199254740992. So
    swapping a closed set for its arity silently reintroduced, at the client only, the corruption the
    setting was turned on to prevent. Payloads change for anyone serializing a numeric wrapper arity
    under WriteAsString
    , from a number to the string their primitive sibling was already writing.
    Reads are unaffected — the numeric converters have always accepted a JSON string unconditionally.

  • DecimalRange.Length threw OverflowException for a range wider than decimal itself.
    DecimalRange.CreateFinite(decimal.MinValue, decimal.MaxValue).Length raised instead of
    answering; the span is twice decimal.MaxValue and there is no wider type to compute it in. It
    now returns null, which is the answer Int64Range.Length already gave for a count above
    long.MaxValue and documented as "too large to represent". Only a range straddling zero can
    reach it, and the refusal is exact — a span of exactly decimal.MaxValue still measures.

    This was the only measure in the family that could fail, and a property that throws breaks
    debugger evaluation and LINQ projections as much as it breaks the caller.

  • ⚠️ Except subtracted nothing when the two operands were unbounded in opposite directions.
    ((-∞,5]).Except([1,+∞)) returned {(-∞,5]} — the receiver, unchanged — where the answer is
    {(-∞,0]}, and symmetrically ([1,+∞)).Except((-∞,5]) returned {[1,+∞)} instead of {[6,+∞)}.
    RangeSet.Except reaches the same engine through its merge-join and had it too.

    This is the most damaging of the five, because the result is a well-formed range of the right
    shape carrying the wrong values
    — nothing to notice at a glance, and a subtraction that quietly
    keeps what it was asked to remove. Every element type and both discrete and continuous domains
    were affected.

    ExceptEngine dispatched on the receiver's shape; each unbounded receiver's inner switch had an
    arm for a finite operand and one for an operand unbounded the same way, but none for the
    opposing one, so the _ fallback rebuilt the receiver. That fallback is reachable only for the
    opposing-unbounded pair — an empty operand is filtered by the Overlaps guard and an infinite one
    by the Contains guard — so it was wrong on every call that reached it.

  • ⚠️ Contains and IsContainedBy now agree that the empty range is contained by everything.
    [1,5].Contains(Int32Range.Empty) returned false and now returns true, as does
    Int32Range.Empty.IsContainedBy(anything) and Int32Range.Empty.Contains(Int32Range.Empty).

    ∅ ⊆ S for every S: "every value of the inner range is also in the outer" is vacuously satisfied
    when the inner range has no values. PostgreSQL's @> answers the same, so the previous behaviour
    put the two sides of the wire in disagreement — r.Period.Contains(DateRange.Empty) matched no
    row in memory and every row in SQL.

    It also disagreed with this library. RangeSet.Contains(RangeSet) has always answered true for
    an empty operand by iterating zero elements, and RangeSet.From drops empty elements — so
    RangeSet.Empty and Int32Range.Empty are each other's normalized form, and the two overloads
    were answering the same question two ways.

    Migration. Only comparisons with an explicitly empty operand change. Code that relied on
    Contains to mean "contains and is non-empty" should say so: outer.Contains(inner) && !inner.IsEmpty(). Overlaps is unchanged and still false for an empty operand — overlap needs
    a shared value — so a guard that wanted "shares something" was always better written with it.

  • ⚠️ Int64Range.Contains(value) produced SQL PostgreSQL refused to run, whenever the value was
    a constant rather than a captured variable. The range operators are polymorphic
    (anyrange @> anyelement), and PostgreSQL resolves polymorphic operators without applying
    implicit coercions — so a bare 25, which it types as integer, does not match int8range:

    WHERE t."Tickets" @> 25          →  42883: operator does not exist: int8range @> integer
    WHERE t."Tickets" @> 25::bigint  →  runs
    

    Constant element operands now carry an explicit cast when their store type is not the one
    PostgreSQL infers from a bare numeric literal. Int64Range and
    RangeSet<Int64Range, long> were the only types affected: every other element type renders
    self-describing literal text (DATE '2024-06-15', TIMESTAMP '…'), and integer/numeric
    literals already arrive as the type their subtype wants — so no other emitted SQL changes.

    The translation test for this asserted @> and stopped there, and no test executed the query,
    which is exactly the pair of gaps that let it ship.

  • ⚠️ IsStrictlyLeftOf answered false for every range unbounded at its start, and
    IsStrictlyRightOf for every such operand. << compares the receiver's upper bound with the
    operand's lower bound, so (-∞, 5] — which has a perfectly finite upper bound — is strictly
    left of [10, 20]. The implementation switched on the receiver's shape and handled only
    IFiniteRange<T> there, while its inner switch handled unbounded operands:

    ((-∞,5]).IsStrictlyLeftOf([10,20])     false     ← before
    '(,5]'::int4range << '[10,20]'         true...
    
Read more

v6.3.0

Choose a tag to compare

@github-actions github-actions released this 16 Aug 18:06
9e76b37

6.3.0 — 2026-08-16

Three additions that close gaps in the existing surface rather than extending the model. No
breaking changes.

Added

  • RangeSet.IsInfinity() and RangeSet.IsFinite() — the two shape predicates a range had and
    its multirange counterpart did not. IsFinite() is true for a non-empty set bounded at both
    ends; IsInfinity() is true only for the set covering the whole domain.

    IsInfinity() is deliberately not IsUnboundedStart() && IsUnboundedEnd(). That equivalence
    holds for a single range, because a range is contiguous, and fails for a set:
    {(,5],[10,)} is unbounded at both ends and does not contain 7. The EF translation reflects
    the same distinction — the range predicate maps to lower_inf(x) AND upper_inf(x), the set
    predicate to equality against the infinite multirange literal, which is exact because
    PostgreSQL canonicalizes multiranges the way the model does. IsFinite() maps to
    NOT lower_inf AND NOT upper_inf AND NOT isempty for both.

  • Collection expressions for RangeSet<TRange, T> — RangeSet<Int32Range, int> set = [a, b];,
    matching what the nineteen value set types and arities already supported. Elements are normalized exactly as
    From normalizes them: empties dropped, sorted by lower bound, overlapping and adjacent
    neighbours merged, any infinity collapsing the set. A From(params ReadOnlySpan<TRange>)
    overload comes with it, alongside the existing From(IEnumerable<TRange>).

    The builder is exposed as a non-generic RangeSet.Create<TRange, T>, because a
    [CollectionBuilder] target cannot be generic. Prefer the collection expression: C# does not
    infer type arguments from constraints, so a direct call has to name both TRange and T, which
    is longer than RangeSet<TRange, T>.From already was.

  • ISpanParsable<T> on every parsable type — the eleven range types, RangeSet<TRange, T>, and
    all nineteen value set types and arities, with public Parse/TryParse overloads over
    ReadOnlySpan<char> beside the existing string ones. The literal grammars were already parsed
    over spans internally, so this exposes the parser that was always there and lets a caller parse
    a slice of a larger buffer without allocating a substring first. IParsable<T> remains
    satisfied — ISpanParsable<T> extends it.

    One consequence worth knowing if you write generic code over these types: where a type parameter
    is constrained to IRangeFactory/IValueSetFactory, both Parse overloads are now visible, and
    a string argument binds to the span overload through the implicit conversion. Every type's two
    overloads are the same call, so results do not change.

  • Length on every range type — the measure of what it covers. A discrete domain counts its
    values inclusive of both ends ([2024-01-01, 2024-01-31] measures 31 days, [1, 10] measures
    10 integers), a continuous one measures the span between the bounds. The empty range measures
    zero and every unbounded shape measures null: the two are different answers and stay
    distinguishable. The type follows the domain — long? for Int32Range/Int64Range, int?
    days for DateRange, TimeSpan? for the timestamp ranges, decimal? for DecimalRange, and
    Duration?/Period? for the NodaTime ranges, which distinguish elapsed time from a calendar
    quantity. Client-side only; it does not translate to SQL.

  • Values() on the discrete range types — enumerates the contained values ascending,
    inclusive of both bounds. Declared only by Int32Range, Int64Range, DateRange,
    LocalDateRange and YearMonthRange, so asking a continuous range for its values is a compile
    error rather than a runtime failure. An unbounded range throws immediately rather than at the
    first iteration, so the failure points at the call rather than at the foreach.

  • A bridge between the value sets and the range sets over the same discrete domain:
    Int32Set/Int64Set/DateSet (plus LocalDateSet/YearMonthSet in the NodaTime satellite)
    gain ToRangeSet(), which collapses runs of consecutive values — {1,2,3,7} becomes
    {[1,3],[7,7]} — and the matching ToInt32Set()/ToDateSet()/… expand back. The two shapes
    describe the same membership; which to store is a question of density, and a thousand
    consecutive dates are better served by one daterange than by a thousand-element array. Both
    directions are client-side: PostgreSQL converts between arrays and multiranges only through
    unnest and a custom aggregate.

  • Clamp(value) on every range — the contained value nearest the argument, or null for the
    empty range. An unbounded side never constrains.

  • An indexer on the value set types, set[0], matching what RangeSet already offered.

  • IRangeFactory<TRange, T>.IsDiscrete — a defaulted virtual static reporting whether the
    domain has a step, for generic code that cannot see which concrete type it holds. It cannot be
    derived from NextValueAfter, which returns null both for a continuous domain and for the
    last value of a discrete one; a convention test now holds the two to agreement.

Published to nuget.org through Trusted Publishing by this run. The CycloneDX SBOM for each package is attached below.

v6.2.1

Choose a tag to compare

@github-actions github-actions released this 16 Aug 14:14
8515e37

6.2.1 — 2026-08-16

Fixed

  • ⚠️ IsAdjacentTo answered false whenever the receiver was unbounded, and normalization
    inherited it.
    The predicate switched on the receiver's shape and handled only
    IFiniteRange<T>; every other shape fell through to false. Its inner switch did handle
    unbounded operands, so the relation was asymmetric — [1,3].IsAdjacentTo((,0]) was true while
    (,0].IsAdjacentTo([1,3]) was false. PostgreSQL's -|- is symmetric and answers true for
    both; the XML doc asserted the broken behaviour as if it were intended, which is why reading the
    code confirmed it.

    The consequence was not confined to the predicate. RangeSet.From and RangeSet.Union merge
    neighbours with current.IsAdjacentTo(next) after sorting by lower bound, so an unbounded-start
    element is always the receiver and always took the broken direction. Sets were built violating
    the invariant they document:

    RangeSet.From([(,0], [1,)])          was {(,0],[1,)}   now {(,)}
    blocks.Union(blocks.Complement())    was {(,0],[1,)}   now the Infinite set
    

    Two sets that should be equal compared unequal depending on how they were built, and a set
    covering the whole domain did not equal RangeSet.Infinite. Results change for any range or
    set involving an unbounded element adjacent to its neighbour
    — always from a wrong answer to
    the one PostgreSQL gives. Model-versus-server agreement is now pinned by the live suite for
    every affected shape pair. Applies to the NodaTime range types, which share the predicate.

Published to nuget.org through Trusted Publishing by this run. The CycloneDX SBOM for each package is attached below.

v6.2.0

Choose a tag to compare

@CaffeinatedCoder CaffeinatedCoder released this 15 Aug 22:12

What's new in v6.2

Two defects found by an audit, both of which had been documented as correct. That is what made them survive review: reading the code confirmed the comment, and the comment was the bug. Neither changes an outcome that was previously right.

  • ⚠️ A null range now reads back as null instead of throwing. A null Int32Range? property serialized to {"Seats":null} and threw JsonException on the way back in — the package could not read a document it had just written, so an API could return a body it was unable to accept. null is now left to System.Text.Json in both directions, as RangeSet and the value sets always did. null and the empty range stay distinct: absent is null, empty is the literal "empty". If you relied on the exception to reject a null where a non-nullable range was expected, that validation is gone — the property now receives null, as any other reference-typed property would. Malformed literals are still rejected. Applies to the NodaTime ranges too, which serialize through the same factory.
  • Count over a union reached through Remove counted shared elements twice. Union translates to array_cat, which concatenates, so Count over a server-computed union has always been refused — but the check matched only the outermost call, and array_remove preserves canonical form rather than establishing it. Against live PostgreSQL, {a,c} unioned with {a,b} answered 4 where the in-memory expression is {a,b,c} — 3. A query that previously ran now behaves differently: in a predicate it fails translation rather than filtering on an inflated number, and in a projection it falls back to client evaluation and returns the correct count.

Also in this release, for every package: symbol packages (.snupkg) and Source Link, so you can step into the code you are running and confirm it was built from the commit it claims; deterministic builds; and a CycloneDX SBOM per release. CodoMetis.ValueRanges and CodoMetis.ValueRanges.EFCore.PostgreSQL now ship their own package READMEs instead of this one.

v6.1.0

Choose a tag to compare

@CaffeinatedCoder CaffeinatedCoder released this 14 Aug 22:54

What's new in v6.1

A JSON audit, and three defects it found. All three shared one shape: System.Text.Json fell back to reflection where the library expected a converter, and the result was silence rather than an exception. Nothing that previously worked changes — every fix replaces a crash or a wrong answer.

  • ⚠️ Value set elements without a converter now serialize as their text form, not as an object. This is the one visible payload change. A validated wrapper — StringSet<PermissionKey>, GuidSet<TenantId>, … — whose element type carries no [JsonConverter] used to be handed to System.Text.Json's reflection path, which wrote [{"Value":"users.read"}], or [{}] for the generator-typical shape of a record struct over a private field. The [{}] form destroyed data on read; both disagreed with the {users.read} stored in PostgreSQL. Elements now go through the family's own text form — ["users.read"] for string- and Guid-backed sets, [1,2] for integer-backed ones, identical to the primitive each wraps — and reads re-run the element's IParsable validation. If you serialize such a set and have persisted or published the old object form, that payload shape changes. Registering a converter for the element type (on the options, the property, or the type) overrides this, exactly as before.
  • The same fix reaches the NodaTime sets, which had the identical failure — [{"Calendar":{…},"Year":2024,…}] on write, default on read. AddRangeConverters() alone is now enough; the satellite additionally exposes AddNodaTimeRangeConverters() for bare NodaTime values sitting next to a set, which the element hook does not reach. Composes with ConfigureForNodaTime in either registration order.
  • Nullable range properties no longer throw. HandleNull routed nulls into the write path, which dereferenced them: serializing an object with a null Int32Range? threw NullReferenceException. It writes null. Reads still reject a null token — use "empty".
  • Ranges reached through object no longer throw. Serialize<object>(range), an object-typed property and heterogeneous collections all present the union's sealed variant, for which the converter could not be constructed — a reflection ArgumentException escaped. Variants now serialize to the same literal, and reads into a variant-typed declaration reject a literal of the wrong shape.

New API: IValueSetFactory<TSet, T>.ElementJsonConverter (a defaulted virtual static; the interface is closed to external implementation), RangeVariantJsonConverter<TVariant, TRange, T>, and AddNodaTimeRangeConverters().

v6.0.0

Choose a tag to compare

@CaffeinatedCoder CaffeinatedCoder released this 14 Aug 21:35

What's new in v6.0

Value sets — a second type family. The package's model was never "ranges" narrowly; it is immutable, canonical-at-construction value domains with PostgreSQL-native storage shapes. RangeSet has embodied "canonical set with a native store shape" (multirange) since v2; v6 applies the same concept one level down: canonical sets of scalar values, stored as native PostgreSQL arrays (text[], uuid[], integer[], …) — deduplicated, sorted, structurally equal, with the membership algebra PostgreSQL's own array operators speak.

  • Ten closed types in the core package — StringSet, GuidSet, Int16Set, Int32Set, Int64Set, DecimalSet, DateSet, TimeSet, DateTimeSet, DateTimeOffsetSet — plus validated-wrapper arities StringSet<T>, GuidSet<T>, Int32Set<T>, Int64Set<T> for generator-produced domain values (Vogen, Metalama aspects, StronglyTypedId, hand-written wrappers), constrained only on BCL interfaces so domain types never reference this package. See Value Sets.
  • Five NodaTime types in the satellite: LocalDateSet, LocalDateTimeSet, InstantSet, LocalTimeSet, and the month-granularity YearMonthSet (stored as a month-aligned date[], like YearMonthRange's daterange).
  • Membership algebra — Contains, Overlaps, IsSubsetOf, IsSupersetOf, IsProperSubsetOf, IsProperSupersetOf, Union, Remove, Count, IsEmpty (plus client-side Intersect/Except/Add) — PostgreSQL array literals ({a,b}), JSON support through the existing converter factory, and collection expressions (StringSet tags = ["a", "b"];).
  • The EF Core packages map them by convention to native array columns — no configuration, no registration, wrapper instantiations recognized automatically — with LINQ translation to the array operator algebra (@>, &&, <@, cardinality, array_cat, array_remove). Containment always translates as @>, so a plain GIN index serves it. See Value set columns.

v6.0 contains no breaking changes; the major marks the package growing a second type family.

v5.0.0

Choose a tag to compare

@CaffeinatedCoder CaffeinatedCoder released this 13 Aug 12:26

What's new in v5.0

Two new range domains — the first additions beyond PostgreSQL's six built-ins, chosen because their element types clear the same bar (a total order the type's own comparisons agree with, and a defined step where adjacency needs one):

  • TimeRange (core package) — a time-of-day range over TimeOnly, the equivalent of the most common custom range type in PostgreSQL practice: CREATE TYPE timerange AS RANGE (subtype = time). Continuous, half-open by default, so [09:00, 12:00) and [12:00, 17:00) compose the way opening hours and shifts do. A window that crosses midnight is two ranges — which RangeSet represents naturally. See TimeRange and the custom timerange type for the EF Core mapping.
  • YearMonthRange (NodaTime satellite) — a month-granularity range over NodaTime's YearMonth for billing and reporting periods. Discrete with a one-month step: [2024-01, 2024-03] and [2024-04, 2024-06] are adjacent and merge. The EF Core NodaTime satellite stores it as a month-aligned daterange — no custom database type needed, and every operator works server-side. Conversions to and from LocalDateRange and DateInterval are included.

Both types carry the complete algebra, multiranges, literals, JSON support and aggregate overloads of the existing eight. The EF Core packages map them by convention; timerange needs two one-line opt-ins on the database side (documented below).

v5.0 contains no breaking changes to existing APIs; the major bump marks the model growing beyond the PostgreSQL built-ins.

v4.1.0

Choose a tag to compare

@CaffeinatedCoder CaffeinatedCoder released this 12 Aug 12:24

What's new in v4.1

NodaTime satellites — two new packages bring the range model to NodaTime-based projects:

  • CodoMetis.ValueRanges.NodaTime — LocalDateRange (daterange), LocalDateTimeRange (tsrange) and InstantRange (tstzrange) with the complete algebra, multiranges, literals and JSON support, plus conversions to and from NodaTime's own Interval and DateInterval. See NodaTime.
  • CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime — maps them to PostgreSQL via Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime: options.UseNpgsql(..., npgsql => npgsql.UseValueRangesNodaTime()).

The core and base EF packages are unchanged apart from the EF plugin's internal type registry becoming extensible for satellites. No source changes are required.

v4.0.0

Choose a tag to compare

@CaffeinatedCoder CaffeinatedCoder released this 10 Aug 12:47

What's new in v4.0

v4.0 completes the PostgreSQL feature matrix: every remaining range and multirange operator and function now has an in-memory implementation and a LINQ-to-SQL translation — and a new integration test suite executes the translated SQL against live PostgreSQL to prove that both worlds return identical results.

Bound accessors — lower / upper / lower_inc / upper_inc

The variants expose Start/End only where they exist structurally; the new accessors provide the dynamic view, returning T? with null for a missing bound — exactly PostgreSQL's NULL semantics:

Int32Range.CreateFinite(1, 10).LowerBound();           // 1
Int32Range.CreateUnboundedStart(5, true).LowerBound(); // null — no lower bound
Int32Range.Empty.UpperBound();                         // null
DecimalRange.CreateFinite(1m, 5m).UpperBoundInclusive(); // false — half-open default

Sorting by range start finally works straight from LINQ:

// ORDER BY lower(b."Period")
bookings.OrderBy(b => b.Period.LowerBound());

For the discrete types (int4range, int8range, daterange), PostgreSQL canonicalizes to half-open [lower, upper) while the model canonicalizes to closed [lower, upper]. The translation compensates — UpperBound() becomes upper(x) - 1 and UpperBoundInclusive() becomes NOT upper_inf(x) AND NOT isempty(x) — so server results always equal the in-memory results.

Merge — the convex hull (range_merge)

The smallest single range containing both operands. Unlike Union, the result also covers any gap between disjoint operands:

var a = Int32Range.CreateFinite(1, 3);
var b = Int32Range.CreateFinite(10, 12);

a.Union(b);  // { [1, 3], [10, 12] } — two elements, the gap stays open
a.Merge(b);  // [1, 12]              — one range, the gap is covered

RangeSet<Int32Range, int>.From([a, b]).Merge(); // [1, 12] — spans the whole set

Translates to range_merge(a, b) and range_merge(multirange).

Aggregates — range_agg / range_intersect_agg

RangeAgg() aggregates a sequence of ranges into a normalized RangeSet; RangeIntersectAgg() folds it into the common intersection (null for an empty source, matching the NULL PostgreSQL returns over zero rows):

new[] { Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(3, 8) }.RangeAgg();
// { [1, 8] }

// range_agg(b."Period") per group, straight from LINQ:
bookings.GroupBy(b => b.CustomerId)
        .Select(g => g.Select(b => b.Period).RangeAgg());

Multirange operator parity

RangeSet<TRange, T> now covers the complete multirange operator matrix, in memory and in SQL:

New RangeSet member PostgreSQL equivalent
Contains(RangeSet) @> with a multirange operand
Overlaps(RangeSet) && with a multirange operand
IsAdjacentTo(range / set) -|-
IsStrictlyLeftOf / IsStrictlyRightOf << / >>
DoesNotExtendRightOf / DoesNotExtendLeftOf &< / &>
IsEmpty() / IsUnboundedStart() / IsUnboundedEnd() isempty / lower_inf / upper_inf
LowerBound() / UpperBound() + inclusiveness lower / upper / lower_inc / upper_inc
Merge() range_merge(multirange)
== / != = / <>

Adjacency mirrors PostgreSQL exactly — it is directional through the outer edges: the operand must end exactly where the set's first element begins, or begin exactly where the set's last element ends. Touching any interior boundary, even the inner side of the first or last element, does not count (verified against live PostgreSQL):

var set = RangeSet<Int32Range, int>.From([
    Int32Range.CreateFinite(1, 3), Int32Range.CreateFinite(7, 9), Int32Range.CreateFinite(20, 22)
]);
set.IsAdjacentTo(Int32Range.CreateFinite(23, 25)); // true  — attaches after the last element
set.IsAdjacentTo(Int32Range.CreateFinite(4, 6));   // false — inner side of the first element
set.IsAdjacentTo(Int32Range.CreateFinite(10, 12)); // false — touches only the interior [7, 9]

Live-PostgreSQL integration suite

A new Testcontainers-based test project executes the translated SQL against real PostgreSQL and asserts agreement with the in-memory results: round-trips for all six range and both multirange column types, the timestamp normalization rules (DateTimeKind reinterpretation for timestamp, UTC normalization for timestamptz, DateTime.MaxValue ↔ infinity), and every v4 operation end-to-end. Docker is required; without it the suite reports Inconclusive instead of failing. This suite is what pinned down the discrete upper() compensation and the directional adjacency rule above.

Bug Fixes

RangeSet.Infinite queries no longer throw — RangeSet.Infinite.Contains(range) and RangeSet.Infinite.Overlaps(range) threw InvalidOperationException for operands with a finite bound, because the Infinity element reached the internal bound helpers that reject that shape. Both now short-circuit and return the expected result.

Breaking Changes

RangeSet == / != are now structural

RangeSet<TRange, T> defines the equality operators as value equality, delegating to Equals — consistent with the range types themselves (records) and with the SQL = the EF Core provider generates for multirange comparisons:

// v3.x — reference equality: false for distinct instances with equal content
setA == setB;

// v4.0.0 — structural equality: true when both sets normalize identically
setA == setB;

The change is silent on recompile: no compiler error flags affected sites. If you relied on reference identity, switch to ReferenceEquals(a, b).

DoesNotExtendRightOf / DoesNotExtendLeftOf match PostgreSQL for infinite bounds

An infinite bound now compares equal to another infinite bound (+∞ ≤ +∞, -∞ ≥ -∞), exactly like the &< / &> operators. Previously an unbounded receiver always returned false, even against an operand unbounded on the same side:

var a = Int32Range.CreateUnboundedEnd(5);   // [5, +∞)
var b = Int32Range.CreateUnboundedEnd(100); // [100, +∞)

// v3.x
a.DoesNotExtendRightOf(b); // false — unbounded receiver always false

// v4.0.0
a.DoesNotExtendRightOf(b); // true — +∞ ≤ +∞, matching PostgreSQL &<

Results against finite-bounded or empty operands are unchanged.

Full Changelog: v3.1.0...v4.0.0