v4.0.0
What's new in v4.0
v4.0 completes the PostgreSQL feature matrix: every remaining range and multirange operator and function now has an in-memory implementation and a LINQ-to-SQL translation — and a new integration test suite executes the translated SQL against live PostgreSQL to prove that both worlds return identical results.
Bound accessors — lower / upper / lower_inc / upper_inc
The variants expose Start/End only where they exist structurally; the new accessors provide the dynamic view, returning T? with null for a missing bound — exactly PostgreSQL's NULL semantics:
Int32Range.CreateFinite(1, 10).LowerBound(); // 1
Int32Range.CreateUnboundedStart(5, true).LowerBound(); // null — no lower bound
Int32Range.Empty.UpperBound(); // null
DecimalRange.CreateFinite(1m, 5m).UpperBoundInclusive(); // false — half-open defaultSorting by range start finally works straight from LINQ:
// ORDER BY lower(b."Period")
bookings.OrderBy(b => b.Period.LowerBound());For the discrete types (int4range, int8range, daterange), PostgreSQL canonicalizes to half-open [lower, upper) while the model canonicalizes to closed [lower, upper]. The translation compensates — UpperBound() becomes upper(x) - 1 and UpperBoundInclusive() becomes NOT upper_inf(x) AND NOT isempty(x) — so server results always equal the in-memory results.
Merge — the convex hull (range_merge)
The smallest single range containing both operands. Unlike Union, the result also covers any gap between disjoint operands:
var a = Int32Range.CreateFinite(1, 3);
var b = Int32Range.CreateFinite(10, 12);
a.Union(b); // { [1, 3], [10, 12] } — two elements, the gap stays open
a.Merge(b); // [1, 12] — one range, the gap is covered
RangeSet<Int32Range, int>.From([a, b]).Merge(); // [1, 12] — spans the whole setTranslates to range_merge(a, b) and range_merge(multirange).
Aggregates — range_agg / range_intersect_agg
RangeAgg() aggregates a sequence of ranges into a normalized RangeSet; RangeIntersectAgg() folds it into the common intersection (null for an empty source, matching the NULL PostgreSQL returns over zero rows):
new[] { Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(3, 8) }.RangeAgg();
// { [1, 8] }
// range_agg(b."Period") per group, straight from LINQ:
bookings.GroupBy(b => b.CustomerId)
.Select(g => g.Select(b => b.Period).RangeAgg());Multirange operator parity
RangeSet<TRange, T> now covers the complete multirange operator matrix, in memory and in SQL:
New RangeSet member |
PostgreSQL equivalent |
|---|---|
Contains(RangeSet) |
@> with a multirange operand |
Overlaps(RangeSet) |
&& with a multirange operand |
IsAdjacentTo(range / set) |
-|- |
IsStrictlyLeftOf / IsStrictlyRightOf |
<< / >> |
DoesNotExtendRightOf / DoesNotExtendLeftOf |
&< / &> |
IsEmpty() / IsUnboundedStart() / IsUnboundedEnd() |
isempty / lower_inf / upper_inf |
LowerBound() / UpperBound() + inclusiveness |
lower / upper / lower_inc / upper_inc |
Merge() |
range_merge(multirange) |
== / != |
= / <> |
Adjacency mirrors PostgreSQL exactly — it is directional through the outer edges: the operand must end exactly where the set's first element begins, or begin exactly where the set's last element ends. Touching any interior boundary, even the inner side of the first or last element, does not count (verified against live PostgreSQL):
var set = RangeSet<Int32Range, int>.From([
Int32Range.CreateFinite(1, 3), Int32Range.CreateFinite(7, 9), Int32Range.CreateFinite(20, 22)
]);
set.IsAdjacentTo(Int32Range.CreateFinite(23, 25)); // true — attaches after the last element
set.IsAdjacentTo(Int32Range.CreateFinite(4, 6)); // false — inner side of the first element
set.IsAdjacentTo(Int32Range.CreateFinite(10, 12)); // false — touches only the interior [7, 9]Live-PostgreSQL integration suite
A new Testcontainers-based test project executes the translated SQL against real PostgreSQL and asserts agreement with the in-memory results: round-trips for all six range and both multirange column types, the timestamp normalization rules (DateTimeKind reinterpretation for timestamp, UTC normalization for timestamptz, DateTime.MaxValue ↔ infinity), and every v4 operation end-to-end. Docker is required; without it the suite reports Inconclusive instead of failing. This suite is what pinned down the discrete upper() compensation and the directional adjacency rule above.
Bug Fixes
RangeSet.Infinite queries no longer throw — RangeSet.Infinite.Contains(range) and RangeSet.Infinite.Overlaps(range) threw InvalidOperationException for operands with a finite bound, because the Infinity element reached the internal bound helpers that reject that shape. Both now short-circuit and return the expected result.
Breaking Changes
RangeSet == / != are now structural
RangeSet<TRange, T> defines the equality operators as value equality, delegating to Equals — consistent with the range types themselves (records) and with the SQL = the EF Core provider generates for multirange comparisons:
// v3.x — reference equality: false for distinct instances with equal content
setA == setB;
// v4.0.0 — structural equality: true when both sets normalize identically
setA == setB;The change is silent on recompile: no compiler error flags affected sites. If you relied on reference identity, switch to ReferenceEquals(a, b).
DoesNotExtendRightOf / DoesNotExtendLeftOf match PostgreSQL for infinite bounds
An infinite bound now compares equal to another infinite bound (+∞ ≤ +∞, -∞ ≥ -∞), exactly like the &< / &> operators. Previously an unbounded receiver always returned false, even against an operand unbounded on the same side:
var a = Int32Range.CreateUnboundedEnd(5); // [5, +∞)
var b = Int32Range.CreateUnboundedEnd(100); // [100, +∞)
// v3.x
a.DoesNotExtendRightOf(b); // false — unbounded receiver always false
// v4.0.0
a.DoesNotExtendRightOf(b); // true — +∞ ≤ +∞, matching PostgreSQL &<Results against finite-bounded or empty operands are unchanged.
Full Changelog: v3.1.0...v4.0.0