Repository navigation
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 ← 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 andfalse
when 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 withIsStrictlyLeftOf
orIsStrictlyRightOfin memory — from a wrongfalseto the answer PostgreSQL already gave.
Known difference
-
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.
Published to nuget.org through Trusted Publishing by this run. The CycloneDX SBOM for each package is attached below.