CodoMetis.ValueRanges.NodaTime
8.0.0
Prefix Reserved
dotnet add package CodoMetis.ValueRanges.NodaTime --version 8.0.0
NuGet\Install-Package CodoMetis.ValueRanges.NodaTime -Version 8.0.0
<PackageReference Include="CodoMetis.ValueRanges.NodaTime" Version="8.0.0" />
<PackageVersion Include="CodoMetis.ValueRanges.NodaTime" Version="8.0.0" />
<PackageReference Include="CodoMetis.ValueRanges.NodaTime" />
paket add CodoMetis.ValueRanges.NodaTime --version 8.0.0
#r "nuget: CodoMetis.ValueRanges.NodaTime, 8.0.0"
#:package CodoMetis.ValueRanges.NodaTime@8.0.0
#addin nuget:?package=CodoMetis.ValueRanges.NodaTime&version=8.0.0
#tool nuget:?package=CodoMetis.ValueRanges.NodaTime&version=8.0.0
CodoMetis.ValueRanges.NodaTime
NodaTime types for CodoMetis.ValueRanges: the full PostgreSQL interval algebra over NodaTime's temporal primitives — and, since v6, the value-set family over the same elements.
Range types
| Type | Element | PostgreSQL equivalent | Discrete |
|---|---|---|---|
LocalDateRange |
LocalDate |
daterange |
✓ (step: one day) |
LocalDateTimeRange |
LocalDateTime |
tsrange |
— |
InstantRange |
Instant |
tstzrange |
— |
YearMonthRange |
YearMonth |
daterange (month-aligned) |
✓ (step: one month) |
Each type is the same discriminated union of five sealed variants as the core package (Finite, UnboundedStart, UnboundedEnd, EmptyRange, Infinity), and every operation of the core algebra works unchanged: Contains, Overlaps, IsAdjacentTo, the directional comparisons, Intersect, Union, Except, Merge, bound accessors, RangeAgg/RangeIntersectAgg, RangeSet<TRange, T> multiranges, PostgreSQL literal parsing/formatting, and System.Text.Json serialization.
Value set types (v6)
A range says "every moment between these two"; a set says "exactly these moments" — public holidays, billing months, appointment slots. Immutable, canonical (deduplicated, sorted, never null), and stored as a native PostgreSQL array rather than a range.
| Type | Element | PostgreSQL equivalent |
|---|---|---|
LocalDateSet |
LocalDate |
date[] |
LocalDateTimeSet |
LocalDateTime |
timestamp[] |
InstantSet |
Instant |
timestamptz[] |
LocalTimeSet |
LocalTime |
time[] |
YearMonthSet |
YearMonth |
date[] (month-aligned) |
The whole core set algebra applies: Contains, Overlaps, IsSubsetOf/IsSupersetOf and their proper variants, Union, Remove, Count, IsEmpty, plus client-side Intersect/Except/Add — along with array-literal parsing/formatting, JSON (one call to set up), and collection expressions.
using CodoMetis.ValueRanges;
using NodaTime;
var sprint = LocalDateRange.CreateFinite(new LocalDate(2025, 1, 6), new LocalDate(2025, 1, 17));
sprint.Contains(new LocalDate(2025, 1, 10)); // true
var deploy = InstantRange.CreateFinite(
Instant.FromUtc(2025, 6, 1, 22, 0),
Instant.FromUtc(2025, 6, 2, 2, 0)); // [start, end) — half-open, like tstzrange
var blocked = RangeSet<LocalDateRange, LocalDate>.From([
LocalDateRange.CreateFinite(new LocalDate(2025, 1, 1), new LocalDate(2025, 1, 31)),
LocalDateRange.CreateFinite(new LocalDate(2025, 2, 1), new LocalDate(2025, 2, 28))
]); // { [2025-01-01,2025-02-28] } — adjacent months merge (discrete step)
var billing = YearMonthRange.CreateFinite(new YearMonth(2025, 1), new YearMonth(2025, 12));
billing.Contains(new YearMonth(2025, 6)); // true — month-granularity periods (v5)
// Value sets (v6) — "exactly these", not "everything between"
LocalDateSet holidays = [new LocalDate(2025, 12, 26), new LocalDate(2025, 1, 1)];
holidays.ToString(); // {2025-01-01,2025-12-26} — sorted, deduplicated
holidays.Contains(new LocalDate(2025, 1, 1)); // true
var closed = LocalDateSet.From(new LocalDate(2025, 1, 1));
closed.IsProperSubsetOf(holidays); // true
var slots = LocalTimeSet.From(new LocalTime(17, 30), new LocalTime(9, 0));
slots.ToString(); // {09:00:00,17:30:00}
Installation
dotnet add package CodoMetis.ValueRanges.NodaTime
Requires .NET 10 or later. A companion EF Core package, CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime, maps these types to PostgreSQL columns via
Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime.
JSON
Ranges and value sets both work under the core package's AddRangeConverters(), with no NodaTime-specific setup:
using CodoMetis.ValueRanges.Serialization;
var options = new JsonSerializerOptions().AddRangeConverters();
JsonSerializer.Serialize(sprint, options); // "[2025-01-06,2025-01-17]"
JsonSerializer.Serialize(holidays, options); // ["2025-01-01","2025-12-26"]
The range types format themselves. The set types get there differently: they delegate elements to System.Text.Json, which has no built-in converter for NodaTime types, so each family supplies an ISO 8601 fallback through IValueSetFactory<TSet, T>.ElementJsonConverter — the same text form its array literals use. The fallback is consulted last, so any converter you register for the element type still wins.
Use AddNodaTimeRangeConverters() when the payload also carries bare NodaTime values next to the sets:
var options = new JsonSerializerOptions().AddNodaTimeRangeConverters();
JsonSerializer.Serialize(new { Day = new LocalDate(2025, 1, 1), Days = holidays }, options);
// {"Day":"2025-01-01","Days":["2025-01-01","2025-12-26"]}
It registers the converter factory plus the same five element converters on the options, so a LocalDate property outside a set gets the ISO 8601 form too. Element types already claimed by a registered converter are left alone, which makes it idempotent and order-independent against NodaTime.Serialization.SystemTextJson:
var options = new JsonSerializerOptions()
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb) // Duration, Period, ZonedDateTime, …
.AddNodaTimeRangeConverters(); // ranges, sets, and anything left over
Reach for that package when the payload carries NodaTime types beyond these five.
Two documented caveats, dissolved
The core package documents two reinterpretation rules at the database boundary for the BCL-based types. With NodaTime they do not arise, because the types cannot express the ambiguity in the first place:
tsrange/DateTimeRange: a UTC-kindedDateTimeis reinterpreted as wall-clock time. ALocalDateTimeis wall-clock time by construction — there is noKindto reinterpret.tstzrange/DateTimeOffsetRange: bounds are normalized to UTC and the original offset is not round-tripped. AnInstantis whattimestamptzstores — a point on the global timeline with no offset attached — so there is nothing to normalize and nothing to lose. Zoned or offset values convert explicitly (zonedDateTime.ToInstant()) before entering a range, which is exactly NodaTime's own philosophy.
This is the same design move the core package makes for unboundedness: the invalid state is not validated away, it is unrepresentable.
Why these element types
The core package restricts its element types to domains with a total order that the type's own comparisons agree with, plus — for adjacency — a defined step between neighbours ("Why these element types" in the core README). Applying the same bar to NodaTime:
LocalDate,LocalDateTime,Instantpass, and each maps onto a PostgreSQL built-in range domain.YearMonth(v5) passes too — totally ordered with a one-month step — and although PostgreSQL has no month-granularity type, every month range is a month-aligneddaterange, which is how the EF Core satellite stores it. Billing and reporting periods finally get a type instead of adaterangeplus a CHECK constraint.ZonedDateTimeandOffsetDateTimehave no default ordering at all — NodaTime deliberately declines to implementIComparable<T>on them, because ordering by instant and ordering by local time give different answers, and ships named comparers (Comparer.Instant,Comparer.Local) instead. The core'sT : struct, IComparable<T>, IEquatable<T>constraint therefore rejects them at compile time. This is thedouble/NaNargument from the core README with the enforcement moved a level earlier:doubleslipped through the constraint and had to be excluded by policy; here the type system does the excluding. Hold instants in anInstantRangeand convert at the boundary — the zone is presentation, the instant is the value (and the offset is exactly whattstzrangediscards on the server, too).LocalTimeis totally ordered, and since v6 it has a set type —LocalTimeSet, over the built-intime[]. There is still noLocalTimeRange: a time-of-day interval is covered by the core package'sTimeRange(overTimeOnly, mapping to the customtimerangetype, which the database mustCREATE TYPEfirst); convert withlocalTime.ToTimeOnly(). The asymmetry is PostgreSQL's, not ours —time[]is built in,timerangeis not.Duration,Offsetremain excluded — no PostgreSQL domain and no interval-algebra meaning.Periodis not comparable at all — NodaTime refuses to rank 30 days against 1 month, for the same reason this library refusesdouble: an ordering would have to lie.
Semantics worth knowing
- Defaults match the core conventions.
LocalDateRange.CreateFiniteis closed[start, end](discrete);LocalDateTimeRange/InstantRangedefault to half-open[start, end)(continuous timestamp convention). Discrete canonicalization applies:(2025-01-01, 2025-01-31)normalizes to[2025-01-02, 2025-01-30]. - The ISO calendar is a construction rule.
LocalDate.CompareTois only defined between dates of the same calendar system, and PostgreSQLdate/timestampare proleptic Gregorian.LocalDateRangeandLocalDateTimeRangetherefore normalize bounds to the ISO calendar at construction (WithCalendar(CalendarSystem.Iso)— same day on the timeline, ISO representation). Ranges never hold mixed-calendar bounds, so comparisons cannot throw. A date outside the ISO calendar's year range (far-future non-ISO dates) throwsArgumentOutOfRangeExceptionat construction.YearMonthRangeis stricter: a non-ISO year-month spans parts of two ISO months and has no lossless equivalent, so it rejects non-ISO bounds withArgumentExceptioninstead of reinterpreting them. - The same calendar rule covers the set types, at every entry point.
LocalDateSet/LocalDateTimeSetnormalize to ISO andYearMonthSetrejects non-ISO — not only inFrom, but inContains,AddandRemove, which take a bare element rather than a set. That matters becauseLocalDate.Equalsis calendar-sensitive andLocalDate.CompareTothrows across calendars: without it,holidays.Contains(copticDate)would quietly answerfalsefor a date the set actually holds. The EF Core package applies the same normalization to a bare probe bound as a query parameter. YearMonthRangefollows the discrete conventions. Closed[start, end]by default, one-month step:[2025-01, 2025-03]and[2025-04, 2025-06]are adjacent and merge;[2025-01, 2026-01)canonicalizes to[2025-01, 2025-12]. Literals use the ISOuuuu-MMform:[2025-01,2025-12].- Formatting is culture-free. Literals use NodaTime's ISO patterns:
[2025-01-01,2025-03-31],[2024-06-01T08:00:00,2024-06-01T17:30:00),[2024-06-01T00:00:00Z,2024-07-01T00:00:00Z). Subsecond digits appear only when present, up to nanosecond precision. - Parsing accepts the PostgreSQL wire form too. Besides its own canonical output,
Parsehandles literals aspsqlprints them: space-separated timestamps ("2024-06-01 00:00:00") and numeric offsets ("2024-06-01 14:30:00+02"— converted to the instant they denote). - Precision at the database boundary. NodaTime carries nanoseconds; PostgreSQL stores microseconds. Sub-microsecond precision is reduced when persisting through the EF Core package (pinned by the live-PostgreSQL integration suite). In-memory operations keep full nanosecond precision.
Instant.MinValue/Instant.MaxValuemap to PostgreSQL-infinity/infinityby default (an Npgsql rule) — a finite bound that happens to be infinite, still distinct from an unbounded side, exactly as the core README describes forDateTime.MinValue/MaxValue.
Interop with NodaTime's own interval types
NodaTime ships two interval types of its own; both are deliberately narrower than the range model, and conversions are provided in both directions:
| NodaTime type | Shape it can express | Conversions |
|---|---|---|
Interval |
[start, end) over instants; ends may be absent; no empty |
interval.ToInstantRange() (total) · range.ToInterval() (throws for shapes an Interval cannot express) |
DateInterval |
finite, fully closed [start, end] over dates |
dateInterval.ToLocalDateRange() (total) · finite.ToDateInterval() (declared on LocalDateRange.Finite — pattern match first) |
YearMonthRange additionally converts through its days: range.ToLocalDateRange() (total — each bound month expands to its first/last day), localDateRange.ToYearMonthRange() (inverse — throws when a canonical bound is not a month boundary), and finite.ToDateInterval() on YearMonthRange.Finite.
var interval = new Interval(Instant.FromUtc(2025, 1, 1, 0, 0), Instant.FromUtc(2025, 2, 1, 0, 0));
InstantRange range = interval.ToInstantRange(); // [start, end) Finite
Interval back = range.ToInterval(); // round-trips
if (LocalDateRange.Parse("[2025-01-01,2025-01-31]", null) is LocalDateRange.Finite finite)
DateInterval dates = finite.ToDateInterval();
What the range types add over Interval/DateInterval: the empty range, unbounded date ranges, all four bound-inclusiveness combinations, the full set algebra (Union/Except/Intersect/Merge/Complement), multiranges, PostgreSQL literals, and LINQ-to-SQL translation.
Entity Framework Core
dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime
options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRangesNodaTime());
One line — it implies both UseNodaTime() (the Npgsql NodaTime plugin) and UseValueRanges() (the base plugin), so BCL-based and NodaTime-based range types coexist in one model. The full algebra translates to SQL exactly as documented in the core README, including the discrete upper(x) - 1 compensation for LocalDateRange and the satellite's RangeAgg/RangeIntersectAgg overloads inside GroupBy projections.
The same call registers the five value set types, mapped to native array columns — no configuration beyond the property itself:
public LocalDateSet Holidays { get; set; } = LocalDateSet.Empty; // date[]
public YearMonthSet BillingMonths { get; set; } = YearMonthSet.Empty; // month-aligned date[]
reservations.Where(r => r.Holidays.Contains(day)); // r."Holidays" @> ARRAY['2025-01-01']::date[]
reservations.Where(r => r.Holidays.Overlaps(closures)); // r."Holidays" && @closures
reservations.Where(r => r.BillingMonths.Count > 2); // cardinality(r."BillingMonths") > 2
Containment always translates as @>, so a plain GIN index serves it.
YearMonthRange columns are stored as month-aligned daterange ([2025-01, 2025-03] ⇒ [2025-01-01, 2025-04-01)) — no custom database type involved. Every operator, bound accessor and aggregate translates; reads validate month alignment rather than silently shifting boundaries. Only server-side construction from column values (YearMonthRange.CreateFinite(x.SomeColumn, …) inside a query) is unsupported, because months are coarser than the date subtype.
License
MIT — see LICENSE.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- CodoMetis.ValueRanges (>= 8.0.0)
- NodaTime (>= 3.3.3)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on CodoMetis.ValueRanges.NodaTime:
| Package | Downloads |
|---|---|
|
CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime
NodaTime support for the CodoMetis.ValueRanges EF Core (Npgsql) plugin: maps LocalDateRange to daterange, LocalDateTimeRange to tsrange, InstantRange to tstzrange and YearMonthRange to a month-aligned daterange (plus their RangeSet<TRange, T> multirange counterparts), bridging through NpgsqlRange<T> via Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime. The full range algebra translates from LINQ to SQL exactly as for the BCL-based types — operators, bound accessors, range_merge, range_agg/range_intersect_agg, factories and multirange operations. v6 also maps the NodaTime value sets to native arrays — LocalDateSet to date[], LocalDateTimeSet to timestamp[], InstantSet to timestamptz[], LocalTimeSet to time[] (no CREATE TYPE needed, unlike timerange) and YearMonthSet to a month-aligned date[] — with the set algebra translating to the array operators (@>, &&, <@, cardinality). Enable with one line: options.UseNpgsql(..., npgsql => npgsql.UseValueRangesNodaTime()) — this implies both UseNodaTime() and UseValueRanges(). |
GitHub repositories
This package is not used by any popular GitHub repositories.