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. Carries internal default methods IntersectWith<TRange>() and MergeWith<TRange>() that dispatch per-shape to the engines in Internals/.
  • IRangeFactory<TRange, T> — Abstract static factory: Empty, Infinite, CreateFinite(), CreateUnboundedStart(), CreateUnboundedEnd(), plus virtual NextValueAfter/PreviousValueBefore. Also implements IParsable<TRange> and IFormattable with PostgreSQL range literal syntax.
  • Structural interfaces — IFiniteRange<T>, IUnboundedStartRange<T>, IUnboundedEndRange<T>, IEmptyRange<T>, IInfinityRange<T> — each provides its own concrete IntersectWith/MergeWith implementations (e.g., IInfinityRange<T> always returns the other operand for intersection, always returns Infinite for 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:

  1. Query operations on IRange<T> — state checks (IsEmpty, IsInfinity, etc.), bound accessors (LowerBound/UpperBound returning T?, LowerBoundInclusive/UpperBoundInclusive — PostgreSQL lower/upper/lower_inc/upper_inc), containment, overlap, adjacency, directional comparisons
  2. Set operations on IRangeFactory<TRange, T> — Intersect (returns TRange), Merge (convex hull, range_merge, returns TRange), Gap (the hull minus both operands, returns TRange), Union/Except (return RangeSet<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 Infinity input collapses the set to Infinite singleton

Key methods:

  • From(IEnumerable<TRange>) — normalizes (filter → sort via Internals/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 to Equals)
  • State checks IsEmpty()/IsUnboundedStart()/IsUnboundedEnd(), bound accessors (LowerBound = first element's lower, UpperBound = last element's upper), Merge() (convex hull of first + last element), and Gaps() / 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 — static IComparer<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> (public Values) + IValueSetFactory<TSet, T> (abstract static Empty/From, internal abstract static FromTrusted — which is what keeps the family closed to external implementations — and static virtual policy hooks CanonicalComparer/ParseValue/FormatValue, mirroring IRangeFactory).
  • 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 own IComparable — generated wrappers delegate to culture-sensitive string.CompareTo); all others sort by element comparison. Load-bearing twice: cheap EF change detection and SQL = ⇔ set equality.
  • Algebra lives in one ValueSetExtensions class (two C# 14 extension blocks over the interfaces) backed by Internals/ValueSetCore (stable-sort canonicalization, O(n+m) merge scans, instance-preserving results). Count/IsEmpty are 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 struct GetEnumerator() + Values only): EF's conventions discover IEnumerable<primitive> types as primitive collections, the exact machinery the scalar-mapping design avoids. [CollectionBuilder] enables collection expressions.
  • NodaTime satellite adds LocalDateSet, LocalDateTimeSet, InstantSet, LocalTimeSet (native time[] — no CREATE TYPE, unlike timerange) and YearMonthSet. LocalDate/ LocalDateTime elements normalize to ISO calendar at construction; YearMonth rejects non-ISO (no lossless conversion) — mirroring the range types.
  • IValueSet<T>.NormalizeElement (internal, default identity) is the seam that extends that normalization past From to the element-level operations — Contains, Add, Remove take 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.CompareTo throws across calendars and Equals silently returns false, 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's normalizeValue, which now also backs an ElementTypeMapping so the same probe normalizes on the server.
  • Internals/SetFormat implements PostgreSQL array-literal parse/format with array_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; unquoted NULL rejected — sets never contain null; reads throw on corrupt data). Internals/RangeFormat reads bounds with range_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, no DictionaryKeyPolicy). null is left to System.Text.Json in both directions; HandleNull was dropped in 6.2 because it made a written null unreadable
  • ValueSetJsonConverter<TSet, T> — serializes value sets as plain JSON arrays, delegating element serialization to System.Text.Json (element converters apply); reads normalize and reject null elements
  • RangeVariantJsonConverter<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 shape
  • RangeJsonConverterFactory — auto-registers for any type implementing IRangeFactory<TRange, T> or IValueSetFactory<TSet, T>, or RangeSet<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 names RangeVariantJsonConverter<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 through object is handed over as its variant. An attribute on the union alone leaves every object-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 name RangeJsonConverterFactory. An attribute cannot name an open generic converter (typeof(RangeSetJsonConverter<,>) fails at runtime with ContainsGenericParameters), so these name the factory, which takes the closed converter from the type's own GenericConverters registration (see Native AOT below) — never from MakeGenericType.

[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 multiranges
  • ValueRangesAggregateMethodCallTranslator — translates RangeAgg/RangeIntersectAgg to range_agg/range_intersect_agg inside grouped queries, for every declaring class registered via RangeTypeRegistry.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 contribute RangeTypeDefinitions at options-configuration time via Register (idempotent per range CLR type, thread-safe immutable-snapshot swap). Lookups: by range/set CLR type, by element type (the IRange<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 share daterange etc., so store-name-only resolution stays with the BCL types)
  • IRangeTypeDefinition extension points — ElementTypeMapping (default null: 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 (NodaTime YearMonth ⇄ date); SupportsSqlConstruction (default true) 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 of RangeTypeRegistry): 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 via Register and additional families via RegisterFamily (the NodaTime satellite adds five of each from UseValueRangesNodaTime(); 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 plain string[] properties and scaffolding are untouched
    • ValueSetTypeMapping<TSet, TElement, TPrimitive> converts sets to primitive arrays at the provider boundary (Npgsql binds those natively); reads route through From — non-canonical rows normalize, null elements throw. Literals render uniformly as ARRAY['…',…]::type[], with a per-definition literal-text hook (NodaTime's null-format IFormattable is the culture long form, not ISO)
    • Element mappings (BridgedElementTypeMapping): a bare element parameter in col @> ARRAY[@p] binds as its backing primitive — the same definition-supplied converting-element-mapping seam YearMonth uses. Two producers: the wrapper families (text-form contract fails loudly here with an error naming it), and any SetTypeDefinition carrying a normalizeValue, which gets an element mapping applying that same normalization (the server-side half of NormalizeElement)
    • BridgedSetTypeDefinition carries the wrapper bridge itself: an element format plus parse/format delegates over the primitive, rather than the element's default text form and IParsable<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 because IParsable is too narrow twice over — DateTime.Parse reached through it cannot ask for DateTimeStyles.RoundtripKind, so a UTC element arrives as DateTimeKind.Local, and NodaTime's value types do not implement IParsable at 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 with ISetTypeDefinition.CanonicalizesInSql, an unguarded placeholder the guard builds unsorted for text) and ValueSetsMemberTranslator (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 as ValueSetCore does (#478). Text has no such translation: ORDER BY sorts it by the database collation, not ordinally (#479), so Intersect/Except/Add on StringSet and StringSet<T> stay in memory. uuid sorts as Guid.CompareTo does (measured over every byte position); a wrapper arity sorts by its element's CompareTo, which docs/value-sets.md requires to order as the primitive does. Set == is translated by EF itself as col = @p and 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, in RelationalQueryTranslationPostprocessor) reads every alias as one letter and a number with int.Parse, so a custom alias cannot exist before it runs. The translator therefore emits ValueSetSqlGuard.CanonicalUnion/CanonicalAdd/CanonicalIntersect/CanonicalExcept (functions PostgreSQL does not have) inside the NULL-over-NULL CASE, and ValueSetSqlGuard.CanonicalExpander builds each after that pass with vrN aliases: EF Core's immutable SelectExpression constructor with a throwaway SqlAliasManager, Npgsql's PgUnnestExpression, a ScalarSubqueryExpression as the argument of a function named ARRAY (ARRAY((SELECT …)) parses), and the DISTINCT ON projection as a SqlFragmentExpression, since neither EF Core nor Npgsql has a node for it (Npgsql also writes array_agg(…)[1] without the parentheses the GROUP BY form needs). Where order and duplicates cannot change the answer — an operand of @>/<@/&&, cardinality(…) = 0, a null test, a column of an outermost SELECT that neither groups nor deduplicates, and a placeholder's own operands — it writes the multiset form instead (array_cat, or the filter without DISTINCT ON/ORDER BY): the canonical subquery costs about 1.5 µs a row over ten elements, some fifteen times a bare array_cat. The filters test containment (b @> ARRAY[x]), because Npgsql's ANY node requires an enumerable CLR type. The same code builds on EF Core 10 and 11
    • Composing on a text Union, Add, Intersect or Except: the guard builds each unsorted (array_cat, or a filter of the first array), since PostgreSQL sorts text by the collation. That is invisible to the operators above and to materialization (reads route through From); Count and 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 by ValueSetSqlGuard, run from ValueRangesParameterBasedSqlProcessorFactory — a decorator around the provider's IRelationalParameterBasedSqlProcessorFactory, the last stage before SQL generation and the one every query, ExecuteUpdate and ExecuteDelete passes through (EF has no plugin seam there, so the registration wraps Npgsql's rather than replacing it). The guard fails closed: the translator emits Union as ValueSetSqlGuard.UnguardedUnion (valueranges_union_without_sql_guard, a function PostgreSQL does not have) and only Check renames it to array_cat, so a later registration that replaces the factory — dropping the decorator — makes every server-side Union fail 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 from array_cat — through array_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, IN and UPDATE setters are refused. ValueSetsMemberTranslator additionally declines Count over a union it can see (via ValueSetSqlGuard.MayBeConcatenation), which is what lets EF client-evaluate it in a projection. The 8.0.0 LINQ-level guard it replaced matched ==/Equals inside a list of operators and missed every other shape. PostgreSQL has no array-distinct function (verified against 18.4 — array_sort/array_reverse/array_shuffle exist, nothing that deduplicates; intarray.uniq is int[]-only and not installed by default), and the canonicalizing subquery the other families get cannot serve text: its ORDER BY follows the database collation, not ordinal order
    • ExecuteUpdate into a JSON column: Npgsql writes a scalar setter inside a document as jsonb_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 any to_jsonb/to_json of a value set inside an UPDATE setter, 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 allowed
    • YearMonthSet gets a hand-written YearMonthSetTypeDefinition (month-aligned date[], reads throw on non-aligned dates), reusing the range family's YearMonthTypeMapping as its element mapping
  • Enable: options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges()); — or npgsql.UseValueRangesNodaTime() from the NodaTime satellite, which implies it

Engine Internals (Internals/)

  • IntersectEngine.cs, MergeEngine.cs — per-shape intersection and merge logic
  • ExceptEngine.cs — set difference with boundary inversion at cut points
  • DiscreteCanonical.cs — canonicalizes discrete ranges to closed form
  • RangeOrder.cs — PostgreSQL's range order (range_cmp) behind every range type's CompareTo and, element by element, RangeSet.CompareTo (multirange_cmp); separate from RangeSetHelpers.CompareByLowerBound, which orders by lower bound only so From can merge neighbours
  • RangeBoundHelpers.cs, RangeFormat.cs, RangeSetHelpers.cs — shared utilities
  • ValueSetCore.cs — the value set engine: canonicalization, membership (binary search over the canonical order — IValueSet<T>.CanonicalOrder is the instance-side view of CanonicalComparer that Contains needs and cannot reach statically), equality, merge-scan algebra. Union keeps the left operand's representative among comparer-equal elements, the same "first in input order survives" tie-break Canonicalize applies; only the left-hand identity shortcut is sound, since a count matching the right operand merely means the left was a subset
  • SetFormat.cs — PostgreSQL array-literal parse/format for value sets (sibling of RangeFormat)

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) or ValueSetJsonConverter.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 in RangeTypeRegistry; a SetTypeDefinition<XSet, T> row and a Bridged<P> entry for the arity in SetTypeRegistry; typed constant operands in ValueRangesMethodCallTranslator if the element's literal is not already cast (anyrange @> anyelement resolves without implicit coercions). A custom range type is documented in docs/efcore.md beside timerange, with its HasPostgresRange and EnableUnmappedTypes setup.
  • NodaTime. Both satellites register from NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions (Register, RegisterFamily), never from the core tables.
  • Tests that list types by hand. RangeProbes.Bounds and SetProbes.Probes (two ordered values per element type; every *ProbeTests sweep reads them); a column in TestDbContext and IntegrationDbContext; the set-type discovery floors in MappingParityTests.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 of RangeCensus, which counts unions by their nested variants rather than by an interface (#222). Last, a Case per package in test/consumer-smoke-test.sh when the type exercises a path the existing cases do not.
  • Catalogues. test/oracles/claims.json for every new sentence in the consumer docs (OracleCatalogueTests fails on a sentence without a claim, OracleSurfaceCoverageTests on a public member without one or a surface-waivers.json reason); postgres-surface.json for the type's constructor functions and operators (dotnet run tools/oracles.cs pg-surface regenerates the listing; a custom domain contributes no built-in rows); clone-waivers.json for 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.md or docs/value-sets.md, the root and package READMEs, and the package changelog (ChangelogConventionTests holds 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.

  1. dotnet build — CS1591 names every public member whose doc comment the copy did not bring, and the shipped .xml is what IntelliSense shows, so the comments are not optional.
  2. dotnet test --filter "FullyQualifiedName~Conventions.Tests" — CloneDivergenceTests lists every member that differs from the family consensus (fix it or waive it with a reason); JsonDefaultContractTests and TypeConverterContractTests the variant or set without its own attribute; MappingParityTests the type no IRelationalTypeMappingSource resolves; OracleSurfaceCoverageTests and OracleCatalogueTests the unclaimed member and the unheld sentence; the literal sweeps (LiteralProviderContractTests, LiteralCultureContractTests, TemporalLiteralStrictnessContractTests, CultureEntryPointProbeTests) and JsonOptionsProbeTests pick the type up by reflection and need nothing but the Bounds row.
  3. The unit and translation projects, which are per-operation and discover nothing: a new type gets a [DataRow] wherever Int64Range has one and the operation's answer depends on the domain (RangeLengthTests, RangeParseFormatTests, RangeValuesAndClampTests).
  4. The integration projects with Docker — the authority on the semantics step 1 measured. ShapeMatrixParityTests asks PostgreSQL for every ordered shape pair of every discovered type; LiteralGrammarParityTests and TemporalStoreParityTests hold the literal and the store value; InlineRangeConstantExecutionTests needs one constant-operand query per element type; CapturedCollectionProbeTests and HostCultureProbeTests read RangeProbes.
  5. dotnet run tools/oracles.cs status (the unheld claims and unwaived members), … clones (the divergences with their waiver status), then test/consumer-smoke-test.sh for 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.