Repository navigation
Releases: CaffeinatedCoder/CodoMetis.ValueRanges
Release list
v8.0.0
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:
==/!=/Equalsover a server-computed value setUnionnow fails translation. Those
queries were returning wrong rows; they now throw with a message naming the alternatives.RangeSet.Except(TRange)with an infinity operand returned the whole domain and now returns
the empty set,Complement()on the infinite set with it.YearMonthRange.NextValueAfter/PreviousValueBeforenow reject a non-ISO calendar instead
of stepping in it and returning a value the type's own constructors refuse.RangeSet.Contains(T)on the infinite set threw and now answerstrue; the NodaTime step
functions threw at a Gregorian-spelled domain maximum and now answernull. 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 setUnionfor equality is now refused instead of
answering wrongly.Uniontranslates toarray_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}isfalsefor the repeateda, and — the half that
surprises —{a,c} ∪ {b} = {a,b,c}isfalsetoo, where nothing repeats and only the order
differs. In memory both aretrue. Nothing threw; the row simply did not match.==,!=andEqualsover 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
Countover 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 theCountrefusal, which reaches the same outcome by a
different route: returningnullfrom 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 orderstextby 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.NextValueAftercompared againstLocalDate.MaxIsoValuewith==, and NodaTime's
equality includes the calendar system. Of its nineteen calendars only ISO and Gregorian can
represent9999-12-31at all — Gregorian shares ISO's arithmetic but is a distinct
CalendarSysteminstance, so the Gregorian spelling of the domain maximum compared unequal, the
guard was skipped andPlusDays(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:LocalDateRangenormalizes to ISO before comparing,YearMonthRangerejects
non-ISO outright rather than stepping in the caller's calendar and returning a value its own
constructors would refuse. -
IRangeFactory.ToStringformatted an unrecognised range as"empty". All five shapes are
named above the fallback, so it is reachable only through anIRange<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 whatParseround-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 outsideInternals/— 60 of them, recorded in
docs/discard-triage.md, of which this was the only defect. -
BridgedElementTypeMappingproduced astringelement's literal by accident.stringis not
IFormattable, so it missed every named arm and reached the fallback, whereToString()returned
it unchanged — the right answer for the wrong reason, and the arm that would have silently handed
PostgreSQL whateverToStringproduced 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 boundfor every value, where the
answer istrue— the infinite set contains everything, andInt32Range.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{(,)}whereX \ (-∞, +∞)is the empty set for everyX, andComplement()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 itsContainsguard, 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,MergeandExceptengines 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 returnedEmpty— the structure all four bugs shared.
They are now one entry point per engine takingIRange<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
UnreachableExceptionnaming 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 takeIRange<T>on both sides rather than one operand's shape. Both are
discovered by globbingsrc/, 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
RangeSetarities. 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...
v7.0.0
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>andDateTimeOffsetSet<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,
TElementis 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:TimeOnlyrenders as09:30with a null format,
DateTimeas06/15/2024 10:30:00, so an arity built the wayInt32Set<T>is built would have
stored every timestamp truncated to the second, and everyDateTimeKindlost — 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"forDateSet<T>, and the ISO pattern for the NodaTime arities) is exactly the
backing primitive's. A wrapper that forwards itsformatargument — 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>andDateTimeOffsetSet<T>normalize at the provider boundary exactly as
their closed siblings do: wall-clockDateTimeKind.Unspecifiedfortimestamp, UTC for
timestamptz.
Fixed
-
⚠️ The numeric wrapper arities ignoredJsonSerializerOptions.NumberHandling.Int16Set<T>,
Int32Set<T>,Int64Set<T>andDecimalSet<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.
UnderJsonNumberHandling.WriteAsStringan arity emitted a bare number where its primitive
sibling emitted a string:Int64Set ["9007199254740993"] Int64Set<OrderId> [9007199254740993] ← beforeWriteAsStringis 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
underWriteAsString, 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.LengththrewOverflowExceptionfor a range wider thandecimalitself.
DecimalRange.CreateFinite(decimal.MinValue, decimal.MaxValue).Lengthraised instead of
answering; the span is twicedecimal.MaxValueand there is no wider type to compute it in. It
now returnsnull, which is the answerInt64Range.Lengthalready gave for a count above
long.MaxValueand documented as "too large to represent". Only a range straddling zero can
reach it, and the refusal is exact — a span of exactlydecimal.MaxValuestill 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. -
⚠️ Exceptsubtracted 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.Exceptreaches 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.ExceptEnginedispatched 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 theOverlapsguard and an infinite one
by theContainsguard — so it was wrong on every call that reached it. -
⚠️ ContainsandIsContainedBynow agree that the empty range is contained by everything.
[1,5].Contains(Int32Range.Empty)returnedfalseand now returnstrue, as does
Int32Range.Empty.IsContainedBy(anything)andInt32Range.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 answeredtruefor
an empty operand by iterating zero elements, andRangeSet.Fromdrops empty elements — so
RangeSet.EmptyandInt32Range.Emptyare 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
Containsto mean "contains and is non-empty" should say so:outer.Contains(inner) && !inner.IsEmpty().Overlapsis unchanged and stillfalsefor 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 bare25, which it types asinteger, does not matchint8range:WHERE t."Tickets" @> 25 → 42883: operator does not exist: int8range @> integer WHERE t."Tickets" @> 25::bigint → runsConstant element operands now carry an explicit cast when their store type is not the one
PostgreSQL infers from a bare numeric literal.Int64Rangeand
RangeSet<Int64Range, long>were the only types affected: every other element type renders
self-describing literal text (DATE '2024-06-15',TIMESTAMP '…'), andinteger/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. -
⚠️ IsStrictlyLeftOfansweredfalsefor every range unbounded at its start, and
IsStrictlyRightOffor 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...
v6.3.0
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()andRangeSet.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 notIsUnboundedStart() && 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 tolower_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 isemptyfor 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
Fromnormalizes them: empties dropped, sorted by lower bound, overlapping and adjacent
neighbours merged, any infinity collapsing the set. AFrom(params ReadOnlySpan<TRange>)
overload comes with it, alongside the existingFrom(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 bothTRangeandT, which
is longer thanRangeSet<TRange, T>.Fromalready was. -
ISpanParsable<T>on every parsable type — the eleven range types,RangeSet<TRange, T>, and
all nineteen value set types and arities, with publicParse/TryParseoverloads over
ReadOnlySpan<char>beside the existingstringones. 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 toIRangeFactory/IValueSetFactory, bothParseoverloads are now visible, and
astringargument binds to the span overload through the implicit conversion. Every type's two
overloads are the same call, so results do not change. -
Lengthon 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 measuresnull: the two are different answers and stay
distinguishable. The type follows the domain —long?forInt32Range/Int64Range,int?
days forDateRange,TimeSpan?for the timestamp ranges,decimal?forDecimalRange, 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 byInt32Range,Int64Range,DateRange,
LocalDateRangeandYearMonthRange, 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 theforeach. -
A bridge between the value sets and the range sets over the same discrete domain:
Int32Set/Int64Set/DateSet(plusLocalDateSet/YearMonthSetin the NodaTime satellite)
gainToRangeSet(), which collapses runs of consecutive values —{1,2,3,7}becomes
{[1,3],[7,7]}— and the matchingToInt32Set()/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 onedaterangethan by a thousand-element array. Both
directions are client-side: PostgreSQL converts between arrays and multiranges only through
unnestand a custom aggregate. -
Clamp(value)on every range — the contained value nearest the argument, ornullfor the
empty range. An unbounded side never constrains. -
An indexer on the value set types,
set[0], matching whatRangeSetalready 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 fromNextValueAfter, which returnsnullboth 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
6.2.1 — 2026-08-16
Fixed
-
⚠️ IsAdjacentToansweredfalsewhenever 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 tofalse. Its inner switch did handle
unbounded operands, so the relation was asymmetric —[1,3].IsAdjacentTo((,0])wastruewhile
(,0].IsAdjacentTo([1,3])wasfalse. PostgreSQL's-|-is symmetric and answerstruefor
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.FromandRangeSet.Unionmerge
neighbours withcurrent.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 setTwo sets that should be equal compared unequal depending on how they were built, and a set
covering the whole domain did not equalRangeSet.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
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 asnullinstead of throwing. A nullInt32Range?property serialized to{"Seats":null}and threwJsonExceptionon 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.nullis now left to System.Text.Json in both directions, asRangeSetand the value sets always did.nulland the empty range stay distinct: absent isnull, 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 receivesnull, as any other reference-typed property would. Malformed literals are still rejected. Applies to the NodaTime ranges too, which serialize through the same factory.Countover a union reached throughRemovecounted shared elements twice.Uniontranslates toarray_cat, which concatenates, soCountover a server-computed union has always been refused — but the check matched only the outermost call, andarray_removepreserves 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
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'sIParsablevalidation. 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,defaulton read.AddRangeConverters()alone is now enough; the satellite additionally exposesAddNodaTimeRangeConverters()for bare NodaTime values sitting next to a set, which the element hook does not reach. Composes withConfigureForNodaTimein either registration order. - Nullable range properties no longer throw.
HandleNullrouted nulls into the write path, which dereferenced them: serializing an object with a nullInt32Range?threwNullReferenceException. It writesnull. Reads still reject a null token — use"empty". - Ranges reached through
objectno longer throw.Serialize<object>(range), anobject-typed property and heterogeneous collections all present the union's sealed variant, for which the converter could not be constructed — a reflectionArgumentExceptionescaped. 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
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 aritiesStringSet<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-granularityYearMonthSet(stored as a month-aligneddate[], likeYearMonthRange'sdaterange). - Membership algebra —
Contains,Overlaps,IsSubsetOf,IsSupersetOf,IsProperSubsetOf,IsProperSupersetOf,Union,Remove,Count,IsEmpty(plus client-sideIntersect/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
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 overTimeOnly, 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 — whichRangeSetrepresents naturally. See TimeRange and the custom timerange type for the EF Core mapping.YearMonthRange(NodaTime satellite) — a month-granularity range over NodaTime'sYearMonthfor 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-aligneddaterange— no custom database type needed, and every operator works server-side. Conversions to and fromLocalDateRangeandDateIntervalare 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
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) andInstantRange(tstzrange) with the complete algebra, multiranges, literals and JSON support, plus conversions to and from NodaTime's ownIntervalandDateInterval. 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
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 defaultSorting 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 setTranslates 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