Interval strong types: inclusive-by-default bounds, JSON + EF Core + OpenAPI support#77
Interval strong types: inclusive-by-default bounds, JSON + EF Core + OpenAPI support#77KaliCZ wants to merge 23 commits into
Conversation
CoverageLines: 4608 / 6100 (75.5%) Branches: 2423 / 3168 (76.5%) Files changed in this PR
StrongTypes — lines 93.9% (1866/1987), branches 89.4% (1192/1334)Booleans — lines 100.0% (14/14), branches 84.6% (22/26)
Collections — lines 90.2% (276/306), branches 85.1% (114/134)
Digits — lines 97.7% (85/87), branches 93.8% (30/32)
Emails — lines 98.6% (68/69), branches 92.5% (37/40)
Enums — lines 100.0% (58/58), branches 95.5% (21/22)
Exceptions — lines 78.9% (15/19), branches 50.0% (7/14)
Intervals — lines 97.5% (346/355), branches 95.6% (195/204)
Maybe — lines 92.1% (151/164), branches 83.9% (94/112)
Nullables — lines 100.0% (12/12), branches 83.3% (20/24)
Numbers — lines 100.0% (84/84), branches 94.4% (17/18)
Result — lines 98.5% (333/338), branches 95.6% (417/436)
Strings — lines 84.7% (133/157), branches 84.4% (54/64)
generated — lines 89.8% (291/324), branches 78.8% (164/208)
StrongTypes.Analyzers — lines 94.7% (322/340), branches 86.8% (132/152)(root) — lines 94.7% (322/340), branches 86.8% (132/152)
StrongTypes.Api — lines 97.1% (374/385), branches 86.3% (88/102)(root) — lines 100.0% (11/11), branches n/a (0/0)
Controllers — lines 97.0% (225/232), branches 86.3% (88/102)
Data — lines 100.0% (97/97), branches n/a (0/0)
Entities — lines 75.0% (12/16), branches n/a (0/0)
Models — lines 100.0% (29/29), branches n/a (0/0)
StrongTypes.AspNetCore — lines 89.3% (151/169), branches 84.5% (71/84)(root) — lines 89.3% (151/169), branches 84.5% (71/84)
StrongTypes.AspNetCore.TestApi — lines 90.7% (39/43), branches 92.5% (37/40)(root) — lines 100.0% (6/6), branches n/a (0/0)
Controllers — lines 89.2% (33/37), branches 92.5% (37/40)
StrongTypes.EfCore — lines 90.6% (375/414), branches 76.5% (176/230)(root) — lines 90.6% (375/414), branches 76.5% (176/230)
StrongTypes.FsCheck — lines 81.9% (127/155), branches 100.0% (2/2)(root) — lines 81.9% (127/155), branches 100.0% (2/2)
StrongTypes.OpenApi.Core — lines 91.6% (456/498), branches 83.9% (307/366)(root) — lines 92.2% (271/294), branches 86.8% (177/204)
Inlining — lines 90.7% (185/204), branches 80.2% (130/162)
StrongTypes.OpenApi.Microsoft — lines 37.2% (454/1219), branches 41.2% (177/430)(root) — lines 94.6% (246/260), branches 74.6% (97/130)
Binding — lines 98.1% (103/105), branches 78.6% (55/70)
Collections — lines 100.0% (19/19), branches 100.0% (8/8)
Digits — lines 100.0% (11/11), branches 100.0% (2/2)
Emails — lines 100.0% (11/11), branches 100.0% (2/2)
Inlining — lines 100.0% (7/7), branches 50.0% (1/2)
Intervals — lines 100.0% (28/28), branches 100.0% (6/6)
Maybe — lines 100.0% (12/12), branches 100.0% (2/2)
Numbers — lines 100.0% (8/8), branches 100.0% (2/2)
Strings — lines 100.0% (9/9), branches 100.0% (2/2)
obj/Debug/net10.0/Microsoft.AspNetCore.OpenApi.SourceGenerators/Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator — lines 0.0% (0/749), branches 0.0% (0/204)
StrongTypes.OpenApi.Swashbuckle — lines 94.7% (270/285), branches 79.1% (174/220)(root) — lines 98.7% (77/78), branches 93.4% (71/76)
Binding — lines 88.3% (106/120), branches 64.3% (72/112)
Collections — lines 100.0% (6/6), branches 100.0% (4/4)
Digits — lines 100.0% (10/10), branches 100.0% (4/4)
Emails — lines 100.0% (10/10), branches 100.0% (4/4)
Inlining — lines 100.0% (2/2), branches n/a (0/0)
Intervals — lines 100.0% (30/30), branches 100.0% (8/8)
Maybe — lines 100.0% (13/13), branches 75.0% (3/4)
Numbers — lines 100.0% (8/8), branches 100.0% (4/4)
Strings — lines 100.0% (8/8), branches 100.0% (4/4)
StrongTypes.OpenApi.TestApi.Microsoft — lines 41.0% (163/398), branches 31.6% (65/206)(root) — lines 100.0% (14/14), branches 100.0% (2/2)
obj/Debug/net10.0/Microsoft.AspNetCore.OpenApi.SourceGenerators/Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator — lines 38.8% (149/384), branches 30.9% (63/204)
StrongTypes.OpenApi.TestApi.Shared — lines 0.0% (0/196), branches n/a (0/0)(root) — lines 0.0% (0/196), branches n/a (0/0)
StrongTypes.OpenApi.TestApi.Swashbuckle — lines 100.0% (11/11), branches 100.0% (2/2)(root) — lines 100.0% (11/11), branches 100.0% (2/2)
|
|
Codecov Report❌ Patch coverage is
@@ Coverage Diff @@
## main #77 +/- ##
==========================================
- Coverage 89.07% 87.56% -1.52%
==========================================
Files 85 92 +7
Lines 1877 2091 +214
Branches 408 447 +39
==========================================
+ Hits 1672 1831 +159
- Misses 134 177 +43
- Partials 71 83 +12
🚀 New features to boost your workflow:
|
Introduce four interval struct types covering every nullability combination so the C# compiler enforces "closed interval ⇒ non-nullable endpoints": - ClosedInterval<T>: (T Start, T End) both bounded - IntervalFrom<T>: (T Start, T? End) "from X" - IntervalUntil<T>: (T? Start, T End) "until Y" - Interval<T>: (T? Start, T? End) fully open All four enforce Start <= End wherever both endpoints are present, follow the TryCreate / Create factory pattern, and ship a Deconstruct method that enables the 4-arm switch from issue #60. Persistence: - IntervalJsonConverterFactory handles all four shapes through System.Text.Json. - IntervalJsonValueConverter<TInterval> stores the interval as a JSON string column in EF Core; alternatively, callers can map to two scalar columns via EF Core's standard ComplexProperty (no custom converter needed). Tests: - FsCheck arbitraries for each type (always satisfying start <= end). - Property tests cover validation, deconstruction, equality, and JSON round-trip across all four nullability shapes. https://claude.ai/code/session_01Qc2Xf1PTwgi9jPSBGbv8f6
Reviewing PR #77 (interval strong types) on top of latest main surfaced gaps in error reporting, integration coverage, and docs. This addresses them. Implementation: - IntervalJsonConverterFactory: nested endpoint reads now rethrow path-less so System.Text.Json reattaches the property path. A null/type-mismatch endpoint was surfacing as the document root ("$") instead of "$.value" — the same bug #106 fixed for the numeric converter. Error messages also no longer leak the arity-suffixed CLR name ("ClosedInterval`1"). Unit tests (StrongTypes.Tests): - Contains over open/closed bounds and ToString for every variant, JSON edge cases (missing/extra/reordered properties, non-object tokens, a DateOnly/DateTime endpoint), error-message quality, and the path-less rethrow. Added generator-branch coverage facts per testing.md. API integration tests (StrongTypes.Api*): - Four interval entities/controllers mapped to a single JSON column via the shipped HasIntervalJsonConversion. Dedicated wire-to-DB suite covering the round-trip on both providers, null handling, update, and invalid payloads keyed at "$.value" (the #106 contract). OpenAPI (StrongTypes.OpenApi.*): - Interval schema transformer (Microsoft) and filter (Swashbuckle) render each variant as { Start, End } with required: [Start, End] and per-variant endpoint nullability, inlined like the other wrappers. CloneWireShape now preserves `required`. Tested on all three pipeline configs. Docs: - README, SKILL.md + new references/intervals.md, and the EfCore/OpenApi references and package readmes. Corrected the ComplexProperty claim: EF Core cannot bind the interval's private constructor as a complex type, and column materialization would bypass the Start <= End invariant — the JSON column is the supported shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
c63f864 to
e6f0bf5
Compare
|
Rebased onto latest
Full build passes with |
WPF two-way binding goes through ParsableTypeConverter<T> (T : IParsable<T>), which the type-description provider only synthesises for the scalar strong types (NonEmptyString, Email, Digit, numeric wrappers). The intervals — like Maybe<T> and NonEmptyEnumerable<T> — are composite, have no single-TextBox string form, and are correctly not registered. The readme and skill claimed "every strong type"; tighten both to say string-round-trippable types and point composite types at field-by-field binding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each more-constrained interval variant widens implicitly to a less-constrained one (lossless, never throws): ClosedInterval to IntervalFrom/IntervalUntil/Interval, and IntervalFrom/IntervalUntil to Interval. This is also how mixed variants share one collection — widen them to Interval<T>, stored inline as structs with no boxing. Narrowing back is partial, so it follows the library's As* convention and returns a nullable mirroring TryCreate: Interval.AsClosed/AsFrom/ AsUntil and AsClosed on the half-open variants, null when a required endpoint is open. Property + fact coverage for both directions; readme and skill updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each As* narrowing helper now has a To* sibling that throws InvalidOperationException instead of returning null when a required endpoint is open: Interval.ToClosed/ToFrom/ToUntil and ToClosed on the half-open variants. Completes the As*/To* pairing used across the library; each To* delegates to its As* so the gate lives in one place. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The parse-style To* extensions on string and NonEmptyString listed their exceptions only in a // comment; move that to per-method <exception> XML so it reaches the caller via IntelliSense. The string variants throw ArgumentNullException on null input; the NonEmptyString variants can't (the value is guaranteed non-empty), so they document only Format/ Overflow. float/double omit OverflowException (Parse returns Infinity since .NET Core 3.0). Also document ArgumentNullException on NonEmptyString.ToLower/ToUpper (CultureInfo overloads), IEnumerableExtensions.ToReadOnlyList, and sharpen ToEnum to name both ArgumentNullException and the not-a-member ArgumentException. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Intervals can now persist either as one JSON column
(HasIntervalJsonConversion, re-validates on read) or as two scalar
endpoint columns (new HasIntervalColumns, mapped as an EF Core complex
type) — each endpoint its own queryable/indexable column, nullable
exactly when the variant's endpoint is.
EF complex types need settable members, so the four interval structs'
Start/End move from { get; } to { get; private init; }. They stay
readonly structs and publicly immutable (private init is not externally
accessible; the private constructor is unchanged). Two-column reads
materialize from the columns and so do not re-check Start <= End — the
database is the source of truth.
A fully-open Interval<T> mapped to a nullable column needs a shadow
discriminator (HasDiscriminator) so a null property stays distinct from
an unbounded interval; the harness applies it uniformly to the optional
slot.
Verified end-to-end against SQL Server and PostgreSQL (Testcontainers):
round-trip for all four variants plus the nullable slot, and that Value
maps to Value_Start/Value_End rather than a single column. readme, skill
(intervals + efcore), and the EF Core package readme document both shapes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The interval JSON converter requires both Start and End keys present on the wire (an open endpoint is an explicit null, not an absent key). Omitting one throws JsonException -> 400 keyed by $.value; this path had no coverage. Added to the shared interval suite, so it runs for all four variants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two related interval refinements:
1. UseStrongTypes() now auto-maps every interval property to a single
JSON column by default (named after the property), the same zero-config
path the scalar wrappers already take. HasIntervalColumns stays the
explicit opt-in to two endpoint columns and overrides the convention.
HasIntervalJsonConversion is now only needed when not using the
convention.
2. The JSON converter no longer requires both keys present. A missing key
for an OPTIONAL endpoint now means null (so IntervalUntil accepts
{ "end": 10 } and Interval accepts {}); only a missing REQUIRED endpoint
is rejected. The OpenAPI schema follows suit — `required` now lists just
the non-nullable endpoints per variant, instead of always both.
Verified against SQL Server + PostgreSQL (Testcontainers) and both OpenAPI
pipelines: unit 1118, OpenAPI 309, interval API/convention/columns 56 (2
skipped — the open Interval has no required endpoint). readme, skill, and
the EF Core package readme updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erying Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onversion overloads HasIntervalJsonColumn maps an interval as a complex type in a native JSON column so endpoint access in LINQ translates to server-side JSON path lookups. HasIntervalJsonConversion gains PropertyBuilder overloads (including the nullable form) so it composes with entity.Property(...). New IntervalFilterTests cover filtering, range containment, ordering, and open-endpoint null checks against both providers for both queryable shapes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…SON shape Column-mapped intervals now re-validate Start <= End when materialized, via an IMaterializationInterceptor registered by UseStrongTypes(). The native JSON complex mapping (HasIntervalJsonColumn) is removed: EF's JSON shaper assigns complex values after every available interception point, so that shape cannot honor the throw-on-read guarantee. HasIntervalJsonConversion gains PropertyBuilder overloads (including the nullable form) and HasIntervalColumns a nullable overload that adds the shadow discriminator. New tests corrupt rows via raw SQL and assert both shapes throw on read; the filter suite covers Where/OrderBy/IS NULL translation on the two-column shape against both providers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GHSA-v5pm-xwqc-g5wc flags Microsoft.OpenApi <= 2.7.4; restore treats the audit warning as an error, so CI fails at restore. Direct pins in the four projects where nearest-wins otherwise keeps a vulnerable transitive copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The default single-JSON-column shape was opaque to LINQ — filtering on Start/End forced the two-column shape. An IMemberTranslatorPlugin now rewrites interval Start/End access into a JsonScalarExpression, which renders as JSON_VALUE(...) on SQL Server and ->> on PostgreSQL, so Where/OrderBy/IS NULL on endpoints run server-side while materialization still goes through the validating converter (Start <= End throws on read). PostgreSQL's JSON operators do not exist for text columns, so a model finalizing convention stores JSON-mapped intervals as jsonb when the Npgsql provider is active. Detection is by provider name: probing the type mapping source for a jsonb store type is not reliable — SQL Server's mapping source fabricates a mapping for the unknown store type name and EnsureCreated then fails with 'Cannot find data type jsonb'. Filter coverage mirrors the two-column suite on the JSON shape against both providers, plus endpoint access through a nullable interval property; model-shape facts pin jsonb on Npgsql and nvarchar(max) on SQL Server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two plain columns are the more useful default: endpoint queries translate to bare column references and the columns can carry ordinary indexes, which JSON path lookups cannot. UseStrongTypes() now maps every interval property as a complex type by convention (shadow discriminator on the nullable form); HasIntervalJsonConversion opts a property into the single validating JSON column instead, and HasIntervalColumns remains for configuring endpoint columns or mapping without the convention. The harness *ColumnsEntity set drops its explicit mapping so the wire-to-DB suite exercises the bare convention. The convention model-shape facts move off InMemory: its mapping source claims any struct as a scalar, which outranks a convention-source complex mapping, so only a relational model witnesses the default shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The suite validates against real providers on principle; these facts now build offline SQL Server / Npgsql models (placeholder connection string, never opened). InMemory also could not witness the two-column default — its mapping source claims any struct as a scalar, outranking the convention's complex mapping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tests UseSqlServer()/UseNpgsql() without a connection string is the intended form for model-only contexts; the fake "Server=model-only" strings read like real addresses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ClosedInterval.TryCreate(1, 10) and IntervalFrom.Create(today, null) now work without spelling out the endpoint type, following the existing Maybe companion pattern. Docs switched to the inferred form; only the all-null Interval.Create<int>(null, null) still needs an explicit type argument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Overlaps/GetOverlap on all four variants take Interval<T> so any variant widens in; endpoints are inclusive, matching Contains. GetOverlap keeps the receiver's bounded endpoints (ClosedInterval -> ClosedInterval?). The endpoint math lives once in IntervalEndpoints, shared by Contains. Date bridging extensions: a DateTime interval Contains a DateOnly when it covers any instant of that day, the reverse goes by calendar day, ToDateInterval() truncates shape-preservingly, Days() counts inclusively. The Interval companion gains per-nullability factory overloads because inference cannot derive T from a plain value against a T? parameter, so Interval.Create(1, 10) and Interval.Create(5, null) now infer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o companion factories The OpenApi adapter packages embed Core.dll compiled against Microsoft.OpenApi 2.7.5 while AspNetCore.OpenApi and Swashbuckle only declare a >= 2.0.0 floor, so the adapters now declare the dependency themselves; the four audit-only pins in test projects are gone and the patched version flows transitively to tests and package consumers alike. Test call sites construct intervals through the inferring companions, and IntervalEndpointTypesTests pins the generic surface on DateTime, DateOnly, TimeOnly, and decimal endpoints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contains, Overlaps, and GetOverlap now treat End as exclusive, so touching intervals no longer overlap and equal endpoints form a valid, empty interval; construction rules are unchanged. ToString renders the end bracket as ")". Date bridging follows: a day is a half-open [midnight, next midnight) window, ToDateInterval() returns the calendar days the interval covers (a partially covered end day rounds up, keeping Contains(day) and ToDateInterval().Contains(day) in agreement), and Days() measures the half-open range without the previous +1. Readme, skill reference, SKILL.md catalog, and the EF Core readme document the semantics and use strict end comparisons in query examples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…; rename ClosedInterval to FiniteInterval - StartInclusive/EndInclusive on all four variants (default true), carried through widening/narrowing and equality; a null endpoint normalizes its flag to true. Equal endpoints form a single-value interval and require both bounds inclusive; empty intervals are no longer constructible. - JSON writes the bound flags only when false and reads absence as true. - EF Core two-column shape gains a per-bound IntervalBoundMode (AlwaysInclusive default with no flag columns, AlwaysExclusive, Stored with per-value flag columns), enforced on save and applied on read. - OpenAPI schemas add the optional boolean bound flags (default true). - Date bridging honors bounds; ToDateInterval returns the covered days as an inclusive date interval and Days() counts contained days. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Implements #60. Adds four interval
readonly structtypes covering every endpoint-nullability combination, so the C# compiler — not a runtime check — enforces "a bounded endpoint is nevernull". All four arewhere T : struct, IComparable<T>, use theTryCreate/Createfactory pattern — with non-generic companion classes that infer the endpoint type, soFiniteInterval.TryCreate(1, 10)andIntervalFrom.Create(today, null)work without spelling out<int>/<DateOnly>(theIntervalcompanion carries per-nullability overloads so plain values infer too) — enforceStart <= Endwhenever both endpoints are present, and shipContains,Overlaps/GetOverlap,Deconstruct, equality, andToString.Bounds are inclusive by default (
[Start, End]), with per-value opt-out: the factories take optionalstartInclusive/endInclusiveparameters (Create(9, 17, endInclusive: false)is[9, 17)), exposed asStartInclusive/EndInclusiveand part of equality. Equal endpoints form a single-value interval and require both bounds inclusive — an empty interval is not constructible ("no interval" is a nullable interval that'snull). The inclusivity of an unbounded endpoint is meaningless, so it normalizes totrue— no phantom state to compare or serialize.ToStringrenders brackets per bound ([1, 10],[9, 17),(-∞, 10]), and every variant'sdefaultsatisfies its invariant (default(FiniteInterval<int>)is the point[0, 0]).Overlap.
Overlaps(other)/GetOverlap(other)takeInterval<T>, so any variant widens in. Semantics followContains: intervals touching at a shared endpoint overlap in that single point when both touching bounds are inclusive; make one exclusive (back-to-back time windows) and they don't. On a boundary tie the intersection's bound is the AND of the two sides'.GetOverlapreturnsnullwhen disjoint and keeps the receiver's bounded endpoints — aFiniteInterval<T>receiver returnsFiniteInterval<T>?,IntervalFrom<T>returnsIntervalFrom<T>?, etc. The endpoint math lives once in an internalIntervalEndpointshelper (shared with the fourContainsimplementations), not four times.Date bridging. Extensions connect
DateTimeandDateOnlyintervals: aDateTimeintervalContainsaDateOnlywhen it covers any instant of that calendar day (an interval with an exclusive end at exactly midnight stops short of the day it ends on), aDateOnlyintervalContainsaDateTimeby its calendar day,ToDateInterval()converts to the covered days as an inclusive date interval (soi.Contains(day) ⟺ i.ToDateInterval().Contains(day)), andDays()counts the days aFiniteInterval<DateOnly>contains ([Jan 1, Jan 3]is 3; excluded endpoint days don't count). These helpers (and overlap) are in-memory only — EF Core translates endpoint access, not these methods.StartEndFiniteInterval<T>TTIntervalFrom<T>TT?IntervalUntil<T>T?TInterval<T>T?T?Deconstructenables the 4-arm switch from #60:Conversions
FiniteInterval<T>→IntervalFrom<T>/IntervalUntil<T>/Interval<T>;IntervalFrom<T>andIntervalUntil<T>→Interval<T>. This is also the idiomatic way to hold mixed variants in one collection — widen them toInterval<T>(e.g.Interval<int>[] x = [finite, from, open]), stored inline as structs with no boxing.AsFinite()/AsFrom()/AsUntil()return a nullable (mirroringTryCreate,nullwhen a required endpoint is unbounded), each with a throwingTo*sibling (InvalidOperationException). There is deliberately no implicitIntervalFrom↔IntervalUntil— swapping which end is unbounded isn't a total widening.JSON
IntervalJsonConverterFactoryhandles all four shapes throughSystem.Text.Json. On write, the wire format is{"Start": …, "End": …}(both endpoint keys present; an unbounded endpoint isnull), withStartInclusive/EndInclusivewritten only whenfalse— the common inclusive case stays clean — honouring the activeJsonNamingPolicy. On read, an absent key for an optional endpoint meansnull— soIntervalUntilaccepts{"End":10}andIntervalaccepts{}— and an absent bound flag meanstrue; a missing/nullrequired endpoint,Start > End, or equal endpoints with an exclusive bound throwsJsonException, which ASP.NET Core surfaces as a400keyed by the property path ($.value) — matching the #106 error-key contract (a nested deserialize failure rethrows path-less so STJ reattaches the path).EF Core
By default,
UseStrongTypes()auto-maps each interval property to two scalar endpoint columns as an EF Core complex type — no per-property config. Each endpoint is its own queryable, indexable column, nullable exactly when the variant's endpoint is; a nullable interval property adds a shadow discriminator column so anullproperty stays distinct from an unbounded interval. Endpoint access in LINQ (Where(b => b.Window.Start <= at && at <= b.Window.End),OrderBy,== nullon unbounded endpoints) translates to plain column references. Columns default toStart/End(prefixed with the property name on clashes); rename via the complex-property builder thatHasIntervalColumnsreturns. EF Core cannot declare an index over a complex-type member, so an endpoint index is added in a migration (migrationBuilder.CreateIndex(...)on the endpoint column).Bound inclusivity is a mapping choice —
HasIntervalColumnstakes a per-boundIntervalBoundMode:AlwaysInclusive(the convention default) /AlwaysExclusive— every stored value has that bound, no flag column; saving a value whose bound contradicts the mode throws with a message naming the fix, and the configured bound is restored on read (anISaveChangesInterceptor+ the materialization interceptor thatUseStrongTypes()registers).Stored— adds aStartInclusive/EndInclusivebitcolumn and round-trips each value's own bounds; the flag is a plain column, soWhere(b => !b.Window.EndInclusive)translates.Opt into a single JSON column with
HasIntervalJsonConversion(entity or property builder, including nullable properties) — bound flags ride in the payload (written only whenfalse), so noIntervalBoundModeis involved:The JSON column is
jsonbon PostgreSQL andnvarchar(max)on SQL Server, round-tripped through the validating converter (re-checks the invariant on read). This shape is queryable too: anIMemberTranslatorPluginregistered byUseStrongTypes()rewrites intervalStart/Endaccess into EF'sJsonScalarExpression, rendered asJSON_VALUE(...)on SQL Server and->>on PostgreSQL — filterable and orderable, just not index-backed. To make the immutable structs materializable,Start/Enduse{ get; private init; }— they stayreadonly structand publicly immutable (private initisn't externally accessible). The bound flags are stored inverted in private fields so an unmapped flag materializes as the inclusive default, anddefault(TInterval)is always valid.Read validation in both shapes: a stored row violating the invariant throws when materialized — the JSON shape through its converter, the two-column shape through an
IMaterializationInterceptorregistered byUseStrongTypes()(the same interceptor applies fixed bound modes). A native JSON complex mapping (ComplexProperty(...).ToJson()) was prototyped and dropped: EF's JSON shaper assigns complex values after every available interception point (and rejects wrapped materializer expressions), so that shape cannot honor the throw-on-read guarantee — the member-translated converter shape above provides the same queryability without giving up validation.The
jsonbcolumn type is applied by a model-finalizing convention gated on the Npgsql provider name — probing the type-mapping source for ajsonbstore type is not reliable, because SQL Server's mapping source fabricates a mapping for the unknown store-type name andEnsureCreatedthen fails with Cannot find data type jsonb.(
HasIntervalJsonConversionremains for mapping JSON explicitly when not using the convention — on the entity builder or the property builder, including nullable properties.)OpenAPI
Both supported stacks —
Microsoft.AspNetCore.OpenApiand Swashbuckle — render an interval as an object schema{ Start, End, StartInclusive, EndInclusive }, inlined at the use site. Only the variant's required (non-nullable) endpoints appear inrequired; the bound flags are optional booleans withdefault: true, matching the converter's omit-when-inclusive wire format.WPF
Binding stays scoped to parsable scalar types; composite types (the intervals,
Maybe<T>,NonEmptyEnumerable<T>) have no single-TextBoxstring form and bind field-by-field, so they correctly get noTypeConverter.Tests
StrongTypes.Tests): validation (including points accepted / empties rejected per bound flags),Containshonoring inclusivity, deconstruction, equality (flags participate; unbounded-endpoint normalization pinned),ToStringbrackets, JSON round-trip, flag wire format (omit-when-default, absent-means-true, camelCase), rejection cases, and the conversion operators /As*/To*. Overlap is property-tested (symmetry, agreement betweenOverlapsandGetOverlap, and the defining lawoverlap.Contains(v) ⟺ a.Contains(v) && b.Contains(v)) plus touch/point facts; date bridging is tested against independent date-comparison oracles plus boundary facts (midnight ends inclusive vs exclusive,Days()per flag combination). Non-intendpoint types (DateTime,DateOnly,TimeOnly,decimal) are pinned inIntervalEndpointTypesTests.StrongTypes.Api.IntegrationTests): full wire-to-DB round-trip through the ASP.NET Core pipeline and EF Core against both SQL Server and PostgreSQL — for both the JSON-column and two-column shapes, including an exclusive-bound payload round-trip and a400for equal endpoints with an exclusive bound. Invalid payloads →400keyed$.value; null handling for the nullable variant.IntervalBoundModeTests+ model-shape facts):Storedround-trips per-value flags through their own columns (and the flag column translates inWhere), theAlwaysInclusivedefault andAlwaysExclusivereject contradicting values on save with a guiding message,AlwaysExclusiverestores the bound on read without a column, and the model shape pins which flag columns exist per mode (none by default, per-bound withStored, explicit API works with and without the convention).IntervalReadValidationTests);IntervalFilterTestsprovesWhere/OrderBy/IS NULLendpoint translation on both shapes against both providers, including endpoint access through a nullable interval property. The*ColumnsEntitywire-to-DB suite runs on the bare convention (no explicit mapping), so the two-column default is what's integration-tested.StrongTypes.OpenApi.IntegrationTests): schema shape asserted on both pipelines, including the optional boolean bound flags withdefault: true.To*conversion helpers (intervals and the string/number/collection families) document their exceptions via<exception>XML.Verification
dotnet buildof the full solution succeeds withTreatWarningsAsErrors=true.dotnet testgreen locally against SQL Server + PostgreSQL Testcontainers: unit 1163, API integration 936 (+2 pre-existing skips), OpenAPI 312, AspNetCore 46, analyzers 39, WPF 11.Microsoft.OpenApi2.7.5 clears the NU1903 restore failure from GHSA-v5pm-xwqc-g5wc that was failing CI. The dependency is declared where it belongs: onStrongTypes.OpenApi.Coreand the two adapter packages (OpenApi.Microsoft/OpenApi.Swashbuckle), whose embedded Core.dll is compiled against it —Microsoft.AspNetCore.OpenApiand Swashbuckle themselves only declare a>= 2.0.0floor, so the vulnerable version would otherwise resolve for package consumers too. Test projects carry no directMicrosoft.OpenApireference; the patched version reaches them transitively.Docs
readme.md, the skill (SKILL.md+references/intervals.md,efcore.md,openapi.md,fscheck.md), and the EF Core / OpenAPI / WPF package readmes are updated for the interval types, inclusive-by-default bounds,IntervalBoundMode, and their conversions.🤖 Generated with Claude Code