Skip to content

feat: 6.3.0 — shape predicates, collection expressions, spans, measure, enumeration and the set/range bridge - #18

Merged
CaffeinatedCoder merged 3 commits into
mainfrom
release/6.3.0
Aug 16, 2026
Merged

CaffeinatedCoder merged 3 commits into
mainfrom
release/6.3.0

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Owner

Seven additions, no breaking changes. Three closed gaps where one half of the library had something the other did not; four came from a follow-up pass over the same list.

Round one

RangeSet.IsInfinity() / IsFinite() — the shape predicates a range had and its multirange counterpart did not.

The design decision worth reviewing: IsInfinity() is not IsUnboundedStart() && IsUnboundedEnd(). That equivalence holds for a range, which is contiguous, and fails for a set — {(,5],[10,)} is unbounded at both ends and does not contain 7. It translates to x = '{(,)}'::datemultirange, exact because PostgreSQL canonicalizes multiranges the way the model does. Verified against live PostgreSQL, where the gapped set answers lower_inf and upper_inf true and the equality false.

Collection expressions for RangeSet<TRange, T> — via a non-generic RangeSet.Create builder plus From(params ReadOnlySpan<TRange>). Note this does not also buy RangeSet.From(a, b) inference: C# does not infer type arguments from constraints, so a direct Create call still names both.

ISpanParsable<T> on both factory interfaces, RangeSet, and all 30 concrete types. The parsers were already span-based internally, so each overload is the existing call minus .AsSpan().

Round two

Length on all eleven range types — a count for discrete domains inclusive of both bounds, a span for continuous ones. Empty measures zero, unbounded measures null; the two stay distinguishable. Int64Range is the one type whose count can exceed what reports it, so it is computed in decimal and refused rather than wrapping to a plausible negative.

Values() on the discrete range types only. Declaring it per type rather than as an extension makes asking a DecimalRange for its values a compile error instead of a runtime throw. Validation is eager, so an unbounded range fails at the call rather than at the foreach.

A bridge between the two type families — {1,2,3,7} ↔ {[1,3],[7,7]} for the discrete domains. Client-side: PostgreSQL converts between arrays and multiranges only through unnest and a custom aggregate.

Clamp(value) on every range, and an indexer on the nineteen value set types.

Supporting this: IRangeFactory.IsDiscrete, a defaulted virtual static. It cannot be derived from NextValueAfter, which returns null both for a continuous domain and for the last value of a discrete one.

Documentation

Adds "What runs where" to the README — one table for the translated surface with its PostgreSQL operators, one for the client-side operations with the reason each cannot translate, built by enumerating the translators rather than from memory. The EF package README gains the same client-side table.

Also folds a ## [Unreleased] changelog section into 6.2.1, where it had already shipped — the tag was cut from a main that already contained the parse-rejection change, so consumers got a changed exception-message shape with no 6.2.1 entry describing it. ChangelogConventionTests now fails on any heading naming no version, so a tag cannot outrun its own release notes again.

Verification

1117 tests, 0 warnings under TreatWarningsAsErrors, packing clean through PackageValidation against the published 6.2.1 baseline.

Every guard was verified by seeding its defect and watching it go red:

Seeded Caught by
IsInfinity weakened to lower_inf AND upper_inf translation test and the live-PostgreSQL test, naming row 2072
RangeSet.Create throws all four collection-expression tests
A range type forgets its IsDiscrete override convention test, naming the disagreement
Length measures a span instead of a count two length tests
The bridge merges across gaps seven bridge tests
Eager validation made lazy the eager-throw test
An [Unreleased] heading restored the new changelog test

JSON needed no change: the additions are members on existing types, and NamedJsonConverterTests already fails on a new family without a converter.

🤖 Generated with Claude Code

CaffeinatedCoder and others added 3 commits August 16, 2026 16:20
Three gaps in the existing surface, each something one half of the library had
and the other half did not. No breaking changes.

RangeSet.IsInfinity() / IsFinite() — the two shape predicates a range had and
its multirange counterpart did not. IsInfinity() is deliberately not
IsUnboundedStart() && IsUnboundedEnd(): that equivalence holds for a contiguous
range and fails for a set, since {(,5],[10,)} is unbounded at both ends and does
not contain 7. It translates to equality against the infinite multirange rather
than to lower_inf AND upper_inf, which is exact because PostgreSQL canonicalizes
multiranges the way the model does — verified against live PostgreSQL, where the
gapped set answers both lower_inf and upper_inf true and the equality false.

Collection expressions for RangeSet<TRange, T>, matching the fifteen value set
types. The builder is a non-generic RangeSet.Create<TRange, T>, since a
[CollectionBuilder] target cannot be generic; a From(params ReadOnlySpan<TRange>)
overload comes with it and shares the normalization tail with the sequence
overload. Note that C# does not infer type arguments from constraints, so a
direct Create call must name both — the collection expression is the ergonomic
path, not the factory.

ISpanParsable<T> on the eleven range types, RangeSet and all nineteen set types
and arities. The literal grammars were already parsed over spans internally, so
each overload is the existing call minus the .AsSpan() widening. One consequence
documented at the RangeSet.Parse call site: where a type parameter is constrained
to IRangeFactory/IValueSetFactory both overloads are now visible and a string
argument binds to the span one — harmless because every type's two overloads are
the same call.

PackageValidationBaselineVersion moves to 6.2.1, the release this supersedes, per
the convention in Directory.Build.props. Restore therefore fails until 6.2.1 is
published to NuGet; verified green against the 6.2.0 baseline in the meantime.

Co-Authored-By: Claude Opus 5 <[email protected]>
Length on every range type. The convention follows the domain: a discrete one
counts its values inclusive of both bounds ([2024-01-01, 2024-01-31] measures 31
days, [1,10] measures 10 integers), a continuous one measures the span. Empty
measures zero and unbounded measures null — different answers that stay
distinguishable. The type follows too: long? for the integer ranges, int? days
for DateRange, TimeSpan? for the timestamp ranges, decimal? for DecimalRange,
and Duration?/Period? for the NodaTime ranges, which separate exact elapsed time
from a calendar quantity. Int64Range is the one type whose count can exceed what
reports it — the near-full domain holds more than long.MaxValue values — so it is
computed in decimal and refused rather than wrapping to a plausible negative.

Values() on the discrete range types only. Declaring it per type rather than as
an extension over IRange<T> makes asking a DecimalRange for its values a compile
error instead of a runtime throw, which is the same bargain the five sealed
variants already make. Validation is eager: an iterator would defer the unbounded
refusal to the first MoveNext and surface it at the foreach rather than at the
call that was wrong.

A bridge between the two type families over the discrete domains: ToRangeSet()
collapses runs of consecutive values ({1,2,3,7} to {[1,3],[7,7]}) and
ToInt32Set()/ToDateSet()/... expand back. The shapes describe the same membership
and differ only in density — a thousand consecutive dates are one daterange or a
thousand-element array, and @> against the range is the cheaper question. Both
directions are client-side; PostgreSQL converts between arrays and multiranges
only through unnest and a custom aggregate.

Clamp(value) on every range, and an indexer on the nineteen value set types,
matching what RangeSet already offered.

IRangeFactory.IsDiscrete, a defaulted virtual static, because the property cannot
be derived from NextValueAfter — that returns null both for a continuous domain
and for the last value of a discrete one. RangeFactoryContractTests holds the two
to agreement and checks that a type claiming a step also canonicalizes closed,
with a discovery floor so it cannot pass by matching nothing.

Each guard verified by seeding its defect: dropping an IsDiscrete override, making
Length measure a span instead of a count, letting the bridge merge across gaps, and
deferring the eager validation each turn the relevant tests red.

Co-Authored-By: Claude Opus 5 <[email protected]>
… label

The parse-rejection change was filed under "## [Unreleased]" and then shipped in
6.2.1 — the tag was cut from a main that already contained it. Consumers got a
changed exception-message shape with no 6.2.1 entry describing it, and the
version-is-documented tests passed throughout, because 6.2.1 did have an entry,
just not the whole one. Folded into 6.2.1 where it belongs, in the root and core
changelogs.

Guarded so it cannot recur: ChangelogConventionTests now fails on any heading
that names no version. Every commit on main is a tag away from publishing, and
the release workflow ships whatever Directory.Build.props names, so a heading
meaning "not yet" is false the moment someone tags. Verified by putting an
Unreleased heading back and watching the test name it.

Added "What runs where" to the README: one table for the translated surface with
its PostgreSQL operators, one for the client-side operations with the reason each
cannot translate. The information existed but was scattered across six places in
prose, and the 6.3.0 additions made the client-side half large enough to be worth
stating in one place. The EF package README gains the same client-side table,
since that is where an EF consumer looks.

Fixed a count that contradicted itself: collection expressions were described as
supported by "fifteen value set types" in one place and nineteen in another. It is
nineteen — fifteen closed types plus four wrapper arities. Every remaining count
claim was cross-checked against the code.

Co-Authored-By: Claude Opus 5 <[email protected]>
@CaffeinatedCoder
CaffeinatedCoder merged commit 9e76b37 into main Aug 16, 2026
8 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the release/6.3.0 branch August 16, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant