Skip to content

Interval strong types: inclusive-by-default bounds, JSON + EF Core + OpenAPI support#77

Open
KaliCZ wants to merge 23 commits into
mainfrom
claude/json-converter-intervals-pB8eY
Open

Interval strong types: inclusive-by-default bounds, JSON + EF Core + OpenAPI support#77
KaliCZ wants to merge 23 commits into
mainfrom
claude/json-converter-intervals-pB8eY

Conversation

@KaliCZ

@KaliCZ KaliCZ commented Apr 26, 2026

Copy link
Copy Markdown
Owner

Summary

Implements #60. Adds four interval readonly struct types covering every endpoint-nullability combination, so the C# compiler — not a runtime check — enforces "a bounded endpoint is never null". All four are where T : struct, IComparable<T>, use the TryCreate / Create factory pattern — with non-generic companion classes that infer the endpoint type, so FiniteInterval.TryCreate(1, 10) and IntervalFrom.Create(today, null) work without spelling out <int> / <DateOnly> (the Interval companion carries per-nullability overloads so plain values infer too) — enforce Start <= End whenever both endpoints are present, and ship Contains, Overlaps / GetOverlap, Deconstruct, equality, and ToString.

Bounds are inclusive by default ([Start, End]), with per-value opt-out: the factories take optional startInclusive / endInclusive parameters (Create(9, 17, endInclusive: false) is [9, 17)), exposed as StartInclusive / EndInclusive and 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's null). The inclusivity of an unbounded endpoint is meaningless, so it normalizes to true — no phantom state to compare or serialize. ToString renders brackets per bound ([1, 10], [9, 17), (-∞, 10]), and every variant's default satisfies its invariant (default(FiniteInterval<int>) is the point [0, 0]).

Overlap. Overlaps(other) / GetOverlap(other) take Interval<T>, so any variant widens in. Semantics follow Contains: 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'. GetOverlap returns null when disjoint and keeps the receiver's bounded endpoints — a FiniteInterval<T> receiver returns FiniteInterval<T>?, IntervalFrom<T> returns IntervalFrom<T>?, etc. The endpoint math lives once in an internal IntervalEndpoints helper (shared with the four Contains implementations), not four times.

Date bridging. Extensions connect DateTime and DateOnly intervals: a DateTime interval Contains a DateOnly when 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), a DateOnly interval Contains a DateTime by its calendar day, ToDateInterval() converts to the covered days as an inclusive date interval (so i.Contains(day) ⟺ i.ToDateInterval().Contains(day)), and Days() counts the days a FiniteInterval<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.

Type Start End Meaning
FiniteInterval<T> T T bounded both ends
IntervalFrom<T> T T? "from X" — no upper bound
IntervalUntil<T> T? T "until Y" — no lower bound
Interval<T> T? T? either / both ends optional

Deconstruct enables the 4-arm switch from #60:

var label = interval switch
{
    (null, null)         => "unbounded",
    (null, { } end)      => $"up to {end}",
    ({ } start, null)    => $"from {start}",
    ({ } start, { } end) => $"{start}..{end}",
};

Conversions

  • Widening — implicit, lossless (never throws, bound flags carry along): FiniteInterval<T>IntervalFrom<T> / IntervalUntil<T> / Interval<T>; IntervalFrom<T> and IntervalUntil<T>Interval<T>. This is also the idiomatic way to hold mixed variants in one collection — widen them to Interval<T> (e.g. Interval<int>[] x = [finite, from, open]), stored inline as structs with no boxing.
  • Narrowing — partial: AsFinite() / AsFrom() / AsUntil() return a nullable (mirroring TryCreate, null when a required endpoint is unbounded), each with a throwing To* sibling (InvalidOperationException). There is deliberately no implicit IntervalFromIntervalUntil — swapping which end is unbounded isn't a total widening.

JSON

IntervalJsonConverterFactory handles all four shapes through System.Text.Json. On write, the wire format is {"Start": …, "End": …} (both endpoint keys present; an unbounded endpoint is null), with StartInclusive / EndInclusive written only when false — the common inclusive case stays clean — honouring the active JsonNamingPolicy. On read, an absent key for an optional endpoint means null — so IntervalUntil accepts {"End":10} and Interval accepts {} — and an absent bound flag means true; a missing/null required endpoint, Start > End, or equal endpoints with an exclusive bound throws JsonException, which ASP.NET Core surfaces as a 400 keyed 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 a null property stays distinct from an unbounded interval. Endpoint access in LINQ (Where(b => b.Window.Start <= at && at <= b.Window.End), OrderBy, == null on unbounded endpoints) translates to plain column references. Columns default to Start/End (prefixed with the property name on clashes); rename via the complex-property builder that HasIntervalColumns returns. 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 choiceHasIntervalColumns takes a per-bound IntervalBoundMode:

  • 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 (an ISaveChangesInterceptor + the materialization interceptor that UseStrongTypes() registers).
  • Stored — adds a StartInclusive / EndInclusive bit column and round-trips each value's own bounds; the flag is a plain column, so Where(b => !b.Window.EndInclusive) translates.
modelBuilder.Entity<Shift>().HasIntervalColumns(s => s.Window, endBound: IntervalBoundMode.AlwaysExclusive);   // [start, end) windows

Opt into a single JSON column with HasIntervalJsonConversion (entity or property builder, including nullable properties) — bound flags ride in the payload (written only when false), so no IntervalBoundMode is involved:

modelBuilder.Entity<Booking>().HasIntervalJsonConversion(b => b.Window);   // one JSON column, validating converter

The JSON column is jsonb on PostgreSQL and nvarchar(max) on SQL Server, round-tripped through the validating converter (re-checks the invariant on read). This shape is queryable too: an IMemberTranslatorPlugin registered by UseStrongTypes() rewrites interval Start/End access into EF's JsonScalarExpression, rendered as JSON_VALUE(...) on SQL Server and ->> on PostgreSQL — filterable and orderable, just not index-backed. To make the immutable structs materializable, Start/End use { get; private init; } — they stay readonly struct and publicly immutable (private init isn't externally accessible). The bound flags are stored inverted in private fields so an unmapped flag materializes as the inclusive default, and default(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 IMaterializationInterceptor registered by UseStrongTypes() (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 jsonb column type is applied by a model-finalizing convention gated on the Npgsql provider name — probing the type-mapping source for a jsonb store type is not reliable, because SQL Server's mapping source fabricates a mapping for the unknown store-type name and EnsureCreated then fails with Cannot find data type jsonb.

(HasIntervalJsonConversion remains 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.OpenApi and 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 in required; the bound flags are optional booleans with default: 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-TextBox string form and bind field-by-field, so they correctly get no TypeConverter.

Tests

  • FsCheck arbitraries for all four types (always satisfying the invariant, covering all nullability cases and both bound inclusivities).
  • Unit (StrongTypes.Tests): validation (including points accepted / empties rejected per bound flags), Contains honoring inclusivity, deconstruction, equality (flags participate; unbounded-endpoint normalization pinned), ToString brackets, 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 between Overlaps and GetOverlap, and the defining law overlap.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-int endpoint types (DateTime, DateOnly, TimeOnly, decimal) are pinned in IntervalEndpointTypesTests.
  • API integration (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 a 400 for equal endpoints with an exclusive bound. Invalid payloads → 400 keyed $.value; null handling for the nullable variant.
  • Bound modes (IntervalBoundModeTests + model-shape facts): Stored round-trips per-value flags through their own columns (and the flag column translates in Where), the AlwaysInclusive default and AlwaysExclusive reject contradicting values on save with a guiding message, AlwaysExclusive restores the bound on read without a column, and the model shape pins which flag columns exist per mode (none by default, per-bound with Stored, explicit API works with and without the convention).
  • Read integrity + querying: raw-SQL corruption tests assert both persistence shapes throw on read (IntervalReadValidationTests); IntervalFilterTests proves Where / OrderBy / IS NULL endpoint translation on both shapes against both providers, including endpoint access through a nullable interval property. The *ColumnsEntity wire-to-DB suite runs on the bare convention (no explicit mapping), so the two-column default is what's integration-tested.
  • OpenAPI integration (StrongTypes.OpenApi.IntegrationTests): schema shape asserted on both pipelines, including the optional boolean bound flags with default: true.
  • Throwing To* conversion helpers (intervals and the string/number/collection families) document their exceptions via <exception> XML.

Verification

  • dotnet build of the full solution succeeds with TreatWarningsAsErrors=true.
  • Full solution dotnet test green locally against SQL Server + PostgreSQL Testcontainers: unit 1163, API integration 936 (+2 pre-existing skips), OpenAPI 312, AspNetCore 46, analyzers 39, WPF 11.
  • Microsoft.OpenApi 2.7.5 clears the NU1903 restore failure from GHSA-v5pm-xwqc-g5wc that was failing CI. The dependency is declared where it belongs: on StrongTypes.OpenApi.Core and the two adapter packages (OpenApi.Microsoft / OpenApi.Swashbuckle), whose embedded Core.dll is compiled against it — Microsoft.AspNetCore.OpenApi and Swashbuckle themselves only declare a >= 2.0.0 floor, so the vulnerable version would otherwise resolve for package consumers too. Test projects carry no direct Microsoft.OpenApi reference; 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

@KaliCZ KaliCZ self-assigned this Apr 26, 2026
@github-actions

github-actions Bot commented Apr 26, 2026

Copy link
Copy Markdown

Coverage

Lines: 4608 / 6100 (75.5%)    Branches: 2423 / 3168 (76.5%)

Files changed in this PR

File Lines Branches
StrongTypes/Collections/IEnumerableExtensions_Types.cs 0 / 8 (0.0%) 0 / 6 (0.0%)
StrongTypes/Intervals/FiniteInterval.cs 33 / 33 (100.0%) 13 / 14 (92.9%)
StrongTypes/Intervals/Interval.cs 44 / 45 (97.8%) 29 / 30 (96.7%)
StrongTypes/Intervals/IntervalDateExtensions.cs 25 / 25 (100.0%) 17 / 18 (94.4%)
StrongTypes/Intervals/IntervalEndpoints.cs 55 / 55 (100.0%) 56 / 56 (100.0%)
StrongTypes/Intervals/IntervalFrom.cs 32 / 34 (94.1%) 19 / 20 (95.0%)
StrongTypes/Intervals/IntervalJsonConverterFactory.cs 125 / 129 (96.9%) 42 / 46 (91.3%)
StrongTypes/Intervals/IntervalUntil.cs 32 / 34 (94.1%) 19 / 20 (95.0%)
StrongTypes/Strings/NonEmptyString.cs 87 / 87 (100.0%) 32 / 32 (100.0%)
StrongTypes/Strings/NonEmptyStringExtensions.cs 13 / 27 (48.1%) 0 / 0 (n/a)
StrongTypes/Strings/StringExtensions.cs 21 / 28 (75.0%) 18 / 26 (69.2%)
StrongTypes.Api/Controllers/FiniteIntervalEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
StrongTypes.Api/Controllers/IntervalEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
StrongTypes.Api/Controllers/IntervalFromEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
StrongTypes.Api/Controllers/IntervalUntilEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
StrongTypes.Api/Data/IntervalEntityConfiguration.cs 17 / 17 (100.0%) 0 / 0 (n/a)
StrongTypes.Api/Data/PostgreSqlDbContext.cs 40 / 40 (100.0%) 0 / 0 (n/a)
StrongTypes.Api/Data/SqlServerDbContext.cs 40 / 40 (100.0%) 0 / 0 (n/a)
StrongTypes.EfCore/IntervalEfCoreExtensions.cs 33 / 33 (100.0%) 3 / 6 (50.0%)
StrongTypes.EfCore/IntervalIntegrityInterceptor.cs 162 / 182 (89.0%) 94 / 118 (79.7%)
StrongTypes.EfCore/IntervalJsonValueConverter.cs 6 / 6 (100.0%) 0 / 0 (n/a)
StrongTypes.EfCore/IntervalMemberTranslator.cs 12 / 16 (75.0%) 11 / 16 (68.8%)
StrongTypes.EfCore/IntervalTypes.cs 11 / 11 (100.0%) 10 / 10 (100.0%)
StrongTypes.EfCore/StrongTypesConvention.cs 77 / 91 (84.6%) 46 / 66 (69.7%)
StrongTypes.EfCore/StrongTypesDbContextOptionsExtension.cs 21 / 22 (95.5%) 2 / 2 (100.0%)
StrongTypes.FsCheck/Generators.cs 127 / 155 (81.9%) 2 / 2 (100.0%)
StrongTypes.OpenApi.Core/StrongTypeSchemaTypes.cs 57 / 57 (100.0%) 45 / 50 (90.0%)
StrongTypes.OpenApi.Core/Inlining/StrongTypeInliner.cs 185 / 204 (90.7%) 130 / 162 (80.2%)
StrongTypes.OpenApi.Microsoft/Startup.cs 15 / 15 (100.0%) 0 / 0 (n/a)
StrongTypes.OpenApi.Microsoft/Intervals/IntervalSchemaTransformer.cs 28 / 28 (100.0%) 6 / 6 (100.0%)
StrongTypes.OpenApi.Swashbuckle/Startup.cs 13 / 13 (100.0%) 0 / 0 (n/a)
StrongTypes.OpenApi.Swashbuckle/Intervals/IntervalSchemaFilter.cs 30 / 30 (100.0%) 8 / 8 (100.0%)
StrongTypes.OpenApi.TestApi.Shared/BasicControllers.cs 0 / 63 (0.0%) 0 / 0 (n/a)
StrongTypes — lines 93.9% (1866/1987), branches 89.4% (1192/1334)
Booleans — lines 100.0% (14/14), branches 84.6% (22/26)
File Lines Branches
BooleanExtensions.cs 2 / 2 (100.0%) 2 / 2 (100.0%)
BooleanMapExtensions.cs 12 / 12 (100.0%) 20 / 24 (83.3%)
Collections — lines 90.2% (276/306), branches 85.1% (114/134)
File Lines Branches
IEnumerableExtensions.cs 7 / 7 (100.0%) 6 / 6 (100.0%)
IEnumerableExtensions_Concatenating.cs 2 / 2 (100.0%) 2 / 2 (100.0%)
IEnumerableExtensions_Flattening.cs 1 / 1 (100.0%) 2 / 2 (100.0%)
IEnumerableExtensions_Null.cs 5 / 5 (100.0%) 10 / 10 (100.0%)
IEnumerableExtensions_Partition.cs 11 / 11 (100.0%) 6 / 6 (100.0%)
IEnumerableExtensions_Types.cs 0 / 8 (0.0%) 0 / 6 (0.0%)
NonEmptyEnumerable.cs 59 / 65 (90.8%) 30 / 38 (78.9%)
NonEmptyEnumerableDebugView.cs 0 / 2 (0.0%) 0 / 0 (n/a)
NonEmptyEnumerableExtensions.cs 144 / 149 (96.6%) 37 / 38 (97.4%)
NonEmptyEnumerableJsonConverter.cs 47 / 50 (94.0%) 21 / 26 (80.8%)
ReadOnlyList.cs 0 / 6 (0.0%) 0 / 0 (n/a)
Digits — lines 97.7% (85/87), branches 93.8% (30/32)
File Lines Branches
Digit.cs 78 / 80 (97.5%) 26 / 28 (92.9%)
DigitExtensions.cs 7 / 7 (100.0%) 4 / 4 (100.0%)
Emails — lines 98.6% (68/69), branches 92.5% (37/40)
File Lines Branches
Email.cs 50 / 50 (100.0%) 27 / 30 (90.0%)
EmailExtensions.cs 10 / 11 (90.9%) 8 / 8 (100.0%)
EmailJsonConverter.cs 8 / 8 (100.0%) 2 / 2 (100.0%)
Enums — lines 100.0% (58/58), branches 95.5% (21/22)
File Lines Branches
EnumExtensions.cs 58 / 58 (100.0%) 21 / 22 (95.5%)
Exceptions — lines 78.9% (15/19), branches 50.0% (7/14)
File Lines Branches
ExceptionEnumerableExtensions.cs 15 / 19 (78.9%) 7 / 14 (50.0%)
Intervals — lines 97.5% (346/355), branches 95.6% (195/204)
File Lines Branches
FiniteInterval.cs 33 / 33 (100.0%) 13 / 14 (92.9%)
Interval.cs 44 / 45 (97.8%) 29 / 30 (96.7%)
IntervalDateExtensions.cs 25 / 25 (100.0%) 17 / 18 (94.4%)
IntervalEndpoints.cs 55 / 55 (100.0%) 56 / 56 (100.0%)
IntervalFrom.cs 32 / 34 (94.1%) 19 / 20 (95.0%)
IntervalJsonConverterFactory.cs 125 / 129 (96.9%) 42 / 46 (91.3%)
IntervalUntil.cs 32 / 34 (94.1%) 19 / 20 (95.0%)
Maybe — lines 92.1% (151/164), branches 83.9% (94/112)
File Lines Branches
Maybe.cs 58 / 59 (98.3%) 40 / 44 (90.9%)
MaybeCollectionExtensions.cs 41 / 45 (91.1%) 28 / 30 (93.3%)
MaybeExtensions.cs 9 / 14 (64.3%) 10 / 18 (55.6%)
MaybeJsonConverter.cs 43 / 46 (93.5%) 16 / 20 (80.0%)
Nullables — lines 100.0% (12/12), branches 83.3% (20/24)
File Lines Branches
NullableMapExtensions.cs 12 / 12 (100.0%) 20 / 24 (83.3%)
Numbers — lines 100.0% (84/84), branches 94.4% (17/18)
File Lines Branches
Negative.cs 7 / 7 (100.0%) 2 / 2 (100.0%)
NonNegative.cs 7 / 7 (100.0%) 2 / 2 (100.0%)
NonPositive.cs 7 / 7 (100.0%) 2 / 2 (100.0%)
NumberExtensions.cs 12 / 12 (100.0%) 4 / 4 (100.0%)
NumericStrongTypeJsonConverterFactory.cs 42 / 42 (100.0%) 5 / 6 (83.3%)
NumericWrapperAttribute.cs 2 / 2 (100.0%) 0 / 0 (n/a)
Positive.cs 7 / 7 (100.0%) 2 / 2 (100.0%)
Result — lines 98.5% (333/338), branches 95.6% (417/436)
File Lines Branches
Result.cs 68 / 70 (97.1%) 55 / 68 (80.9%)
ResultAccessExtensions.cs 4 / 4 (100.0%) 8 / 8 (100.0%)
ResultAggregate.cs 188 / 188 (100.0%) 318 / 318 (100.0%)
ResultCatch.cs 20 / 20 (100.0%) 0 / 0 (n/a)
ResultFlattenExtensions.cs 7 / 7 (100.0%) 8 / 8 (100.0%)
ResultFromNullableExtensions.cs 10 / 10 (100.0%) 15 / 20 (75.0%)
ResultPartitionExtensions.cs 20 / 20 (100.0%) 5 / 6 (83.3%)
ResultThrowIfErrorExtensions.cs 16 / 19 (84.2%) 8 / 8 (100.0%)
Strings — lines 84.7% (133/157), branches 84.4% (54/64)
File Lines Branches
NonEmptyString.cs 87 / 87 (100.0%) 32 / 32 (100.0%)
NonEmptyStringExtensions.cs 13 / 27 (48.1%) 0 / 0 (n/a)
NonEmptyStringJsonConverter.cs 12 / 15 (80.0%) 4 / 6 (66.7%)
StringExtensions.cs 21 / 28 (75.0%) 18 / 26 (69.2%)
generated — lines 89.8% (291/324), branches 78.8% (164/208)
File Lines Branches
Negative<T>.Extensions.g.cs 32 / 32 (100.0%) 20 / 20 (100.0%)
Negative<T>.g.cs 45 / 49 (91.8%) 24 / 32 (75.0%)
NonNegative<T>.Extensions.g.cs 32 / 32 (100.0%) 20 / 20 (100.0%)
NonNegative<T>.g.cs 36 / 49 (73.5%) 18 / 32 (56.2%)
NonPositive<T>.Extensions.g.cs 32 / 32 (100.0%) 20 / 20 (100.0%)
NonPositive<T>.g.cs 36 / 49 (73.5%) 18 / 32 (56.2%)
Positive<T>.Extensions.g.cs 32 / 32 (100.0%) 20 / 20 (100.0%)
Positive<T>.g.cs 46 / 49 (93.9%) 24 / 32 (75.0%)
StrongTypes.Analyzers — lines 94.7% (322/340), branches 86.8% (132/152)
(root) — lines 94.7% (322/340), branches 86.8% (132/152)
File Lines Branches
AddEfCorePackageCodeFixProvider.cs 48 / 52 (92.3%) 18 / 20 (90.0%)
AddOpenApiPackageCodeFixProvider.cs 52 / 53 (98.1%) 21 / 26 (80.8%)
MissingEfCorePackageAnalyzer.cs 140 / 153 (91.5%) 53 / 62 (85.5%)
MissingOpenApiPackageAnalyzer.cs 82 / 82 (100.0%) 40 / 44 (90.9%)
StrongTypes.Api — lines 97.1% (374/385), branches 86.3% (88/102)
(root) — lines 100.0% (11/11), branches n/a (0/0)
File Lines Branches
Program.cs 11 / 11 (100.0%) 0 / 0 (n/a)
Controllers — lines 97.0% (225/232), branches 86.3% (88/102)
File Lines Branches
BindingProbeController.cs 55 / 62 (88.7%) 32 / 34 (94.1%)
CollectionJsonController.cs 4 / 4 (100.0%) 0 / 0 (n/a)
EmailEntityController.cs 44 / 44 (100.0%) 26 / 30 (86.7%)
EntityControllerBase.cs 9 / 9 (100.0%) 0 / 0 (n/a)
FiniteIntervalEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
IntervalEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
IntervalFromEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
IntervalUntilEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NegativeDecimalEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NegativeDoubleEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NegativeFloatEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NegativeIntEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NegativeLongEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NegativeShortEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonEmptyStringEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonNegativeDecimalEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonNegativeDoubleEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonNegativeFloatEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonNegativeIntEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonNegativeLongEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonNegativeShortEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonPositiveDecimalEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonPositiveDoubleEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonPositiveFloatEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonPositiveIntEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonPositiveLongEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
NonPositiveShortEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
PositiveDecimalEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
PositiveDoubleEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
PositiveFloatEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
PositiveIntEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
PositiveLongEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
PositiveShortEntityController.cs 1 / 1 (100.0%) 0 / 0 (n/a)
ReferenceTypeEntityControllerBase.cs 42 / 42 (100.0%) 14 / 18 (77.8%)
StructTypeEntityControllerBase.cs 42 / 42 (100.0%) 16 / 20 (80.0%)
Data — lines 100.0% (97/97), branches n/a (0/0)
File Lines Branches
IntervalEntityConfiguration.cs 17 / 17 (100.0%) 0 / 0 (n/a)
PostgreSqlDbContext.cs 40 / 40 (100.0%) 0 / 0 (n/a)
SqlServerDbContext.cs 40 / 40 (100.0%) 0 / 0 (n/a)
Entities — lines 75.0% (12/16), branches n/a (0/0)
File Lines Branches
EntityBase.cs 12 / 12 (100.0%) 0 / 0 (n/a)
IEntity.cs 0 / 4 (0.0%) 0 / 0 (n/a)
Models — lines 100.0% (29/29), branches n/a (0/0)
File Lines Branches
CollectionJsonModels.cs 20 / 20 (100.0%) 0 / 0 (n/a)
EntityModels.cs 9 / 9 (100.0%) 0 / 0 (n/a)
StrongTypes.AspNetCore — lines 89.3% (151/169), branches 84.5% (71/84)
(root) — lines 89.3% (151/169), branches 84.5% (71/84)
File Lines Branches
JsonValidationErrorKeyNormalizer.cs 22 / 22 (100.0%) 20 / 20 (100.0%)
ModelMetadataNullability.cs 12 / 13 (92.3%) 7 / 10 (70.0%)
NonEmptyEnumerableModelBinder.cs 51 / 62 (82.3%) 25 / 30 (83.3%)
NonEmptyEnumerableModelBinderProvider.cs 9 / 9 (100.0%) 4 / 4 (100.0%)
StringElementParser.cs 16 / 19 (84.2%) 2 / 4 (50.0%)
StrongTypesAspNetCoreOptions.cs 2 / 2 (100.0%) 0 / 0 (n/a)
StrongTypesServiceCollectionExtensions.cs 39 / 42 (92.9%) 13 / 16 (81.2%)
StrongTypes.AspNetCore.TestApi — lines 90.7% (39/43), branches 92.5% (37/40)
(root) — lines 100.0% (6/6), branches n/a (0/0)
File Lines Branches
Program.cs 6 / 6 (100.0%) 0 / 0 (n/a)
Controllers — lines 89.2% (33/37), branches 92.5% (37/40)
File Lines Branches
BindingProbeController.cs 29 / 29 (100.0%) 37 / 40 (92.5%)
JsonBodyProbeController.cs 4 / 8 (50.0%) 0 / 0 (n/a)
StrongTypes.EfCore — lines 90.6% (375/414), branches 76.5% (176/230)
(root) — lines 90.6% (375/414), branches 76.5% (176/230)
File Lines Branches
IntervalEfCoreExtensions.cs 33 / 33 (100.0%) 3 / 6 (50.0%)
IntervalIntegrityInterceptor.cs 162 / 182 (89.0%) 94 / 118 (79.7%)
IntervalJsonValueConverter.cs 6 / 6 (100.0%) 0 / 0 (n/a)
IntervalMemberTranslator.cs 12 / 16 (75.0%) 11 / 16 (68.8%)
IntervalTypes.cs 11 / 11 (100.0%) 10 / 10 (100.0%)
MailAddressValueConverter.cs 3 / 3 (100.0%) 0 / 0 (n/a)
NonEmptyStringValueConverter.cs 3 / 3 (100.0%) 0 / 0 (n/a)
NumericStrongTypeValueConverter.cs 17 / 17 (100.0%) 0 / 0 (n/a)
StrongTypesConvention.cs 77 / 91 (84.6%) 46 / 66 (69.7%)
StrongTypesDbContextOptionsExtension.cs 21 / 22 (95.5%) 2 / 2 (100.0%)
UnwrapMethodCallTranslator.cs 30 / 30 (100.0%) 10 / 12 (83.3%)
StrongTypes.FsCheck — lines 81.9% (127/155), branches 100.0% (2/2)
(root) — lines 81.9% (127/155), branches 100.0% (2/2)
File Lines Branches
Generators.cs 127 / 155 (81.9%) 2 / 2 (100.0%)
StrongTypes.OpenApi.Core — lines 91.6% (456/498), branches 83.9% (307/366)
(root) — lines 92.2% (271/294), branches 86.8% (177/204)
File Lines Branches
NumericWrapperKinds.cs 11 / 11 (100.0%) 0 / 0 (n/a)
NumericWrapperPainter.cs 12 / 12 (100.0%) 3 / 4 (75.0%)
PrimitiveSchemaMap.cs 19 / 19 (100.0%) 0 / 0 (n/a)
SchemaPaint.cs 84 / 91 (92.3%) 69 / 84 (82.1%)
StrongTypeInlineMarker.cs 6 / 6 (100.0%) 6 / 6 (100.0%)
StrongTypeSchemaTypes.cs 57 / 57 (100.0%) 45 / 50 (90.0%)
WrapperAnnotationApplier.cs 82 / 98 (83.7%) 54 / 60 (90.0%)
Inlining — lines 90.7% (185/204), branches 80.2% (130/162)
File Lines Branches
StrongTypeInliner.cs 185 / 204 (90.7%) 130 / 162 (80.2%)
StrongTypes.OpenApi.Microsoft — lines 37.2% (454/1219), branches 41.2% (177/430)
(root) — lines 94.6% (246/260), branches 74.6% (97/130)
File Lines Branches
MicrosoftSchemaNaming.cs 52 / 52 (100.0%) 8 / 8 (100.0%)
PropertyAnnotationSchemaTransformer.cs 77 / 89 (86.5%) 52 / 74 (70.3%)
Startup.cs 15 / 15 (100.0%) 0 / 0 (n/a)
StrongTypesComponentSchemaFiller.cs 102 / 104 (98.1%) 37 / 48 (77.1%)
Binding — lines 98.1% (103/105), branches 78.6% (55/70)
File Lines Branches
NonBodyStrongTypeOperationTransformer.cs 103 / 105 (98.1%) 55 / 70 (78.6%)
Collections — lines 100.0% (19/19), branches 100.0% (8/8)
File Lines Branches
NonEmptyEnumerableSchemaTransformer.cs 10 / 10 (100.0%) 2 / 2 (100.0%)
StrongTypeCollectionShapeTransformer.cs 9 / 9 (100.0%) 6 / 6 (100.0%)
Digits — lines 100.0% (11/11), branches 100.0% (2/2)
File Lines Branches
DigitSchemaTransformer.cs 11 / 11 (100.0%) 2 / 2 (100.0%)
Emails — lines 100.0% (11/11), branches 100.0% (2/2)
File Lines Branches
EmailSchemaTransformer.cs 11 / 11 (100.0%) 2 / 2 (100.0%)
Inlining — lines 100.0% (7/7), branches 50.0% (1/2)
File Lines Branches
StrongTypeInliningDocumentTransformer.cs 7 / 7 (100.0%) 1 / 2 (50.0%)
Intervals — lines 100.0% (28/28), branches 100.0% (6/6)
File Lines Branches
IntervalSchemaTransformer.cs 28 / 28 (100.0%) 6 / 6 (100.0%)
Maybe — lines 100.0% (12/12), branches 100.0% (2/2)
File Lines Branches
MaybeSchemaTransformer.cs 12 / 12 (100.0%) 2 / 2 (100.0%)
Numbers — lines 100.0% (8/8), branches 100.0% (2/2)
File Lines Branches
NumericStrongTypeSchemaTransformer.cs 8 / 8 (100.0%) 2 / 2 (100.0%)
Strings — lines 100.0% (9/9), branches 100.0% (2/2)
File Lines Branches
NonEmptyStringSchemaTransformer.cs 9 / 9 (100.0%) 2 / 2 (100.0%)
obj/Debug/net10.0/Microsoft.AspNetCore.OpenApi.SourceGenerators/Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator — lines 0.0% (0/749), branches 0.0% (0/204)
File Lines Branches
OpenApiXmlCommentSupport.generated.cs 0 / 749 (0.0%) 0 / 204 (0.0%)
StrongTypes.OpenApi.Swashbuckle — lines 94.7% (270/285), branches 79.1% (174/220)
(root) — lines 98.7% (77/78), branches 93.4% (71/76)
File Lines Branches
PropertyAnnotationSchemaFilter.cs 64 / 65 (98.5%) 71 / 76 (93.4%)
Startup.cs 13 / 13 (100.0%) 0 / 0 (n/a)
Binding — lines 88.3% (106/120), branches 64.3% (72/112)
File Lines Branches
NonBodyStrongTypeOperationFilter.cs 106 / 120 (88.3%) 72 / 112 (64.3%)
Collections — lines 100.0% (6/6), branches 100.0% (4/4)
File Lines Branches
NonEmptyEnumerableSchemaFilter.cs 6 / 6 (100.0%) 4 / 4 (100.0%)
Digits — lines 100.0% (10/10), branches 100.0% (4/4)
File Lines Branches
DigitSchemaFilter.cs 10 / 10 (100.0%) 4 / 4 (100.0%)
Emails — lines 100.0% (10/10), branches 100.0% (4/4)
File Lines Branches
EmailSchemaFilter.cs 10 / 10 (100.0%) 4 / 4 (100.0%)
Inlining — lines 100.0% (2/2), branches n/a (0/0)
File Lines Branches
StrongTypeInliningDocumentFilter.cs 2 / 2 (100.0%) 0 / 0 (n/a)
Intervals — lines 100.0% (30/30), branches 100.0% (8/8)
File Lines Branches
IntervalSchemaFilter.cs 30 / 30 (100.0%) 8 / 8 (100.0%)
Maybe — lines 100.0% (13/13), branches 75.0% (3/4)
File Lines Branches
MaybeSchemaFilter.cs 13 / 13 (100.0%) 3 / 4 (75.0%)
Numbers — lines 100.0% (8/8), branches 100.0% (4/4)
File Lines Branches
NumericStrongTypeSchemaFilter.cs 8 / 8 (100.0%) 4 / 4 (100.0%)
Strings — lines 100.0% (8/8), branches 100.0% (4/4)
File Lines Branches
NonEmptyStringSchemaFilter.cs 8 / 8 (100.0%) 4 / 4 (100.0%)
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)
File Lines Branches
Program.cs 14 / 14 (100.0%) 2 / 2 (100.0%)
obj/Debug/net10.0/Microsoft.AspNetCore.OpenApi.SourceGenerators/Microsoft.AspNetCore.OpenApi.SourceGenerators.XmlCommentGenerator — lines 38.8% (149/384), branches 30.9% (63/204)
File Lines Branches
OpenApiXmlCommentSupport.generated.cs 149 / 384 (38.8%) 63 / 204 (30.9%)
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)
File Lines Branches
AnnotatedRequests.cs 0 / 84 (0.0%) 0 / 0 (n/a)
BasicControllers.cs 0 / 63 (0.0%) 0 / 0 (n/a)
CustomAnnotationsControllers.cs 0 / 4 (0.0%) 0 / 0 (n/a)
Models.cs 0 / 45 (0.0%) 0 / 0 (n/a)
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)
File Lines Branches
Program.cs 11 / 11 (100.0%) 2 / 2 (100.0%)

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 74.29907% with 55 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ongTypes/Intervals/IntervalJsonConverterFactory.cs 80.21% 11 Missing and 7 partials ⚠️
src/StrongTypes/Intervals/Interval.cs 60.00% 11 Missing and 1 partial ⚠️
src/StrongTypes/Intervals/IntervalFrom.cs 70.83% 6 Missing and 1 partial ⚠️
src/StrongTypes/Intervals/IntervalUntil.cs 70.83% 6 Missing and 1 partial ⚠️
...c/StrongTypes.EfCore/IntervalJsonValueConverter.cs 0.00% 6 Missing ⚠️
src/StrongTypes/Intervals/ClosedInterval.cs 77.77% 2 Missing and 2 partials ⚠️
src/StrongTypes.EfCore/IntervalEfCoreExtensions.cs 0.00% 1 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
@@            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     
Components Coverage Δ
StrongTypes 87.63% <74.33%> (-2.06%) ⬇️
StrongTypes.Analyzers 86.82% <ø> (ø)
StrongTypes.EfCore 77.86% <0.00%> (-4.40%) ⬇️
StrongTypes.Api 97.34% <ø> (ø)
StrongTypes.FsCheck 80.76% <100.00%> (+3.49%) ⬆️
Files with missing lines Coverage Δ
src/StrongTypes.FsCheck/Generators.cs 80.76% <100.00%> (+3.49%) ⬆️
src/StrongTypes.EfCore/IntervalEfCoreExtensions.cs 0.00% <0.00%> (ø)
src/StrongTypes/Intervals/ClosedInterval.cs 77.77% <77.77%> (ø)
...c/StrongTypes.EfCore/IntervalJsonValueConverter.cs 0.00% <0.00%> (ø)
src/StrongTypes/Intervals/IntervalFrom.cs 70.83% <70.83%> (ø)
src/StrongTypes/Intervals/IntervalUntil.cs 70.83% <70.83%> (ø)
src/StrongTypes/Intervals/Interval.cs 60.00% <60.00%> (ø)
...ongTypes/Intervals/IntervalJsonConverterFactory.cs 80.21% <80.21%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

claude and others added 2 commits June 24, 2026 09:38
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>
@KaliCZ KaliCZ force-pushed the claude/json-converter-intervals-pB8eY branch from c63f864 to e6f0bf5 Compare June 24, 2026 09:45
@KaliCZ

KaliCZ commented Jun 24, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto latest main and added a hardening commit. Highlights:

  • Error-path fix (matches Normalize JSON request-body validation error keys #106): the converter's nested endpoint reads lost the property path — a null/type-mismatch endpoint surfaced the validation error at the document root $ instead of $.value. Now rethrows path-less so System.Text.Json reattaches the path, exactly as Normalize JSON request-body validation error keys #106 did for the numeric converter. Error messages also no longer leak the ClosedInterval`1 arity name.
  • API integration tests: four interval entities/controllers mapped via HasIntervalJsonConversion; wire→DB round-trip on SQL Server + PostgreSQL, null handling, update, and invalid payloads asserted at $.value.
  • OpenAPI: the original PR shipped no schema support (intervals rendered opaque). Added a Microsoft schema transformer and a Swashbuckle filter rendering { Start, End } with required: [Start, End] and per-variant endpoint nullability; verified on Microsoft 3.0, Microsoft 3.1, and Swashbuckle.
  • Expanded unit tests: Contains/ToString per variant, JSON edge cases (missing/extra/reordered keys, non-object tokens, DateOnly/DateTime endpoints), and generator-branch coverage facts.
  • Docs: README, SKILL.md + references/intervals.md, and the EfCore/OpenApi references and package readmes.

⚠️ Correction to the description above: EF Core ComplexProperty is not a supported persistence shape — EF can't bind the interval's private constructor as a complex type, and column-by-column materialization would bypass the Start <= End invariant. The single JSON column (HasIntervalJsonConversion, which round-trips through the validating converter) is the supported shape; docs were corrected accordingly.

Full build passes with TreatWarningsAsErrors=true; unit + API + OpenAPI suites green (API/OpenAPI run locally with STRONGTYPES_SKIP_SQLSERVER=1 on an ARM64 box — CI exercises the real SQL Server path).

KaliCZ and others added 4 commits June 24, 2026 11:51
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>
@KaliCZ KaliCZ changed the title Add Interval strong types with JSON and EF Core support (closes #60) Add Interval strong types with JSON, EF Core, OpenAPI, and conversions (closes #60) Jun 29, 2026
KaliCZ and others added 17 commits June 29, 2026 09:36
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>
@KaliCZ KaliCZ changed the title Add Interval strong types with JSON, EF Core, OpenAPI, and conversions (closes #60) Interval strong types: inclusive-by-default bounds, JSON + EF Core + OpenAPI support Jul 4, 2026
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.

3 participants