Architecture
Overview
CodoMetis.ValueRanges is a .NET 10 class library providing type-safe range types that mirror PostgreSQL's six built-in range domains. Each range is a discriminated union of five sealed variants, making invalid states unrepresentable and pattern matching exhaustive by contract. A companion EF Core package (CodoMetis.ValueRanges.EFCore.PostgreSQL) bridges these types to NpgsqlRange<T> for LINQ-to-SQL translation.
Range Types
| C# Type | PostgreSQL | Element Type | Discrete |
|---|---|---|---|
Int32Range |
int4range |
int |
✓ |
Int64Range |
int8range |
long |
✓ |
DateRange |
daterange |
DateOnly |
✓ |
DecimalRange |
numrange |
decimal |
— |
DateTimeRange |
tsrange |
DateTime |
— |
DateTimeOffsetRange |
tstzrange |
DateTimeOffset |
— |
TimeRange |
timerange (custom) |
TimeOnly |
— |
Float64Range |
float8range (custom) |
double (finite) |
— |
Float32Range |
float4range (custom) |
float (finite) |
— |
Discrete types (int, long, DateOnly) implement NextValueAfter/PreviousValueBefore to return the adjacent value. Continuous types leave them returning null. Discrete ranges canonicalize to closed [lower, upper] at construction (Internals/DiscreteCanonical.cs); continuous ranges default to half-open [lower, upper).
Float64Range and Float32Range (10.0) are the second and third custom domains, Float32Range over float4range with no diff function possible (float4mi returns real) and text exactly float4out's, never formatted through double. Float64Range is float8range over finite doubles, created without a subtype_diff (float8mi overflows a GiST index over extreme values). NaN and the infinities are refused on every construction path and every scalar operand (IRange<T>.NormalizeElement), because .NET sorts NaN first and PostgreSQL last; its text is exactly float8out's (Internals/FloatLiteral.cs), not .NET's shortest form (#153).
TimeRange (v5) is the first domain beyond the six built-ins: timerange does not exist in PostgreSQL until the database runs CREATE TYPE timerange AS RANGE (subtype = time) (via HasPostgresRange), and the Npgsql data source needs EnableUnmappedTypes(). A single range cannot cross midnight — overnight windows are two-element RangeSets. PostgreSQL time's special value 24:00:00 is not representable in TimeOnly.
Domain Width and the Boundary
A CLR type and the PostgreSQL type it maps to do not always cover the same values, and where they differ the model is authoritative for what it can represent — not for what the server can store.
Width. int4range/int and int8range/long match exactly. Nothing else does. The widest gap
is numrange: PostgreSQL numeric carries up to 131,072 digits before the point, against
decimal's ~28 significant digits, so most of that type is unrepresentable here. daterange runs
4713 BC – 5874897 AD against DateOnly's 0001–9999, and tsrange/DateTime likewise. A stored
range whose bounds fall outside the CLR type cannot be materialized; that failure is loud, at the
Npgsql boundary, and is not something this library can paper over.
The boundary itself diverges even where the widths match. The two sides mean different things by
an unbounded side. Here -∞/+∞ denote the absence of a bound over a domain that stops at
int.MinValue/int.MaxValue, so (int.MaxValue, +∞) has no first value and is the empty range —
DiscreteCanonical and each discrete type's unbounded factories enforce that, and
DiscreteDomainBoundaryTests pins it for all five discrete types. In PostgreSQL the sentinels sit
outside the element type, so the same range still has room. Two consequences:
| Construction | This model | PostgreSQL |
|---|---|---|
(max, +∞) |
empty | raises 22003 integer out of range — canonicalizing needs max + 1 |
(-∞, min) |
empty | not empty; its lower sentinel is below int.MinValue |
(max, max] |
empty | empty |
[min, min) |
empty | empty |
Neither side is wrong — they answer over different domains — but a range at the boundary is not
round-trip-safe. DomainBoundaryDivergenceTests asserts each row against a live server, so the
difference stays a known property. This is also the one place a ShapeMatrixParityTests row would be
wrong to write: matching element domains are not sufficient, because the disagreement is about the
sentinel rather than the domain.
Precision. The BCL temporal types hold 100 ns and NodaTime's 1 ns; PostgreSQL holds a
microsecond. A literal keeps every digit the type can hold, so a value round-trips through its own
text (claim S08). PostgreSQL's text input rounds more than six fraction digits half-to-even, while
Npgsql truncates a parameter toward 2000-01-01, and the plugin's store text follows Npgsql
(StoreTemporal, NodaStoreTemporal). So 2026-01-01 10:00:00.1234567 is three values — .1234567
in memory, .123457 on the server read from that text, .123456 stored — at most 1 µs apart (#126).
Rounding on parse would remove the second gap and break the first promise; it would also disagree
with every parameter. DomainBoundaryDivergenceTests pins the three values.
The date extremes are sentinels on the server. Npgsql writes DateOnly.MinValue / MaxValue and
LocalDate.MinIsoValue / MaxIsoValue as -infinity / infinity (the store rule, StoreTemporal),
so a daterange bounded at an extreme holds a sentinel where the model holds a date. Every operator
that compares the sentinel with itself still agrees — equality, containment, the bound accessors, and
anything read back, since the reader turns the sentinel into the extreme again. Adjacency is the one
that does not: no finite date is next to -infinity, so the model's step from 0001-01-01 to
0001-01-02 has no counterpart there.
| Construction | This model | PostgreSQL (Npgsql.DisableDateTimeInfinityConversions off) |
|---|---|---|
[0001-01-01,0001-01-01] -\|- [0001-01-02,) |
adjacent | [-infinity,-infinity] -\|- [0001-01-02,) — not adjacent |
[9999-12-30,9999-12-30] -\|- [9999-12-31,) |
adjacent | [9999-12-30,9999-12-31) -\|- [infinity,) — not adjacent |
| the same pairs one day inside the extremes | adjacent | adjacent |
The model keeps its answer on purpose: with the switch set, the extremes reach the server as dates
and -|- answers true there (InfinityConversionsDisabledTests), so a model that stopped
stepping across an extreme would disagree with that configuration instead. DifferentialFuzzParityTests
sets an adjacency row at a date extreme aside only when it disagrees in exactly this shape — memory
true, both database legs false — and counts it; RangeSet merges across the extreme on both
sides because the reader canonicalizes what it loads.
An exclusive infinite bound excludes the extreme. The same store rule means the server's real
9999-12-31 and 0001-01-01 have no value of their own here: the extreme is the infinity. So an
exclusive infinite bound excludes the extreme, and every path reads it alike — a column, the
server's text through Parse, and the literal as written:
| PostgreSQL value | This model | The server's range also holds |
|---|---|---|
[2026-01-01,infinity) |
[2026-01-01,9999-12-30] |
9999-12-31 |
(-infinity,2026-01-02) |
[0001-01-02,2026-01-01] |
0001-01-01 |
[2026-01-01,infinity] |
[2026-01-01,9999-12-31] |
— |
Reading the exclusive bound as the inclusive extreme instead would keep that last date, but it can't
be done consistently (#117). On the upper side a column can't tell infinity) from 9999-12-31),
which is how PostgreSQL stores [x,9999-12-30]. On the lower side the server's own algebra produces
the exclusive form: (,) - (,-infinity] is (-infinity,), which excludes exactly the minimum that
was subtracted. Ranges another tool wrote this way load one day short
(Rows written by other tools).
DomainBoundaryDivergenceTests asserts that the column and the text agree for every form.
A query's bound readers read the same model (#489). upper(x) - 1 leaves infinity as it is and
lower(x) returns -infinity, so a definition with ExclusiveInfinity names its extremes and the
bounds memory loads for an exclusive one, and LowerBound(), UpperBound() and Clamp test for the
exclusive sentinel in a CASE. LowerBoundInclusive() on a discrete type is NOT lower_inf(x) AND NOT isempty(x), as the upper side is, because lower_inc is false for an exclusive -infinity.
LocalDateRange has the upper side only: memory starts (-infinity on -9998-01-02, below
PostgreSQL's dates. With Npgsql.DisableDateTimeInfinityConversions set no extreme is an infinity,
and the SQL is the plain one. ExclusiveInfinityBoundParityTests asserts every reader over every form.
YearMonthRange is discrete, but one element spans a month of its date subtype (#506). upper(x) - 1 is the
last day of the last month, which loads as that month and compares unequal to it, because a YearMonth operand
binds as the first day. Its definition names a CoarseUpperBound, so the upper bound readers write
CAST(upper(x) - INTERVAL '1 month' AS date), and 9999-12-01 where the bound is infinity (9999-12 ends on
LocalDate.MaxIsoValue). YearMonthBoundParityTests and ClampDomainParityTests compare the readers with each
month in a filter, where a projection would load both answers as the same month.
Calendars. NodaTime equality includes the calendar system, and of its nineteen calendars only ISO
and Gregorian can represent 9999-12-31 at all. Gregorian shares ISO's arithmetic but is a distinct
CalendarSystem instance, so a Gregorian-spelled domain maximum compares unequal to
LocalDate.MaxIsoValue. The step functions therefore apply each type's own stated policy before
comparing: LocalDateRange normalizes to ISO, YearMonthRange rejects non-ISO outright.
CalendarBoundaryTests covers both.
NodaTime Satellites
CodoMetis.ValueRanges.NodaTime adds three range types over NodaTime elements, following the identical discriminated-union pattern:
| C# Type | PostgreSQL | Element Type | Discrete |
|---|---|---|---|
LocalDateRange |
daterange |
LocalDate |
✓ |
LocalDateTimeRange |
tsrange |
LocalDateTime |
— |
InstantRange |
tstzrange |
Instant |
— |
YearMonthRange |
daterange (month-aligned) |
YearMonth |
✓ |
Because the whole algebra (extensions, engines, RangeSet, parsing defaults, JSON factory) is generic over IRange<T>/IRangeFactory<TRange, T>, the satellite only defines the unions themselves plus per-type RangeAgg/RangeIntersectAgg overloads (NodaTimeRangeAggregateExtensions) and Interval/DateInterval interop adapters. It accesses Internals/ (DiscreteCanonical, RangeFormat) via InternalsVisibleTo — the public API stays closed to third parties, preserving the exhaustive-matching guarantee. Types live in the shared CodoMetis.ValueRanges namespace (a NodaTime namespace segment would shadow the NodaTime root namespace).
Satellite-specific construction rules: LocalDate/LocalDateTime bounds normalize to the ISO calendar (WithCalendar) so CompareTo can never see mixed calendars; text I/O uses NodaTime's culture-free ISO patterns, with PostgreSQL wire-form fallbacks (space separator, numeric offsets) on parse. YearMonth bounds reject non-ISO calendars instead of normalizing — a non-ISO year-month spans parts of two ISO months, so unlike a date there is no lossless conversion. YearMonthRange (v5, one-month discrete step) interops with LocalDateRange via ToLocalDateRange() (total, expands months to days) and ToYearMonthRange() (partial, validates month alignment).
CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime registers the RangeTypeDefinitions and the aggregate overload class via UseValueRangesNodaTime(), which also chains Npgsql's UseNodaTime() (element mappings) and UseValueRanges(). YearMonthRange gets a hand-written YearMonthRangeTypeDefinition instead of the generic one: YearMonth has no wire representation, so its mappings convert through month-aligned NpgsqlRange<LocalDate>/daterange values (Internal/YearMonthRangeTypeDefinition.cs), it supplies its own element mapping (YearMonth ⇄ first-of-month date), and it opts out of SQL factory construction (SupportsSqlConstruction = false) because months are coarser than the date subtype — see the extended IRangeTypeDefinition members.
Discriminated Union Pattern
Every range type is an abstract record with five sealed nested variants:
RangeType (abstract, private ctor)
├── EmptyRange : IEmptyRange<T> — contains no values
├── Finite : IFiniteRange<T> — [start, end] (bounded both sides)
├── UnboundedStart : IUnboundedStartRange<T> — (-∞, end]
├── UnboundedEnd : IUnboundedEndRange<T> — [start, +∞)
└── Infinity : IInfinityRange<T> — (-∞, +∞)
These five are the only ranges that can exist, so a switch over them is complete in fact. The private constructor is not what guarantees it: a record's copy constructor cannot be private (CS8878), so a record declared anywhere can chain to it and become a sixth variant — before 10.0.0 one did, implementing no shape interface, and IsEmpty(), Contains and Overlaps answered it false (#206). Since 10.0.0 every union declares the copy constructor itself and calls Internals/ClosedUnion.cs, which throws for any runtime type not nested in the union, at construction and before the value can reach a switch; with on a real variant passes. ClosedUnionContractTests plays the consumer against every discovered union. C# does not prove it — exhaustiveness analysis ignores the closed hierarchy, so a switch expression still warns CS8509 without a discard arm, which is why the discard must throw rather than answer (see pattern matching). Invalid ranges (inverted bounds, degenerate half-open) normalize to EmptyRange at construction time.
Interface Hierarchy (Core/)
IRange<T>— Marker interface. Carriesinternal default methodsIntersectWith<TRange>()andMergeWith<TRange>()that dispatch per-shape to the engines inInternals/.IRangeFactory<TRange, T>— Abstract static factory:Empty,Infinite,CreateFinite(),CreateUnboundedStart(),CreateUnboundedEnd(), plus virtualNextValueAfter/PreviousValueBefore. Also implementsIParsable<TRange>andIFormattablewith PostgreSQL range literal syntax.- Structural interfaces —
IFiniteRange<T>,IUnboundedStartRange<T>,IUnboundedEndRange<T>,IEmptyRange<T>,IInfinityRange<T>— each provides its own concreteIntersectWith/MergeWithimplementations (e.g.,IInfinityRange<T>always returns the other operand for intersection, always returnsInfinitefor merge).
All type parameters are constrained to struct, IComparable<T>, IEquatable<T>.
Not an extension point. These interfaces are the shared vocabulary of the shipped range types:
implementing them outside the library is unsupported, and they may gain members in a minor version.
The engines, the EF Core provider — whose registries are internal — and the parity tests against
PostgreSQL cover only the shipped types. The value sets are closed in the type system already
(IValueSetFactory's internal abstract FromTrusted); the range interfaces are closed by this policy
rather than by the compiler, which outside implementations of IRange<T> meet as a refusal
(UnknownRangeVariantTests).
Extension Methods (RangeExtensions.cs)
Uses the C# 14 extension keyword. Two extension<T> blocks:
- Query operations on
IRange<T>— state checks (IsEmpty,IsInfinity, etc.), bound accessors (LowerBound/UpperBoundreturningT?,LowerBoundInclusive/UpperBoundInclusive— PostgreSQLlower/upper/lower_inc/upper_inc), containment, overlap, adjacency, directional comparisons - Set operations on
IRangeFactory<TRange, T>—Intersect(returnsTRange),Merge(convex hull,range_merge, returnsTRange),Gap(the hull minus both operands, returnsTRange),Union/Except(returnRangeSet<TRange, T>)
See src/CodoMetis.ValueRanges/RangeExtensions.cs for the full implementation.
Aggregates (RangeAggregateExtensions.cs)
RangeAgg() (→ RangeSet, PostgreSQL range_agg) and RangeIntersectAgg() (→ TRange?, null on empty source, PostgreSQL range_intersect_agg) over IEnumerable<TRange>. Declared as per-type overloads (six each) because C# cannot infer the element type T from TRange alone — constraints do not participate in type inference. A generic private core holds the intersect-fold logic. The overloads over IEnumerable<RangeSet<TRange, T>> (range_agg(anymultirange), range_intersect_agg(anymultirange)) are generic instead, because the set type names both type arguments, so they serve the NodaTime types too; the aggregate translator tells the two sources apart by the method's parameter type, since RangeAgg returns a set either way.
RangeSet<TRange, T> (RangeSet.cs)
Immutable multirange counterpart of PostgreSQL's int4multirange, etc. A sealed class over ImmutableArray<TRange> with a strict invariant:
- Sorted by lower bound
- Pairwise disjoint, pairwise non-adjacent
- No empty elements
- Any
Infinityinput collapses the set toInfinitesingleton
Key methods:
From(IEnumerable<TRange>)— normalizes (filter → sort viaInternals/RangeSetHelpers.CompareByLowerBound→ greedy merge)- Bulk ops (
Union,Intersect,Except) use O(n+m) merge-join instead of nested loops - Operators:
\|for union,&for intersect,-for except;==/!=are structural equality (delegating toEquals) - State checks
IsEmpty()/IsUnboundedStart()/IsUnboundedEnd(), bound accessors (LowerBound= first element's lower,UpperBound= last element's upper),Merge()(convex hull of first + last element), andGaps()/Gaps(window)(the hull or the window minus the set) - Set-operand comparisons
Contains(RangeSet)/Overlaps(RangeSet)delegate per element (O(m log n)) - Positional comparisons (
IsAdjacentTo,IsStrictlyLeftOf/RightOf,DoesNotExtendLeftOf/RightOf) mirror PostgreSQL exactly: they consult the outermost elements only, and adjacency is directional through the outer edges — the operand must end where the set begins or begin where the set ends; interior boundaries (even the inner side of the first/last element) never count. Verified against live PostgreSQL in the integration suite. LowerBoundComparer— staticIComparer<TRange>for external sorting
See src/CodoMetis.ValueRanges/RangeSet.cs and src/CodoMetis.ValueRanges/RangeLowerBoundComparer.cs.
Value Set Types (Sets/, v6)
The second type family: immutable, canonical sets of scalar values whose PostgreSQL storage
shape is a native array — StringSet/text[] is to arrays what RangeSet/multirange is to
ranges. Ten closed types (StringSet, GuidSet, Int16Set, Int32Set, Int64Set,
DecimalSet, DateSet, TimeSet, DateTimeSet, DateTimeOffsetSet) plus a validated-wrapper
arity for each of them, whose TElement is constrained only on BCL interfaces
(struct, IEquatable, IFormattable, IParsable; every family except StringSet<T> adds
IComparable) so generator-produced domain values never reference this package.
The text-form contract (convention, not constraint): the element's text form must be exactly the
backing primitive's. Which text form differs by family, and that is deliberate. StringSet<T>,
GuidSet<T>, Int16Set<T>, Int32Set<T>, Int64Set<T> and DecimalSet<T> use the element's
invariant default, because for those primitives it round-trips. The four temporal arities ask for
a round-trip format instead — "yyyy-MM-dd" for DateSet<T>, "O" for the other three — because
the default does not: TimeOnly renders as 09:30 and DateTime as 06/15/2024 10:30:00,
losing sub-seconds and, for DateTime, the Kind. Same rule in the NodaTime satellite, whose
five arities pin their ISO patterns for the same reason the closed NodaTime sets pass an explicit
literal text. A wrapper that forwards its format argument — the generated shape — satisfies all
of them; one that swallows it is rejected at the persistence boundary.
- Interfaces (
Core/):IValueSet<T>(publicValues) +IValueSetFactory<TSet, T>(abstract staticEmpty/From, internal abstract staticFromTrusted— which is what keeps the family closed to external implementations — andstatic virtualpolicy hooksCanonicalComparer/ParseValue/FormatValue, mirroringIRangeFactory). - Canonical form enforced on every construction path (
From, parse, JSON, materialization): deduplicated, sorted, no nulls. String-backed families sort ordinal (never culture, never the element's ownIComparable— generated wrappers delegate to culture-sensitivestring.CompareTo); all others sort by element comparison. Load-bearing twice: cheap EF change detection and SQL=⇔ set equality. - Algebra lives in one
ValueSetExtensionsclass (two C# 14 extension blocks over the interfaces) backed byInternals/ValueSetCore(stable-sort canonicalization, O(n+m) merge scans, instance-preserving results).Count/IsEmptyare instance properties on each concrete type — C# forbids extension properties in expression trees (CS9296), which would make them untranslatable. - Deliberately no
IEnumerable<T>(duck-typed structGetEnumerator()+Valuesonly): EF's conventions discoverIEnumerable<primitive>types as primitive collections, the exact machinery the scalar-mapping design avoids.[CollectionBuilder]enables collection expressions. - NodaTime satellite adds
LocalDateSet,LocalDateTimeSet,InstantSet,LocalTimeSet(nativetime[]— noCREATE TYPE, unliketimerange) andYearMonthSet.LocalDate/LocalDateTimeelements normalize to ISO calendar at construction;YearMonthrejects non-ISO (no lossless conversion) — mirroring the range types. IValueSet<T>.NormalizeElement(internal, default identity) is the seam that extends that normalization pastFromto the element-level operations —Contains,Add,Removetake a bare element, not a set, and would otherwise compare an un-normalized probe against normalized storage. A set type that normalizes or validates elements must override it:LocalDate.CompareTothrows across calendars andEqualssilently returnsfalse, so the failure modes are a wrong answer, a leaked NodaTime exception, or a non-ISO element smuggled into an empty set. The EF satellite mirrors it through the definition'snormalizeValue, which now also backs anElementTypeMappingso the same probe normalizes on the server.Internals/SetFormatimplements PostgreSQL array-literal parse/format witharray_in's grammar (quoting; backslash escapes in quoted and unquoted elements; an unescaped brace or quote inside an unquoted element, or a nested{, refused — sets are one-dimensional; ASCII whitespace only around elements; unquotedNULLrejected — sets never contain null; reads throw on corrupt data).Internals/RangeFormatreads bounds withrange_in's: only an absent bound is infinite.
JSON Serialization (Serialization/)
RangeJsonConverter<TRange, T>— serializes to/from PostgreSQL range literal strings, as a value and as a dictionary key (ReadAsPropertyName/WriteAsPropertyName, same literal, noDictionaryKeyPolicy).nullis left to System.Text.Json in both directions;HandleNullwas dropped in 6.2 because it made a written null unreadableValueSetJsonConverter<TSet, T>— serializes value sets as plain JSON arrays, delegating element serialization to System.Text.Json (element converters apply); reads normalize and reject null elementsRangeVariantJsonConverter<TVariant, TRange, T>— the same literal for a value reached as one of the union's sealed variants; parses through the union and narrows, rejecting a literal of a different shapeRangeJsonConverterFactory— auto-registers for any type implementingIRangeFactory<TRange, T>orIValueSetFactory<TSet, T>, orRangeSet<TRange, T>- Extension:
AddRangeConverters()registers all at once — redundant since 9.0.0, see below
Type-level attributes
Since 9.0.0 every type carries its converter, so nothing has to be registered. Three rules decide what each attribute names:
- The union names
RangeJsonConverter<TRange, T>, and every sealed variant namesRangeVariantJsonConverter<TVariant, TRange, T>itself. System.Text.Json looks the attribute up on the exact type it is handed and does not inherit it, and a value reached throughobjectis handed over as its variant. An attribute on the union alone leaves everyobject-typed range on the property dump. - Non-generic types name a closed converter, so a source-generated context instantiates it directly with no reflection.
RangeSet<TRange, T>and the wrapper arities nameRangeJsonConverterFactory. An attribute cannot name an open generic converter (typeof(RangeSetJsonConverter<,>)fails at runtime withContainsGenericParameters), so these name the factory, which takes the closed converter from the type's ownGenericConvertersregistration (see Native AOT below) — never fromMakeGenericType.
[TypeConverter] follows the same three rules with RangeTypeConverter, RangeVariantTypeConverter,
ValueSetTypeConverter and, for the generic types, LiteralTypeConverter — which TypeDescriptor
constructs with the closed type (its ctor(Type)), the converter equivalent of the factory. The
reason the variants need their own is the opposite of JSON's: [TypeConverter] is inherited, so
a variant without one silently gets the union's, which returns whatever shape the literal has.
The JSON attribute ranks below a converter on the options or on the property, so it never overrides one.
A new range type, variant or set family must carry its attribute; JsonDefaultContractTests in the
conventions project discovers every type and fails on one that serializes differently unregistered.
Variants and the factory
System.Text.Json resolves converters by the type it is handed, so Serialize<object>(range), an
object-typed property and an object-typed collection all present the runtime type, which is
always a variant. That is why every variant carries its own [JsonConverter] naming
RangeVariantJsonConverter (see Type-level attributes above).
The same rule means a converter a consumer registers on the options for the union never matches a
variant on its own — JsonConverter<Int32Range>.CanConvert(typeof(Int32Range.Finite)) is false — so
the variant's attribute would answer with the literal while union-typed declarations got the custom
form. RangeVariantJsonConverter therefore looks up what System.Text.Json would choose for the union
from Converters (first match, a factory expanded through its own CreateConverter) and delegates to
it, values and keys, unless that is a converter from this package. The lookup is cached per options
instance once the options are read-only. Reads go through the union and narrow, as the literal path
does. Nothing is built by reflection.
Since 9.0.0 RangeJsonConverterFactory builds nothing at runtime. It reads the type's own
[JsonConverter]: a non-generic type names a closed converter, which the factory creates from the
attribute (whose ConverterType is annotated for its constructor, so this is trim-safe); a generic
type names the factory itself, and the closed converter comes from GenericConverters (below).
CanConvert is "names a converter of this package", so the factory no longer walks interfaces.
A value declared as one of the interfaces — IRange<T>, the five shape interfaces, IValueSet<T> —
resolves the interface, whose attribute is not inherited from anything and would otherwise be absent
(System.Text.Json then dumps the interface's members). Each carries
[JsonConverter(typeof(InterfaceDeclaredJsonConverter))], one non-generic JsonConverter<object>
that System.Text.Json casts to the declared interface: it writes through
options.GetTypeInfo(value.GetType()) — the runtime type's own resolution, reflection-based or
source-generated, so no generic converter is closed at runtime — and refuses a read with
NotSupportedException, since an interface does not say which type to construct.
JsonDefaultContractTests discovers the core-namespace interfaces each variant implements and checks
both legs for every shape. The factory interfaces are excluded: their static abstract members rule
them out as a declared type.
Native AOT and GenericConverters
RangeSet<TRange, T> and the wrapper arities cannot name a converter closed over their own type
arguments, so they name RangeJsonConverterFactory/LiteralTypeConverter, which receive only a
Type. Until 9.0.0 those built the converter with MakeGenericType — fine under the JIT, and a
NotSupportedException under Native AOT, because the instantiation over a value type
(RangeSetJsonConverter<DateRange, DateOnly>) was never compiled. Whether it was depended on
unrelated code elsewhere in the program, so an AOT test could pass by accident.
Each generic type now registers delegates for its own closed converters from a static field
initializer (ConvertersRegistered); GenericConverters.Get runs the type's initializer and looks
it up. The delegates are part of the type, so they are compiled for every instantiation the program
uses — including one it only ever deserializes, which the Native AOT consumer in
test/consumer-smoke-test.sh proves for a core range set, a NodaTime range set and a wrapper set.
Explicit registration of the eleven range sets (they are a closed set) was tried and removed: seeded
out, the AOT consumer still passed, so it was guarding nothing. Seeding out a type's own
registration fails exactly that type's cases with the registry's NotSupportedException.
Both shipping libraries build with IsAotCompatible, so the trim and AOT analyzers run on every
build and, with warnings as errors, an unannotated reflection path cannot be merged. The analyzers
cannot see whether code is compiled under AOT — only the native consumer shows that.
Element converters (IValueSetFactory<TSet, T>.ElementJsonConverter)
Delegating elements to System.Text.Json is right for element types it knows, and a silent trap for
types it does not: no converter anywhere means a property dump on write and default on read, with
no exception on either leg. ElementJsonConverter is the family's fallback for exactly that case.
ValueSetJsonConverter picks between the two per call via options.GetTypeInfo(typeof(T)).Kind:
JsonTypeInfoKind.None means System.Text.Json already resolves a scalar converter — built-in, on
the options, or a [JsonConverter] on the element type — and stays authoritative. Any other kind
means the reflection fallback, so the family's converter takes over. That ordering is the point:
the hook is last, never an override. Resolution failures (no contract for T) fall back to
delegation, preserving the previous behaviour.
The primitive-backed families serialize natively and leave the default null. Every wrapper arity
defines one, since its element type is arbitrary, and the token type always follows the primitive:
ValueSetIntegerElementJsonConverter (a JSON number) for the three integer families,
ValueSetDecimalElementJsonConverter for DecimalSet<T> — separate rather than a widening, since
the integer one reads and writes through long and would truncate every decimal element — and
ValueSetTextElementJsonConverter (a JSON string) for the string, Guid, temporal and NodaTime
arities. For the non-temporal families the payload is byte-identical to the primitive sibling's.
For the temporal ones it is the same value in different text: the round-trip format writes seven
fraction digits where System.Text.Json trims them, and the default encoder escapes +. That is
asserted rather than fixed (SetJsonConverterTests) — matching the native writer byte for byte
would mean reproducing System.Text.Json's temporal formatting for an element known only through
IFormattable, and the round-trip form is the one the array literal and the EF bridge share.
The NodaTime satellite defines
one per element type (Serialization/NodaElementJsonConverters.cs, namespace
CodoMetis.ValueRanges.Serialization), each reusing the family's own ParseValue/FormatValue so
JSON, array literals and the wire form share one text form; the three calendar-bearing ones refuse a
non-ISO value on write, as NodaTime's own converter does (a set has already normalized it). The
satellite additionally exposes AddNodaTimeRangeConverters(), which puts those same converters on
the options behind a factory — the hook only reaches set elements, so this is what covers bare
NodaTime properties alongside a set. The factory is resolved only once the options are in use, so it
looks past itself and hands over any other converter registered for the type, wherever it sits in
Converters: that is what keeps ConfigureForNodaTime authoritative in either call order (a plain
converter list is first-match, which made the order decide). Range types are unaffected throughout —
they format themselves.
EF Core PostgreSQL (src/CodoMetis.ValueRanges.EFCore.PostgreSQL/)
ValueRangesMethodCallTranslator— translates LINQ methods to PostgreSQL operators (@>,&&,<@,<<,>>,&<,&>,-|-,*,+,-) and functions (lower,upper,lower_inc,upper_inc,isempty,lower_inf,upper_inf,range_merge), for ranges and multirangesValueRangesAggregateMethodCallTranslator— translatesRangeAgg/RangeIntersectAggtorange_agg/range_intersect_agginside grouped queries, for every declaring class registered viaRangeTypeRegistry.RegisterAggregateExtensions- Type mapping — maps range types to PostgreSQL range columns, RangeSet to multirange columns
RangeTypeRegistry(Internal/) — the single wiring point. Process-wide and additive: the seven core types (six built-ins +timerange) are registered up front; satellites contributeRangeTypeDefinitions at options-configuration time viaRegister(idempotent per range CLR type, thread-safe immutable-snapshot swap). Lookups: by range/set CLR type, by element type (theIRange<T>-typed-operand fallback — one range type per element type, enforced), and by store type name (first registration owns the name; BCL and NodaTime types sharedaterangeetc., so store-name-only resolution stays with the BCL types)IRangeTypeDefinitionextension points —ElementTypeMapping(defaultnull: resolve the subtype mapping from the type mapping source) lets a definition supply a converting element mapping when the element CLR type is unknown to the provider (NodaTimeYearMonth⇄date);SupportsSqlConstruction(defaulttrue) lets a definition whose model granularity is coarser than its store subtype opt out of server-side factory-constructor translation- Value sets (v6) — mirror wiring beside the range machinery:
SetTypeRegistry(Internal/, sibling ofRangeTypeRegistry): ten closed core definitions up front; the ten core wrapper families are matched by open generic definition with closed instantiations built lazily and cached — no per-element registration exists, so there is nothing to misconfigure. Satellites register closed definitions viaRegisterand additional families viaRegisterFamily(the NodaTime satellite adds five of each fromUseValueRangesNodaTime(); a family cannot be registered as closed definitions, because its element type is whatever the consumer supplies). Deliberately no store-name lookup:text[]etc. stay with the provider's native array mappings, so plainstring[]properties and scaffolding are untouchedValueSetTypeMapping<TSet, TElement, TPrimitive>converts sets to primitive arrays at the provider boundary (Npgsql binds those natively); reads route throughFrom— non-canonical rows normalize, null elements throw. Literals render uniformly asARRAY['…',…]::type[], with a per-definition literal-text hook (NodaTime's null-formatIFormattableis the culture long form, not ISO)- Element mappings (
BridgedElementTypeMapping): a bare element parameter incol @> ARRAY[@p]binds as its backing primitive — the same definition-supplied converting-element-mapping seamYearMonthuses. Two producers: the wrapper families (text-form contract fails loudly here with an error naming it), and anySetTypeDefinitioncarrying anormalizeValue, which gets an element mapping applying that same normalization (the server-side half ofNormalizeElement) BridgedSetTypeDefinitioncarries the wrapper bridge itself: an element format plus parse/format delegates over the primitive, rather than the element's default text form andIParsable<TPrimitive>. Both halves are load-bearing. The format is what keeps a temporal element from being truncated on the way to the column. The delegates exist becauseIParsableis too narrow twice over —DateTime.Parsereached through it cannot ask forDateTimeStyles.RoundtripKind, so a UTC element arrives asDateTimeKind.Local, and NodaTime's value types do not implementIParsableat all. The temporal families parse strictly (ParseExact), which turns "the wrapper ignored the format specifier" from silent truncation into the contract error- Translators:
ValueSetsMethodCallTranslator(Contains →@>ARRAY[value]unconditionally — always GIN-servable; Overlaps/IsSubsetOf/IsSupersetOf →&&/<@/@>; IsProperSubsetOf/IsProperSupersetOf → the operator paired with its negated converse rather than<>, keeping both halves multiplicity-insensitive; Remove →array_remove; Union/Add/Intersect/Except → a canonical placeholder for every family withISetTypeDefinition.CanonicalizesInSql, an unguarded placeholder the guard builds unsorted for text) andValueSetsMemberTranslator(Count/IsEmpty→cardinality). PostgreSQL's array type has only 9 operators (@> <@ && = <> < <= > >=) against the range type's 17 — no intersection, difference, distinct or sorted insert — so canonical form is a subquery:ARRAY((SELECT DISTINCT ON (x) x FROM unnest(…) WITH ORDINALITY … WHERE x IS NOT NULL ORDER BY x, ordinality)), keeping the first copy of each value asValueSetCoredoes (#478). Text has no such translation:ORDER BYsorts it by the database collation, not ordinally (#479), soIntersect/Except/AddonStringSetandStringSet<T>stay in memory.uuidsorts asGuid.CompareTodoes (measured over every byte position); a wrapper arity sorts by its element'sCompareTo, whichdocs/value-sets.mdrequires to order as the primitive does. Set==is translated by EF itself ascol = @pand assumes canonical writers; the operators are order-insensitive and stay correct against non-canonical rows, and the canonical operations establish canonical form whatever the row holds - Canonical form is built by the guard, not the translator. The subquery needs table aliases unique in the query. A method translator is given no
SqlAliasManager, and EF Core's alias pass (SqlAliasManager.PostprocessAliases, inRelationalQueryTranslationPostprocessor) reads every alias as one letter and a number withint.Parse, so a custom alias cannot exist before it runs. The translator therefore emitsValueSetSqlGuard.CanonicalUnion/CanonicalAdd/CanonicalIntersect/CanonicalExcept(functions PostgreSQL does not have) inside the NULL-over-NULLCASE, andValueSetSqlGuard.CanonicalExpanderbuilds each after that pass withvrNaliases: EF Core's immutableSelectExpressionconstructor with a throwawaySqlAliasManager, Npgsql'sPgUnnestExpression, aScalarSubqueryExpressionas the argument of a function namedARRAY(ARRAY((SELECT …))parses), and theDISTINCT ONprojection as aSqlFragmentExpression, since neither EF Core nor Npgsql has a node for it (Npgsql also writesarray_agg(…)[1]without the parentheses theGROUP BYform needs). Where order and duplicates cannot change the answer — an operand of@>/<@/&&,cardinality(…) = 0, a null test, a column of an outermostSELECTthat neither groups nor deduplicates, and a placeholder's own operands — it writes the multiset form instead (array_cat, or the filter withoutDISTINCT ON/ORDER BY): the canonical subquery costs about 1.5 µs a row over ten elements, some fifteen times a barearray_cat. The filters test containment (b @> ARRAY[x]), because Npgsql'sANYnode requires an enumerable CLR type. The same code builds on EF Core 10 and 11 - Composing on a text
Union,Add,IntersectorExcept: the guard builds each unsorted (array_cat, or a filter of the first array), since PostgreSQL sortstextby the collation. That is invisible to the operators above and to materialization (reads route throughFrom);Countand equality over one are rewritten as a distinct count and mutual containment (#485); anything else sensitive to order or multiplicity is refused. Since 9.0.0 that is refused on the final SQL tree byValueSetSqlGuard, run fromValueRangesParameterBasedSqlProcessorFactory— a decorator around the provider'sIRelationalParameterBasedSqlProcessorFactory, the last stage before SQL generation and the one every query,ExecuteUpdateandExecuteDeletepasses through (EF has no plugin seam there, so the registration wraps Npgsql's rather than replacing it). The guard fails closed: the translator emitsUnionasValueSetSqlGuard.UnguardedUnion(valueranges_union_without_sql_guard, a function PostgreSQL does not have) and onlyCheckrenames it toarray_cat, so a later registration that replaces the factory — dropping the decorator — makes every server-sideUnionfail on the server rather than skip the guard; one that decorates around it still reaches it. Detecting the decorator instead would refuse that legitimate wrapper. The guard tracks values derived fromarray_cat— througharray_remove,CASE,COALESCE, and subquery or set-operation columns — and allows them only under@>/<@/&&,cardinality(…) = 0(IsEmpty), a null test, or in the outermost projection; equality,cardinality,GROUP BY,DISTINCT,ORDER BY, deduplicating set operations,INandUPDATEsetters are refused.ValueSetsMemberTranslatoradditionally declinesCountover a union it can see (viaValueSetSqlGuard.MayBeConcatenation), which is what lets EF client-evaluate it in a projection. The 8.0.0 LINQ-level guard it replaced matched==/Equalsinside a list of operators and missed every other shape. PostgreSQL has no array-distinct function (verified against 18.4 —array_sort/array_reverse/array_shuffleexist, nothing that deduplicates;intarray.uniqisint[]-only and not installed by default), and the canonicalizing subquery the other families get cannot serve text: itsORDER BYfollows the database collation, not ordinal order ExecuteUpdateinto a JSON column: Npgsql writes a scalar setter inside a document asjsonb_set(doc, path, to_jsonb(value)), which for a value set stores a JSON array where the document holds the literal — the row then no longer loads. The same guard refuses anyto_jsonb/to_jsonof a value set inside anUPDATEsetter, naming the column and path, so it holds however the setter reached the property (join,EF.Property, nested complex type); a whole complex value is serialized by EF through the plugin's writer and stays allowedYearMonthSetgets a hand-writtenYearMonthSetTypeDefinition(month-aligneddate[], reads throw on non-aligned dates), reusing the range family'sYearMonthTypeMappingas its element mapping
- Enable:
options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges());— ornpgsql.UseValueRangesNodaTime()from the NodaTime satellite, which implies it
Engine Internals (Internals/)
IntersectEngine.cs,MergeEngine.cs— per-shape intersection and merge logicExceptEngine.cs— set difference with boundary inversion at cut pointsDiscreteCanonical.cs— canonicalizes discrete ranges to closed formRangeOrder.cs— PostgreSQL's range order (range_cmp) behind every range type'sCompareToand, element by element,RangeSet.CompareTo(multirange_cmp); separate fromRangeSetHelpers.CompareByLowerBound, which orders by lower bound only soFromcan merge neighboursRangeBoundHelpers.cs,RangeFormat.cs,RangeSetHelpers.cs— shared utilitiesValueSetCore.cs— the value set engine: canonicalization, membership (binary search over the canonical order —IValueSet<T>.CanonicalOrderis the instance-side view ofCanonicalComparerthatContainsneeds and cannot reach statically), equality, merge-scan algebra.Unionkeeps the left operand's representative among comparer-equal elements, the same "first in input order survives" tie-breakCanonicalizeapplies; only the left-hand identity shortcut is sound, since a count matching the right operand merely means the left was a subsetSetFormat.cs— PostgreSQL array-literal parse/format for value sets (sibling ofRangeFormat)
Adding a Range or Set Type
Every concrete type is a hand-written copy of its siblings, and that is the design, not a gap.
Measured on 10.0.0 with the type names normalized, Int32Range and Int64Range differ in about
25 of 254 lines and Int32Set and Int64Set in 20 of 258; across an axis the copy is only half
(Int32Range and DecimalRange differ in about 120 lines), because discrete versus continuous
changes the Finite constructor, both unbounded records, CreateFinite, Length and Values,
and temporal versus numeric changes ParseValue, FormatValue and the literal grammar. The lines
that differ are mostly the shipped doc comments and the per-type semantics: which PostgreSQL type
it mirrors, the boundary convention and why, what Length counts, how it overflows.
A scaffolding script or template was considered for this and rejected (#174). It would be a
twelfth sibling that no sweep reads, it rots the first time the live files take a fix, and it
scaffolds the hundred lines that take ten minutes while leaving the hundred that take thought. A
generic base record is not available either: the private constructor and the nested sealed
variants are what keep each union closed, so the five variants and the factory — about a hundred
lines per type — are the irreducible floor. The repository took the other side of the trade with
the clone-divergence sweep (test/oracles/CloneSweep.cs, #145): the siblings are the template,
and CloneDivergenceTests keeps them in step. The procedure below is built on that.
1. Measure the domain before writing a line
This is the part no sweep can do. Against a live server, establish what the doc comments will
promise: whether the subtype is discrete (PostgreSQL canonicalizes int4range, int8range and
daterange to [); the model to []) or continuous; how the CLR extremes map
(MinValue/MaxValue versus -infinity/infinity, see Domain Width);
what Length should count and where it overflows; which values the CLR type holds that the
database refuses, or the reverse (numrange holds NaN, DecimalRange cannot; time holds
24:00:00, TimeOnly cannot); and what the literal looks like in ISO DateStyle. A domain the
server does not ship (timerange, float8range) needs CREATE TYPE … AS RANGE on the consumer's
side, and the measurement includes whether a subtype_diff is safe to declare. #153
(Float64Range) is the worked example: its measurement table is the issue body, and every design
decision in it follows from one row of that table.
2. Copy the nearest sibling on every axis
The sweep's families name the siblings. Pick the one that matches on each axis and copy its file; rename; then change only what step 1 measured.
| Axis | One side | Other side | What it changes |
|---|---|---|---|
| Discrete / continuous | Int32Range, Int64Range, DateRange, LocalDateRange, YearMonthRange |
DecimalRange, Float64Range, Float32Range, DateTimeRange, DateTimeOffsetRange, TimeRange, LocalDateTimeRange, InstantRange |
Finite constructor and inclusiveness storage, the unbounded records, CreateFinite (canonicalizing via DiscreteCanonical or not), IsDiscrete, NextValueAfter/PreviousValueBefore, Values, what Length means |
| Numeric / temporal | Int32Range, DecimalRange, Int32Set, DecimalSet |
DateRange, TimeRange, DateSet, TimeSet and the NodaTime types |
ParseValue through IsoTemporal / NodaPatterns rather than T.Parse, a pinned FormatValue, the strictness remark, the EF storeValue |
| Primitive set / wrapper arity | Int32Set |
Int32Set<T> |
Copy both: the sweep pairs each primitive with its own arity (primitive vs arity family), and the arity carries ElementJsonConverter, the BCL-only constraint and a GenericConverters.Register line |
| Core / NodaTime satellite | DateRange, DateSet |
LocalDateRange, LocalDateSet |
Own ConvertersRegistered for AOT, NodaPatterns, calendar normalization in From and NormalizeElement, the satellite's registration method rather than the core registry's table |
A new type that sits on an axis no sibling occupies (a float domain is continuous and numeric,
like DecimalRange, but refuses values at construction, like YearMonthRange.RequireIso) copies
the closest one and borrows the mechanism from the other. Expect the sweep to report the borrowed
member as a divergence; that is what the waiver is for.
3. The wiring outside the type's own file
The sweep covers the EF registrations. The rest is hand-listed, and the tests in step 4 are what tell you which of these you forgot:
- Core. The closed converter pair in
Serialization/RangeJsonConverter.cs(XRangeJsonConverter,XRangeSetJsonConverter) orValueSetJsonConverter.cs(XSetJsonConverter), named by the type's[JsonConverter]attribute;RangeAggregateExtensions(RangeAgg,RangeIntersectAgg);ValueSetRangeExtensions(ToRangeSet,ToXSet) when a range and a set now exist over the same element. - EF Core. A
RangeTypeDefinition<XRange, T>(rangeType, multirangeType, subtype)row inRangeTypeRegistry; aSetTypeDefinition<XSet, T>row and aBridged<P>entry for the arity inSetTypeRegistry; typed constant operands inValueRangesMethodCallTranslatorif the element's literal is not already cast (anyrange @> anyelementresolves without implicit coercions). A custom range type is documented indocs/efcore.mdbesidetimerange, with itsHasPostgresRangeandEnableUnmappedTypessetup. - NodaTime. Both satellites register from
NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions(Register,RegisterFamily), never from the core tables. - Tests that list types by hand.
RangeProbes.BoundsandSetProbes.Probes(two ordered values per element type; every*ProbeTestssweep reads them); a column inTestDbContextandIntegrationDbContext; the set-type discovery floors inMappingParityTests.Discovery_FindsEveryRangeAndSetType(a floor does not fail when a type is added, so raise it, or the next type that goes missing is invisible). Range unions need no edit: the sweeps check that they reached every union ofRangeCensus, which counts unions by their nested variants rather than by an interface (#222). Last, aCaseper package intest/consumer-smoke-test.shwhen the type exercises a path the existing cases do not. - Catalogues.
test/oracles/claims.jsonfor every new sentence in the consumer docs (OracleCatalogueTestsfails on a sentence without a claim,OracleSurfaceCoverageTestson a public member without one or asurface-waivers.jsonreason);postgres-surface.jsonfor the type's constructor functions and operators (dotnet run tools/oracles.cs pg-surfaceregenerates the listing; a custom domain contributes no built-in rows);clone-waivers.jsonfor every deliberate difference from the family consensus, each with the reason the sibling does not share it. - Docs. The Range Types table above,
docs/ranges.mdordocs/value-sets.md, the root and package READMEs, and the package changelog (ChangelogConventionTestsholds the version wiring).
4. Let the tests list what is left
Run them in this order; each layer's failures are the worklist for the next.
dotnet build— CS1591 names every public member whose doc comment the copy did not bring, and the shipped.xmlis what IntelliSense shows, so the comments are not optional.dotnet test --filter "FullyQualifiedName~Conventions.Tests"—CloneDivergenceTestslists every member that differs from the family consensus (fix it or waive it with a reason);JsonDefaultContractTestsandTypeConverterContractTeststhe variant or set without its own attribute;MappingParityTeststhe type noIRelationalTypeMappingSourceresolves;OracleSurfaceCoverageTestsandOracleCatalogueTeststhe unclaimed member and the unheld sentence; the literal sweeps (LiteralProviderContractTests,LiteralCultureContractTests,TemporalLiteralStrictnessContractTests,CultureEntryPointProbeTests) andJsonOptionsProbeTestspick the type up by reflection and need nothing but theBoundsrow.- The unit and translation projects, which are per-operation and discover nothing: a new type
gets a
[DataRow]whereverInt64Rangehas one and the operation's answer depends on the domain (RangeLengthTests,RangeParseFormatTests,RangeValuesAndClampTests). - The integration projects with Docker — the authority on the semantics step 1 measured.
ShapeMatrixParityTestsasks PostgreSQL for every ordered shape pair of every discovered type;LiteralGrammarParityTestsandTemporalStoreParityTestshold the literal and the store value;InlineRangeConstantExecutionTestsneeds one constant-operand query per element type;CapturedCollectionProbeTestsandHostCultureProbeTestsreadRangeProbes. dotnet run tools/oracles.cs status(the unheld claims and unwaived members),… clones(the divergences with their waiver status), thentest/consumer-smoke-test.shfor the packed artefacts and the Native AOT consumer, which is the only place a missing closed converter registration shows.
5. Seed the guard
A type that refuses values at construction, normalizes elements or pins a format has a guard, and
the workflow rule applies to it as to any fix: strip the guard, confirm which oracle fails and
why, keep the revert as test/oracles/seeds/<defect>.patch naming that oracle. For #153 the
obvious seed is the one that lets [5,NaN) through to the parity sweep.