All notable changes to CodoMetis.ValueRanges and its satellite packages.
The four published packages share one version number (the Version property in
Directory.Build.props) and are released together, so a version appears here even when a given
package saw no source change in it. Each package additionally carries its own CHANGELOG.md,
filtered to the entries that affect it.
Versions follow Semantic Versioning. Entries are newest-first.
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.
-
⚠️ 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 howCountover 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 withAsEnumerable()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
textby 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 distinctCalendarSysteminstance, 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 indocs/discard-triage.md, of which this was the only defect. -
BridgedElementTypeMappingproduced astringelement's literal by accident.stringis notIFormattable, 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)raisedInvalidOperationException: 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).
- 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.
-
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 theRangeSetarities. 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
Exceptsweep reported zero disagreements on the exact defect it was written to catch. All five bugs of this family were seeded back in to prove it catches them: the adjacency asymmetry (6.2.1),IsStrictlyLeftOfon an unbounded start andExceptbetween opposing unbounded operands (7.0.0), and both fixes above. The model has one axiom — thatContains(T)is correct, since results are read back through it — and that is pinned by its own test rather than assumed. -
An equality sweep in
SmallModelOracleTests, closing the last surface the oracles reached at the multirange and value-set arities but not at the range's own. Two ranges are equal exactly when they hold the same values, and equal ranges hash alike — the lawDiscreteCanonicalexists to uphold, whose summary says the bounds are closed "so that structural record equality coincides with set equality", and which nothing checked. Over the integers(1,5),[2,5),(1,4]and[2,4]are four spellings of one range and equality has to see through all of them; the enumeration holds every spelling at every bound.Equals(object), theIEquatable<T>path and the==/!=operators are checked separately because they can diverge, along with reflexivity and comparison againstnulland against another type.Seeded both halves: a
Finite.Equalsthat forgets its upper bound, and aGetHashCodeon reference identity, which reports[1,1]and[1,2)— the same discrete range spelled twice — as hashing apart. The first attempt at the equality seed would not compile, since definingEqualson a record withoutGetHashCodeis CS8851, so the compiler already guards that half. -
The bound accessors and
Clampjoined the sweep, having been argued total during the discard triage rather than checked. Grounded in three independent links: nullness from the specification's shape, inclusivity cross-checked againstContains, andClampagainst the bounds those establish — predicting a bound's value directly does not work, because a discrete range canonicalizes and an exclusive continuous bound is a value the range does not contain. -
DiscreteDomainBoundaryTests, covering what no oracle can reach. At the edge of a discrete domain the successor does not exist, so(max, +∞),(-∞, min),(max, max]and[min, min)are all the empty range; the guards that enforce that — inDiscreteCanonicaland each type's two unbounded factories — were load-bearing and untested. This is the exact hole Guava'sRangehas had open since 2014 (#1767). The three small-model oracles cannot see it and not by oversight: they run over eight to fifteen grid points with bounds drawn from the interior, which is what makes them faithful, so exhaustive-over-a-small-model and boundary testing are complements. Types are discovered and domain extremes tabulated, so a discrete type added without bounds fails rather than being skipped. Proven by reverting each guard; the inclusive forms at the same bounds are asserted non-empty so the checks cannot pass by everything being empty. -
CalendarBoundaryTests, pinning the fix above along with its premise — that only ISO and Gregorian reach the ISO extremes — so a NodaTime release that widened another calendar would surface here. -
DomainBoundaryDivergenceTests, recording where the model and PostgreSQL deliberately part. The boundary is the one place aShapeMatrixParityTestsrow would be wrong to write, and it is wrong even forint4range/int8range, whose element domains matchintandlongexactly: the disagreement is about the sentinel, not the domain. Here±∞mean the absence of a bound over a domain that stops atint.MinValue/int.MaxValue; in PostgreSQL they sit outside the element type. Soint4range(2147483647, NULL, '()')raises22003on the server where the model answers empty, andint4range(NULL, -2147483648, '()')is non-empty there where the model says empty. Neither side is wrong, but a range at the boundary is not round-trip-safe — now asserted against a live server rather than left to be discovered. -
ValueSetNullContractTests, pinning by discovery that canonical form's exclusion of nulls is enforced by refusing, never by dropping. Silently discarding a null is the value-set shape of the fallback that produced five range bugs — an input nobody wrote a case for, answered plausibly: three supplied elements would yield a set of two, indistinguishable from a duplicate. Every entry point is checked — bothFromoverloads,Add,Remove,Contains, an unquotedNULLin the PostgreSQL array literal, and a JSONnullelement — and all of them already refused, so nothing changed in the library.What is new is that the rule is enforced over discovered families rather than by hand. Exactly one qualifies today: every wrapper element is a
readonly record structand every NodaTime element is a struct, sostringinStringSetis the only nullable element type among the 30. A family added later over a reference type is covered without anyone remembering to come back. Nulls stay out ofSetProbes, which feeds sweeps that build valid sets — they need their own test, not a probe entry. -
Reflect.InvokeGeneric, so the three discovery-driven suites report a failed assertion directly instead of behind aTargetInvocationExceptionframe that says nothing. -
ShapeMatrixCoverageTests, which fails when a binary range operation has no row inShapeMatrixParityTests. The matrix found three of the five bugs in the receiver-shaped-dispatch family, and its weakness is that it is a list: an operation added toRangeExtensionswithout a row there is simply not swept, and until now nothing said so — the suite stayed green while the one check that would catch the next instance quietly stopped covering it.Operations are discovered by reflection (C# 14 extension members lower to static methods, so the twelve show up as
(receiver, IRange<T>)pairs) and coverage is parsed out of the matrix's own source. The two forms are checked separately by return type: a predicate must appear in the table of(name, operator)pairs, a value-producing operation must be invoked against its SQL counterpart. That split is load-bearing — the matrix's dispatch switch calls all eight predicates, so an invocation alone would mark one covered after its row was deleted. All three ways coverage can be lost were seeded: a new operation with no row, a deleted predicate row, and a dropped value-operation sweep. -
SmallModelMultirangeOracleTests, reaching the multi-element algorithms that no oracle touched: the greedy merge, the sorted merge behindUnion, the two-pointer merge-join behindExceptand the single-pass gap walk behindComplement— the most intricate code here, each carrying a hand-written correctness argument in its doc comment, and covered by worked examples only. Over eight grid points every subset is a representable multirange, so all 256 subsets and all 65,536 ordered pairs is exhaustive over the whole multirange value space.It differs from the single-range sweep in two ways that matter. It compares results element by element against the canonical run decomposition rather than only probing membership, so an unmerged neighbour or a stray empty fails — the
RangeSetinvariant was load-bearing and unasserted, becauseContains(value)cannot see it whileCount,Equals,GetHashCode,ToStringand the EF multirange literal all can. And it feedsFroma deliberately non-canonical decomposition, since building from maximal runs asks normalization to do nothing; a seeded defect disabling adjacency merging passed every other check in the file until that path existed. Found no defects; three seeded ones — that adjacency arm, an inclusiveness flip in the complement walk, and a merge-join pointer advancing too far — are each caught.Discrete domains only, and not incidentally: a run of consecutive grid points is the canonical decomposition exactly when consecutive points are contiguous, which is false of the reals.
-
SmallModelSetOracleTests— the same treatment for the value set families, where it matters more:Intersect,ExceptandAddare deliberately client-side only, because a PostgreSQL array has no intersection, no difference and no sorted insert, so unlike the ranges there is no second implementation anywhere to cross-check against. For all 30 set types — core and NodaTime, plain and wrapper arity — it builds every subset of the probe universe (2^n, so exhaustive over the whole value space rather than a sample) and checksValues,Count,IsEmpty,Contains,Add,Remove, all five relational predicates,Union,Intersect,Except, equality and hashing over every ordered pair, plus five construction paths per subset — reversed input, duplicated input,IEnumerable, repeatedAdd, and both round trips.It found no defects, which is the honest result; seven were seeded in to show it would have. Its model reads the canonical order from
CanonicalComparer, so it verifies that every path agrees with the declared order but not that the order is the specified one — a limit recorded on the class and closed by the test below. -
ValueSetContractTests.StringBackedFamilies_SortOrdinal, pinning the one ordering claim no self-consistency check can make:StringSetand its wrapper arity must orderZebrabeforeapple. Ordinal putsZ(90) first and every culture putsapplefirst, so swappingStringComparer.OrdinalforStringComparer.InvariantCultureused to leave the whole suite green while changing what the client considers sorted — and PostgreSQL's ordering of atext[]is not the current culture's. -
A shared
SetProbesholding the value set probe table and type discovery, so the contract tests and the new oracle cannot drift apart on which families exist or what to feed them. Two tables would let a family lose its probes in one suite and go unexercised there while the other stayed green — the failure mode a discovery-driven suite has to defend against hardest. -
SetOperatorParityTests, the value set counterpart of the range shape matrix. The sets had an exhaustive in-memory oracle and example-based integration tests, and nothing in between: nothing asked PostgreSQL to confirm@>,<@,&&andcardinalityover the whole value space. It now sweeps every ordered pair of subsets of a four-element universe — through EF, so the translation is inside the loop — for three families chosen for what each can break that the others cannot:StringSet(text[], where the client's ordinal order and the database's collation disagree),Int64Set(the element literals PostgreSQL will not coerce), andStringSet<TestKey>(the wrapper arities, which reach the store through a text bridge). The three compositions onUnionthat this release kept legal are swept alongside, so that claim is now checked against a server rather than restated.Two seeds. Dropping the
NOT <@half ofIsProperSupersetOfin the translator fails all three families on exactly the equal-set rows; and refusing to translateUnion— which sends the whole projection to client evaluation, where every comparison in the sweep would still pass, since the model would be agreeing with itself — fails on the assertion that the operators are in the SQL. -
RangeAggregateParityTests.RangeAgg/RangeIntersectAggwere example-tested on both sides and swept on neither, and nothing had ever fed either aggregate an empty or an unbounded range — the two inputs whose handling is a choice rather than a consequence. Every subset of an eight-range universe holding both is now one group, over a discrete and a continuous domain, and the server's fold has to equal the client's. The empty and infinity results are additionally written down as literals rather than compared, because "whatever the other one does" is not a specification:range_aggdrops empties and absorbs into(,),range_intersect_aggempties the fold at the first empty input and treats(,)as the identity, and the two implementations agree on all of it. The one divergence — a PostgreSQL aggregate over zero rows isNULLwhereRangeAgganswers the empty set — is now executed rather than only documented.Seeding
RangeIntersectAggto skip empty inputs is what exposed the first draft comparing the server against a fold re-derived in the test rather than against the shipped aggregate; the sweep now calls the real ones, and the seed fails 26 of its 510 comparisons. -
ContinuousMultirangeParityTests, closing the hole the multirange oracle documents. That oracle is exhaustive and discrete by construction — its model is a set of grid points, and decomposition into maximal runs is canonical only where consecutive points are contiguous — so continuous adjacency deciding a merge went unswept. PostgreSQL has no such difficulty: its multirange constructor merges adjacent ranges by comparing bounds, which is the same rule stated the same way, so it is a usable oracle exactly where a point set is not. Every subset of six fragments and every ordered pair of those subsets now goes through+,-,*and the complement againstnummultirange, about 12,500 comparisons.Seeded twice. A blunt break of continuous adjacency fails this sweep and nine worked examples, so it proves only that the sweep works. Making the lower-bound comparer blind to inclusivity is the seed that shows what it adds: the entire core suite — both oracles, every algebra test, every worked example — notices only through the comparer's own unit test, while its consequence, unions that quietly stop merging, is caught by nothing at the value level. This sweep reports it in hundreds of pairs.
-
RegistryConcurrencyTests. The two registries are the only mutable process-wide state in the packages, and their design is sound — immutable snapshots, atomic replacement under a lock — but the write is a read-modify-write over the whole snapshot, so the lock is load-bearing and was the one part a later edit could drop with nothing noticing. Each registry is now hammered through both of its mutating entry points at once, with readers alongside checking the built-in registrations stay visible, over four rounds. Removing both locks fails both tests on 5 runs of 5; a single round let the set registry through about one time in three, which is why there are four.
No behaviour changed here, but one of these corrects a claim that shipped on a NuGet page, so it ships in this release rather than after it.
-
⚠️ The pattern-matching guidance was wrong, and the documented snippet did not compile. Every page showing a switch over the five range shapes said the compiler enforces the coverage. It does not: the example warnsCS8509, which is a build error underTreatWarningsAsErrors— as in this repository — so a reader's first copy-paste failed to build while the prose beside it promised a guarantee.Adding a
nullarm does not help, and that is the diagnostic worth recording: the complaint moves from'_'to'not null', which is C# declining to accept the five subtype patterns as covering a non-null range at all. Exhaustiveness analysis does not reason about a closed class hierarchy, so the private base constructor buys the runtime guarantee and none of the compile-time one.The narrower true statement — no external subtype can be declared, so the five arms are complete in fact and the discard is unreachable — now appears alongside a discard arm that throws. That is the same rule the library already imposes on its own shape dispatches, for the same reason: a fallback returning a plausible value is how four of this release's family of bugs stayed hidden. Corrected in the README,
docs/ranges.md,docs/architecture.md,CLAUDE.mdand the core package's own README, which is where it reached NuGet. -
The value set table listed six families as having no wrapper arity. 7.0.0 added all eleven and completed the set; the table had not moved. The EF package's README also linked twice into root-README anchors that have never existed there.
-
A getting-started guide (
docs/getting-started.md) — in-memory first, then entity, migration and a translated query — and the migration guide now covers v7 and v8, having stopped at v3.x while the package shipped 8.0.0. The README leads with installation and code instead of positioning, which moves todocs/why.md.
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.
-
Eleven new wrapper arities, completing the set:
Int16Set<T>,DecimalSet<T>,DateSet<T>,TimeSet<T>,DateTimeSet<T>andDateTimeOffsetSet<T>in the core package, andLocalDateSet<T>,LocalDateTimeSet<T>,InstantSet<T>,LocalTimeSet<T>andYearMonthSet<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.
-
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 fortimestamptz.
-
⚠️ 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 abovelong.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 doesInt32Range.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 — soRangeSet.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.
Int64RangeandRangeSet<Int64Range, long>were the only types affected: every other element type renders self-describing literal text (DATE '2024-06-15',TIMESTAMP '…'), andinteger/numericliterals 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, andIsStrictlyRightOffor 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 onlyIFiniteRange<T>there, while its inner switch handled unbounded operands:((-∞,5]).IsStrictlyLeftOf([10,20]) false ← before '(,5]'::int4range << '[10,20]' true ← PostgreSQL, and now the modelThe disagreement was between the two sides of the wire: the EF translation emits
<<and was always right, so the same predicate answeredtruewhen it ran in the database andfalsewhen it ran in memory — over the same two values.RangeSet.IsStrictlyLeftOf/RightOfinherited it through their outermost element, so a one-element multirange{(,5]}was affected too.This is the same receiver-vs-operand asymmetry as the
IsAdjacentTobug fixed in 6.2.1, in the one other predicate whose answer depends on the receiver's shape. It is now decided by reading the two bounds rather than by switching on the receiver, so the two directions cannot drift again. Behaviour changes for anyone comparing an unbounded-start range withIsStrictlyLeftOforIsStrictlyRightOfin memory — from a wrongfalseto the answer PostgreSQL already gave.
-
A temporal arity's JSON is not byte-identical to its closed sibling's, though the token type and the value are the same and each payload deserializes into the other's type. The round-trip format always writes seven fraction digits where System.Text.Json trims them, and the default encoder escapes
+:DateTimeOffsetSet ["2024-06-15T10:30:00+02:00"] DateTimeOffsetSet<T> ["2024-06-15T10:30:00.0000000+02:00"]The string, Guid, integer and decimal arities remain byte-identical to their siblings. Give the element type its own
[JsonConverter]if an existing response shape matters. -
Counton a value set carries the canonical-writers precondition, as==does. The documentation claimed only==did.Counttranslates tocardinality, which ignores order but not duplicates, so a row another tool stored as{b,a,b}reads back as the two-element set{a,b}while the server counts three. No behaviour changed —docs/efcore.mdis corrected and the live suite now pins it.IsEmptyis unaffected.
Three additions that close gaps in the existing surface rather than extending the model. No breaking changes.
-
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 toNOT 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 asFromnormalizes 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 overReadOnlySpan<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 to
IRangeFactory/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, andDuration?/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 throughunnestand 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.
- Parse and JSON rejections no longer echo the whole input. A malformed range, multirange or
array literal used to come back in the
FormatException— and a bad bound or element chained the BCL's exception, whose message embeds the text again — so a megabyte of hostile input became a megabyte of exception message, copied into every log sink and, in development, returned to the client. Messages now carry a 64-character excerpt plus the input's length, and element failures report the inner parser's reason as an excerpt instead of chaining it. If you matched on the full message text or readInnerExceptionon these failures, that shape has changed. Exception types are unchanged:FormatException,OverflowException,JsonException;TryParsestill never throws. - Tests: a parser resilience suite (
ParserResilienceTests) now backs the "denial of service through parsing" line in SECURITY.md — megabyte-scale malformed literals and 200,000-element sets are accepted or rejected in bounded time,TryParsenever throws, and no rejection echoes the payload.
-
⚠️ IsAdjacentToansweredfalsewhenever the receiver was unbounded, and normalization inherited it. The predicate switched on the receiver's shape and handled onlyIFiniteRange<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 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.
6.2.0 — 2026-08-16
⚠️ A null range now reads back asnullinstead of throwing. Writing a nullInt32Range?property emitted{"Seats":null}, and reading that same document threwJsonException— the package could not read a payload it had just written, so an ASP.NET Core API could return a body it was unable to accept.nullis now left to System.Text.Json in both directions, as for any other reference type, matching whatRangeSetand the value sets already 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, exactly as any other reference-typed property would. Malformed literals are still rejected.
Countover a union wrapped inRemovecounted shared elements twice.Uniontranslates toarray_cat, which concatenates rather than deduplicating, soCountover a server-computed union has always been refused rather than answered. The refusal matched only the outermost call, andarray_removepreserves canonical form rather than establishing it — soTags.Union(Other).Remove(x).Countslipped through tocardinalityover the concatenation. Against live PostgreSQL,{a,c}unioned with{a,b}counted 4 where the in-memory expression is{a,b,c}— 3. AWhereon that count filtered on a number that was quietly too large; aSelectreturned it. Canonicality-preserving wrappers are now looked through, so the expression is refused in a predicate and falls back to client evaluation in a projection, where it answers 3.
6.1.0 — 2026-08-15
A System.Text.Json audit and the three defects it found. All three shared one shape: the serializer fell back to reflection where the library expected a converter, and the result was silence rather than an exception. Every fix replaces a crash or a wrong answer.
- Value set elements without a converter serialized as objects. A validated wrapper —
StringSet<PermissionKey>,GuidSet<TenantId>, … — whose element type carried no[JsonConverter]was handed to the reflection path, which wrote[{"Value":"users.read"}], or[{}]for the generator-typical record struct over a private field. The[{}]form destroyed data on read, and both disagreed with the{users.read}stored in PostgreSQL. Elements now go through the family's own text form, and reads re-run the element'sIParsablevalidation. Payload change: string- and Guid-backed sets now write["users.read"], integer-backed ones write[1,2]— indistinguishable from the primitive each wraps. If you persisted or published the old object form, that payload shape changes. A registered converter still wins. - The NodaTime sets had the identical failure —
[{"Calendar":{…},"Year":2024,…}]on write,defaulton read. - Nullable range properties threw.
HandleNullrouted nulls into the write path, which dereferenced them: serializing an object with a nullInt32Range?threwNullReferenceException. It now writesnull. Reads still reject a null token — use"empty". - Ranges reached through
objectthrew.Serialize<object>(range), anobject-typed property and heterogeneous collections all present the union's sealed variant, for which the converter could not be constructed, and a reflectionArgumentExceptionescaped.
IValueSetFactory<TSet, T>.ElementJsonConverter— a defaulted virtual static hook consulted only when the element type has no registered converter. The interface stays closed to external implementation.RangeVariantJsonConverter<TVariant, TRange, T>.AddNodaTimeRangeConverters()in the NodaTime satellite, for bare NodaTime values sitting next to a set, which the element hook does not reach. Composes withConfigureForNodaTimein either registration order.
6.0.0 — 2026-08-14
Value sets — a second type family. The model was never "ranges" narrowly; it is immutable,
canonical-at-construction value domains with PostgreSQL-native storage shapes. RangeSet has
embodied that for multiranges since v2; v6 applies it one level down, to canonical sets of scalar
values stored as native PostgreSQL arrays.
- Ten closed set types in the core package —
StringSet,GuidSet,Int16Set,Int32Set,Int64Set,DecimalSet,DateSet,TimeSet,DateTimeSet,DateTimeOffsetSet— plus the validated-wrapper aritiesStringSet<T>,GuidSet<T>,Int32Set<T>,Int64Set<T>for generator-produced domain values (Vogen, Metalama, StronglyTypedId, hand-written), constrained only on BCL interfaces so domain types never reference this package. - Five NodaTime set types in the satellite:
LocalDateSet,LocalDateTimeSet,InstantSet,LocalTimeSet, and the month-granularityYearMonthSet. - Membership algebra —
Contains,Overlaps,IsSubsetOf,IsSupersetOf,IsProperSubsetOf,IsProperSupersetOf,Union,Remove,Count,IsEmpty, plus client-side-onlyIntersect,ExceptandAdd— with PostgreSQL array literals, JSON support and collection expressions. - EF Core mapping by convention to native array columns, wrapper instantiations recognized
automatically, translating to
@>,&&,<@,cardinality,array_catandarray_remove. Containment always translates as@>, so a plain GIN index serves it.
No breaking changes; the major marks the package growing a second type family.
5.0.0 — 2026-08-13
TimeRange(core) — a time-of-day range overTimeOnly, matching the most common custom range type in PostgreSQL practice (CREATE TYPE timerange AS RANGE (subtype = time)). Its EF mapping needsHasPostgresRangeplusEnableUnmappedTypeson the database side.YearMonthRange(NodaTime satellite) — a month-granularity range overYearMonthfor billing and reporting periods, stored by the EF satellite as a month-aligneddaterange, so no custom database type is required. Conversions to and fromLocalDateRangeandDateIntervalincluded.
Both carry the complete algebra, multiranges, literals, JSON support and aggregate overloads of the existing eight. No breaking changes; the major marks the model growing beyond the PostgreSQL built-ins.
4.1.0 — 2026-08-12
CodoMetis.ValueRanges.NodaTime—LocalDateRange(daterange),LocalDateTimeRange(tsrange) andInstantRange(tstzrange), with conversions to and from NodaTime's ownIntervalandDateInterval.CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime— maps them viaNpgsql.EntityFrameworkCore.PostgreSQL.NodaTime, enabled withnpgsql.UseValueRangesNodaTime().
- The EF plugin's internal type registry became extensible so satellites can register their own range types. No source changes required in consuming code.
4.0.0 — 2026-08-10
PostgreSQL feature-matrix completion — every remaining range and multirange operator and function gained an in-memory implementation and a LINQ-to-SQL translation.
- Bound accessors —
LowerBound()/UpperBound()returningT?with PostgreSQL'sNULLsemantics, andLowerBoundInclusive()/UpperBoundInclusive()mirroringlower_inc/upper_inc, on ranges and onRangeSet. Merge— the smallest single range spanning both operands including any gap (range_merge).- Aggregates —
RangeAgg()andRangeIntersectAgg()(range_agg,range_intersect_agg), translated insideGroupByprojections. - Multirange operator parity —
Contains,Overlaps,IsAdjacentTo,IsStrictlyLeftOf/RightOf,DoesNotExtendLeftOf/RightOfonRangeSet, plus the state checksIsEmpty(),IsUnboundedStart(),IsUnboundedEnd(). - Live-PostgreSQL integration suite via Testcontainers, asserting the translated SQL agrees with the in-memory results.
- Breaking —
==/!=onRangeSetis now structural equality, consistent with the range records and with the SQL=the provider generates. Call sites that relied on reference identity must switch toReferenceEquals. - Breaking —
DoesNotExtendRightOf/LeftOfnow match PostgreSQL for infinite bounds. An infinite bound compares equal to another infinite bound (+∞ ≤ +∞), so an unbounded receiver no longer always returnsfalse. Results against finite-bounded or empty operands are unchanged.
RangeSet.Infinite.Contains(range)and.Overlaps(range)threwInvalidOperationExceptionfor operands with a finite bound.
3.1.0 — 2026-06-17
- Performance —
RangeSet<TRange, T>now exploits its sorted, disjoint, non-adjacent invariant:Contains/Overlapsgo from O(n) to O(log n),Union/Intersect/Exceptbecome merge-joins over two pre-sorted streams,ExceptfromInfinitebecomes a single-pass complement walk, and single-elementFromtakes a zero-allocation fast path. No public API or result changed.
RangeSet<TRange, T>.LowerBoundComparer, exposing the set's internal lower-bound ordering as a publicIComparer<TRange>singleton (alsoRangeLowerBoundComparer<TRange, T>.Instance).
- Quoted range bounds now unescape
\"→"and\\→\on parse, so element types whose stringification contains quotes or backslashes round-trip correctly.
3.0.0 — 2026-06-11
CodoMetis.ValueRanges.EFCore.PostgreSQL— the EF Core (Npgsql) plugin, mapping the range types to PostgreSQL range columns and translating the algebra from LINQ to SQL.
- Breaking — the state checks are extension methods, not extension properties.
IsEmpty,IsFinite,IsInfinity,IsUnboundedStartandIsUnboundedEndneed parentheses at every call site. Extension properties cannot appear in LINQ expression trees, which blocked SQL translation; as methods they translate.
2.0.0 — 2026-06-11
Parse/TryParseandToStringsupport for PostgreSQL range literals.
- Breaking —
ToString()returns a PostgreSQL range literal ([1,10]) rather than the default C# record representation (Finite { Start = 1, End = 10, … }). Code depending on the old format for logging, display, serialization or string comparison must be updated.
1.0.0 — 2026-06-10
Initial release: the six PostgreSQL range domains as discriminated unions of five sealed variants
(Finite, UnboundedStart, UnboundedEnd, EmptyRange, Infinity), the interval algebra, and
RangeSet<TRange, T> as an always-normalized multirange.