diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 45f257a..658eff8 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -69,7 +69,7 @@ jobs: - name: Build run: dotnet build -c Release --no-restore - # Everything except the integration project, which is the next job. + # Everything except the integration projects, which are the next job. - name: Unit and convention tests run: | for project in test/*/; do @@ -111,14 +111,19 @@ jobs: # CI=true turns an unavailable container into a failure instead of an inconclusive skip. # Without it a broken Docker setup would retire the layer that is the authority on # PostgreSQL semantics, and the build would still report green. + # + # More than one project because some configuration is per process: the InfinityDisabled + # project runs with an AppContext switch that Npgsql and the plugin read once. - name: Integration tests env: CI: true - run: > - dotnet test test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests - -c Release --no-build - --logger "trx;LogFileName=integration.trx" - --results-directory TestResults + run: | + for project in test/*IntegrationTests/; do + echo "==> $project" + dotnet test "$project" -c Release --no-build \ + --logger "trx;LogFileName=$(basename "$project").trx" \ + --results-directory TestResults + done - name: Upload test results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b5e52c..ebd9ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,10 +29,19 @@ must not pick any of these up silently. 2. **A culture provider no longer reaches the literal.** `Parse(s, culture)` reads the invariant literal, and formatting without a format string writes it. Code that parsed culture-formatted text through `Parse(s, de-DE)` deliberately must parse the bounds itself. -3. **Lenient decimal forms are refused.** `"1,000"`, `"(5)"` and currency symbols in a +3. **Temporal literals are strict ISO 8601.** `[01.02.2027,05.03.2027)` used to parse — month + first, as January 2 to May 2 — and a `DateTimeOffset` without an offset took the parsing + machine's. Dotted, slashed and named-month dates, date-only text for a date-time and an + offset-less `DateTimeOffset` are now a `FormatException`; `HH:mm` and PostgreSQL's own output + still parse. +4. **Lenient decimal forms are refused.** `"1,000"`, `"(5)"` and currency symbols in a `DecimalRange` or `DecimalSet` literal are a `FormatException`, as they are in PostgreSQL. -4. **Interpolating an integer or decimal range is invariant under every culture.** Only code that +5. **Interpolating an integer or decimal range is invariant under every culture.** Only code that depended on `[1,5,2)` — which parsed back as a different range — is affected. +6. **The EF Core provider writes temporal values as text at the database's resolution.** A SQL + constant now carries the microsecond Npgsql would bind, so a query that compared a constant + against its own row and missed now matches; and a temporal set whose elements are less than a + microsecond apart is stored with one of them, as the database could only ever hold one. Everything else is additive: a `TypeConverter` on every type, ranges as dictionary keys, ranges and sets inside EF Core JSON columns, Native AOT, `Deconstruct` on every bounded variant, and an @@ -63,6 +72,30 @@ OpenAPI transformer to copy. The - **Payload change** for anyone who wrote the old form: a response or document that carried `{"Length":…}` now carries the literal. +- **⚠️ Temporal bounds and elements parse as strict ISO 8601.** `DateRange`, `DateTimeRange`, + `DateTimeOffsetRange`, `TimeRange`, `DateSet`, `DateTimeSet`, `DateTimeOffsetSet`, `TimeSet` and + the temporal wrapper arities read element text with the lenient BCL `Parse`, which reads the + invariant culture month first: `[01.02.2027,05.03.2027)` was January 2 to May 2, `9:30 AM` was + 09:30, and a `DateTimeOffset` without an offset took whichever machine parsed it — all silently. + They now accept exactly what this library writes and what PostgreSQL prints in its ISO + `DateStyle`: `yyyy-MM-dd`; `HH:mm` or `HH:mm:ss` with any number of fraction digits, truncated to + 100 ns; `T` or a space between them; for a `DateTime` an optional `Z`, `±HH` or `±HH:mm` + (round-trip semantics), for a + `DateTimeOffset` a required `Z`, `±HH`, `±HH:mm` or `±HH:mm:ss` (a historical local-mean-time + offset, kept as the instant at offset zero); and `infinity` / `-infinity` as `MaxValue` / + `MinValue`, what Npgsql materializes for them. Everything else is a `FormatException` through + `Parse`, `TryParse`, JSON, the `TypeConverter` and configuration binding. The wrapper arities — + BCL and NodaTime — read the text as their primitive first and hand the element its round-trip + form, so a lenient generated element `Parse` never sees non-ISO text, and an element that + ignores the format specifier fails on read instead of truncating silently. The NodaTime types + were already strict; they now also read `infinity` for `Instant`, `LocalDate` and + `LocalDateTime`, the types Npgsql maps it for. **A closed temporal set reads its JSON elements with + the same grammar** — System.Text.Json's own converter read an offset-less `DateTimeOffset`, or a + bare date, in the parsing machine's offset — unless a converter is registered for the element + type, which still wins; the written form is System.Text.Json's, unchanged. `TemporalLiteralGrammarTests` pins the + grammar; `TemporalLiteralStrictnessContractTests` (conventions) refuses a non-ISO element through + every entry point of all 26 temporal range and set types. + ### Fixed - **⚠️ Interpolating a `DecimalRange` under a comma-decimal culture produced a different range.** @@ -105,12 +138,55 @@ OpenAPI transformer to copy. The - **Ranges, multiranges and value sets inside an EF Core JSON column** (`ToJson()`) could not be saved — the plugin's type mappings had no `JsonValueReaderWriter`, and `SaveChanges` threw a - bare `NullReferenceException` from inside EF. Each mapping now writes the store type's literal, + bare `NullReferenceException` from inside EF. Each mapping now writes the store type's text, which also makes queries into the document translate: - `CAST(doc ->> 'Period' AS daterange) @> @day`. The `YearMonth` types write their month-aligned - date form, since that is what the cast reads. `JsonColumnTests` (integration) round-trips and - queries all 25 types inside one jsonb column; `MappingParityTests` now requires a reader/writer - on every mapping. + `CAST(doc ->> 'Period' AS daterange) @> @day`. + - **The document holds the store's representation, not the API JSON**, and it is a persisted + format: a value set is `"{a,b}"` (an array in API JSON), the `YearMonth` types — wrapper + arity included — their month-aligned dates, since that is what the cast reads. + - **Temporal values are stored as a parameter would bind them**: truncated to the microsecond + toward 2000-01-01 as Npgsql truncates, the extremes Npgsql maps as `-infinity` / `infinity`. + Writing every digit and letting the server round them half-to-even made the document hold a + different value from the column, so `Doc.Period == period` could miss the row it was saved + from and `Contains` disagreed at the bounds. Materializing from the document now gives what + materializing from a native column gives. + - **`ExecuteUpdate` of a value set inside the document is refused** at query compilation: EF Core + writes it with `to_jsonb`, storing a JSON array the document cannot hold, after which the row no + longer loads. Ranges and multiranges can be set; the whole JSON property too. + - `SaveChanges` (complex and owned, nested, null and empty kept apart) and the operators inside + the document were checked against a live server, `ExecuteUpdate` to `null` fails in Npgsql for + any type, and EF Core refuses `ExecuteUpdate` on owned JSON types — `docs/efcore.md` has the + matrix. + + `JsonColumnTests` (integration) round-trips and queries every type inside one jsonb column; + `TemporalStoreParityTests` holds the temporal rule for 18 types and seven probes (remainders + above, below and at 500 ns, before 2000, the extremes, sub-microsecond ranges); + `ExecuteUpdateJsonTests` the update paths. `MappingParityTests` requires a reader/writer on every + mapping. +- **⚠️ Temporal SQL constants carried more precision than the database, and set arrays duplicates.** + The EF Core provider rendered a temporal range, multirange or set constant with seven fraction + digits (nine for NodaTime), which the server rounds half-to-even, while a parameter is truncated + toward 2000-01-01 — so a constant could disagree with a parameter for the same value, and EF 10 + inlines evaluatable expressions as constants without `EF.Constant`. `DateTime.MinValue` rendered + as a finite date where a parameter carries `-infinity`. Constants now carry the value Npgsql + would bind — a set's element constant too, which for `InstantSet` and `LocalTimeSet` was Npgsql's + own nine-digit literal, so `set.Contains(EF.Constant(x))` missed the element `x` was stored as. + And a temporal set whose elements truncate to one microsecond was bound with both, + so the stored array's `cardinality` disagreed with the set's `Count`; it is now deduplicated on + every path. A range's *element* operand (`range.Contains(x)`, a constructor argument) has the + range's own mapping now too: a constant is the stored value rather than Npgsql's literal, and a + parameter is normalized like the bounds — `DateTimeOffsetRange.Contains(dto)` threw "only offset + 0 (UTC) is supported" for any non-UTC offset, and `DateTimeRange.Contains` likewise for a + UTC-kinded `DateTime`. + +- **A range built inline in a query failed to translate.** `r.Period == DateRange.CreateFinite(new + DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31))` — or `Int32Range.Parse("[1,5]", null)`, or any + factory call over values that depend on nothing in the query — threw "does not have a type mapping + assigned" under `==`, `!=`, `Equals` and inside a conditional. EF Core evaluates such a call on the + client and inlines the result as a constant typed by its sealed variant, for which no mapping + exists. The query interceptor now retypes it to its range type; the literal goes through the + store text like any other constant. `Contains`, `Overlaps`, captured variables and `EF.Constant` + were never affected. `InlineRangeConstantExecutionTests` (integration) runs one per range type. ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 23063b1..2c0e158 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ so moving a project cannot silently retarget a test. - `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/` — EF Core SQL translation tests (no database) - `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/` — NodaTime EF translation tests (no database) - `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/` — Live PostgreSQL via Testcontainers (needs Docker; Inconclusive without, but hard failure under CI=true). Covers BCL and NodaTime types. Authority on PostgreSQL semantics — run when changing translations or range algebra +- `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/` — The same live layer for configuration that is per process: its runtimeconfig sets `Npgsql.DisableDateTimeInfinityConversions`, which Npgsql and the plugin each read once, so it cannot be flipped inside the other project's run. Holds the temporal store rule at the extremes with the conversion off. Own container; CI's integration job runs every `test/*IntegrationTests/` - `test/CodoMetis.ValueRanges.AspNetCore.Tests/` — Host-level behaviour with no ValueRanges registration: configuration binding (`TypeConverter`), MVC and minimal-API binding and JSON bodies via `TestServer`, and the documented OpenAPI schema transformer (`ValueRangesSchemaTransformer.cs`), which `docs/serialization.md` must carry verbatim — edit the test copy, then the doc - `test/CodoMetis.ValueRanges.Conventions.Tests/` — Repo-level conventions: changelog consistency, packaging metadata, release wiring, the SECURITY.md supported-versions table, value set contract compliance, EF mapping parity, the default JSON contract. Everything is discovered (projects by globbing `src/`, types by reflection), so adding a package or a type needs no edit here - `test/consumer-smoke-test.sh` — The only check of *delivery* rather than code: packs the four packages, restores them into throwaway projects created outside the repository (inside it, `Directory.Build.props` would apply), compiles and runs real code against them, and asserts on printed output and translated SQL — never on the exit code alone. Three consumers: core only; a Native AOT publish of core + NodaTime, run as a native binary (extra linker paths via `SMOKE_AOT_LINKER_ARGS`, e.g. for Homebrew's dotnet); and the NodaTime EF satellite, which pulls in all four through the nuspec chain. `NUGET_PACKAGES` is redirected to a private folder because NuGet resolves id+version from the global cache before any source, so a locally built package is otherwise shadowed by whatever build of that version was restored before; the generated `nuget.config` uses package source mapping so this repo's ids come from the local feed only and everything else from nuget.org. Runs on every PR in both modes (own feed; the pre-built `dist` the release workflow passes). Everything is discovered (projects by globbing `src/`, types by reflection), so adding a package or a type needs no edit here @@ -63,7 +64,10 @@ repo-wide. - **JSON and TypeConverter — every range union, every sealed variant, `RangeSet` and every value set type MUST carry its own `[JsonConverter]` and `[TypeConverter]`.** For `[TypeConverter]` the variant trap is reversed: it *is* inherited, so a variant without its own silently uses the union's and answers with whatever shape the literal has; `TypeConverterContractTests` catches that. Since 9.0.0 the literal is the wire form with nothing registered. System.Text.Json does not inherit the attribute, and a value reached through `object` is handed over as its *variant* — an attribute on the union alone silently leaves those on the `{"Length":…}` property dump. Non-generic types name a closed converter (source-generation friendly); generic ones name `RangeJsonConverterFactory`, because an attribute cannot name an open generic converter. `JsonDefaultContractTests` discovers every type and fails on a missing one - **AOT — a generic range set or value set type MUST register its own closed converters** (`internal static readonly bool ConvertersRegistered = GenericConverters.Register(…)`), and nothing may build a converter with `MakeGenericType`. Under Native AOT an instantiation over a value type that no compiled code creates does not exist, and whether one does depends on unrelated code — an AOT test can pass by accident. Both libraries build with `IsAotCompatible`; the NativeAOT consumer in `test/consumer-smoke-test.sh` is the only check that code is actually compiled (seed a defect there, not in a unit test) - **Literals — NEVER let an `IFormatProvider` reach the literal.** `Parse`/`TryParse` read the invariant literal and ignore the provider; formatting without a format string is the invariant literal whatever the provider (a provider applies only with an explicit format). Before 9.0.0, `th-TH` parsed `2026-01-01` as Gregorian 1483 and `de-DE` turned `$"{DecimalRange [1.5,2)}"` into `[1,5,2)` → `[1,52)`, silently. `LiteralProviderContractTests` and `LiteralCultureContractTests` sweep every type under five cultures +- **Literals — temporal element text is strict ISO 8601, NEVER a lenient BCL `Parse`.** `IsoTemporal` (BCL) and `NodaPatterns` (NodaTime) accept exactly what this library writes and what PostgreSQL prints in ISO `DateStyle` — space separator, `+05` / `+05:30` / `±HH:mm:ss` offsets, `infinity` / `-infinity` as the extremes — plus `HH:mm`. Before 9.0.0 `DateOnly.Parse(invariant)` read `[01.02.2027,05.03.2027)` month first and an offset-less `DateTimeOffset` took the machine's offset, silently. A wrapper arity parses as its primitive first and hands the element its round-trip form, and a closed temporal set reads its JSON elements through the same grammar whenever System.Text.Json's built-in converter would have (a registered converter still wins). `TemporalLiteralStrictnessContractTests` refuses a non-ISO element through every entry point of every discovered temporal type - **EF Core**: new range types are wired exclusively through `RangeTypeRegistry.Register` (satellites call it from their options-builder extension); never bypass the registry +- **EF Core — a temporal value written as text MUST be the value Npgsql would bind.** A JSON document and a SQL literal hold the microsecond truncated toward 2000-01-01 (`StoreTemporal` / `NodaStoreTemporal`, passed as a definition's `storeValue` — never folded into `normalizeValue`, which must stay the core's `NormalizeElement`) and spell the extremes Npgsql maps as `-infinity` / `infinity`; ranges are rebuilt through their factory afterwards and sets deduplicated. PostgreSQL's text input rounds half-to-even, so seven fraction digits make a document or constant a different value from the column. The JSON writer resolves its store text through the registries (`StoreText`), never from state a definition's constructor set — a compiled model may never build a wrapper family's definition. `TemporalStoreParityTests` (integration) is the authority +- **EF Core — an inlined range constant arrives typed as its sealed variant.** EF evaluates a range built from query-independent values on the client and inlines it as `Constant(value, Int32Range.Finite)`, for which no mapping exists; `RangeVariantConstantRetyper` in the query interceptor retypes it to the union. Do not "fix" this by mapping the variant types — that would make variant-typed properties mappable - **EF Core — a constant element operand must be typed.** The range operators are polymorphic (`anyrange @> anyelement`) and PostgreSQL resolves those without implicit coercions, so a bare numeric literal (which it types as `integer`) does not match `int8range`. Assert the whole operand in translation tests, never the `@> ` prefix, and execute at least one constant-operand query per element type against a live server - **Value sets — ALWAYS** preserve canonical form (deduplicated, sorted by the family's canonical comparer, no nulls) on every construction path, including reads. String-backed families sort **ordinal** — never culture, never the element's own `IComparable` - **Value sets — NEVER** implement `IEnumerable` on a set type (EF would discover it as a primitive collection), and **never** let `SetTypeRegistry` match by store-type name (`text[]` belongs to the provider's native `string[]` mapping). New set types are wired exclusively through `SetTypeRegistry.Register` @@ -71,5 +75,5 @@ repo-wide. - **Value sets — a set type that normalizes or validates elements in `From` MUST also override `IValueSet.NormalizeElement`** (and, on the EF side, pass that same function as the definition's `normalizeValue`). `Contains`/`Add`/`Remove` take a bare element, so without it the probe is compared un-normalized against normalized storage — a silently wrong answer client-side and a wrong bound parameter server-side - **Value sets — a set type that overrides `CanonicalComparer` MUST also override `IValueSet.CanonicalOrder` to return it.** `Contains`/`Add`/`Remove` binary-search the canonical array; searching with an order the array was not sorted by misses elements that are present - **Value sets — a set type whose element type System.Text.Json cannot serialize as a scalar MUST override `IValueSetFactory.ElementJsonConverter`.** JSON delegates elements to the serializer, which property-dumps an unknown type on write and yields `default` on read — silently, both legs. The hook is consulted last (only when `GetTypeInfo(typeof(T)).Kind != None`), so it never overrides a registered converter. The primitive-backed families are natively serializable and leave it `null`; the wrapper arities and the five NodaTime sets define one. Integer-backed wrappers write a JSON **number**, not a string, and decimal-backed ones a number keeping scale — the token type must always match the primitive the wrapper wraps. Byte-for-byte parity additionally holds for the string, Guid, integer and decimal arities; for the temporal and NodaTime ones the text differs (seven fraction digits, `+` escaped) while the value does not, which is asserted in `SetJsonConverterTests` rather than fixed -- **Value sets — a wrapper arity over a type whose default text form is lossy MUST pin a round-trip format**, in `FormatValue` *and* in its `SetTypeRegistry` bridge entry, and parse it strictly. `TimeOnly.ToString(null, invariant)` is `09:30` and `DateTime`'s is `06/15/2024 10:30:00` — an arity built like `Int32Set` stores every temporal truncated to the second, and loses `DateTimeKind`, silently on the way to the column. The four temporal arities and the five NodaTime ones do this; the string, Guid, integer and decimal ones correctly take the default -- **Value sets — `Union` translates to `array_cat`, which does NOT canonicalize.** Only order- and multiplicity-insensitive operators may compose on it: `@>`, `<@`, `&&`, and the proper subset/superset pair written as `<@ AND NOT @>` for exactly that reason. Everything multiplicity- or order-sensitive is refused rather than translated — `Count` in `ValueSetsMemberTranslator`, and `==`/`!=`/`Equals` in `ValueRangesQueryExpressionInterceptor`'s `UnionEqualityGuard` (8.0.0; it was the last known-wrong translation). Both refusals look *through* `array_remove`, which preserves canonical form rather than establishing it. Do not "fix" this by sorting in SQL: `ORDER BY` on `text` uses the database collation, not ordinal, so it would disagree with the client's canonical order — the same defect one layer down +- **Value sets — a wrapper arity over a type whose default text form is lossy MUST pin a round-trip format**, in `FormatValue` *and* in its `SetTypeRegistry` bridge entry, and parse it strictly. `TimeOnly.ToString(null, invariant)` is `09:30` and `DateTime`'s is `06/15/2024 10:30:00` — an arity built like `Int32Set` stores every temporal truncated to the second, and loses `DateTimeKind`, silently on the way to the column. The four temporal arities and the five NodaTime ones do this — since 9.0.0 the core `ParseValue` parses strictly too, not only the EF bridge; the string, Guid, integer and decimal ones correctly take the default +- **Value sets — `Union` translates to `array_cat`, which does NOT canonicalize.** Only order- and multiplicity-insensitive operators may compose on it: `@>`, `<@`, `&&`, and the proper subset/superset pair written as `<@ AND NOT @>` for exactly that reason. Everything multiplicity- or order-sensitive is refused rather than translated — `Count` in `ValueSetsMemberTranslator`, and `==`/`!=`/`Equals` in `ValueRangesQueryExpressionInterceptor`'s `UnionEqualityGuard` (8.0.0; it was the last known-wrong translation). Both refusals look *through* `array_remove`, which preserves canonical form rather than establishing it. A third refusal: `ExecuteUpdate` of a value set inside a JSON column (`JsonValueSetUpdateGuard`), which EF writes with `to_jsonb` — a JSON array where the document holds the literal, after which the row no longer loads. Do not "fix" this by sorting in SQL: `ORDER BY` on `text` uses the database collation, not ordinal, so it would disagree with the client's canonical order — the same defect one layer down diff --git a/CodoMetis.ValueRanges.slnx b/CodoMetis.ValueRanges.slnx index 6a5f665..8b23d93 100644 --- a/CodoMetis.ValueRanges.slnx +++ b/CodoMetis.ValueRanges.slnx @@ -11,6 +11,7 @@ + diff --git a/docs/architecture.md b/docs/architecture.md index c4251e1..f256d1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,6 +99,14 @@ The private base constructor prevents external subtyping, so these five are the All type parameters are constrained to `struct, IComparable, IEquatable`. +**Not an extension point.** These interfaces are the shared vocabulary of the shipped range types: +implementing them outside the library is unsupported, and they may gain members in a minor version. +The engines, the EF Core provider — whose registries are internal — and the parity tests against +PostgreSQL cover only the shipped types. The value sets are closed in the type system already +(`IValueSetFactory`'s internal abstract `FromTrusted`); the range interfaces are closed by this policy +rather than by the compiler, which outside implementations of `IRange` meet as a refusal +(`UnknownRangeVariantTests`). + ## Extension Methods (`RangeExtensions.cs`) Uses the C# 14 `extension` keyword. Two `extension` blocks: diff --git a/docs/efcore.md b/docs/efcore.md index a610083..7114409 100644 --- a/docs/efcore.md +++ b/docs/efcore.md @@ -83,7 +83,7 @@ Notes: Timestamp semantics: -- `DateTimeRange` bounds are written as `timestamp` with `DateTimeKind.Unspecified` — a UTC-kinded `DateTime` is reinterpreted as wall-clock time, not converted. `DateTimeOffsetRange` bounds are normalized to UTC for `timestamptz`: the instant is preserved, but the original offset is not round-tripped (values read back carry offset `+00:00` and compare equal to what was written, since `DateTimeOffset` equality is instant-based). +- `DateTimeRange` bounds are written as `timestamp` with `DateTimeKind.Unspecified` — a UTC-kinded `DateTime` is reinterpreted as wall-clock time, not converted. `DateTimeOffsetRange` bounds are normalized to UTC for `timestamptz`: the instant is preserved, but the original offset is not round-tripped (values read back carry offset `+00:00` and compare equal to what was written, since `DateTimeOffset` equality is instant-based). The same normalization applies to an element operand: `range.Contains(dto)` takes a `DateTimeOffset` with any offset, and `range.Contains(dt)` a `DateTime` of any `Kind`. - Npgsql by default maps `DateTime.MinValue`/`MaxValue` to PostgreSQL `-infinity`/`infinity`. A *finite* bound of `DateTime.MaxValue` therefore becomes an explicit `infinity` bound in the database — which is distinct from an *unbounded* side (`upper_inf` stays `false`), so shape checks behave consistently. - Reverse engineering (`dotnet ef dbcontext scaffold`) maps range columns to `NpgsqlRange`, not to these types — the plugin provides no design-time services. Apply the range types manually after scaffolding. @@ -227,24 +227,49 @@ A range, multirange or value set can sit inside a complex or owned type mapped t modelBuilder.Entity().ComplexProperty(c => c.Terms, terms => terms.ToJson()); ``` -Each is stored as its PostgreSQL literal, so the document reads naturally and other clients can -parse it: +**The document holds the store's text, not the API's JSON.** Each property is written as the text of +its PostgreSQL store type, which is what a query reaching into the document casts it to — so the +operators translate exactly as on a plain column: ```json { "Period": "[2026-01-01,2026-06-30]", "Blocks": "{[2026-02-01,2026-02-28]}", "Tags": "{a,b}" } ``` -The literal is also what makes queries into the document work. EF extracts a property as text and -casts it to the store type, so the operators translate exactly as on a plain column: - ```sql WHERE CAST(c."Terms" ->> 'Period' AS daterange) @> @day ``` -The `YearMonth` types are the exception to "their own literal": they are written in their store's -month-aligned date form (`[2026-01-01,2026-06-30]`, `{2026-01-01,2026-06-01}`), because the cast -reads dates, not `2026-01`. A filter that casts out of the document does not use an index on the -column; if such a query is hot, keep the value in a plain column of its own as well. +That is a database representation, and it differs from what System.Text.Json writes for the same +value in an API payload: + +| Value | API JSON | Inside the document | +|---|---|---| +| a value set | `["a","b"]` | `"{a,b}"` | +| a `YearMonth` range or set (wrapper arity too) | `"[2026-01,2026-06]"`, `["2026-01"]` | month-aligned dates: `"[2026-01-01,2026-06-30]"`, `"{2026-01-01}"` — the cast reads dates | +| a temporal value | every tick (BCL) or nanosecond (NodaTime) | the store's microsecond, truncated toward 2000-01-01 as Npgsql truncates a parameter | +| `MinValue` / `MaxValue` of `DateTime`, `DateTimeOffset`, `DateOnly`, and `Instant`, `LocalDate`, `LocalDateTime` extremes | the extreme value | `-infinity` / `infinity`, as Npgsql sends them | + +It is also a **persisted format**: changing it later would mean migrating stored documents, not +recompiling. The temporal rule is what keeps the three copies of a value in agreement — a document +materializes to what a native column would, and a query that casts out of the document compares +the value a parameter carries. Writing every digit and letting the server round them would make +`Doc.Period == period` miss the row it was saved from. `TemporalStoreParityTests` holds the rule +against a live server. + +A filter that casts out of the document does not use an index on the column; if such a query is +hot, keep the value in a plain column of its own as well. + +**What works inside the document** (measured against PostgreSQL 17): + +| Operation | | +|---|---| +| `SaveChanges`: insert, tracked change (to and from `null` too), nested complex types, replacing the whole document | ✓ — a complex type rewrites the whole document; an owned type writes a lone changed property with `jsonb_set`, and rewrites the document when several change | +| `null` and empty, kept apart | ✓ | +| the range and set operators, bounds, `Count`, projections | ✓ | +| `ExecuteUpdate` of the whole JSON property, a JSON-to-JSON copy, a range or multirange | ✓ — a range is then stored in PostgreSQL's own text form, which reads back | +| `ExecuteUpdate` of a value set | **refused** — EF Core would write it with `to_jsonb`, a JSON array the document cannot hold, and the row would no longer load | +| `ExecuteUpdate` setting a property to `null` | fails in Npgsql (`42804`), for every type | +| `ExecuteUpdate` on an owned JSON type | refused by EF Core | > Before 9.0 a `ToJson()` type holding any of these could not be saved: `SaveChanges` threw a > `NullReferenceException` from inside EF, because the plugin's type mappings had no JSON diff --git a/docs/migration.md b/docs/migration.md index 943c2a7..199ab41 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -239,6 +239,35 @@ var range = DateRange.CreateFinite(DateOnly.Parse("01.01.2025", de), DateOnly.Pa Decimal bounds also parse with PostgreSQL's `numeric` grammar now: `"1,000"`, `"(5)"` and currency symbols are a `FormatException` rather than 1000, −5 and 5. +### Temporal literals are strict ISO 8601 + +A temporal bound or element — through `Parse`, `TryParse`, JSON, the `TypeConverter` or configuration +binding — must be ISO 8601, as this library and PostgreSQL write it. The BCL parser used before read +whatever the invariant culture could, month first. These are now a `FormatException`: + +| Input | Was read as | Write instead | +|---|---|---| +| `01.02.2027`, `01/02/2027`, `Feb 1 2027` | January 2 | `2027-02-01` | +| `2027-02-01` for a `DateTime` or `DateTimeOffset` | midnight | `2027-02-01T00:00` | +| a `DateTimeOffset` without an offset | the parsing machine's offset | append `Z` or the offset | +| `9:30 AM`, `9:30` | 09:30 | `09:30` | +| the same forms as JSON elements of a `DateTimeOffsetSet`, `DateTimeSet` or `TimeSet` | as above — System.Text.Json's converter read the offset-less ones in the machine's offset | as above, or register a converter for the element type | + +`HH:mm` without seconds, PostgreSQL's space-separated output, any number of fraction digits (truncated to 100 ns) and `infinity` / `-infinity` are accepted. +The temporal wrapper arities read the text as their primitive first, so a lenient generated element +`Parse` no longer sees non-ISO text, and an element that ignores the format specifier now fails on +read instead of truncating silently. The NodaTime types were already strict. + +### Temporal values reach the database as text at its resolution + +The EF Core provider writes a temporal SQL constant, and a temporal value inside a JSON column, as +the value Npgsql would bind: truncated to the microsecond toward 2000-01-01, with the extremes as +`-infinity` / `infinity`. A constant used to carry seven fraction digits that the server rounded, so +it could miss the row a parameter matched. A temporal set whose elements are less than a microsecond +apart is now stored with one of them — the column could only ever hold one value, and the stored +array's `cardinality` disagreed with the set's `Count`. `ExecuteUpdate` of a value set inside a JSON +column is refused (it would have written a document the row no longer loads from). + ### Ranges can be dictionary keys `Dictionary` used to throw `NotSupportedException`; it now writes the literal as the diff --git a/docs/ranges.md b/docs/ranges.md index 88e767a..e11f50a 100644 --- a/docs/ranges.md +++ b/docs/ranges.md @@ -240,7 +240,7 @@ set.UpperBound(); // 9 ## Set Operations -Set operations are extension methods on the concrete range types (any type that implements `IRangeFactory`). +Set operations are extension methods on the range types, written against `IRangeFactory`, which every shipped range type implements. ### Intersection diff --git a/docs/serialization.md b/docs/serialization.md index 3a96158..ff125b3 100644 --- a/docs/serialization.md +++ b/docs/serialization.md @@ -39,6 +39,9 @@ The optional `format` parameter is forwarded to the element type, so you can con // "[Jan 1 2025,Mar 31 2025]" ``` +That is display text, not a literal: it does not parse back, because temporal literals are ISO 8601 +only (see below). + **The literal never depends on a culture.** Without a format string — `ToString()`, `ToString(null, provider)`, string interpolation, `string.Format`, a logging template — the result is the invariant literal, whatever provider is passed or current. A provider applies only alongside an explicit format, because that asks for display text rather than a literal: ```csharp @@ -76,6 +79,8 @@ if (Int32Range.TryParse(userInput, null, out var range)) `Parse` and `TryParse` read the invariant literal and **ignore the provider**, as `Guid.Parse` does: the text is PostgreSQL's, which has no culture. Before 9.0 the provider reached the element parser, so `DateRange.Parse("[2026-01-01,2026-06-30]", th-TH)` returned a range in 1483 and `DecimalRange.Parse("[1.5,2.5)", de-DE)` returned `[15,25)`, with no error. Decimal bounds follow PostgreSQL's `numeric` grammar — sign, digits, point, exponent — so a group separator or currency symbol is a `FormatException`. +Temporal bounds and elements are **strict ISO 8601** — the forms this library writes and the forms PostgreSQL prints in its default ISO `DateStyle`, nothing else: a date `yyyy-MM-dd`; a time `HH:mm` or `HH:mm:ss` with any number of fraction digits, truncated to the 100 ns the BCL types hold (Go and Java write nine); date and time joined by `T` or a space; for a `DateTime` an optional `Z`, `±HH` or `±HH:mm`, read with round-trip semantics; for a `DateTimeOffset` a required `Z`, `±HH`, `±HH:mm` or `±HH:mm:ss` (PostgreSQL's historical local-mean-time offsets, kept as the instant at offset zero). `infinity` / `-infinity` read as `MaxValue` / `MinValue`, which is what Npgsql materializes for them. Before 9.0 the lenient BCL parser read `[01.02.2027,05.03.2027)` month first, as January 2 to May 2, and a `DateTimeOffset` without an offset took the parsing machine's. A closed temporal set's JSON array is read with the same grammar. System.Text.Json's own converter — what read it before 9.0 — is ISO 8601 too, but reads a `DateTimeOffset` without an offset, or a bare date, in the parsing machine's offset, so one payload held different instants on different servers. A converter you register for the element type still wins, and the written form is System.Text.Json's, unchanged. + Discrete types canonicalize on parse — `"[1,10)"` is equivalent to `"[1,9]"` and both parse to the same closed `[1, 9]` range: ```csharp diff --git a/docs/testing.md b/docs/testing.md index 0881bdd..a70d30f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -55,7 +55,11 @@ Coverage of the arities *as a family* lives in the conventions suite, because it `JsonConverterPrecedenceTests.cs` pins the 9.0.0 type-level `[JsonConverter]` attributes from the outside: the unregistered wire form, a converter on the options or on a property still winning, and a source-generated context. `JsonDictionaryKeyTests.cs` covers ranges, variants and multiranges as dictionary keys. The per-type sweep of both — every range shape and set family, unregistered against registered — is `JsonDefaultContractTests` in the conventions project. -`JsonColumnTests` (integration) puts every range, multirange and set family into one `jsonb` column via `ComplexProperty(…).ToJson()`, round-trips it, queries each property server-side (proving the stored text casts to its store type), and pins the stored text, including the `YearMonth` types' month-aligned form. +`JsonColumnTests` (integration) puts every range, multirange and set family into one `jsonb` column via `ComplexProperty(…).ToJson()`, round-trips it, queries each property server-side (proving the stored text casts to its store type), and pins the stored text, including the `YearMonth` types' month-aligned form. Its two tracked-change tests take the path an application does — load, change, `SaveChanges` — once on the complex document, which EF rewrites whole, and once on an owned one, which EF updates with `jsonb_set` for a lone changed property: a range and a value set are replaced and a nullable range, temporal range and value set go from `null` to a value and back, one change per save so the change tracker has to see each. Once every value is set, and again once the nullable ones are back to `null`, the document is read through a new context and queried on the server, and every property no change touched must still hold its original value. + +`TemporalStoreParityTests` (integration) is the authority on temporal values written as text. It holds one property per registered store conversion — every temporal range type, closed set and wrapper set family except the three over `YearMonth`, which have nothing below a day to truncate, plus two multiranges standing for the rest, since a multirange takes its range type's definition — in one row twice, a column and a JSON document, built from probes chosen by rounding case (remainders above, below and at 500 ns, before 2000, the extremes, sub-microsecond ranges). Per property it asserts that the server reads the document as the column, that a parameter and a constant each match both copies, that the copies materialize equal, and that `Contains` agrees with memory one microsecond either side of every bound. The element operands are held to an expected answer rather than to each other: every element a set was built from must match its own row as a parameter and as a constant, and a range's `Contains` on its original sub-microsecond bounds must answer as the materialized range does for the probe as stored — two paths that agree would also agree on a miss. `InfinityConversionsDisabledTests`, in its own project `…InfinityDisabled.IntegrationTests` because the switch is read once per process, holds the same rule at the extremes with `Npgsql.DisableDateTimeInfinityConversions` set: `MinValue` / `MaxValue` written finite and truncated, as Npgsql then sends them, in the document, the literal and the element operands. Its first test checks the switch actually reached the process — without it, every other assertion there would pass for the default configuration. `TemporalStoreTextTests` (unit) pins the text itself. `ExecuteUpdateJsonTests` (integration) covers what `ExecuteUpdate` may do inside a document, `ExecuteUpdateGuardTests` (unit) the refusal and the shape of EF Core's lowering it matches. + +`TemporalLiteralGrammarTests` (unit) pins the strict ISO 8601 temporal grammar, and `TemporalLiteralStrictnessContractTests` (conventions) refuses a non-ISO element through every entry point of every temporal range and set type, discovered. `test/CodoMetis.ValueRanges.AspNetCore.Tests/` runs the host-level behaviour with no ValueRanges registration at all: configuration binding through the `TypeConverter`, a `TestServer` host with MVC and minimal-API endpoints (query binding, JSON bodies both ways), and the OpenAPI document generated with and without the documented `ValueRangesSchemaTransformer` — whose patterns are checked to accept every literal form the parsers accept, and whose source is held verbatim against `docs/serialization.md`. diff --git a/docs/value-sets.md b/docs/value-sets.md index 21653ef..7197b01 100644 --- a/docs/value-sets.md +++ b/docs/value-sets.md @@ -78,7 +78,7 @@ Every value set family has an arity: `StringSet`, `GuidSet`, `Int16Set` `IFormattable` supplies the element's backing text on the way out; `IParsable` **re-runs the element's validation on the way in**, so materializing corrupt data throws instead of smuggling invalid values into the domain. Every arity except `StringSet` additionally requires `IComparable` (canonical order delegates to the backing primitive; `StringSet` orders ordinally over the text form and deliberately ignores the element's own comparison). -There is no private constructor to find, no source generator to integrate with and no value converter to write. Reading calls `TElement.Parse(text, invariant)` and writing calls `element.ToString(format, invariant)` — which is why Vogen, Metalama, StronglyTypedId and hand-written wrappers all work identically, and why a wrapper that omits `IParsable` does not work at all regardless of what generated it. +There is no private constructor to find, no source generator to integrate with and no value converter to write. Reading calls `TElement.Parse(text, invariant)` — for the temporal families after parsing the text strictly as the backing primitive and handing the element that primitive's round-trip form, so a lenient generated `Parse` never sees non-ISO text — and writing calls `element.ToString(format, invariant)` — which is why Vogen, Metalama, StronglyTypedId and hand-written wrappers all work identically, and why a wrapper that omits `IParsable` does not work at all regardless of what generated it. ### Why the element must be a struct diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md index 98808de..a388e70 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md @@ -14,7 +14,17 @@ covers all four packages, which share one version number and release together. because a query into the document casts the text to `daterange`/`date[]`, which only reads dates. Reads validate month alignment. `YearMonthRangeJsonValueReaderWriter`, `YearMonthRangeSetJsonValueReaderWriter` and `YearMonthSetJsonValueReaderWriter` are public for - compiled models. + compiled models. The wrapper `YearMonthSet` is written in the date form too; its own + `{2026-02}` literal could not be cast, so every query into it failed. +- **NodaTime values written as text at the database's resolution.** In a JSON document and in a SQL + constant, `Instant`, `LocalDateTime` and `LocalTime` carried all nine fraction digits, which the + server rounds while Npgsql truncates a parameter toward 2000-01-01; and `Instant.MinValue` / + `MaxValue`, `LocalDate` and `LocalDateTime` `MinIsoValue` / `MaxIsoValue` stayed finite where a + parameter carries `-infinity` / `infinity` — `-9998-01-01` is not even valid SQL. Both now carry + the value Npgsql would bind, including a `YearMonthRange` that ends in 9999-12 and the element + constant of an `InstantSet` or `LocalTimeSet` probe — and of an `InstantRange` or + `LocalDateTimeRange` element operand — which was Npgsql's nine-digit literal. See + `CodoMetis.ValueRanges.EFCore.PostgreSQL` 9.0.0. ## [8.0.0] — 2026-08-17 diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs index 3718140..21ae6e4 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs @@ -24,15 +24,18 @@ public static class NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions // idempotent no-op for already-known range CLR types on every later options build. private static readonly IRangeTypeDefinition[] Definitions = [ - new RangeTypeDefinition("daterange", "datemultirange", "date"), + // No Kind or offset reinterpretation: a LocalDateTime is wall-clock time by construction + // and an Instant is an instant. What remains is the store's resolution — nanoseconds + // truncated to microseconds and the extremes written as infinity, exactly as Npgsql + // sends them, so a JSON document and a SQL literal carry the value a parameter would. + new RangeTypeDefinition( + "daterange", "datemultirange", "date", infinityText: NodaStoreTemporal.InfinityText), new RangeTypeDefinition( - "tsrange", "tsmultirange", "timestamp without time zone"), + "tsrange", "tsmultirange", "timestamp without time zone", + storeValue: NodaStoreTemporal.Truncate, infinityText: NodaStoreTemporal.InfinityText), new RangeTypeDefinition( - "tstzrange", "tstzmultirange", "timestamp with time zone"), - - // No element normalization anywhere: a LocalDateTime is wall-clock time by - // construction and an Instant is an instant by construction — the Kind and - // offset reinterpretations of the BCL definitions have nothing to fix here. + "tstzrange", "tstzmultirange", "timestamp with time zone", + storeValue: NodaStoreTemporal.Truncate, infinityText: NodaStoreTemporal.InfinityText), // YearMonth has no PostgreSQL representation: this definition stores the range // as a month-aligned daterange, converting every boundary through its days. @@ -49,21 +52,28 @@ public static class NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions // the normalization also backs the element mapping used for a bare probe in // `column @> ARRAY[@p]`, where a non-ISO value would otherwise bind its own // calendar's year/month/day as if they were ISO. + // + // The temporal sets also truncate to the store's microsecond and write the extremes as + // infinity, as Npgsql does, and a JSON document holds those store values (storeText). new SetTypeDefinition( "date", static value => value.Calendar == CalendarSystem.Iso ? value : value.WithCalendar(CalendarSystem.Iso), - NodaTime.Text.LocalDatePattern.Iso.Format), + NodaStoreTemporal.Literal, + storeText: true), new SetTypeDefinition( "timestamp without time zone", static value => value.Calendar == CalendarSystem.Iso ? value : value.WithCalendar(CalendarSystem.Iso), - NodaTime.Text.LocalDateTimePattern.ExtendedIso.Format), + NodaStoreTemporal.Literal, + storeValue: NodaStoreTemporal.Truncate, storeText: true), - // No normalization: an Instant is an instant and a LocalTime a time of day by - // construction — neither carries a calendar. + // An Instant is an instant and a LocalTime a time of day by construction — neither + // carries a calendar, so the store's resolution is all there is to normalize. new SetTypeDefinition( - "timestamp with time zone", literalText: NodaTime.Text.InstantPattern.ExtendedIso.Format), + "timestamp with time zone", literalText: NodaStoreTemporal.Literal, + storeValue: NodaStoreTemporal.Truncate, storeText: true), new SetTypeDefinition( - "time without time zone", literalText: NodaTime.Text.LocalTimePattern.ExtendedIso.Format), + "time without time zone", literalText: NodaTime.Text.LocalTimePattern.ExtendedIso.Format, + storeValue: NodaStoreTemporal.Truncate, storeText: true), // YearMonth has no PostgreSQL representation: this definition stores the set // as a month-aligned date[], converting every element through its first day. @@ -87,34 +97,41 @@ private static readonly (Type Family, Func Factory)[] // a LocalDate becomes 'Saturday, 15 June 2024', which is not a date literal at all. // Instant and LocalTime would survive the default because theirs happen to be ISO; // relying on that per type is exactly the trap this argument exists to close. + // The temporal families truncate to the store's microsecond and write the extremes as + // infinity, as Npgsql does; a JSON document holds those store values (storeText). (typeof(LocalDateSet<>), SetTypeRegistry.Bridged( "date", LocalDatePattern.Iso.PatternText, ParseWith(LocalDatePattern.Iso), LocalDatePattern.Iso.Format, - LocalDatePattern.Iso.Format)), + NodaStoreTemporal.Literal, storeText: true)), (typeof(LocalDateTimeSet<>), SetTypeRegistry.Bridged( "timestamp without time zone", LocalDateTimePattern.ExtendedIso.PatternText, ParseWith(LocalDateTimePattern.ExtendedIso), LocalDateTimePattern.ExtendedIso.Format, - LocalDateTimePattern.ExtendedIso.Format)), + NodaStoreTemporal.Literal, NodaStoreTemporal.Truncate, storeText: true)), (typeof(InstantSet<>), SetTypeRegistry.Bridged( "timestamp with time zone", InstantPattern.ExtendedIso.PatternText, ParseWith(InstantPattern.ExtendedIso), InstantPattern.ExtendedIso.Format, - InstantPattern.ExtendedIso.Format)), + NodaStoreTemporal.Literal, NodaStoreTemporal.Truncate, storeText: true)), (typeof(LocalTimeSet<>), SetTypeRegistry.Bridged( "time without time zone", LocalTimePattern.ExtendedIso.PatternText, ParseWith(LocalTimePattern.ExtendedIso), LocalTimePattern.ExtendedIso.Format, - LocalTimePattern.ExtendedIso.Format)), + LocalTimePattern.ExtendedIso.Format, NodaStoreTemporal.Truncate, storeText: true)), // The one family whose element text form and store text form differ: the element // speaks 2024-06 and the column holds 2024-06-01. formatPrimitive therefore feeds // the element its own granularity back, while literalText renders the date. + // + // In a JSON document it is written as those dates too (storeText) — its own literal says + // {2024-06}, which a query cannot cast to date[] — and read back through LocalDateSet. (typeof(YearMonthSet<>), SetTypeRegistry.Bridged( "date", YearMonthPattern.Iso.PatternText, static text => YearMonthPattern.Iso.Parse(text).GetValueOrThrow().OnDayOfMonth(1), static date => YearMonthPattern.Iso.Format(YearMonthOf(date)), - LocalDatePattern.Iso.Format)) + LocalDatePattern.Iso.Format, + storeText: true, + parseStoreText: static text => LocalDateSet.Parse(text, null).Values)) ]; /// diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/NodaStoreTemporal.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/NodaStoreTemporal.cs new file mode 100644 index 0000000..ed668d8 --- /dev/null +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/NodaStoreTemporal.cs @@ -0,0 +1,58 @@ +using CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; +using NodaTime; +using NodaTime.Text; + +namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.NodaTime.Internal; + +// EF1001 is EF's analyzer reading the core plugin's `EntityFrameworkCore.*.Internal` namespace as EF's +// own internal API; this satellite builds on those types by design (see the options-builder extension). +#pragma warning disable EF1001 + +/// +/// The NodaTime temporal values as Npgsql puts them on the wire — the satellite's counterpart of +/// , measured the same way against PostgreSQL 17. +/// +/// +/// NodaTime counts nanoseconds, so the cut to whole microseconds drops three digits more than the +/// BCL types do; Npgsql makes it toward 2000-01-01 here too. The extremes it maps to +/// -infinity / infinity are / +/// and, for and , MinIsoValue / +/// MaxIsoValue; a time of day has none. +/// +internal static class NodaStoreTemporal +{ + private static readonly Instant PostgresEpoch = Instant.FromUtc(2000, 1, 1, 0, 0); + + internal static Instant Truncate(Instant value) + { + if (StoreTemporal.InfinityConversions && (value == Instant.MinValue || value == Instant.MaxValue)) return value; + + // BigInteger: the nanoseconds across NodaTime's ±9999 years do not fit a long. + var nanoseconds = (value - PostgresEpoch).ToBigIntegerNanoseconds(); + return PostgresEpoch + Duration.FromNanoseconds(nanoseconds / 1000 * 1000); + } + + internal static LocalDateTime Truncate(LocalDateTime value) + => StoreTemporal.InfinityConversions && (value == LocalDateTime.MinIsoValue || value == LocalDateTime.MaxIsoValue) + ? value + : Truncate(value.InUtc().ToInstant()).InUtc().LocalDateTime.WithCalendar(value.Calendar); + + internal static LocalTime Truncate(LocalTime value) + => LocalTime.FromNanosecondsSinceMidnight(value.NanosecondOfDay / 1000 * 1000); + + internal static string? InfinityText(Instant value) + => StoreTemporal.Text(value == Instant.MinValue, value == Instant.MaxValue); + + internal static string? InfinityText(LocalDate value) + => StoreTemporal.Text(value == LocalDate.MinIsoValue, value == LocalDate.MaxIsoValue); + + internal static string? InfinityText(LocalDateTime value) + => StoreTemporal.Text(value == LocalDateTime.MinIsoValue, value == LocalDateTime.MaxIsoValue); + + /// The SQL literal form of an element: infinity for an extreme, otherwise ISO. + internal static string Literal(Instant value) => InfinityText(value) ?? InstantPattern.ExtendedIso.Format(value); + + internal static string Literal(LocalDate value) => InfinityText(value) ?? LocalDatePattern.Iso.Format(value); + + internal static string Literal(LocalDateTime value) => InfinityText(value) ?? LocalDateTimePattern.ExtendedIso.Format(value); +} diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs index e859772..1398e8b 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs @@ -143,9 +143,9 @@ protected override string GenerateNonNullSqlLiteral(object value) $"Cannot generate a '{StoreType}' SQL literal for a value of type '{value.GetType()}'.") }; - // ToString() formats with the invariant culture; string interpolation would route - // through IFormattable with the current culture instead. - return $"'{range.ToString()}'::{StoreType}"; + // The date form's store text: its invariant literal, with LocalDate.MaxIsoValue — the end of a + // range up to 9999-12 — written as infinity, as Npgsql sends it. + return $"'{StoreText.Format(range)}'::{StoreType}"; } } @@ -204,7 +204,7 @@ protected override string GenerateNonNullSqlLiteral(object value) $"Cannot generate a '{StoreType}' SQL literal for a value of type '{value.GetType()}'.") }; - return $"'{set.ToString()}'::{StoreType}"; + return $"'{StoreText>.Format(set)}'::{StoreType}"; } } @@ -273,6 +273,6 @@ protected override string GenerateNonNullSqlLiteral(object value) $"Cannot generate a '{StoreType}' SQL literal for a value of type '{value.GetType()}'.") }; - return $"DATE '{LocalDatePattern.Iso.Format(date)}'"; + return $"DATE '{NodaStoreTemporal.Literal(date)}'"; } } diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthSetTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthSetTypeDefinition.cs index 13addd2..eb2a6e8 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthSetTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthSetTypeDefinition.cs @@ -43,7 +43,7 @@ internal static YearMonth FromPrimitive(LocalDate primitive) public string ArrayStoreType => "date[]"; public RelationalTypeMapping SetTypeMapping { get; } = new ValueSetTypeMapping( - "date[]", ToPrimitive, FromPrimitive, global::NodaTime.Text.LocalDatePattern.Iso.Format, + "date[]", ToPrimitive, FromPrimitive, NodaStoreTemporal.Literal, YearMonthSetJsonValueReaderWriter.Instance); /// diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Storage/YearMonthJsonValueReaderWriters.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Storage/YearMonthJsonValueReaderWriters.cs index 5bbb60b..bf8ef2f 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Storage/YearMonthJsonValueReaderWriters.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Storage/YearMonthJsonValueReaderWriters.cs @@ -2,12 +2,16 @@ using System.Linq.Expressions; using System.Reflection; using System.Text.Json; +using CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; using CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.NodaTime.Internal; using Microsoft.EntityFrameworkCore.Storage.Json; using NodaTime; namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.NodaTime.Storage; +// EF1001: the core plugin's `EntityFrameworkCore.*.Internal` namespace, which this satellite builds on by design. +#pragma warning disable EF1001 + // The YearMonth types are stored month-aligned (daterange, datemultirange, date[]), so inside an // EF Core JSON column they are written in that same date form rather than their own "2026-01" // literal: EF queries into a document by extracting the text and casting it to the store type, @@ -37,7 +41,7 @@ public override YearMonthRange FromJsonTyped(ref Utf8JsonReaderManager manager, /// public override void ToJsonTyped(Utf8JsonWriter writer, YearMonthRange value) - => writer.WriteStringValue(YearMonthRangeConversion.DateForm(value).ToString()); + => writer.WriteStringValue(StoreText.Format(YearMonthRangeConversion.DateForm(value))); /// public override Expression ConstructorExpression => Expression.Property(null, InstanceProperty); @@ -67,7 +71,7 @@ public override RangeSet FromJsonTyped(ref Utf8JsonRe /// public override void ToJsonTyped(Utf8JsonWriter writer, RangeSet value) - => writer.WriteStringValue(YearMonthRangeConversion.DateForm(value).ToString()); + => writer.WriteStringValue(StoreText>.Format(YearMonthRangeConversion.DateForm(value))); /// public override Expression ConstructorExpression => Expression.Property(null, InstanceProperty); @@ -98,7 +102,7 @@ public override YearMonthSet FromJsonTyped(ref Utf8JsonReaderManager manager, ob /// public override void ToJsonTyped(Utf8JsonWriter writer, YearMonthSet value) => writer.WriteStringValue( - LocalDateSet.From(value.Values.Select(YearMonthSetTypeDefinition.ToPrimitive)).ToString()); + StoreText.Format(LocalDateSet.From(value.Values.Select(YearMonthSetTypeDefinition.ToPrimitive)))); /// public override Expression ConstructorExpression => Expression.Property(null, InstanceProperty); diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md index 61682a5..dc96d51 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md @@ -16,6 +16,27 @@ covers all four packages, which share one version number and release together. `CAST(doc ->> 'Period' AS daterange) @> @day` — so the range and set operators translate inside JSON columns as they do on plain columns. `LiteralJsonValueReaderWriter` is public, because a compiled model re-creates it. + - The document holds the store's representation (a value set is `"{a,b}"`, not an array), and it + is a persisted format. Temporal values are stored as a parameter would bind them — truncated to + the microsecond toward 2000-01-01, the extremes as `-infinity` / `infinity` — so a query that + casts out of the document compares the value the column would hold. + - `ExecuteUpdate` of a value set inside a JSON column is refused at query compilation: EF Core + writes it with `to_jsonb`, a JSON array the document cannot hold. Ranges, multiranges and the + whole JSON property can be set. `docs/efcore.md` has the full matrix. +- **A range built inline in a query failed to translate** — `r.Period == DateRange.CreateFinite(…)` + over values that depend on nothing in the query threw "does not have a type mapping assigned". + EF Core inlines such a range as a constant typed by its sealed variant; the query interceptor now + retypes it to its range type. +- **⚠️ Temporal SQL constants carried more precision than the database.** A temporal range, + multirange or set constant was rendered with seven fraction digits, which the server rounds while + a parameter is truncated, and `DateTime.MinValue` as a finite date where a parameter carries + `-infinity`; a constant could miss the row a parameter matched. Constants now carry the value + Npgsql would bind. A temporal set whose elements truncate to one microsecond is deduplicated on + every path, so the stored array's `cardinality` is the set's `Count`. +- **A range's element operand used the provider's mapping.** `DateTimeOffsetRange.Contains(dto)` + threw for any non-UTC offset ("only offset 0 (UTC) is supported"), `DateTimeRange.Contains` for a + UTC-kinded `DateTime`, and an element constant was Npgsql's literal. Temporal ranges now give + their element operand the range's own normalization and store text. See also `CodoMetis.ValueRanges` 9.0.0: the types now carry their System.Text.Json converters and TypeConverters, and the literal no longer depends on a culture. diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeStoreText.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeStoreText.cs new file mode 100644 index 0000000..064bdd1 --- /dev/null +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeStoreText.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using CodoMetis.ValueRanges.Core; + +namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; + +/// +/// The store text of a temporal range or multirange: each bound normalized as Npgsql would send it, +/// the range rebuilt through its factory, and the extremes spelled -infinity / infinity. +/// +/// +/// +/// Rebuilding matters as much as the bounds. A range narrower than a microsecond has two equal bounds +/// once truncated, and the factory collapses [x,x) to empty exactly as PostgreSQL does +/// when the parameter arrives; a multirange is rebuilt through RangeSet.From, which merges the +/// ranges that truncation made touch, as the server merges them. Without that, the document would +/// hold a range the server would never have stored for the same value. +/// +/// +/// Written from the public variant interfaces: the core does not expose its literal writer to this +/// package. The five shapes are named and anything else is refused, as in the core's own formatter. +/// +/// +internal static class RangeStoreText +{ + internal static string Format(TRange range, Func normalize, Func infinityText) + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + => Write(Normalize(range, normalize), infinityText); + + internal static string Format(RangeSet set, Func normalize, Func infinityText) + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + { + var normalized = RangeSet.From([.. set.Select(range => Normalize(range, normalize))]); + return "{" + string.Join(",", normalized.Select(range => Write(range, infinityText))) + "}"; + } + + private static TRange Normalize(TRange range, Func normalize) + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + => range switch + { + IEmptyRange => range, + IInfinityRange => range, + IFiniteRange f => TRange.CreateFinite(normalize(f.Start), normalize(f.End), f.StartInclusive, f.EndInclusive), + IUnboundedStartRange us => TRange.CreateUnboundedStart(normalize(us.End), us.EndInclusive), + IUnboundedEndRange ue => TRange.CreateUnboundedEnd(normalize(ue.Start), ue.StartInclusive), + _ => throw new InvalidOperationException($"Unknown range variant: {range.GetType()}.") + }; + + private static string Write(TRange range, Func infinityText) + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + { + string Bound(T value) + { + var text = infinityText(value) ?? TRange.FormatValue(value, null, CultureInfo.InvariantCulture); + + // A temporal bound in its round-trip form never contains a range delimiter, so it is + // written unquoted. Fail loudly rather than write a literal that reads back differently. + return text.AsSpan().IndexOfAny("\"\\,()[] ") < 0 + ? text + : throw new InvalidOperationException( + $"The {typeof(T).Name} bound text '{text}' of {typeof(TRange).Name} would need quoting in a range literal."); + } + + return range switch + { + IEmptyRange => "empty", + IInfinityRange => "(,)", + IFiniteRange f => $"{(f.StartInclusive ? '[' : '(')}{Bound(f.Start)},{Bound(f.End)}{(f.EndInclusive ? ']' : ')')}", + IUnboundedStartRange us => $"(,{Bound(us.End)}{(us.EndInclusive ? ']' : ')')}", + IUnboundedEndRange ue => $"{(ue.StartInclusive ? '[' : '(')}{Bound(ue.Start)},)", + _ => throw new InvalidOperationException($"Unknown range variant: {range.GetType()}.") + }; + } +} diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeDefinition.cs index 2374a54..a33b2ff 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeDefinition.cs @@ -11,35 +11,79 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; /// /// The range type, e.g. DateRange. /// The element type of the range. -internal sealed class RangeTypeDefinition( - string rangeStoreType, - string multirangeStoreType, - string elementStoreType, - Func? normalizeValue = null -) - : IRangeTypeDefinition +internal sealed class RangeTypeDefinition : IRangeTypeDefinition where TRange : class, IRangeFactory, IRange where T : struct, IComparable, IEquatable { + /// The range store type, e.g. tsrange. + /// The multirange store type, e.g. tsmultirange. + /// The element store type, e.g. timestamp without time zone. + /// + /// The reinterpretation a bound needs before Npgsql accepts it — DateTimeKind.Unspecified + /// for timestamp, offset zero for timestamptz. + /// + /// + /// The bound at the store's resolution, applied after : for a + /// temporal type 's truncation, so a parameter, a JSON document and a + /// SQL literal all carry the value Npgsql would send. Kept apart from the normalization so that + /// one stays the element normalization the core type applies. + /// + /// + /// -infinity / infinity for a bound Npgsql maps to it, otherwise . + /// + internal RangeTypeDefinition( + string rangeStoreType, + string multirangeStoreType, + string elementStoreType, + Func? normalizeValue = null, + Func? storeValue = null, + Func? infinityText = null) + { + RangeStoreType = rangeStoreType; + MultirangeStoreType = multirangeStoreType; + ElementStoreType = elementStoreType; + + var toStore = (normalizeValue, storeValue) switch + { + (null, null) => null, + ({ } n, null) => n, + (null, { } v) => v, + ({ } n, { } v) => (Func)(bound => v(n(bound))) + }; + + RangeTypeMapping = new ValueRangeTypeMapping(rangeStoreType, toStore, infinityText); + RangeSetTypeMapping = new ValueRangeSetTypeMapping(multirangeStoreType, toStore, infinityText); + + // A bound or element operand — range @> x, a constructor argument — has to reach the store as + // the range's own bounds do: normalized (a non-UTC DateTimeOffset otherwise throws on the way to + // a timestamptz parameter) and at the store's resolution (a constant was Npgsql's literal). + // Ranges without either keep the provider's element mapping. + ElementTypeMapping = toStore is null && infinityText is null + ? null + : new RangeElementTypeMapping( + elementStoreType, toStore ?? (static value => value), infinityText ?? (static _ => null)); + } + public Type RangeClrType => typeof(TRange); public Type ElementClrType => typeof(T); public Type RangeSetClrType => typeof(RangeSet); - public string RangeStoreType { get; } = rangeStoreType; + public string RangeStoreType { get; } - public string MultirangeStoreType { get; } = multirangeStoreType; + public string MultirangeStoreType { get; } - public string ElementStoreType { get; } = elementStoreType; + public string ElementStoreType { get; } // A domain with a successor function is discrete; continuous types return null. public bool IsDiscrete { get; } = TRange.NextValueAfter(default) is not null; - public RelationalTypeMapping RangeTypeMapping { get; } = new ValueRangeTypeMapping(rangeStoreType, normalizeValue); + public RelationalTypeMapping RangeTypeMapping { get; } + + public RelationalTypeMapping RangeSetTypeMapping { get; } - public RelationalTypeMapping RangeSetTypeMapping { get; } = - new ValueRangeSetTypeMapping(multirangeStoreType, normalizeValue); + public RelationalTypeMapping? ElementTypeMapping { get; } public object EmptyRange => TRange.Empty; diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeRegistry.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeRegistry.cs index bcbcbb4..966620b 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeRegistry.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/RangeTypeRegistry.cs @@ -34,24 +34,31 @@ FrozenSet AggregateDeclari new RangeTypeDefinition("int4range", "int4multirange", "integer"), new RangeTypeDefinition("int8range", "int8multirange", "bigint"), new RangeTypeDefinition("numrange", "nummultirange", "numeric"), - new RangeTypeDefinition("daterange", "datemultirange", "date"), + // The temporal definitions end in StoreTemporal: bounds truncated to the store's + // microsecond and the extremes written as infinity, exactly as Npgsql sends them, so a + // JSON document and a SQL literal carry the value a parameter would. + new RangeTypeDefinition( + "daterange", "datemultirange", "date", infinityText: StoreTemporal.InfinityText), // PostgreSQL `timestamp` has no time zone: Npgsql rejects UTC-kinded values, so // bounds are written as wall-clock time with DateTimeKind.Unspecified. new RangeTypeDefinition( "tsrange", "tsmultirange", "timestamp without time zone", - value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified)), + value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified), + StoreTemporal.Truncate, StoreTemporal.InfinityText), // PostgreSQL `timestamptz` stores an instant: Npgsql requires offset zero, so bounds // are normalized to UTC. This preserves the instant (DateTimeOffset compares by UTC ticks). new RangeTypeDefinition( "tstzrange", "tstzmultirange", "timestamp with time zone", - value => value.ToUniversalTime()), + value => value.ToUniversalTime(), + StoreTemporal.Truncate, StoreTemporal.InfinityText), // `timerange` is not built into PostgreSQL: the database needs // `CREATE TYPE timerange AS RANGE (subtype = time)` (e.g. via HasPostgresRange) // and the Npgsql data source needs EnableUnmappedTypes() to put it on the wire. - new RangeTypeDefinition("timerange", "timemultirange", "time without time zone") + new RangeTypeDefinition( + "timerange", "timemultirange", "time without time zone", storeValue: StoreTemporal.Truncate) ], [typeof(RangeAggregateExtensions)]); diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeDefinition.cs index b84c62c..724c8ad 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeDefinition.cs @@ -17,29 +17,56 @@ internal sealed class SetTypeDefinition : ISetTypeDefinition where TSet : class, IValueSetFactory, IValueSet where T : IEquatable { + /// The PostgreSQL type of one element, e.g. date. + /// + /// The element normalization the set type's From applies (CLAUDE.md: the same function as + /// NormalizeElement), for parameters, literals and a bare element operand. + /// + /// The element's SQL literal form; defaults to . + /// + /// The element at the store's resolution, applied after — for a + /// temporal family 's truncation. + /// + /// + /// Whether the set's store text — a JSON document — is the array of its store primitives rather + /// than its own literal: the temporal families, whose store value is truncated and whose extremes + /// are infinity. + /// internal SetTypeDefinition( string elementStoreType, Func? normalizeValue = null, - Func? literalText = null + Func? literalText = null, + Func? storeValue = null, + bool storeText = false ) { + var toStore = (normalizeValue, storeValue) switch + { + (null, null) => static value => value, + ({ } n, null) => n, + (null, { } v) => v, + ({ } n, { } v) => (Func)(value => v(n(value))) + }; + ElementStoreType = elementStoreType; ArrayStoreType = elementStoreType + "[]"; SetTypeMapping = new ValueSetTypeMapping( - ArrayStoreType, - normalizeValue ?? (static value => value), - static value => value, - literalText); + ArrayStoreType, toStore, static value => value, literalText, storeText: storeText); // The same normalization has to reach a bare element operand: in `column @> ARRAY[@p]` // the probe bypasses the set mapping entirely, and the provider's default mapping for // the element CLR type does not normalize. Without this, a probe that From would have // normalized binds raw — a non-ISO NodaTime date silently queries for its own // calendar's field values read as ISO. - ElementTypeMapping = normalizeValue is null + // + // The store resolution has to reach it too. A parameter would be fine on the provider's + // element mapping — Npgsql truncates on the wire — but a constant would not: Npgsql renders + // a NodaTime Instant or LocalTime constant with nine fraction digits, which the server + // rounds, so `InstantSet.Contains(EF.Constant(x))` missed the element it was stored from. + ElementTypeMapping = normalizeValue is null && storeValue is null ? null : new BridgedElementTypeMapping( - elementStoreType, normalizeValue, static value => value, literalText); + elementStoreType, toStore, static value => value, literalText); } public Type SetClrType => typeof(TSet); @@ -127,6 +154,7 @@ internal sealed class BridgedSetTypeDefinition : ISe private readonly string? _elementFormat; private readonly Func _parsePrimitive; private readonly Func _formatPrimitive; + private readonly Func? _storeValue; /// The PostgreSQL type of one element, e.g. integer. /// @@ -142,22 +170,36 @@ internal sealed class BridgedSetTypeDefinition : ISe /// /// The primitive's SQL literal form; defaults to . /// + /// The primitive at the store's resolution — a temporal family's truncation. + /// + /// Whether the set's store text is the array of its store primitives rather than its own + /// literal — see . + /// + /// + /// The store primitives back from that text, where the set's own Parse cannot read it: + /// YearMonthSet<T> stores 2024-06-01 and its element speaks 2024-06. + /// public BridgedSetTypeDefinition( - string elementStoreType, - string? elementFormat, - Func parsePrimitive, - Func formatPrimitive, - Func? literalText = null + string elementStoreType, + string? elementFormat, + Func parsePrimitive, + Func formatPrimitive, + Func? literalText = null, + Func? storeValue = null, + bool storeText = false, + Func>? parseStoreText = null ) { _elementFormat = elementFormat; _parsePrimitive = parsePrimitive; _formatPrimitive = formatPrimitive; + _storeValue = storeValue; ElementStoreType = elementStoreType; ArrayStoreType = elementStoreType + "[]"; SetTypeMapping = new ValueSetTypeMapping( - ArrayStoreType, ToPrimitive, FromPrimitive, literalText); + ArrayStoreType, ToPrimitive, FromPrimitive, literalText, + storeText: storeText, parseStoreText: parseStoreText); ElementTypeMapping = new BridgedElementTypeMapping( ElementStoreType, ToPrimitive, FromPrimitive, literalText); } @@ -165,9 +207,10 @@ public BridgedSetTypeDefinition( private TPrimitive ToPrimitive(TElement element) { var text = element.ToString(_elementFormat, CultureInfo.InvariantCulture); + TPrimitive primitive; try { - return _parsePrimitive(text); + primitive = _parsePrimitive(text); } catch (FormatException ex) { @@ -180,6 +223,8 @@ private TPrimitive ToPrimitive(TElement element) + $"'{typeof(TPrimitive)}' value. Value set elements must format to exactly the " + "backing primitive's text form.", ex); } + + return _storeValue is null ? primitive : _storeValue(primitive); } private TElement FromPrimitive(TPrimitive primitive) diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeRegistry.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeRegistry.cs index 25c2de7..29387f9 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeRegistry.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeRegistry.cs @@ -63,14 +63,19 @@ FrozenDictionary ByElementType // its own default form is a contract violation, and the strict parse turns it into // the loud InvalidOperationException the bridge raises rather than a value quietly // truncated to whole minutes or seconds. + // + // The temporal families also truncate to the store's microsecond, as Npgsql does on the + // wire, and write a JSON document as those store values (storeText) — see StoreTemporal. [typeof(DateSet<>)] = Bridged( "date", "yyyy-MM-dd", static text => DateOnly.ParseExact(text, "yyyy-MM-dd", Invariant), - static value => value.ToString("yyyy-MM-dd", Invariant)), + static value => value.ToString("yyyy-MM-dd", Invariant), + storeText: true), [typeof(TimeSet<>)] = Bridged( "time without time zone", "O", static text => TimeOnly.ParseExact(text, "O", Invariant), - static value => value.ToString("O", Invariant)), + static value => value.ToString("O", Invariant), + storeValue: StoreTemporal.Truncate, storeText: true), // The same store-side normalization the closed siblings apply: PostgreSQL // `timestamp` has no time zone, so elements are written as wall-clock time with @@ -82,13 +87,15 @@ FrozenDictionary ByElementType static text => DateTime.SpecifyKind( DateTime.ParseExact(text, "O", Invariant, DateTimeStyles.RoundtripKind), DateTimeKind.Unspecified), - static value => value.ToString("O", Invariant)), + static value => value.ToString("O", Invariant), + storeValue: StoreTemporal.Truncate, storeText: true), [typeof(DateTimeOffsetSet<>)] = Bridged( "timestamp with time zone", "O", static text => DateTimeOffset .ParseExact(text, "O", Invariant, DateTimeStyles.AssumeUniversal) .ToUniversalTime(), - static value => value.ToString("O", Invariant)) + static value => value.ToString("O", Invariant), + storeValue: StoreTemporal.Truncate, storeText: true) }.ToFrozenDictionary(); private static CultureInfo Invariant => CultureInfo.InvariantCulture; @@ -111,12 +118,18 @@ private static string Text(TPrimitive value) where TPrimitive : IFor /// The element's text form to the store primitive. /// The store primitive back to text the element can parse. /// The primitive's SQL literal form; defaults to the invariant one. + /// The primitive at the store's resolution — a temporal family's truncation. + /// Whether a JSON document holds the store primitives — the temporal families. + /// The store primitives back from that text, where the set cannot parse it. internal static Func Bridged( - string elementStoreType, - string? elementFormat, - Func parsePrimitive, - Func formatPrimitive, - Func? literalText = null + string elementStoreType, + string? elementFormat, + Func parsePrimitive, + Func formatPrimitive, + Func? literalText = null, + Func? storeValue = null, + bool storeText = false, + Func>? parseStoreText = null ) where TPrimitive : struct => closed => @@ -126,7 +139,7 @@ internal static Func Bridged( return (ISetTypeDefinition)Activator.CreateInstance( definitionType, - [elementStoreType, elementFormat, parsePrimitive, formatPrimitive, literalText])!; + [elementStoreType, elementFormat, parsePrimitive, formatPrimitive, literalText, storeValue, storeText, parseStoreText])!; }; private static volatile Snapshot Current = BuildSnapshot( @@ -137,21 +150,26 @@ internal static Func Bridged( new SetTypeDefinition("integer"), new SetTypeDefinition("bigint"), new SetTypeDefinition("numeric"), - new SetTypeDefinition("date"), - new SetTypeDefinition("time without time zone"), + // The temporal sets write a JSON document as their store values (storeText): truncated + // to the store's microsecond as Npgsql truncates them, the extremes as infinity. + new SetTypeDefinition("date", storeText: true), + new SetTypeDefinition( + "time without time zone", storeValue: StoreTemporal.Truncate, storeText: true), // PostgreSQL `timestamp` has no time zone: Npgsql rejects UTC-kinded values, so // elements are written as wall-clock time with DateTimeKind.Unspecified. new SetTypeDefinition( "timestamp without time zone", - static value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified)), + static value => DateTime.SpecifyKind(value, DateTimeKind.Unspecified), + storeValue: StoreTemporal.Truncate, storeText: true), // PostgreSQL `timestamptz` stores an instant: Npgsql requires offset zero, so // elements are normalized to UTC. This preserves the instant (equality and // canonical order are instant-based). new SetTypeDefinition( "timestamp with time zone", - static value => value.ToUniversalTime()) + static value => value.ToUniversalTime(), + storeValue: StoreTemporal.Truncate, storeText: true) ]); /// All registered closed set type definitions (excluding cached family instantiations). diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/StoreTemporal.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/StoreTemporal.cs new file mode 100644 index 0000000..063d727 --- /dev/null +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/StoreTemporal.cs @@ -0,0 +1,73 @@ +namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; + +/// +/// The BCL temporal values as Npgsql puts them on the wire, so that a value the plugin writes as +/// text — inside a JSON column, or as a SQL literal — is the value a parameter would have +/// sent. +/// +/// +/// +/// PostgreSQL stores timestamps and times in whole microseconds. Npgsql gets there by integer +/// division on ticks counted from the PostgreSQL epoch, 2000-01-01, so the sub-microsecond remainder +/// is cut toward that epoch — rounding down after it and up before it. PostgreSQL's own text input +/// rounds half-to-even instead. Until 9.0.0 the JSON writer and the literals sent all seven fraction +/// digits and let the server round them, so a document or a constant held a different value from +/// the parameter and the native column for the same value: an equality could miss its own row. +/// +/// +/// Npgsql also maps MinValue / MaxValue of , +/// and to -infinity / infinity +/// unless the Npgsql.DisableDateTimeInfinityConversions switch is set. The extremes are left +/// untouched by Truncate so a parameter still reaches that mapping, and +/// InfinityText spells them the same way in text. Both rules were measured against +/// PostgreSQL 17 rather than taken from the documentation; TemporalStoreParityTests holds +/// them, and InfinityConversionsDisabledTests — in a process of its own, with the switch set — +/// holds the extremes staying finite. +/// +/// +internal static class StoreTemporal +{ + private static readonly long PostgresEpochTicks = new DateTime(2000, 1, 1).Ticks; + + /// Whether Npgsql maps the extremes to infinity — read once, as Npgsql itself does. + internal static readonly bool InfinityConversions = + !(AppContext.TryGetSwitch("Npgsql.DisableDateTimeInfinityConversions", out var disabled) && disabled); + + /// Ticks cut to whole microseconds toward 2000-01-01 (C# division truncates toward zero). + internal static long TruncateTicks(long ticks) + => PostgresEpochTicks + (ticks - PostgresEpochTicks) / 10 * 10; + + internal static DateTime Truncate(DateTime value) + => IsInfinity(value == DateTime.MinValue, value == DateTime.MaxValue) + ? value + : new DateTime(TruncateTicks(value.Ticks), value.Kind); + + /// Truncates on the instant and returns it at offset zero — what timestamptz stores. + internal static DateTimeOffset Truncate(DateTimeOffset value) + => IsInfinity(value == DateTimeOffset.MinValue, value == DateTimeOffset.MaxValue) + ? value.ToUniversalTime() + : new DateTimeOffset(TruncateTicks(value.UtcTicks), TimeSpan.Zero); + + /// time counts microseconds from midnight; there is nothing to truncate toward but zero. + internal static TimeOnly Truncate(TimeOnly value) + => new(value.Ticks / 10 * 10); + + internal static string? InfinityText(DateTime value) + => Text(value == DateTime.MinValue, value == DateTime.MaxValue); + + internal static string? InfinityText(DateTimeOffset value) + => Text(value == DateTimeOffset.MinValue, value == DateTimeOffset.MaxValue); + + internal static string? InfinityText(DateOnly value) + => Text(value == DateOnly.MinValue, value == DateOnly.MaxValue); + + /// + /// -infinity / infinity for the value Npgsql maps to it, otherwise + /// . Shared with the NodaTime satellite, whose extremes are other values. + /// + internal static string? Text(bool isMin, bool isMax) + => !InfinityConversions ? null : isMin ? "-infinity" : isMax ? "infinity" : null; + + private static bool IsInfinity(bool isMin, bool isMax) + => InfinityConversions && (isMin || isMax); +} diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/StoreText.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/StoreText.cs new file mode 100644 index 0000000..ebc4b8b --- /dev/null +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/StoreText.cs @@ -0,0 +1,56 @@ +namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; + +/// +/// A type mapping that knows its value's store text: the text the plugin writes when it +/// writes the value as text rather than binding it — inside a JSON column, and in a SQL literal. +/// +/// +/// By default that is the type's own invariant literal. A temporal type writes the value as Npgsql +/// would have bound it (), so the document, the constant and the parameter +/// all mean the same value to the server. +/// +/// The range, multirange or value set type. +internal interface IStoreTextMapping +{ + string FormatStoreText(TValue value); + + TValue ParseStoreText(string text); +} + +/// +/// The store text of a type, for code that has the type but not its mapping — the JSON +/// reader/writer, which a compiled model re-creates from a static singleton. +/// +/// +/// Resolved through the registries on first use rather than set by a definition when it is +/// constructed: a wrapper set family's definition is only built when something asks the registry for +/// that instantiation, which in a compiled model need not have happened before the first document is +/// written. Asking is what builds it. A type nothing registered — no UseValueRanges(), or a +/// NodaTime type without UseValueRangesNodaTime() — throws rather than falling back to a text +/// form that may not be the store's. +/// +internal static class StoreText +{ + private static IStoreTextMapping? _mapping; + + internal static string Format(TValue value) => Mapping.FormatStoreText(value); + + internal static TValue Parse(string text) => Mapping.ParseStoreText(text); + + private static IStoreTextMapping Mapping => _mapping ??= Resolve(); + + private static IStoreTextMapping Resolve() + { + object? mapping = null; + + if (RangeTypeRegistry.TryGetByClrType(typeof(TValue), out var range, out var isSet)) + mapping = isSet ? range.RangeSetTypeMapping : range.RangeTypeMapping; + else if (SetTypeRegistry.TryGetByClrType(typeof(TValue), out var set)) + mapping = set.SetTypeMapping; + + return mapping as IStoreTextMapping + ?? throw new InvalidOperationException( + $"No CodoMetis.ValueRanges type mapping with a store text is registered for '{typeof(TValue)}'. " + + "Configure the context with UseValueRanges(), and UseValueRangesNodaTime() for the NodaTime types."); + } +} diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs index 7285978..f66e2c6 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs @@ -1,10 +1,14 @@ using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata; namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Query; /// -/// Two pre-translation passes over the query: one rewrite and one refusal. +/// Pre-translation passes over the query: two rewrites and two refusals. /// /// /// @@ -12,11 +16,14 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Query; /// -) into their Union / Intersect / Except method-call equivalents. /// EF Core translates user-defined operators as plain SQL binary operators without consulting /// method call translators; the rewrite routes them through -/// instead, producing valid multirange SQL. +/// instead, producing valid multirange SQL. A second +/// rewrite gives a range constant EF Core inlined its union type — see +/// . /// /// -/// The refusal rejects comparing a server-computed value set Union for equality — see -/// . +/// The refusals reject comparing a server-computed value set Union for equality — see +/// — and ExecuteUpdate setting a value set inside a JSON +/// column — see . /// /// public sealed class ValueRangesQueryExpressionInterceptor : IQueryExpressionInterceptor @@ -25,7 +32,83 @@ public sealed class ValueRangesQueryExpressionInterceptor : IQueryExpressionInte public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) { UnionEqualityGuard.Instance.Visit(queryExpression); - return RangeSetOperatorRewriter.Instance.Visit(queryExpression); + if (eventData.Context?.Model is { } model) new JsonValueSetUpdateGuard(model).Visit(queryExpression); + return RangeVariantConstantRetyper.Instance.Visit(RangeSetOperatorRewriter.Instance.Visit(queryExpression)); + } + + /// + /// Refuses ExecuteUpdate setting a value set that sits inside a JSON-mapped complex type. + /// + /// + /// + /// EF Core translates such a setter to jsonb_set(doc, '{Tags}', to_jsonb(@p)), binding the + /// set's provider value — a PostgreSQL array. to_jsonb of an array is a JSON array, while + /// the document holds the set as its literal string, which is what the reader expects and what a + /// query casts to the array type. The update reports success; afterwards the entity no longer + /// loads (Cannot get the value of a token type 'StartArray' as a string) and every query + /// on the property fails. Nothing goes wrong at write time, which is why it is refused here rather + /// than left to surface on the next read. + /// + /// + /// Ranges and multiranges are not refused: to_jsonb of a range is a JSON string holding + /// PostgreSQL's own range output, which the strict literal parser reads. Setting the whole JSON + /// property is not refused either — EF writes that through the plugin's own writer. + /// + /// + /// EF Core's ExecuteUpdate lowers to a private + /// ExecuteUpdate<TSource>(IQueryable<TSource>, IReadOnlyList<ITuple>) whose setters are + /// new Tuple<Delegate, object>(propertyLambda, value). It is matched by name, declaring type + /// and parameter shape rather than through EF's internal ExecuteUpdateMethodInfo, which + /// would draw EF1001; ExecuteUpdateShapeTests fails if an EF upgrade changes that shape. + /// + /// + private sealed class JsonValueSetUpdateGuard(IModel model) : ExpressionVisitor + { + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Method is { Name: "ExecuteUpdate", IsGenericMethod: true } method + && method.DeclaringType == typeof(EntityFrameworkQueryableExtensions) + && method.GetParameters() is [_, { ParameterType: var settersType }] + && settersType == typeof(IReadOnlyList) + && node.Arguments[1] is NewArrayExpression setters) + { + foreach (var setter in setters.Expressions) + if (setter is NewExpression { Arguments: [var property, _] } && Unwrap(property) is LambdaExpression lambda) + Check(lambda); + } + + return base.VisitMethodCall(node); + } + + private void Check(LambdaExpression lambda) + { + var path = new List(); + for (var current = Unwrap(lambda.Body); current is MemberExpression member; current = Unwrap(member.Expression)) + path.Insert(0, member.Member.Name); + + if (path.Count < 2 || model.FindEntityType(lambda.Parameters[0].Type) is not { } entityType) return; + + ITypeBase declaring = entityType; + foreach (var name in path[..^1]) + { + if (declaring.FindComplexProperty(name) is not { } complex) return; + declaring = complex.ComplexType; + } + + var target = Unwrap(lambda.Body)!.Type; + if (!declaring.IsMappedToJson() || !SetTypeRegistry.TryGetByClrType(target, out _)) return; + + throw new InvalidOperationException( + $"'ExecuteUpdate' cannot set the value set '{string.Join('.', path)}' inside a JSON column. " + + "EF Core writes it with PostgreSQL's to_jsonb, which stores a JSON array where the document holds " + + "the set's literal string, so the row would no longer load and queries on the property would fail. " + + $"Load the entity and save it with SaveChanges, or set the whole JSON property (SetProperty(e => e.{path[0]}, …))."); + } + + private static Expression? Unwrap(Expression? expression) + => expression is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked or ExpressionType.Quote } unary + ? Unwrap(unary.Operand) + : expression; } /// @@ -160,6 +243,40 @@ private static bool DerivesFromUnion(Expression expression) => }; } + /// + /// Retypes a range constant that EF Core inlined from the runtime type of its value — the sealed + /// variant — to the range type itself. + /// + /// + /// + /// EF Core evaluates an expression that depends on nothing in the query — a range built inline, + /// r.Period == DateRange.CreateFinite(new DateOnly(2026, 1, 1), new DateOnly(2026, 2, 1)), + /// Int32Range.Parse("[1,5]", null) — on the client before translation, and inlines the + /// result as a constant typed by its value: DateRange.Finite, under a conversion back to + /// DateRange. No type mapping exists for a variant, and EF Core does not carry the column's + /// mapping across to an operand of another CLR type, so ==, !=, Equals and a + /// conditional failed with "does not have a type mapping assigned". The method translators + /// (Contains, Overlaps, …) were unaffected — they type their operands themselves — + /// and so were a captured variable and EF.Constant, which EF types by the declared type. + /// + /// + /// A factory call that depends on a column never reaches here as a constant; the method call + /// translator turns it into the range constructor function. + /// + /// + private sealed class RangeVariantConstantRetyper : ExpressionVisitor + { + public static readonly RangeVariantConstantRetyper Instance = new(); + + protected override Expression VisitConstant(ConstantExpression node) + => node.Value is not null + && node.Type.IsSealed + && node.Type.BaseType is { IsAbstract: true } union + && RangeTypeRegistry.TryGetByClrType(union, out _, out var isSet) && !isSet + ? Expression.Constant(node.Value, union) + : node; + } + private sealed class RangeSetOperatorRewriter : ExpressionVisitor { public static readonly RangeSetOperatorRewriter Instance = new(); diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md index c3c1703..40939ec 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md @@ -115,10 +115,13 @@ tabulates both halves together. `array_cat`, which concatenates without deduplicating. Harmless inside the duplicate-insensitive operators and on materialization (reads re-canonicalize), but `Count` over a union is refused rather than counting duplicates. -- **Inside a `ToJson()` column** a range, multirange or value set is stored as its PostgreSQL - literal, and queries into the document translate through a cast - (`CAST(doc ->> 'Period' AS daterange) @> @day`). The `YearMonth` types are written in their - month-aligned date form there, since the cast reads dates. +- **Inside a `ToJson()` column** a range, multirange or value set is stored as its store type's + text, and queries into the document translate through a cast + (`CAST(doc ->> 'Period' AS daterange) @> @day`). That is a database representation, not the API + JSON: a value set is `"{a,b}"`, the `YearMonth` types month-aligned dates, and a temporal value is + stored at the server's microsecond — truncated as Npgsql truncates a parameter, the extremes as + `infinity` — so the document, the column and a parameter hold the same value. `ExecuteUpdate` of + a value set inside the document is refused; ranges and the whole JSON property can be set. - **Sets are mapped as scalars** with plugin-owned mappings and translators, never through EF's primitive-collection machinery. - **Reverse engineering** (`dotnet ef dbcontext scaffold`) maps range columns to `NpgsqlRange` diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/BridgedElementTypeMapping.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/BridgedElementTypeMapping.cs index 451e080..c8ab9aa 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/BridgedElementTypeMapping.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/BridgedElementTypeMapping.cs @@ -86,14 +86,16 @@ internal static class SetProviderText /// /// The invariant text form of a primitive. BCL date/time types use the round-trip format — /// their null-format output is the culture "general" form, which - /// PostgreSQL reads DateStyle-dependently. + /// PostgreSQL reads DateStyle-dependently — and the extremes Npgsql maps to infinity are written + /// -infinity / infinity, as a parameter would arrive. The values reaching here are + /// already truncated to the store's microsecond by the set definitions' normalization. /// internal static string Of(TPrimitive primitive) => primitive switch { - DateTime value => value.ToString("O", CultureInfo.InvariantCulture), - DateTimeOffset value => value.ToString("O", CultureInfo.InvariantCulture), - DateOnly value => value.ToString("O", CultureInfo.InvariantCulture), + DateTime value => StoreTemporal.InfinityText(value) ?? value.ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset value => StoreTemporal.InfinityText(value) ?? value.ToString("O", CultureInfo.InvariantCulture), + DateOnly value => StoreTemporal.InfinityText(value) ?? value.ToString("O", CultureInfo.InvariantCulture), TimeOnly value => value.ToString("O", CultureInfo.InvariantCulture), IFormattable value => value.ToString(null, CultureInfo.InvariantCulture), null => throw new InvalidOperationException("Value sets cannot contain null elements."), diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/LiteralJsonValueReaderWriter.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/LiteralJsonValueReaderWriter.cs index a3d23d2..d8817ff 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/LiteralJsonValueReaderWriter.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/LiteralJsonValueReaderWriter.cs @@ -8,8 +8,10 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Storage; /// /// Reads and writes a range, multirange or value set inside an EF Core JSON column (a complex or -/// owned type mapped with ToJson()) as its PostgreSQL literal string — the same text the -/// type's ToString() produces and its Parse reads, always invariant. +/// owned type mapped with ToJson()) as a string holding the literal of its store type, always +/// invariant. That is the type's own ToString() literal except where the store differs: a +/// temporal value is written at the store's resolution (see remarks), and the YearMonth types +/// as the month-aligned dates their column holds. /// /// /// @@ -23,6 +25,13 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Storage; /// from the document as text casts to daterange, text[] and so on. /// /// +/// A temporal type is written at the store's resolution, as Npgsql would bind it — microseconds, +/// truncated toward 2000-01-01, with the extremes as -infinity / infinity — so the +/// value a query casts out of the document is the value a parameter or a native column holds. The +/// per-type text comes from , which is static for the reason +/// below. +/// +/// /// Public because a compiled model (dotnet ef dbcontext optimize) re-creates the reader/writer /// through , which generated code must be able to reference. /// @@ -43,11 +52,11 @@ private LiteralJsonValueReaderWriter() /// public override TValue FromJsonTyped(ref Utf8JsonReaderManager manager, object? existingObject = null) - => TValue.Parse(manager.CurrentReader.GetString()!, CultureInfo.InvariantCulture); + => Internal.StoreText.Parse(manager.CurrentReader.GetString()!); /// public override void ToJsonTyped(Utf8JsonWriter writer, TValue value) - => writer.WriteStringValue(value.ToString(null, CultureInfo.InvariantCulture)); + => writer.WriteStringValue(Internal.StoreText.Format(value)); /// public override Expression ConstructorExpression => Expression.Property(null, InstanceProperty); diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/RangeElementTypeMapping.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/RangeElementTypeMapping.cs new file mode 100644 index 0000000..2de22df --- /dev/null +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/RangeElementTypeMapping.cs @@ -0,0 +1,94 @@ +using System.Data.Common; +using System.Globalization; +using CodoMetis.ValueRanges.Core; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql; + +namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Storage; + +/// +/// The element mapping of a temporal range type: a bound or element operand — range @> x, a +/// range constructor's argument — bound and rendered as the range's own definition would store it. +/// +/// +/// +/// Without it the operand fell back to the provider's element mapping, which knows nothing of the +/// range's normalization or of the store's resolution. A DateTimeOffset with a non-zero offset +/// therefore threw on the way to a timestamptz parameter ("only offset 0 (UTC) is supported"), a +/// UTC-kinded DateTime on the way to a timestamp one, and a constant was Npgsql's literal — +/// nine fraction digits for NodaTime, which the server rounds, and before 2000 a cut toward 0001 rather +/// than toward the PostgreSQL epoch — so range.Contains(EF.Constant(x)) could disagree with the +/// same probe as a parameter. +/// +/// +/// The literal stays self-describing, which the polymorphic range operators require (CLAUDE.md: a +/// constant element operand must be typed): PostgreSQL's typed-literal keyword for the four temporal +/// store types, as Npgsql writes them, and an explicit cast for anything else. +/// +/// +/// The range type whose element this is — its FormatValue writes the text. +/// The element type. +internal sealed class RangeElementTypeMapping : RelationalTypeMapping + where TRange : IRangeFactory + where T : struct, IComparable, IEquatable +{ + private readonly Func _toStore; + private readonly Func _infinityText; + + internal RangeElementTypeMapping(string storeType, Func toStore, Func infinityText) + : base(new RelationalTypeMappingParameters( + new CoreTypeMappingParameters( + typeof(T), + new ValueConverter(model => toStore(model), provider => provider)), + storeType)) + { + _toStore = toStore; + _infinityText = infinityText; + } + + private RangeElementTypeMapping(RelationalTypeMappingParameters parameters, Func toStore, Func infinityText) + : base(parameters) + { + _toStore = toStore; + _infinityText = infinityText; + } + + /// + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new RangeElementTypeMapping(parameters, _toStore, _infinityText); + + /// + protected override void ConfigureParameter(DbParameter parameter) + { + base.ConfigureParameter(parameter); + + if (parameter is not NpgsqlParameter npgsqlParameter) return; + + // EF normally applies the value converter before the parameter is configured; converting + // here as well keeps direct usage working, as the range mappings do. + if (npgsqlParameter.Value is T model) npgsqlParameter.Value = _toStore(model); + + npgsqlParameter.DataTypeName = StoreType; + } + + /// + protected override string GenerateNonNullSqlLiteral(object value) + { + if (value is not T model) + throw new InvalidOperationException( + $"Cannot generate a '{StoreType}' SQL literal for a value of type '{value.GetType()}'."); + + var stored = _toStore(model); + var text = _infinityText(stored) ?? TRange.FormatValue(stored, null, CultureInfo.InvariantCulture); + + return StoreType switch + { + "timestamp without time zone" => $"TIMESTAMP '{text}'", + "timestamp with time zone" => $"TIMESTAMPTZ '{text}'", + "date" => $"DATE '{text}'", + "time without time zone" => $"TIME '{text}'", + _ => $"'{text.Replace("'", "''")}'::{StoreType}" + }; + } +} diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueRangeSetTypeMapping.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueRangeSetTypeMapping.cs index df8ec0e..9cc79ce 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueRangeSetTypeMapping.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueRangeSetTypeMapping.cs @@ -16,32 +16,42 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Storage; /// /// The range type the set is composed of. /// The element type of the ranges. -internal sealed class ValueRangeSetTypeMapping : RelationalTypeMapping +internal sealed class ValueRangeSetTypeMapping : RelationalTypeMapping, IStoreTextMapping> where TRange : class, IRangeFactory, IRange where T : struct, IComparable, IEquatable { - private readonly Func? _normalizeValue; + private readonly Func? _toStore; + private readonly Func? _infinityText; - internal ValueRangeSetTypeMapping(string storeType, Func? normalizeValue) + /// The PostgreSQL type, e.g. tsrange. + /// A bound as the store holds it — see . + /// -infinity / infinity for a bound Npgsql maps to it. + internal ValueRangeSetTypeMapping(string storeType, Func? toStore, Func? infinityText) : base(new RelationalTypeMappingParameters( new CoreTypeMappingParameters( typeof(RangeSet), new ValueConverter, NpgsqlRange[]>( - model => RangeProviderConversion.ToProvider(model, normalizeValue), + model => RangeProviderConversion.ToProvider(model, toStore), provider => RangeProviderConversion.SetFromProvider(provider)), new ImmutableValueComparer>(), new ImmutableValueComparer>(), jsonValueReaderWriter: LiteralJsonValueReaderWriter>.Instance), storeType)) - => _normalizeValue = normalizeValue; + { + _toStore = toStore; + _infinityText = infinityText; + } - private ValueRangeSetTypeMapping(RelationalTypeMappingParameters parameters, Func? normalizeValue) + private ValueRangeSetTypeMapping(RelationalTypeMappingParameters parameters, Func? toStore, Func? infinityText) : base(parameters) - => _normalizeValue = normalizeValue; + { + _toStore = toStore; + _infinityText = infinityText; + } /// protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) - => new ValueRangeSetTypeMapping(parameters, _normalizeValue); + => new ValueRangeSetTypeMapping(parameters, _toStore, _infinityText); /// protected override void ConfigureParameter(DbParameter parameter) @@ -51,7 +61,7 @@ protected override void ConfigureParameter(DbParameter parameter) if (parameter is not NpgsqlParameter npgsqlParameter) return; if (npgsqlParameter.Value is RangeSet model) - npgsqlParameter.Value = RangeProviderConversion.ToProvider(model, _normalizeValue); + npgsqlParameter.Value = RangeProviderConversion.ToProvider(model, _toStore); npgsqlParameter.DataTypeName = StoreType; } @@ -68,8 +78,22 @@ protected override string GenerateNonNullSqlLiteral(object value) $"Cannot generate a '{StoreType}' SQL literal for a value of type '{value.GetType()}'.") }; - // ToString() is the invariant literal (since 9.0.0 interpolation is too, but the explicit - // call keeps this independent of how IFormattable treats a null provider). - return $"'{set.ToString()}'::{StoreType}"; + // The store text: the invariant literal, or for a temporal multirange the value as Npgsql + // would bind it (see ValueRangeTypeMapping). + return $"'{FormatStoreText(set)}'::{StoreType}"; } -} \ No newline at end of file + + /// + /// The invariant literal, or for a temporal type the value as Npgsql would bind it: bounds + /// truncated to the store's microsecond, the extremes as infinity, the range rebuilt so it + /// collapses or merges as the server would (). + /// + public string FormatStoreText(RangeSet value) + => _toStore is null && _infinityText is null + ? value.ToString(null, System.Globalization.CultureInfo.InvariantCulture) + : RangeStoreText.Format(value, _toStore ?? (static bound => bound), _infinityText ?? (static _ => null)); + + /// + public RangeSet ParseStoreText(string text) + => RangeSet.Parse(text, System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueRangeTypeMapping.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueRangeTypeMapping.cs index 39e0943..8073b73 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueRangeTypeMapping.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueRangeTypeMapping.cs @@ -15,32 +15,42 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Storage; /// /// The range type being mapped. /// The element type of the range. -internal sealed class ValueRangeTypeMapping : RelationalTypeMapping +internal sealed class ValueRangeTypeMapping : RelationalTypeMapping, IStoreTextMapping where TRange : class, IRangeFactory, IRange where T : struct, IComparable, IEquatable { - private readonly Func? _normalizeValue; + private readonly Func? _toStore; + private readonly Func? _infinityText; - internal ValueRangeTypeMapping(string storeType, Func? normalizeValue) + /// The PostgreSQL type, e.g. tsrange. + /// A bound as the store holds it — see . + /// -infinity / infinity for a bound Npgsql maps to it. + internal ValueRangeTypeMapping(string storeType, Func? toStore, Func? infinityText) : base(new RelationalTypeMappingParameters( new CoreTypeMappingParameters( typeof(TRange), new ValueConverter>( - model => RangeProviderConversion.ToProvider(model, normalizeValue), + model => RangeProviderConversion.ToProvider(model, toStore), provider => RangeProviderConversion.FromProvider(provider)), new ImmutableValueComparer(), new ImmutableValueComparer(), jsonValueReaderWriter: LiteralJsonValueReaderWriter.Instance), storeType)) - => _normalizeValue = normalizeValue; + { + _toStore = toStore; + _infinityText = infinityText; + } - private ValueRangeTypeMapping(RelationalTypeMappingParameters parameters, Func? normalizeValue) + private ValueRangeTypeMapping(RelationalTypeMappingParameters parameters, Func? toStore, Func? infinityText) : base(parameters) - => _normalizeValue = normalizeValue; + { + _toStore = toStore; + _infinityText = infinityText; + } /// protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) - => new ValueRangeTypeMapping(parameters, _normalizeValue); + => new ValueRangeTypeMapping(parameters, _toStore, _infinityText); /// protected override void ConfigureParameter(DbParameter parameter) @@ -52,7 +62,7 @@ protected override void ConfigureParameter(DbParameter parameter) // EF normally applies the value converter before the parameter is configured; // converting here as well keeps direct usage (e.g. raw SQL) working. if (npgsqlParameter.Value is TRange model) - npgsqlParameter.Value = RangeProviderConversion.ToProvider(model, _normalizeValue); + npgsqlParameter.Value = RangeProviderConversion.ToProvider(model, _toStore); npgsqlParameter.DataTypeName = StoreType; } @@ -69,8 +79,23 @@ protected override string GenerateNonNullSqlLiteral(object value) $"Cannot generate a '{StoreType}' SQL literal for a value of type '{value.GetType()}'.") }; - // ToString() is the invariant literal (since 9.0.0 interpolation is too, but the explicit - // call keeps this independent of how IFormattable treats a null provider). - return $"'{range.ToString()}'::{StoreType}"; + // The store text: the invariant literal, or for a temporal range the value as Npgsql would + // bind it — a literal with seven fraction digits would be rounded by the server where the + // parameter is truncated, and the constant would not match its own row. + return $"'{FormatStoreText(range)}'::{StoreType}"; } -} \ No newline at end of file + + /// + /// The invariant literal, or for a temporal type the value as Npgsql would bind it: bounds + /// truncated to the store's microsecond, the extremes as infinity, the range rebuilt so it + /// collapses or merges as the server would (). + /// + public string FormatStoreText(TRange value) + => _toStore is null && _infinityText is null + ? value.ToString(null, System.Globalization.CultureInfo.InvariantCulture) + : RangeStoreText.Format(value, _toStore ?? (static bound => bound), _infinityText ?? (static _ => null)); + + /// + public TRange ParseStoreText(string text) + => TRange.Parse(text, System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueSetTypeMapping.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueSetTypeMapping.cs index 4304c6b..93dbb2d 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueSetTypeMapping.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Storage/ValueSetTypeMapping.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Text; using CodoMetis.ValueRanges.Core; +using CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage.Json; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -23,20 +24,34 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Storage; /// The set type being mapped. /// The element type of the set. /// The primitive store representation of one element. -internal sealed class ValueSetTypeMapping : RelationalTypeMapping +internal sealed class ValueSetTypeMapping : RelationalTypeMapping, IStoreTextMapping where TSet : class, IValueSetFactory, IValueSet where TElement : IEquatable { - private readonly Func _toPrimitive; - private readonly Func _fromPrimitive; - private readonly Func _literalText; - + private readonly Func _toPrimitive; + private readonly Func _fromPrimitive; + private readonly Func _literalText; + private readonly bool _storeText; + private readonly Func>? _parseStoreText; + + /// The array store type, e.g. date[]. + /// An element as the store holds it. + /// A stored primitive back to an element. + /// The primitive's SQL literal form; defaults to . + /// A dedicated JSON reader/writer; defaults to the literal one. + /// + /// Whether the set's store text is the array of its store primitives () + /// rather than its own literal — for the temporal families and YearMonthSet<T>. + /// + /// The store primitives back from that text, where the set's own Parse cannot read it. internal ValueSetTypeMapping( - string storeType, - Func toPrimitive, - Func fromPrimitive, - Func? literalText = null, - JsonValueReaderWriter? jsonValueReaderWriter = null + string storeType, + Func toPrimitive, + Func fromPrimitive, + Func? literalText = null, + JsonValueReaderWriter? jsonValueReaderWriter = null, + bool storeText = false, + Func>? parseStoreText = null ) : base(new RelationalTypeMappingParameters( new CoreTypeMappingParameters( @@ -49,36 +64,85 @@ internal ValueSetTypeMapping( jsonValueReaderWriter: jsonValueReaderWriter ?? LiteralJsonValueReaderWriter.Instance), storeType)) { - _toPrimitive = toPrimitive; - _fromPrimitive = fromPrimitive; - _literalText = literalText ?? SetProviderText.Of; + _toPrimitive = toPrimitive; + _fromPrimitive = fromPrimitive; + _literalText = literalText ?? SetProviderText.Of; + _storeText = storeText; + _parseStoreText = parseStoreText; } private ValueSetTypeMapping( - RelationalTypeMappingParameters parameters, - Func toPrimitive, - Func fromPrimitive, - Func literalText + RelationalTypeMappingParameters parameters, + Func toPrimitive, + Func fromPrimitive, + Func literalText, + bool storeText, + Func>? parseStoreText ) : base(parameters) { - _toPrimitive = toPrimitive; - _fromPrimitive = fromPrimitive; - _literalText = literalText; + _toPrimitive = toPrimitive; + _fromPrimitive = fromPrimitive; + _literalText = literalText; + _storeText = storeText; + _parseStoreText = parseStoreText; } /// protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) - => new ValueSetTypeMapping(parameters, _toPrimitive, _fromPrimitive, _literalText); + => new ValueSetTypeMapping( + parameters, _toPrimitive, _fromPrimitive, _literalText, _storeText, _parseStoreText); private static TPrimitive[] ToProvider(TSet set, Func toPrimitive) + => [.. Primitives(set, toPrimitive)]; + + /// + /// The canonical elements as the store holds them, without the duplicates the conversion can + /// create. A temporal element is truncated to the store's microsecond, so two elements a few + /// hundred nanoseconds apart become one value; the array the server stores — and its + /// cardinality — would otherwise disagree with the set's Count. The first occurrence + /// wins, so canonical order is kept: truncation never reorders, but a wrapper arity's canonical + /// order is its element's, not necessarily the primitive's. + /// + private static IEnumerable Primitives(TSet set, Func toPrimitive) { - var values = set.Values; - var result = new TPrimitive[values.Length]; - for (var i = 0; i < values.Length; i++) result[i] = toPrimitive(values[i]); - return result; + var seen = new HashSet(); + foreach (var element in set.Values) + { + var primitive = toPrimitive(element); + if (seen.Add(primitive)) yield return primitive; + } } + /// + /// The set's store text: its own invariant literal, or — for the families whose primitive text + /// differs from the model's literal, the temporal ones (truncated, infinity) and + /// YearMonthSet<T> (a date) — the array literal of its store primitives, each in its + /// SQL literal form: the same values a parameter binds, so the server reads a JSON document as it + /// would read the column. Those texts never need quoting in an array literal. + /// + public string FormatStoreText(TSet set) + { + if (!_storeText) return set.ToString(null, CultureInfo.InvariantCulture); + + var texts = Primitives(set, _toPrimitive).Select(primitive => + { + var text = _literalText(primitive); + return text.Length > 0 && text.AsSpan().IndexOfAny("\"\\,{} ") < 0 + ? text + : throw new InvalidOperationException( + $"The {typeof(TPrimitive).Name} element text '{text}' of {typeof(TSet).Name} would need quoting in an array literal."); + }); + + return "{" + string.Join(",", texts) + "}"; + } + + /// + public TSet ParseStoreText(string text) + => _parseStoreText is null + ? TSet.Parse(text, CultureInfo.InvariantCulture) + : TSet.From([.. _parseStoreText(text).Select(_fromPrimitive)]); + private static TSet FromProvider(TPrimitive[] array, Func fromPrimitive) { var elements = new TElement[array.Length]; @@ -127,9 +191,10 @@ private IEnumerable ElementTexts(object value) { case TSet model: // Elements format through their primitive store representation — the model's - // own text form can be coarser than the store type (YearMonth vs date). - foreach (var element in model.Values) - yield return _literalText(_toPrimitive(element)); + // own text form can be coarser than the store type (YearMonth vs date) — and + // without the duplicates that conversion can create, as a parameter would be. + foreach (var primitive in Primitives(model, _toPrimitive)) + yield return _literalText(primitive); break; case TPrimitive[] provider: diff --git a/src/CodoMetis.ValueRanges.NodaTime/CHANGELOG.md b/src/CodoMetis.ValueRanges.NodaTime/CHANGELOG.md index 7fc6cee..993cffc 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.NodaTime/CHANGELOG.md @@ -15,6 +15,12 @@ covers all four packages, which share one version number and release together. `IsAotCompatible` and is tagged `native-aot` and `trimming`. Parsing ignores the provider, as for the core types (the NodaTime element parsers already used fixed ISO patterns). See `CodoMetis.ValueRanges` 9.0.0. +- **⚠️ The wrapper arities parse as their primitive first.** `LocalDateSet`, `LocalDateTimeSet`, + `InstantSet`, `LocalTimeSet` and `YearMonthSet` handed element text straight to the + element's own `Parse`, as lenient as the consumer wrote it; they now read it with the family's ISO + patterns and hand the element its round-trip form. `Instant`, `LocalDate` and `LocalDateTime` + bounds and elements also read PostgreSQL's `infinity` / `-infinity`, as the extremes Npgsql maps + them from (`MaxValue` / `MinValue`, `MaxIsoValue` / `MinIsoValue`). ### Added diff --git a/src/CodoMetis.ValueRanges.NodaTime/Internals/NodaPatterns.cs b/src/CodoMetis.ValueRanges.NodaTime/Internals/NodaPatterns.cs index 7075473..32c63c9 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Internals/NodaPatterns.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Internals/NodaPatterns.cs @@ -9,6 +9,12 @@ namespace CodoMetis.ValueRanges.Internals; /// date/time, numeric UTC offsets) so that literals copied from psql output parse /// directly. All patterns are culture-invariant — ISO 8601 has no culture-sensitive parts. /// +/// +/// PostgreSQL's infinity / -infinity are read for the three types Npgsql maps them to +/// — (MaxValue / MinValue), and +/// (MaxIsoValue / MinIsoValue) — so the text PostgreSQL writes +/// for such a bound reads back as the value Npgsql would have materialized. Formatting is unchanged. +/// internal static class NodaPatterns { /// ISO 8601 date: 2024-06-01. Also the PostgreSQL wire form. @@ -40,7 +46,9 @@ internal static class NodaPatterns OffsetDateTimePattern.CreateWithInvariantCulture("uuuu'-'MM'-'dd' 'HH':'mm':'ss;FFFFFFFFFo"); internal static LocalDate ParseDate(string text) - => Date.Parse(text).GetValueOrThrow(); + => IsInfinity(text, out var positive) + ? positive ? LocalDate.MaxIsoValue : LocalDate.MinIsoValue + : Date.Parse(text).GetValueOrThrow(); internal static YearMonth ParseYearMonth(string text) => YearMonth.Parse(text).GetValueOrThrow(); @@ -50,6 +58,8 @@ internal static LocalTime ParseTime(string text) internal static LocalDateTime ParseDateTime(string text) { + if (IsInfinity(text, out var positive)) return positive ? LocalDateTime.MaxIsoValue : LocalDateTime.MinIsoValue; + var iso = DateTime.Parse(text); if (iso.Success) return iso.Value; @@ -59,6 +69,8 @@ internal static LocalDateTime ParseDateTime(string text) internal static Instant ParseInstant(string text) { + if (IsInfinity(text, out var positive)) return positive ? NodaTime.Instant.MaxValue : NodaTime.Instant.MinValue; + var iso = Instant.Parse(text); if (iso.Success) return iso.Value; @@ -68,4 +80,10 @@ internal static Instant ParseInstant(string text) var offsetSpace = InstantOffsetSpace.Parse(text); return offsetSpace.Success ? offsetSpace.Value.ToInstant() : iso.GetValueOrThrow(); } + + private static bool IsInfinity(string text, out bool positive) + { + positive = text.Equals("infinity", StringComparison.OrdinalIgnoreCase); + return positive || text.Equals("-infinity", StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/CodoMetis.ValueRanges.NodaTime/README.md b/src/CodoMetis.ValueRanges.NodaTime/README.md index 438bbad..2b6f2b9 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/README.md +++ b/src/CodoMetis.ValueRanges.NodaTime/README.md @@ -124,8 +124,8 @@ The core package restricts its element types to domains with a total order that - **The same calendar rule covers the set types, at every entry point.** `LocalDateSet`/`LocalDateTimeSet` normalize to ISO and `YearMonthSet` rejects non-ISO — not only in `From`, but in `Contains`, `Add` and `Remove`, which take a bare element rather than a set. That matters because `LocalDate.Equals` is calendar-sensitive and `LocalDate.CompareTo` *throws* across calendars: without it, `holidays.Contains(copticDate)` would quietly answer `false` for a date the set actually holds. The EF Core package applies the same normalization to a bare probe bound as a query parameter. - **`YearMonthRange` follows the discrete conventions.** Closed `[start, end]` by default, one-month step: `[2025-01, 2025-03]` and `[2025-04, 2025-06]` are adjacent and merge; `[2025-01, 2026-01)` canonicalizes to `[2025-01, 2025-12]`. Literals use the ISO `uuuu-MM` form: `[2025-01,2025-12]`. - **Formatting is culture-free.** Literals use NodaTime's ISO patterns: `[2025-01-01,2025-03-31]`, `[2024-06-01T08:00:00,2024-06-01T17:30:00)`, `[2024-06-01T00:00:00Z,2024-07-01T00:00:00Z)`. Subsecond digits appear only when present, up to nanosecond precision. -- **Parsing accepts the PostgreSQL wire form too.** Besides its own canonical output, `Parse` handles literals as `psql` prints them: space-separated timestamps (`"2024-06-01 00:00:00"`) and numeric offsets (`"2024-06-01 14:30:00+02"` — converted to the instant they denote). -- **Precision at the database boundary.** NodaTime carries nanoseconds; PostgreSQL stores microseconds. Sub-microsecond precision is reduced when persisting through the EF Core package (pinned by the live-PostgreSQL integration suite). In-memory operations keep full nanosecond precision. +- **Parsing accepts the PostgreSQL wire form too, and nothing looser.** Besides its own canonical output, `Parse` handles literals as `psql` prints them: space-separated timestamps (`"2024-06-01 00:00:00"`), numeric offsets (`"2024-06-01 14:30:00+02"` — converted to the instant they denote) and `infinity` / `-infinity` for an `Instant`, `LocalDate` or `LocalDateTime` bound, read as the extreme value Npgsql maps them from. The wrapper arities read the text with the same ISO patterns before their element sees it. +- **Precision at the database boundary.** NodaTime carries nanoseconds; PostgreSQL stores microseconds. Sub-microsecond precision is reduced when persisting through the EF Core package — truncated toward 2000-01-01, as Npgsql truncates — in a column, a JSON document and a SQL constant alike (pinned by the live-PostgreSQL integration suite). In-memory operations keep full nanosecond precision. - **`Instant.MinValue` / `Instant.MaxValue`** map to PostgreSQL `-infinity` / `infinity` by default (an Npgsql rule) — a *finite bound that happens to be infinite*, still distinct from an unbounded side, exactly as the core README describes for `DateTime.MinValue`/`MaxValue`. ## Interop with NodaTime's own interval types diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/InstantSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/InstantSet.cs index 24761fb..6ae007d 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/InstantSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/InstantSet.cs @@ -232,11 +232,13 @@ static InstantSet IValueSetFactory, TElement>.Fro public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// - /// Parses an element via , which re-runs the element's - /// validation — corrupt data throws rather than materializing. + /// Parses an element strictly as an instant first — the grammar of — then hands + /// the element its own round-trip form through , which re-runs the + /// element's validation. A lenient element parser cannot misread non-ISO text that way, and + /// corrupt data throws rather than materializing. /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse(NodaPatterns.Instant.Format(NodaPatterns.ParseInstant(s.ToString())), CultureInfo.InvariantCulture); /// Formats an element using extended ISO 8601 /// (uuuu-MM-ddTHH:mm:ss.FFFFFFFFFZ) by default — see the class remarks. diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateSet.cs index 127163b..505adbc 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateSet.cs @@ -247,11 +247,13 @@ static LocalDateSet IValueSetFactory, TElement> public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// - /// Parses an element via , which re-runs the element's - /// validation — corrupt data throws rather than materializing. + /// Parses an element strictly as a date first — the grammar of — then hands + /// the element its own round-trip form through , which re-runs the + /// element's validation. A lenient element parser cannot misread non-ISO text that way, and + /// corrupt data throws rather than materializing. /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse(NodaPatterns.Date.Format(NodaPatterns.ParseDate(s.ToString())), CultureInfo.InvariantCulture); /// Formats an element using ISO 8601 (uuuu-MM-dd) by default — see the /// class remarks. diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateTimeSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateTimeSet.cs index 671f105..5fca234 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateTimeSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateTimeSet.cs @@ -247,11 +247,13 @@ static LocalDateTimeSet IValueSetFactory, T public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// - /// Parses an element via , which re-runs the element's - /// validation — corrupt data throws rather than materializing. + /// Parses an element strictly as a date and time first — the grammar of — then hands + /// the element its own round-trip form through , which re-runs the + /// element's validation. A lenient element parser cannot misread non-ISO text that way, and + /// corrupt data throws rather than materializing. /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse(NodaPatterns.DateTime.Format(NodaPatterns.ParseDateTime(s.ToString())), CultureInfo.InvariantCulture); /// Formats an element using extended ISO 8601 /// (uuuu-MM-ddTHH:mm:ss.FFFFFFFFF) by default — see the class remarks. diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalTimeSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalTimeSet.cs index b2d2943..3c06001 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalTimeSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalTimeSet.cs @@ -232,11 +232,13 @@ static LocalTimeSet IValueSetFactory, TElement> public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// - /// Parses an element via , which re-runs the element's - /// validation — corrupt data throws rather than materializing. + /// Parses an element strictly as a time of day first — the grammar of — then hands + /// the element its own round-trip form through , which re-runs the + /// element's validation. A lenient element parser cannot misread non-ISO text that way, and + /// corrupt data throws rather than materializing. /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse(NodaPatterns.Time.Format(NodaPatterns.ParseTime(s.ToString())), CultureInfo.InvariantCulture); /// Formats an element using extended ISO 8601 (HH:mm:ss.FFFFFFFFF) by /// default — see the class remarks. diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/YearMonthSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/YearMonthSet.cs index 9567182..7de8b9d 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/YearMonthSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/YearMonthSet.cs @@ -258,11 +258,13 @@ static YearMonthSet IValueSetFactory, TElement> public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// - /// Parses an element via , which re-runs the element's - /// validation — corrupt data throws rather than materializing. + /// Parses an element strictly as a year and month first — the grammar of — then hands + /// the element its own round-trip form through , which re-runs the + /// element's validation. A lenient element parser cannot misread non-ISO text that way, and + /// corrupt data throws rather than materializing. /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse(NodaPatterns.YearMonth.Format(NodaPatterns.ParseYearMonth(s.ToString())), CultureInfo.InvariantCulture); /// Formats an element using ISO 8601 (uuuu-MM) by default — see the class /// remarks. diff --git a/src/CodoMetis.ValueRanges/CHANGELOG.md b/src/CodoMetis.ValueRanges/CHANGELOG.md index a6974ab..e408b81 100644 --- a/src/CodoMetis.ValueRanges/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges/CHANGELOG.md @@ -16,6 +16,17 @@ covers all four packages, which share one version number and release together. old form. See the [root changelog](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/CHANGELOG.md) for source generation and why this is a major. +- **⚠️ Temporal bounds and elements parse as strict ISO 8601.** The BCL temporal ranges, sets + and wrapper arities read element text with the lenient BCL `Parse`, month first: + `[01.02.2027,05.03.2027)` was January 2 to May 2, and a `DateTimeOffset` without an offset took + the parsing machine's. They now accept what this library and PostgreSQL write — `yyyy-MM-dd`, + `HH:mm[:ss[.f…]]` (extra fraction digits truncated to 100 ns), `T` or a space, `Z` / `±HH` / + `±HH:mm` / `±HH:mm:ss` offsets (required for a `DateTimeOffset`) — and `infinity` / `-infinity` as + `MaxValue` / `MinValue`; everything else is a `FormatException`. The wrapper arities parse as the + primitive first, so a lenient element `Parse` never sees non-ISO text. **A closed temporal set's + JSON elements use the same grammar**: System.Text.Json's own converter read an offset-less + `DateTimeOffset` in the machine's offset. A converter registered for the element type still wins. + ### Fixed - **⚠️ `$"{range}"` under a comma-decimal culture produced a different range.** `Int32Range`, diff --git a/src/CodoMetis.ValueRanges/Core/IRangeFactory.cs b/src/CodoMetis.ValueRanges/Core/IRangeFactory.cs index 77b7ddb..6565f8b 100644 --- a/src/CodoMetis.ValueRanges/Core/IRangeFactory.cs +++ b/src/CodoMetis.ValueRanges/Core/IRangeFactory.cs @@ -7,10 +7,16 @@ namespace CodoMetis.ValueRanges.Core; /// Provides abstract static factory methods for constructing range instances, and /// default implementations of and /// using the PostgreSQL range literal format (e.g. [1,5), empty, (,)). -/// Implement this interface on a concrete range type to gain access to the set operation -/// extension methods (Intersect, Union, Except) as well as automatic -/// support via RangeJsonConverter<TRange, T>. +/// Every range type this library ships implements it; it is what the set operation extension +/// methods (Intersect, Union, Except), and the +/// JSON and support are written against. /// +/// +/// The range interfaces are the shared vocabulary of the shipped range types, not an extension +/// point: implementing them outside this library is not supported. The engines, the EF Core provider +/// (whose registries are internal) and the parity tests against PostgreSQL cover only the shipped +/// types, and these interfaces may gain members in a minor version. +/// /// The concrete range type being constructed. /// The element type of the range. public interface IRangeFactory : ISpanParsable, IFormattable diff --git a/src/CodoMetis.ValueRanges/DateRange.cs b/src/CodoMetis.ValueRanges/DateRange.cs index 5962d5b..4014fd8 100644 --- a/src/CodoMetis.ValueRanges/DateRange.cs +++ b/src/CodoMetis.ValueRanges/DateRange.cs @@ -220,8 +220,10 @@ public static DateRange CreateFinite( }; /// + /// Strict ISO 8601 — the forms this library writes and PostgreSQL's ISO output, plus + /// infinity / -infinity as the maximum / minimum value; the provider is ignored. A lenient parse read 01.02.2027 month first. public static DateOnly ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => DateOnly.Parse(s, provider ?? CultureInfo.InvariantCulture); + => IsoTemporal.ParseDate(s); /// /// Formats a value using ISO 8601 (yyyy-MM-dd) by default. diff --git a/src/CodoMetis.ValueRanges/DateTimeOffsetRange.cs b/src/CodoMetis.ValueRanges/DateTimeOffsetRange.cs index f0dcd1e..8890e1f 100644 --- a/src/CodoMetis.ValueRanges/DateTimeOffsetRange.cs +++ b/src/CodoMetis.ValueRanges/DateTimeOffsetRange.cs @@ -193,8 +193,10 @@ public static DateTimeOffsetRange CreateFinite( }; /// + /// Strict ISO 8601 — the forms this library writes and PostgreSQL's ISO output, plus + /// infinity / -infinity as the maximum / minimum value; the provider is ignored. A lenient parse read 01.02.2027 month first. public static DateTimeOffset ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => DateTimeOffset.Parse(s, provider ?? CultureInfo.InvariantCulture); + => IsoTemporal.ParseDateTimeOffset(s); /// /// Formats a value using the round-trip format specifier (O) by default, diff --git a/src/CodoMetis.ValueRanges/DateTimeRange.cs b/src/CodoMetis.ValueRanges/DateTimeRange.cs index c0c221f..88784f5 100644 --- a/src/CodoMetis.ValueRanges/DateTimeRange.cs +++ b/src/CodoMetis.ValueRanges/DateTimeRange.cs @@ -192,8 +192,10 @@ public static DateTimeRange CreateFinite( }; /// + /// Strict ISO 8601 — the forms this library writes and PostgreSQL's ISO output, plus + /// infinity / -infinity as the maximum / minimum value; the provider is ignored. A lenient parse read 01.02.2027 month first. public static DateTime ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => DateTime.Parse(s, provider ?? CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + => IsoTemporal.ParseDateTime(s); /// /// Formats a value using the round-trip format specifier (O) by default, diff --git a/src/CodoMetis.ValueRanges/Internals/IsoTemporal.cs b/src/CodoMetis.ValueRanges/Internals/IsoTemporal.cs new file mode 100644 index 0000000..a0b9249 --- /dev/null +++ b/src/CodoMetis.ValueRanges/Internals/IsoTemporal.cs @@ -0,0 +1,150 @@ +using System.Globalization; + +namespace CodoMetis.ValueRanges.Internals; + +/// +/// Strict ISO 8601 parsing for the BCL temporal elements: exactly the text forms this library writes +/// and the forms PostgreSQL writes in its default ISO DateStyle, and nothing else. +/// +/// +/// +/// Until 9.0.0 the elements went through the lenient BCL Parse, which reads whatever the +/// invariant culture can make sense of — and the invariant culture reads month first. So +/// [01.02.2027,05.03.2027) was January 2 to May 2, silently, and a DateTimeOffset +/// without an offset took the offset of whichever machine parsed it. The NodaTime satellite was +/// already strict (NodaPatterns); this is the same rule for the BCL types. +/// +/// +/// Accepted: a date yyyy-MM-dd; a time HH:mm or HH:mm:ss with any number of +/// fraction digits, truncated to the 100 ns the BCL types hold (Go and Java write nine, and +/// System.Text.Json truncates them the same way); date and time joined by T (this library) or a +/// space (PostgreSQL); for a DateTime an optional Z, ±HH or ±HH:mm suffix, +/// read with round-trip semantics; for a +/// DateTimeOffset a required Z, ±HH, ±HH:mm or ±HH:mm:ss — the last is +/// how PostgreSQL writes a historical local-mean-time offset (Asia/Kolkata before 1854 is +/// +05:53:28), which DateTimeOffset cannot hold, so that instant is kept at offset zero; and PostgreSQL's +/// infinity / -infinity, read as MaxValue / MinValue — what Npgsql +/// materializes for them. HH:mm is not something either side writes, but it is valid ISO +/// 8601, it cannot be misread, and it is what a hand-written [09:00,17:00) uses. +/// +/// +/// A rejection is a that names the grammar and not the input: the +/// literal parsers wrap it with a bounded excerpt of the offending text. +/// +/// +internal static class IsoTemporal +{ + private static readonly string[] DateFormats = ["yyyy-MM-dd"]; + + private static readonly string[] TimeFormats = ["HH:mm:ss.FFFFFFF", "HH:mm"]; + + // K: nothing (Unspecified), Z (Utc) or ±HH:mm (Local) under RoundtripKind — what "O" writes; + // zz: the short ±HH, also read as Local. + private static readonly string[] DateTimeFormats = + [ + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK", "yyyy-MM-dd' 'HH:mm:ss.FFFFFFFK", + "yyyy-MM-dd'T'HH:mmK", "yyyy-MM-dd' 'HH:mmK", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzz", "yyyy-MM-dd' 'HH:mm:ss.FFFFFFFzz", + "yyyy-MM-dd'T'HH:mmzz", "yyyy-MM-dd' 'HH:mmzz" + ]; + + // Every form carries an offset, so AssumeUniversal only ever gives the literal Z its meaning. + private static readonly string[] DateTimeOffsetFormats = + [ + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz", "yyyy-MM-dd' 'HH:mm:ss.FFFFFFFzzz", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzz", "yyyy-MM-dd' 'HH:mm:ss.FFFFFFFzz", + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'", "yyyy-MM-dd' 'HH:mm:ss.FFFFFFF'Z'", + "yyyy-MM-dd'T'HH:mmzzz", "yyyy-MM-dd' 'HH:mmzzz", + "yyyy-MM-dd'T'HH:mmzz", "yyyy-MM-dd' 'HH:mmzz", + "yyyy-MM-dd'T'HH:mm'Z'", "yyyy-MM-dd' 'HH:mm'Z'" + ]; + + internal static DateOnly ParseDate(ReadOnlySpan s) + { + if (TryParseInfinity(s, out var positive)) return positive ? DateOnly.MaxValue : DateOnly.MinValue; + + return DateOnly.TryParseExact(s, DateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var value) + ? value + : throw new FormatException("Expected an ISO 8601 date, yyyy-MM-dd, or infinity / -infinity."); + } + + internal static TimeOnly ParseTime(ReadOnlySpan s) + => TimeOnly.TryParseExact(TruncateFraction(s), TimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var value) + ? value + : throw new FormatException("Expected an ISO 8601 time of day, HH:mm or HH:mm:ss with up to seven fraction digits."); + + internal static DateTime ParseDateTime(ReadOnlySpan s) + { + if (TryParseInfinity(s, out var positive)) return positive ? DateTime.MaxValue : DateTime.MinValue; + + return DateTime.TryParseExact(TruncateFraction(s), DateTimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var value) + ? value + : throw new FormatException( + "Expected an ISO 8601 date and time, yyyy-MM-dd then T or a space then HH:mm[:ss[.fffffff]], " + + "optionally followed by Z or ±HH:mm, or infinity / -infinity."); + } + + internal static DateTimeOffset ParseDateTimeOffset(ReadOnlySpan s) + { + if (TryParseInfinity(s, out var positive)) return positive ? DateTimeOffset.MaxValue : DateTimeOffset.MinValue; + + var trimmed = TruncateFraction(s); + return DateTimeOffset.TryParseExact(trimmed, DateTimeOffsetFormats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var value) + || TryParseSecondsOffset(trimmed, out value) + ? value + : throw new FormatException( + "Expected an ISO 8601 date and time with an offset, yyyy-MM-dd then T or a space then " + + "HH:mm[:ss[.fffffff]] then Z, ±HH, ±HH:mm or ±HH:mm:ss, or infinity / -infinity."); + } + + // The date-time part of a value whose offset carries seconds, read as a wall clock. + private static readonly string[] WallClockFormats = + [ + "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", "yyyy-MM-dd' 'HH:mm:ss.FFFFFFF", + "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd' 'HH:mm" + ]; + + /// A trailing ±HH:mm:ss offset, applied to the wall clock and kept as offset zero. + private static bool TryParseSecondsOffset(ReadOnlySpan s, out DateTimeOffset value) + { + value = default; + if (s.Length < 10) return false; + + var tail = s[^9..]; + if (tail[0] is not ('+' or '-') || tail[3] != ':' || tail[6] != ':' + || !int.TryParse(tail[1..3], NumberStyles.None, CultureInfo.InvariantCulture, out var hours) + || !int.TryParse(tail[4..6], NumberStyles.None, CultureInfo.InvariantCulture, out var minutes) + || !int.TryParse(tail[7..9], NumberStyles.None, CultureInfo.InvariantCulture, out var seconds) + || hours > 14 || minutes > 59 || seconds > 59 + || !DateTime.TryParseExact(s[..^9], WallClockFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var wall)) + return false; + + var offset = new TimeSpan(hours, minutes, seconds); + var utc = tail[0] == '-' ? wall.Ticks + offset.Ticks : wall.Ticks - offset.Ticks; + if (utc < DateTime.MinValue.Ticks || utc > DateTime.MaxValue.Ticks) return false; + + value = new DateTimeOffset(utc, TimeSpan.Zero); + return true; + } + + /// + /// The text with a fraction of more than seven digits cut to seven — truncation, as + /// System.Text.Json does. A . anywhere else (a dotted date) is left for the formats to refuse. + /// + private static ReadOnlySpan TruncateFraction(ReadOnlySpan s) + { + var dot = s.IndexOf('.'); + if (dot < 0) return s; + + var end = dot + 1; + while (end < s.Length && char.IsAsciiDigit(s[end])) end++; + + return end - dot - 1 <= 7 ? s : string.Concat(s[..(dot + 8)], s[end..]); + } + + private static bool TryParseInfinity(ReadOnlySpan s, out bool positive) + { + positive = s.Equals("infinity", StringComparison.OrdinalIgnoreCase); + return positive || s.Equals("-infinity", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/CodoMetis.ValueRanges/Serialization/ValueSetJsonConverter.cs b/src/CodoMetis.ValueRanges/Serialization/ValueSetJsonConverter.cs index 50e6cdb..ffa0291 100644 --- a/src/CodoMetis.ValueRanges/Serialization/ValueSetJsonConverter.cs +++ b/src/CodoMetis.ValueRanges/Serialization/ValueSetJsonConverter.cs @@ -29,15 +29,20 @@ public override TSet Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSer throw new JsonException($"Expected a JSON array for {typeof(TSet).Name}."); var fallback = ResolveElementConverter(options); + var strict = ReadsThroughTheLiteralGrammar(options); var values = new List(); while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { if (reader.TokenType == JsonTokenType.Null) throw new JsonException($"{typeof(TSet).Name} cannot contain null elements."); - var element = fallback is null - ? JsonSerializer.Deserialize(ref reader, ElementTypeInfo(options)) - : fallback.Read(ref reader, typeof(T), options); + var element = strict + ? reader.TokenType == JsonTokenType.String + ? ValueSetElementJson.Parse(reader.GetString()!) + : throw new JsonException($"Expected a JSON string for a {typeof(T).Name} element of {typeof(TSet).Name}.") + : fallback is null + ? JsonSerializer.Deserialize(ref reader, ElementTypeInfo(options)) + : fallback.Read(ref reader, typeof(T), options); if (element is null) throw new JsonException($"{typeof(TSet).Name} cannot contain null elements."); @@ -89,6 +94,40 @@ private static JsonTypeInfo ElementTypeInfo(JsonSerializerOptions options) /// reports and stays authoritative; only the reflection /// fallback, which would property-dump the element, hands over. /// + /// + /// Whether a BCL temporal element would be read by System.Text.Json's own converter — in which case + /// it is read through the family's strict + /// instead, the grammar its literal uses. + /// + /// + /// System.Text.Json's converter reads an ISO timestamp without an + /// offset — or a bare date — in the parsing machine's offset, so a DateTimeOffsetSet from the + /// same JSON held different instants on different servers, while its literal refused the text. The + /// write side is unchanged: System.Text.Json's output is ISO 8601 that the literal grammar reads. + /// Compared by converter type, so a converter registered on the options — or one a source-generated + /// context substitutes — still reads, as for every other element type. + /// + private static bool ReadsThroughTheLiteralGrammar(JsonSerializerOptions options) + { + if (BuiltInTemporalConverter is not { } builtIn) return false; + + try + { + return options.GetTypeInfo(typeof(T)).Converter.GetType() == builtIn; + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + { + return false; + } + } + + private static readonly Type? BuiltInTemporalConverter = + typeof(T) == typeof(DateTimeOffset) ? JsonMetadataServices.DateTimeOffsetConverter.GetType() + : typeof(T) == typeof(DateTime) ? JsonMetadataServices.DateTimeConverter.GetType() + : typeof(T) == typeof(DateOnly) ? JsonMetadataServices.DateOnlyConverter.GetType() + : typeof(T) == typeof(TimeOnly) ? JsonMetadataServices.TimeOnlyConverter.GetType() + : null; + private static JsonConverter? ResolveElementConverter(JsonSerializerOptions options) { try diff --git a/src/CodoMetis.ValueRanges/Sets/DateSet.cs b/src/CodoMetis.ValueRanges/Sets/DateSet.cs index e483642..6f92a54 100644 --- a/src/CodoMetis.ValueRanges/Sets/DateSet.cs +++ b/src/CodoMetis.ValueRanges/Sets/DateSet.cs @@ -68,8 +68,10 @@ static DateSet IValueSetFactory.FromTrusted(ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// + /// Strict ISO 8601 — the forms this library writes and PostgreSQL's ISO output, plus + /// infinity / -infinity as the maximum / minimum value; the provider is ignored. A lenient parse read 01.02.2027 month first. public static DateOnly ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => DateOnly.Parse(s, provider ?? CultureInfo.InvariantCulture); + => IsoTemporal.ParseDate(s); /// Formats a value using ISO 8601 (yyyy-MM-dd) by default. public static string FormatValue(DateOnly value, string? format, IFormatProvider? provider) @@ -201,11 +203,13 @@ static DateSet IValueSetFactory, TElement>.FromTrust public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// - /// Parses an element via , which re-runs the element's - /// validation — corrupt data throws rather than materializing. + /// Parses an element strictly as a DateOnly first — the grammar of — then + /// hands the element its own round-trip form through , which re-runs + /// the element's validation. A lenient element parser cannot misread non-ISO text that way, and + /// corrupt data throws rather than materializing. /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse(IsoTemporal.ParseDate(s).ToString(RoundTripFormat, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); /// Formats an element using ISO 8601 (yyyy-MM-dd) by default — see the /// class remarks. diff --git a/src/CodoMetis.ValueRanges/Sets/DateTimeOffsetSet.cs b/src/CodoMetis.ValueRanges/Sets/DateTimeOffsetSet.cs index e71b3bb..2745bb6 100644 --- a/src/CodoMetis.ValueRanges/Sets/DateTimeOffsetSet.cs +++ b/src/CodoMetis.ValueRanges/Sets/DateTimeOffsetSet.cs @@ -71,8 +71,10 @@ static DateTimeOffsetSet IValueSetFactory.Fro public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// + /// Strict ISO 8601 — the forms this library writes and PostgreSQL's ISO output, plus + /// infinity / -infinity as the maximum / minimum value; the provider is ignored. A lenient parse read 01.02.2027 month first. public static DateTimeOffset ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => DateTimeOffset.Parse(s, provider ?? CultureInfo.InvariantCulture); + => IsoTemporal.ParseDateTimeOffset(s); /// Formats a value using the round-trip format specifier (O) by default, preserving full precision and UTC offset. public static string FormatValue(DateTimeOffset value, string? format, IFormatProvider? provider) @@ -205,11 +207,13 @@ static DateTimeOffsetSet IValueSetFactory, public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// - /// Parses an element via , which re-runs the element's - /// validation — corrupt data throws rather than materializing. + /// Parses an element strictly as a DateTimeOffset first — the grammar of — then + /// hands the element its own round-trip form through , which re-runs + /// the element's validation. A lenient element parser cannot misread non-ISO text that way, and + /// corrupt data throws rather than materializing. /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse(IsoTemporal.ParseDateTimeOffset(s).ToString(RoundTripFormat, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); /// Formats an element using the round-trip format specifier (O) by default /// — see the class remarks. diff --git a/src/CodoMetis.ValueRanges/Sets/DateTimeSet.cs b/src/CodoMetis.ValueRanges/Sets/DateTimeSet.cs index 336fd10..2162dfd 100644 --- a/src/CodoMetis.ValueRanges/Sets/DateTimeSet.cs +++ b/src/CodoMetis.ValueRanges/Sets/DateTimeSet.cs @@ -71,8 +71,10 @@ static DateTimeSet IValueSetFactory.FromTrusted(Immutable public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// + /// Strict ISO 8601 — the forms this library writes and PostgreSQL's ISO output, plus + /// infinity / -infinity as the maximum / minimum value; the provider is ignored. A lenient parse read 01.02.2027 month first. public static DateTime ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => DateTime.Parse(s, provider ?? CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + => IsoTemporal.ParseDateTime(s); /// Formats a value using the round-trip format specifier (O) by default, preserving full precision and . public static string FormatValue(DateTime value, string? format, IFormatProvider? provider) @@ -206,11 +208,13 @@ static DateTimeSet IValueSetFactory, TElement>.F public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// - /// Parses an element via , which re-runs the element's - /// validation — corrupt data throws rather than materializing. + /// Parses an element strictly as a DateTime first — the grammar of — then + /// hands the element its own round-trip form through , which re-runs + /// the element's validation. A lenient element parser cannot misread non-ISO text that way, and + /// corrupt data throws rather than materializing. /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse(IsoTemporal.ParseDateTime(s).ToString(RoundTripFormat, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); /// Formats an element using the round-trip format specifier (O) by default /// — see the class remarks. diff --git a/src/CodoMetis.ValueRanges/Sets/TimeSet.cs b/src/CodoMetis.ValueRanges/Sets/TimeSet.cs index 7b40e80..b0161ff 100644 --- a/src/CodoMetis.ValueRanges/Sets/TimeSet.cs +++ b/src/CodoMetis.ValueRanges/Sets/TimeSet.cs @@ -70,8 +70,10 @@ static TimeSet IValueSetFactory.FromTrusted(ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// + /// Strict ISO 8601 — the forms this library writes and PostgreSQL's ISO output, plus + /// HH:mm without seconds; the provider is ignored. A lenient parse read 01.02.2027 month first. public static TimeOnly ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TimeOnly.Parse(s, provider ?? CultureInfo.InvariantCulture); + => IsoTemporal.ParseTime(s); /// Formats a value using the round-trip format specifier (O) by default, preserving full precision. public static string FormatValue(TimeOnly value, string? format, IFormatProvider? provider) @@ -202,11 +204,13 @@ static TimeSet IValueSetFactory, TElement>.FromTrust public ImmutableArray.Enumerator GetEnumerator() => _elements.GetEnumerator(); /// - /// Parses an element via , which re-runs the element's - /// validation — corrupt data throws rather than materializing. + /// Parses an element strictly as a TimeOnly first — the grammar of — then + /// hands the element its own round-trip form through , which re-runs + /// the element's validation. A lenient element parser cannot misread non-ISO text that way, and + /// corrupt data throws rather than materializing. /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse(IsoTemporal.ParseTime(s).ToString(RoundTripFormat, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); /// Formats an element using the round-trip format specifier (O) by default /// — see the class remarks. diff --git a/src/CodoMetis.ValueRanges/TimeRange.cs b/src/CodoMetis.ValueRanges/TimeRange.cs index 0c066bb..0ed81de 100644 --- a/src/CodoMetis.ValueRanges/TimeRange.cs +++ b/src/CodoMetis.ValueRanges/TimeRange.cs @@ -201,8 +201,10 @@ public static TimeRange CreateFinite( }; /// + /// Strict ISO 8601 — the forms this library writes and PostgreSQL's ISO output, plus + /// HH:mm without seconds; the provider is ignored. A lenient parse read 01.02.2027 month first. public static TimeOnly ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TimeOnly.Parse(s, provider ?? CultureInfo.InvariantCulture); + => IsoTemporal.ParseTime(s); /// /// Formats a value using the round-trip format specifier (O) by default, diff --git a/test/CodoMetis.ValueRanges.Conventions.Tests/TemporalLiteralStrictnessContractTests.cs b/test/CodoMetis.ValueRanges.Conventions.Tests/TemporalLiteralStrictnessContractTests.cs new file mode 100644 index 0000000..6b764c1 --- /dev/null +++ b/test/CodoMetis.ValueRanges.Conventions.Tests/TemporalLiteralStrictnessContractTests.cs @@ -0,0 +1,132 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using CodoMetis.ValueRanges.Core; +using NodaTime; + +namespace CodoMetis.ValueRanges.Conventions.Tests; + +/// +/// Every range and value set over a temporal element refuses a non-ISO element, through every entry +/// point that reads one: Parse, TryParse, the span overload, JSON and the +/// TypeConverter. +/// +/// +/// +/// Until 9.0.0 the BCL temporal types — and the wrapper arities, through their element's own +/// Parse — read element text with the lenient BCL parser, which reads the invariant culture +/// month first: [01.02.2027,05.03.2027) was January 2 to May 2, silently. The probes below are +/// all text that parser accepted. A type added later that parses leniently fails here by name; the +/// grammar itself is pinned in TemporalLiteralGrammarTests. +/// +/// +/// The probes are the culture forms the lenient parser read, and — for the offset-bearing types — an +/// ISO timestamp without an offset and a bare date, which System.Text.Json's own converter read in the +/// parsing machine's offset. A closed temporal set reads its JSON elements through that converter's +/// replacement, the strict literal grammar, unless a converter is registered for the element type. +/// +/// +[TestClass] +public sealed class TemporalLiteralStrictnessContractTests +{ + /// Non-ISO elements per temporal primitive, each one a lenient parser read — into a different value. + private static readonly Dictionary NonIso = new() + { + [typeof(DateOnly)] = ["01.02.2027"], + [typeof(DateTime)] = ["01.02.2027 09:30:00", "2027-02-01"], + [typeof(DateTimeOffset)] = ["01.02.2027 09:30:00 +01:00", "2027-02-01T09:30:00", "2027-02-01"], + [typeof(TimeOnly)] = ["9:30 AM", "9:30"], + [typeof(LocalDate)] = ["01.02.2027"], + [typeof(LocalDateTime)] = ["01.02.2027 09:30:00", "2027-02-01"], + [typeof(Instant)] = ["01.02.2027 09:30:00 +01:00", "2027-02-01T09:30:00", "2027-02-01"], + [typeof(LocalTime)] = ["9:30 AM"], + [typeof(YearMonth)] = ["02.2027"] + }; + + /// 8 temporal range types and 18 temporal set types (9 closed, 9 wrapper arities), as of 9.0.0. + private const int KnownTemporalTypes = 26; + + [TestMethod] + public void EveryTemporalRangeAndSet_RefusesANonIsoElement_OnEveryEntryPoint() + { + var checkedTypes = new List(); + + foreach (var (rangeType, elementType) in RangeProbes.AllRangeTypes()) + { + if (!NonIso.ContainsKey(elementType)) continue; + + Reflect.InvokeGeneric(typeof(TemporalLiteralStrictnessContractTests), nameof(AssertRangeRefuses), rangeType, elementType); + checkedTypes.Add(rangeType.Name); + } + + var primitives = SetProbes.AllSetTypes() + .Where(pair => !pair.Set.IsGenericType) + .ToDictionary(pair => pair.Set.Name, pair => pair.Element); + + foreach (var (setType, elementType) in SetProbes.AllSetTypes()) + { + // A wrapper arity is backed by the primitive of its closed sibling: DateSet by DateOnly. + var primitive = primitives.GetValueOrDefault(setType.Name.Split('`')[0]) ?? elementType; + if (!NonIso.ContainsKey(primitive)) continue; + + Reflect.InvokeGeneric(typeof(TemporalLiteralStrictnessContractTests), nameof(AssertSetRefuses), setType, elementType, primitive); + checkedTypes.Add(setType.IsGenericType ? $"{setType.Name.Split('`')[0]}<{elementType.Name}>" : setType.Name); + } + + Assert.IsTrue( + checkedTypes.Count >= KnownTemporalTypes, + $"Only {checkedTypes.Count} temporal range and set types were checked, fewer than the " + + $"{KnownTemporalTypes} known to exist: {string.Join(", ", checkedTypes)}."); + } + + private static object? AssertRangeRefuses() + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + { + foreach (var element in NonIso[typeof(T)]) + RefusesElement(element); + return null; + } + + private static void RefusesElement(string element) + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + { + var literal = $"[\"{element}\",\"{element}\"]"; + var label = $"{typeof(TRange).Name} {literal}"; + + Assert.ThrowsExactly(() => TRange.Parse(literal, CultureInfo.InvariantCulture), $"{label}: Parse accepted it."); + Assert.ThrowsExactly(() => TRange.Parse(literal.AsSpan(), CultureInfo.InvariantCulture), $"{label}: Parse(span) accepted it."); + Assert.IsFalse(TRange.TryParse(literal, CultureInfo.InvariantCulture, out _), $"{label}: TryParse accepted it."); + Assert.ThrowsExactly(() => JsonSerializer.Deserialize(JsonSerializer.Serialize(literal)), $"{label}: JSON accepted it."); + Assert.ThrowsExactly( + () => TypeDescriptor.GetConverter(typeof(TRange)).ConvertFromInvariantString(literal), $"{label}: the TypeConverter accepted it."); + + var multirange = $"{{{literal}}}"; + Assert.ThrowsExactly( + () => RangeSet.Parse(multirange, CultureInfo.InvariantCulture), $"{label}: RangeSet.Parse accepted it."); + } + + private static object? AssertSetRefuses() + where TSet : IValueSetFactory, IValueSet + where T : IEquatable + { + foreach (var element in NonIso[typeof(TPrimitive)]) + SetRefusesElement(element); + return null; + } + + private static void SetRefusesElement(string element) + where TSet : IValueSetFactory, IValueSet + where T : IEquatable + { + var literal = $"{{\"{element}\"}}"; + var label = $"{(typeof(TSet).IsGenericType ? $"{typeof(TSet).Name.Split('`')[0]}<{typeof(T).Name}>" : typeof(TSet).Name)} {literal}"; + + Assert.ThrowsExactly(() => TSet.Parse(literal, CultureInfo.InvariantCulture), $"{label}: Parse accepted it."); + Assert.IsFalse(TSet.TryParse(literal, CultureInfo.InvariantCulture, out _), $"{label}: TryParse accepted it."); + Assert.ThrowsExactly(() => JsonSerializer.Deserialize($"[{JsonSerializer.Serialize(element)}]"), $"{label}: JSON accepted it."); + Assert.ThrowsExactly( + () => TypeDescriptor.GetConverter(typeof(TSet)).ConvertFromInvariantString(literal), $"{label}: the TypeConverter accepted it."); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests.csproj b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests.csproj new file mode 100644 index 0000000..7eed635 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests.csproj @@ -0,0 +1,42 @@ + + + + net10.0 + enable + enable + false + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/ContainerLifecycle.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/ContainerLifecycle.cs new file mode 100644 index 0000000..c3a132f --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/ContainerLifecycle.cs @@ -0,0 +1,60 @@ +using Testcontainers.PostgreSql; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests; + +/// +/// One PostgreSQL container for the assembly, as in the other integration project: Inconclusive +/// without Docker, a failure under CI. Not shared with it, because that one's model is built +/// in a process where the switch is off. +/// +[TestClass] +public sealed class ContainerLifecycle +{ + private static PostgreSqlContainer? _container; + + private static string? _unavailableReason; + + internal static string? ConnectionString { get; private set; } + + [AssemblyInitialize] + public static async Task StartContainer(TestContext _) + { + try + { + _container = new PostgreSqlBuilder("postgres:17-alpine").Build(); + await _container.StartAsync(); + ConnectionString = _container.GetConnectionString(); + + await using var context = new InfinityDisabledDbContext(); + await context.Database.EnsureCreatedAsync(); + } + catch (Exception exception) + { + _unavailableReason = exception.Message; + ConnectionString = null; + } + } + + [AssemblyCleanup] + public static async Task StopContainer() + { + if (_container is not null) await _container.DisposeAsync(); + } + + internal static void RequireDatabase() + { + if (ConnectionString is not null) return; + + var message = $"PostgreSQL test container unavailable ({_unavailableReason ?? "unknown"}). " + + "Docker is required for the integration tests."; + + if (IsCi) + Assert.Fail($"{message} Running in CI, where the live-PostgreSQL layer is mandatory."); + + Assert.Inconclusive(message); + } + + private static bool IsCi => + Environment.GetEnvironmentVariable("CI") is { Length: > 0 } ci + && (ci.Equals("true", StringComparison.OrdinalIgnoreCase) || ci == "1"); +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityConversionsDisabledTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityConversionsDisabledTests.cs new file mode 100644 index 0000000..edbc5b8 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityConversionsDisabledTests.cs @@ -0,0 +1,302 @@ +using System.Globalization; +using System.Linq.Expressions; +using CodoMetis.ValueRanges.Conventions.Tests; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using NodaTime; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests; + +/// +/// The temporal store rule with Npgsql.DisableDateTimeInfinityConversions set: Npgsql then +/// sends MinValue / MaxValue as the finite values they are, truncated to the microsecond +/// like any other, and the plugin's text — a JSON document, a SQL literal — must do the same rather +/// than write infinity. TemporalStoreParityTests holds the rule with the switch off. +/// +/// +/// Two things this configuration cannot do, whatever the path, and so are absent: store the NodaTime +/// minimums (-9998-01-01 is before PostgreSQL's earliest timestamp and date), and read back a +/// date range whose inclusive end is the maximum — the server keeps […,10000-01-01), a date +/// neither nor can hold. The date ranges here end +/// exclusively at the maximum. +/// +[TestClass] +public sealed class InfinityConversionsDisabledTests +{ + private static readonly DateTime Wall = new(2026, 3, 15, 10, 20, 30); + private static readonly Instant Noda = Instant.FromUtc(2026, 3, 15, 10, 20, 30); + private static readonly LocalDateTime NWallAt = LocalDateTime.FromDateTime(Wall); + private static readonly LocalDate NDayAt = new(2026, 3, 15); + + private static ExtremesValues Build() => new() + { + Wall = DateTimeRange.CreateFinite(DateTime.MinValue, DateTime.MaxValue, endInclusive: true), + Stz = DateTimeOffsetRange.CreateFinite(DateTimeOffset.MinValue, DateTimeOffset.MaxValue, endInclusive: true), + Day = DateRange.CreateFinite(DateOnly.MinValue, DateOnly.MaxValue, endInclusive: false), + NWall = LocalDateTimeRange.CreateFinite(NWallAt, LocalDateTime.MaxIsoValue, endInclusive: true), + NInst = InstantRange.CreateFinite(Noda, Instant.MaxValue, endInclusive: true), + NDay = LocalDateRange.CreateFinite(NDayAt, LocalDate.MaxIsoValue, endInclusive: false), + WallMulti = RangeSet.From(DateTimeRange.CreateFinite(DateTime.MinValue, Wall)), + WallSet = DateTimeSet.From(DateTime.MinValue, Wall, DateTime.MaxValue), + StzSet = DateTimeOffsetSet.From(DateTimeOffset.MinValue, DateTimeOffset.MaxValue), + DaySet = DateSet.From(DateOnly.MinValue, DateOnly.MaxValue), + NInstSet = InstantSet.From(Noda, Instant.MaxValue), + NWallSet = LocalDateTimeSet.From(NWallAt, LocalDateTime.MaxIsoValue), + NDaySet = LocalDateSet.From(NDayAt, LocalDate.MaxIsoValue), + Audits = DateTimeSet.From(Stamp(DateTime.MinValue), Stamp(DateTime.MaxValue)) + }; + + private static AuditStamp Stamp(DateTime value) + => AuditStamp.Parse(value.ToString("O", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + + /// Per property: name, store type, both copies' equality filter as a parameter and as a constant. + private static IEnumerable<(string Name, string StoreType, Func Get, + Expression>[] Filters)> Properties(ExtremesValues v) + { + yield return ("Wall", "tsrange", x => x.Wall, + [r => r.Native.Wall == v.Wall, r => r.Doc.Wall == v.Wall, r => r.Native.Wall == EF.Constant(v.Wall), r => r.Doc.Wall == EF.Constant(v.Wall)]); + yield return ("Stz", "tstzrange", x => x.Stz, + [r => r.Native.Stz == v.Stz, r => r.Doc.Stz == v.Stz, r => r.Native.Stz == EF.Constant(v.Stz), r => r.Doc.Stz == EF.Constant(v.Stz)]); + yield return ("Day", "daterange", x => x.Day, + [r => r.Native.Day == v.Day, r => r.Doc.Day == v.Day, r => r.Native.Day == EF.Constant(v.Day), r => r.Doc.Day == EF.Constant(v.Day)]); + yield return ("NWall", "tsrange", x => x.NWall, + [r => r.Native.NWall == v.NWall, r => r.Doc.NWall == v.NWall, r => r.Native.NWall == EF.Constant(v.NWall), r => r.Doc.NWall == EF.Constant(v.NWall)]); + yield return ("NInst", "tstzrange", x => x.NInst, + [r => r.Native.NInst == v.NInst, r => r.Doc.NInst == v.NInst, r => r.Native.NInst == EF.Constant(v.NInst), r => r.Doc.NInst == EF.Constant(v.NInst)]); + yield return ("NDay", "daterange", x => x.NDay, + [r => r.Native.NDay == v.NDay, r => r.Doc.NDay == v.NDay, r => r.Native.NDay == EF.Constant(v.NDay), r => r.Doc.NDay == EF.Constant(v.NDay)]); + yield return ("WallMulti", "tsmultirange", x => x.WallMulti, + [r => r.Native.WallMulti == v.WallMulti, r => r.Doc.WallMulti == v.WallMulti, r => r.Native.WallMulti == EF.Constant(v.WallMulti), r => r.Doc.WallMulti == EF.Constant(v.WallMulti)]); + yield return ("WallSet", "timestamp[]", x => x.WallSet, + [r => r.Native.WallSet == v.WallSet, r => r.Doc.WallSet == v.WallSet, r => r.Native.WallSet == EF.Constant(v.WallSet), r => r.Doc.WallSet == EF.Constant(v.WallSet)]); + yield return ("StzSet", "timestamptz[]", x => x.StzSet, + [r => r.Native.StzSet == v.StzSet, r => r.Doc.StzSet == v.StzSet, r => r.Native.StzSet == EF.Constant(v.StzSet), r => r.Doc.StzSet == EF.Constant(v.StzSet)]); + yield return ("DaySet", "date[]", x => x.DaySet, + [r => r.Native.DaySet == v.DaySet, r => r.Doc.DaySet == v.DaySet, r => r.Native.DaySet == EF.Constant(v.DaySet), r => r.Doc.DaySet == EF.Constant(v.DaySet)]); + yield return ("NInstSet", "timestamptz[]", x => x.NInstSet, + [r => r.Native.NInstSet == v.NInstSet, r => r.Doc.NInstSet == v.NInstSet, r => r.Native.NInstSet == EF.Constant(v.NInstSet), r => r.Doc.NInstSet == EF.Constant(v.NInstSet)]); + yield return ("NWallSet", "timestamp[]", x => x.NWallSet, + [r => r.Native.NWallSet == v.NWallSet, r => r.Doc.NWallSet == v.NWallSet, r => r.Native.NWallSet == EF.Constant(v.NWallSet), r => r.Doc.NWallSet == EF.Constant(v.NWallSet)]); + yield return ("NDaySet", "date[]", x => x.NDaySet, + [r => r.Native.NDaySet == v.NDaySet, r => r.Doc.NDaySet == v.NDaySet, r => r.Native.NDaySet == EF.Constant(v.NDaySet), r => r.Doc.NDaySet == EF.Constant(v.NDaySet)]); + yield return ("Audits", "timestamp[]", x => x.Audits, + [r => r.Native.Audits == v.Audits, r => r.Doc.Audits == v.Audits, r => r.Native.Audits == EF.Constant(v.Audits), r => r.Doc.Audits == EF.Constant(v.Audits)]); + } + + private static readonly string[] Paths = + ["column == parameter", "document == parameter", "column == constant", "document == constant"]; + + /// + /// Without this the rest proves nothing: if the runtimeconfig option stopped reaching the process, + /// Npgsql would convert as usual and every assertion below would pass for the other configuration. + /// + [TestMethod] + public async Task TheSwitch_IsInEffect_ForNpgsqlInThisProcess() + { + ContainerLifecycle.RequireDatabase(); + + Assert.IsTrue(AppContext.TryGetSwitch("Npgsql.DisableDateTimeInfinityConversions", out var disabled) && disabled, + "The switch is not set in this process; see the csproj."); + + await using var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand("SELECT @p::text", connection); + command.Parameters.AddWithValue("p", DateTime.MaxValue); + + Assert.AreEqual("9999-12-31 23:59:59.999999", await command.ExecuteScalarAsync()); + } + + /// The document holds the maximum as Npgsql sends it — truncated, finite. + [TestMethod] + public async Task TheDocument_HoldsTheExtremesFinite() + { + ContainerLifecycle.RequireDatabase(); + await Insert(2); + + await using var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand( + """SELECT "Doc" ->> 'Wall', "Doc" ->> 'DaySet', "Doc" ->> 'NInstSet' FROM "Rows" WHERE "Id" = 2""", connection); + await using var reader = await command.ExecuteReaderAsync(); + await reader.ReadAsync(); + + Assert.AreEqual("[0001-01-01T00:00:00.0000000,9999-12-31T23:59:59.9999990]", reader.GetString(0)); + Assert.AreEqual("{0001-01-01,9999-12-31}", reader.GetString(1)); + Assert.AreEqual("{2026-03-15T10:20:30Z,9999-12-31T23:59:59.999999Z}", reader.GetString(2)); + } + + [TestMethod] + public async Task TheExtremes_AreTheSameValue_AsColumn_Document_Parameter_AndConstant() + { + ContainerLifecycle.RequireDatabase(); + + var values = Build(); + var loaded = await Insert(1); + var failures = new List(); + + foreach (var (name, storeType, get, filters) in Properties(values)) + { + try + { + if (await DocumentText(1, name) is { } text && text.Contains("infinity", StringComparison.Ordinal)) + failures.Add($"{name}: the document holds {text}."); + + if (!await ServerReadsTheDocumentAsTheColumn(1, name, storeType)) + failures.Add($"{name}: the server reads the document as a different value from the column."); + + if (!Equals(get(loaded.Doc), get(loaded.Native))) + failures.Add($"{name}: the document materialized {get(loaded.Doc)}, the column {get(loaded.Native)}."); + + foreach (var (filter, path) in filters.Zip(Paths)) + if (await Count(1, filter) is var count and not 1) + failures.Add($"{name}: {path} matched {count} rows."); + } + catch (Exception e) + { + failures.Add($"{name}: {e.GetType().Name}: {(e.InnerException ?? e).Message.Split('\n')[0]}"); + } + } + + failures.AddRange(await ElementMisses(values)); + + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } + + /// + /// Each extreme as an element operand, as a parameter and as a constant, against an expected answer: + /// every set element matches its own row, and a range answers by its bounds — an exclusive maximum + /// stays outside. + /// + private static async Task> ElementMisses(ExtremesValues v) + { + var failures = new List(); + + async Task Check(string name, T point, bool expected, + Func>> asParameter, + Func>> asConstant) + { + try + { + var answer = expected ? 1 : 0; + var parameter = await Count(1, asParameter(point)); + var constant = await Count(1, asConstant(point)); + if (parameter != answer || constant != answer) + failures.Add($"{name}.Contains({point}): expected {expected}; as a parameter {parameter == 1}, as a constant {constant == 1}."); + } + catch (Exception e) + { + failures.Add($"{name}.Contains({point}): {e.GetType().Name}: {(e.InnerException ?? e).Message.Split('\n')[0]}"); + } + } + + foreach (var e in v.WallSet.Values) + { + await Check("WallSet", e, true, p => r => r.Native.WallSet.Contains(p), p => r => r.Native.WallSet.Contains(EF.Constant(p))); + await Check("WallSet (document)", e, true, p => r => r.Doc.WallSet.Contains(p), p => r => r.Doc.WallSet.Contains(EF.Constant(p))); + } + + foreach (var e in v.StzSet.Values) + { + await Check("StzSet", e, true, p => r => r.Native.StzSet.Contains(p), p => r => r.Native.StzSet.Contains(EF.Constant(p))); + await Check("StzSet (document)", e, true, p => r => r.Doc.StzSet.Contains(p), p => r => r.Doc.StzSet.Contains(EF.Constant(p))); + } + + foreach (var e in v.DaySet.Values) + { + await Check("DaySet", e, true, p => r => r.Native.DaySet.Contains(p), p => r => r.Native.DaySet.Contains(EF.Constant(p))); + await Check("DaySet (document)", e, true, p => r => r.Doc.DaySet.Contains(p), p => r => r.Doc.DaySet.Contains(EF.Constant(p))); + } + + foreach (var e in v.NInstSet.Values) + { + await Check("NInstSet", e, true, p => r => r.Native.NInstSet.Contains(p), p => r => r.Native.NInstSet.Contains(EF.Constant(p))); + await Check("NInstSet (document)", e, true, p => r => r.Doc.NInstSet.Contains(p), p => r => r.Doc.NInstSet.Contains(EF.Constant(p))); + } + + foreach (var e in v.NWallSet.Values) + { + await Check("NWallSet", e, true, p => r => r.Native.NWallSet.Contains(p), p => r => r.Native.NWallSet.Contains(EF.Constant(p))); + await Check("NWallSet (document)", e, true, p => r => r.Doc.NWallSet.Contains(p), p => r => r.Doc.NWallSet.Contains(EF.Constant(p))); + } + + foreach (var e in v.NDaySet.Values) + { + await Check("NDaySet", e, true, p => r => r.Native.NDaySet.Contains(p), p => r => r.Native.NDaySet.Contains(EF.Constant(p))); + await Check("NDaySet (document)", e, true, p => r => r.Doc.NDaySet.Contains(p), p => r => r.Doc.NDaySet.Contains(EF.Constant(p))); + } + + foreach (var e in v.Audits.Values) + { + await Check("Audits", e, true, p => r => r.Native.Audits.Contains(p), p => r => r.Native.Audits.Contains(EF.Constant(p))); + await Check("Audits (document)", e, true, p => r => r.Doc.Audits.Contains(p), p => r => r.Doc.Audits.Contains(EF.Constant(p))); + } + + // Ranges: both ends of an inclusive [MinValue, MaxValue] are inside; DateRange's exclusive maximum is not. + foreach (var (point, expected) in new[] { (DateTime.MinValue, true), (DateTime.MaxValue, true) }) + { + await Check("Wall", point, expected, p => r => r.Native.Wall.Contains(p), p => r => r.Native.Wall.Contains(EF.Constant(p))); + await Check("Wall (document)", point, expected, p => r => r.Doc.Wall.Contains(p), p => r => r.Doc.Wall.Contains(EF.Constant(p))); + } + + foreach (var (point, expected) in new[] { (DateTimeOffset.MinValue, true), (DateTimeOffset.MaxValue, true) }) + { + await Check("Stz", point, expected, p => r => r.Native.Stz.Contains(p), p => r => r.Native.Stz.Contains(EF.Constant(p))); + await Check("Stz (document)", point, expected, p => r => r.Doc.Stz.Contains(p), p => r => r.Doc.Stz.Contains(EF.Constant(p))); + } + + foreach (var (point, expected) in new[] { (DateOnly.MinValue, true), (DateOnly.MaxValue, false) }) + { + await Check("Day", point, expected, p => r => r.Native.Day.Contains(p), p => r => r.Native.Day.Contains(EF.Constant(p))); + await Check("Day (document)", point, expected, p => r => r.Doc.Day.Contains(p), p => r => r.Doc.Day.Contains(EF.Constant(p))); + } + + await Check("NWall", LocalDateTime.MaxIsoValue, true, p => r => r.Native.NWall.Contains(p), p => r => r.Native.NWall.Contains(EF.Constant(p))); + await Check("NWall (document)", LocalDateTime.MaxIsoValue, true, p => r => r.Doc.NWall.Contains(p), p => r => r.Doc.NWall.Contains(EF.Constant(p))); + await Check("NInst", Instant.MaxValue, true, p => r => r.Native.NInst.Contains(p), p => r => r.Native.NInst.Contains(EF.Constant(p))); + await Check("NInst (document)", Instant.MaxValue, true, p => r => r.Doc.NInst.Contains(p), p => r => r.Doc.NInst.Contains(EF.Constant(p))); + await Check("NDay", LocalDate.MaxIsoValue, false, p => r => r.Native.NDay.Contains(p), p => r => r.Native.NDay.Contains(EF.Constant(p))); + await Check("NDay (document)", LocalDate.MaxIsoValue, false, p => r => r.Doc.NDay.Contains(p), p => r => r.Doc.NDay.Contains(EF.Constant(p))); + await Check("WallMulti", DateTime.MinValue, true, p => r => r.Native.WallMulti.Contains(p), p => r => r.Native.WallMulti.Contains(EF.Constant(p))); + await Check("WallMulti (document)", DateTime.MinValue, true, p => r => r.Doc.WallMulti.Contains(p), p => r => r.Doc.WallMulti.Contains(EF.Constant(p))); + + return failures; + } + + private static async Task Insert(int id) + { + await using (var write = new InfinityDisabledDbContext()) + { + var values = Build(); + write.Rows.Add(new ExtremesRow { Id = id, Native = values, Doc = values }); + await write.SaveChangesAsync(); + } + + await using var read = new InfinityDisabledDbContext(); + return await read.Rows.AsNoTracking().SingleAsync(r => r.Id == id); + } + + private static async Task Count(int id, Expression> filter) + { + await using var ctx = new InfinityDisabledDbContext(); + return await ctx.Rows.Where(r => r.Id == id).Where(filter).CountAsync(); + } + + private static async Task DocumentText(int id, string property) + { + await using var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand($"""SELECT "Doc" ->> '{property}' FROM "Rows" WHERE "Id" = {id}""", connection); + return (string?)await command.ExecuteScalarAsync(); + } + + /// Columns are flattened by the complex property: Native_Wall and so on. + private static async Task ServerReadsTheDocumentAsTheColumn(int id, string property, string storeType) + { + await using var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand( + $"""SELECT CAST("Doc" ->> '{property}' AS {storeType}) = "Native_{property}" FROM "Rows" WHERE "Id" = {id}""", + connection); + return (bool)(await command.ExecuteScalarAsync())!; + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityDisabledDbContext.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityDisabledDbContext.cs new file mode 100644 index 0000000..15bdba0 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/InfinityDisabledDbContext.cs @@ -0,0 +1,47 @@ +using CodoMetis.ValueRanges.Conventions.Tests; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests; + +public sealed class ExtremesRow +{ + public int Id { get; set; } + public ExtremesValues Native { get; set; } = new(); + public ExtremesValues Doc { get; set; } = new(); +} + +/// Every temporal type with an extreme Npgsql would otherwise map to infinity. +public sealed class ExtremesValues +{ + public DateTimeRange Wall { get; set; } = DateTimeRange.Empty; + public DateTimeOffsetRange Stz { get; set; } = DateTimeOffsetRange.Empty; + public DateRange Day { get; set; } = DateRange.Empty; + public LocalDateTimeRange NWall { get; set; } = LocalDateTimeRange.Empty; + public InstantRange NInst { get; set; } = InstantRange.Empty; + public LocalDateRange NDay { get; set; } = LocalDateRange.Empty; + public RangeSet WallMulti { get; set; } = RangeSet.Empty; + public DateTimeSet WallSet { get; set; } = DateTimeSet.Empty; + public DateTimeOffsetSet StzSet { get; set; } = DateTimeOffsetSet.Empty; + public DateSet DaySet { get; set; } = DateSet.Empty; + public InstantSet NInstSet { get; set; } = InstantSet.Empty; + public LocalDateTimeSet NWallSet { get; set; } = LocalDateTimeSet.Empty; + public LocalDateSet NDaySet { get; set; } = LocalDateSet.Empty; + public DateTimeSet Audits { get; set; } = DateTimeSet.Empty; +} + +public sealed class InfinityDisabledDbContext : DbContext +{ + public DbSet Rows => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(ContainerLifecycle.ConnectionString!, npgsql => npgsql.UseValueRangesNodaTime()); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(row => + { + row.Property(r => r.Id).ValueGeneratedNever(); + row.ComplexProperty(r => r.Native); + row.ComplexProperty(r => r.Doc, doc => doc.ToJson()); + }); +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/MSTestSettings.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/MSTestSettings.cs new file mode 100644 index 0000000..300f5b1 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecuteUpdateJsonTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecuteUpdateJsonTests.cs new file mode 100644 index 0000000..6066574 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecuteUpdateJsonTests.cs @@ -0,0 +1,112 @@ +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; + +/// +/// ExecuteUpdate inside a JSON column: what is allowed works and reads back, and the value set +/// setter is refused before it can write a document that no longer loads. +/// +/// +/// A range setter is written by the server with to_jsonb, so the document then holds +/// PostgreSQL's own range output — quoted, space-separated, with the session's short offsets +/// (+00) — rather than the plugin's form. Reading that back is the strict temporal parser's +/// job, which is why the temporal ranges are here. +/// +[TestClass] +public sealed class ExecuteUpdateJsonTests +{ + private static async Task Seed(int id) + { + await using (var write = new IntegrationDbContext()) + { + write.JsonDocuments.Add(new JsonDocumentRow { Id = id }); + await write.SaveChangesAsync(); + } + + await using var read = new IntegrationDbContext(); + return await read.JsonDocuments.AsNoTracking().SingleAsync(r => r.Id == id); + } + + private static async Task Reload(int id) + { + await using var read = new IntegrationDbContext(); + return (await read.JsonDocuments.AsNoTracking().SingleAsync(r => r.Id == id)).Doc; + } + + [TestMethod] + public async Task Ranges_SetInsideTheDocument_ReadBack() + { + ContainerLifecycle.RequireDatabase(); + await Seed(9601); + + var seats = Int32Range.CreateFinite(100, 200); + var wall = DateTimeRange.CreateFinite(new DateTime(2026, 1, 1, 9, 30, 0).AddTicks(1_234_560), new DateTime(2026, 1, 1, 17, 0, 0)); + var instant = DateTimeOffsetRange.CreateFinite( + new DateTimeOffset(2026, 1, 1, 9, 30, 0, TimeSpan.Zero), new DateTimeOffset(2026, 1, 1, 17, 0, 0, TimeSpan.Zero)); + var window = InstantRange.CreateFinite(Instant.FromUtc(2026, 1, 1, 9, 30), Instant.FromUtc(2026, 1, 1, 17, 0)); + var blocks = RangeSet.From(DateRange.CreateFinite(new DateOnly(2026, 3, 1), new DateOnly(2026, 3, 31))); + + await using (var update = new IntegrationDbContext()) + { + await update.JsonDocuments.Where(r => r.Id == 9601).ExecuteUpdateAsync(s => s + .SetProperty(r => r.Doc.Seats, seats) + .SetProperty(r => r.Doc.WallClock, wall) + .SetProperty(r => r.Doc.Instants, instant) + .SetProperty(r => r.Doc.NodaWindow, window) + .SetProperty(r => r.Doc.Blocks, blocks)); + } + + var doc = await Reload(9601); + Assert.AreEqual(seats, doc.Seats); + Assert.AreEqual(wall, doc.WallClock); + Assert.AreEqual(instant, doc.Instants); + Assert.AreEqual(window, doc.NodaWindow); + Assert.AreEqual(blocks, doc.Blocks); + } + + [TestMethod] + public async Task AValueSetInsideTheDocument_IsRefused_AndTheRowStillLoads() + { + ContainerLifecycle.RequireDatabase(); + var seeded = await Seed(9602); + + await using (var update = new IntegrationDbContext()) + { + var refusal = await Assert.ThrowsExactlyAsync( + () => update.JsonDocuments.Where(r => r.Id == 9602) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.Doc.Tags, StringSet.From("c")))); + StringAssert.Contains(refusal.Message, "'Doc.Tags'"); + } + + Assert.AreEqual(seeded.Doc.Tags, (await Reload(9602)).Tags); + } + + [TestMethod] + public async Task TheWholeDocument_AndANativeSetColumn_AreStillUpdatable() + { + ContainerLifecycle.RequireDatabase(); + await Seed(9603); + + var replacement = new JsonDocument { Tags = StringSet.From("x", "y"), Seats = Int32Range.CreateFinite(1, 2) }; + await using (var update = new IntegrationDbContext()) + await update.JsonDocuments.Where(r => r.Id == 9603).ExecuteUpdateAsync(s => s.SetProperty(r => r.Doc, replacement)); + + var doc = await Reload(9603); + Assert.AreEqual(replacement.Tags, doc.Tags); + Assert.AreEqual(replacement.Seats, doc.Seats); + + await using (var write = new IntegrationDbContext()) + { + write.Reservations.Add(new Reservation { Id = 9604 }); + await write.SaveChangesAsync(); + } + + var permissions = StringSet.From(TestKey.Parse("users.read", null)); + await using (var update = new IntegrationDbContext()) + await update.Reservations.Where(r => r.Id == 9604).ExecuteUpdateAsync(s => s.SetProperty(r => r.Permissions, permissions)); + + await using var read = new IntegrationDbContext(); + Assert.AreEqual(permissions, (await read.Reservations.AsNoTracking().SingleAsync(r => r.Id == 9604)).Permissions); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/InlineRangeConstantExecutionTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/InlineRangeConstantExecutionTests.cs new file mode 100644 index 0000000..a148f77 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/InlineRangeConstantExecutionTests.cs @@ -0,0 +1,81 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; + +/// +/// A range built inline in a query matches its own row, for every range type, against a live server. +/// EF Core inlines such a range as a constant typed by its sealed variant; until 9.0.0 the comparison +/// failed to translate ("does not have a type mapping assigned"). Each constant here is the whole +/// operand, typed by the column, which also satisfies the rule that a constant-operand query runs per +/// element type. +/// +[TestClass] +public sealed class InlineRangeConstantExecutionTests +{ + private const int Id = 9701; + + [ClassInitialize] + public static async Task Seed(TestContext _) + { + if (ContainerLifecycle.ConnectionString is null) return; + + await using var write = new IntegrationDbContext(); + write.Reservations.Add(new Reservation + { + Id = Id, + Seats = Int32Range.CreateFinite(1, 5), + Tickets = Int64Range.CreateFinite(10, 20), + Price = DecimalRange.CreateFinite(1.5m, 2.5m), + Period = DateRange.CreateFinite(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)), + LocalTime = DateTimeRange.CreateFinite(new DateTime(2026, 1, 1, 9, 0, 0), new DateTime(2026, 1, 1, 17, 0, 0)), + InstantTime = DateTimeOffsetRange.CreateFinite(new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 1, 1, 17, 0, 0, TimeSpan.Zero)), + OpeningHours = TimeRange.CreateFinite(new TimeOnly(9, 0), new TimeOnly(17, 0)), + NodaPeriod = LocalDateRange.CreateFinite(new LocalDate(2026, 1, 1), new LocalDate(2026, 1, 31)), + NodaWallClock = LocalDateTimeRange.CreateFinite(new LocalDateTime(2026, 1, 1, 9, 0), new LocalDateTime(2026, 1, 1, 17, 0)), + NodaWindow = InstantRange.CreateFinite(Instant.FromUtc(2026, 1, 1, 9, 0), Instant.FromUtc(2026, 1, 1, 17, 0)), + BillingPeriod = YearMonthRange.CreateFinite(new YearMonth(2026, 1), new YearMonth(2026, 6)) + }); + await write.SaveChangesAsync(); + } + + private static IEnumerable<(string Name, Expression> Filter)> InlineEqualities() + { + yield return ("Int32Range", r => r.Seats == Int32Range.CreateFinite(1, 5)); + yield return ("Int64Range", r => r.Tickets == Int64Range.CreateFinite(10, 20)); + yield return ("DecimalRange", r => r.Price == DecimalRange.CreateFinite(1.5m, 2.5m)); + yield return ("DateRange", r => r.Period == DateRange.CreateFinite(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31))); + yield return ("DateTimeRange", r => r.LocalTime == DateTimeRange.CreateFinite(new DateTime(2026, 1, 1, 9, 0, 0), new DateTime(2026, 1, 1, 17, 0, 0))); + yield return ("DateTimeOffsetRange", r => r.InstantTime == DateTimeOffsetRange.CreateFinite( + new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 1, 1, 17, 0, 0, TimeSpan.Zero))); + yield return ("TimeRange", r => r.OpeningHours == TimeRange.CreateFinite(new TimeOnly(9, 0), new TimeOnly(17, 0))); + yield return ("LocalDateRange", r => r.NodaPeriod == LocalDateRange.CreateFinite(new LocalDate(2026, 1, 1), new LocalDate(2026, 1, 31))); + yield return ("LocalDateTimeRange", r => r.NodaWallClock == LocalDateTimeRange.CreateFinite(new LocalDateTime(2026, 1, 1, 9, 0), new LocalDateTime(2026, 1, 1, 17, 0))); + yield return ("InstantRange", r => r.NodaWindow == InstantRange.CreateFinite(Instant.FromUtc(2026, 1, 1, 9, 0), Instant.FromUtc(2026, 1, 1, 17, 0))); + yield return ("YearMonthRange", r => r.BillingPeriod == YearMonthRange.CreateFinite(new YearMonth(2026, 1), new YearMonth(2026, 6))); + } + + [TestMethod] + public async Task EveryRangeType_BuiltInline_MatchesItsRow() + { + ContainerLifecycle.RequireDatabase(); + + var failures = new List(); + foreach (var (name, filter) in InlineEqualities()) + { + try + { + await using var ctx = new IntegrationDbContext(); + var count = await ctx.Reservations.Where(r => r.Id == Id).Where(filter).CountAsync(); + if (count != 1) failures.Add($"{name}: matched {count} rows."); + } + catch (Exception e) + { + failures.Add($"{name}: {e.GetType().Name}: {(e.InnerException ?? e).Message.Split('\n')[0]}"); + } + } + + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/IntegrationDbContext.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/IntegrationDbContext.cs index 9e41832..d66b961 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/IntegrationDbContext.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/IntegrationDbContext.cs @@ -178,6 +178,10 @@ public sealed class IntegrationDbContext : DbContext public DbSet JsonDocuments => Set(); + public DbSet OwnedJsonDocuments => Set(); + + public DbSet TemporalParityRows => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql( ContainerLifecycle.ConnectionString!, @@ -198,6 +202,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) row.ComplexProperty(r => r.Doc, doc => doc.ToJson()); }); + // An owned JSON document, which EF updates property by property with jsonb_set (JsonColumnTests). + modelBuilder.Entity(row => + { + row.Property(r => r.Id).ValueGeneratedNever(); + row.OwnsOne(r => r.Doc, doc => doc.ToJson()); + }); + + // Every temporal type as a column and inside a jsonb document (TemporalStoreParityTests). + modelBuilder.Entity(row => + { + row.Property(r => r.Id).ValueGeneratedNever(); + row.ComplexProperty(r => r.Native); + row.ComplexProperty(r => r.Doc, doc => doc.ToJson()); + }); + // Generates CREATE TYPE timerange AS RANGE (SUBTYPE = time) ahead of the tables. modelBuilder.HasPostgresRange("timerange", "time"); } diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/JsonColumnTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/JsonColumnTests.cs index cfb5ec8..54a72cc 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/JsonColumnTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/JsonColumnTests.cs @@ -27,6 +27,8 @@ public sealed class JsonDocument public InstantRange NodaWindow { get; set; } = InstantRange.Empty; public YearMonthRange Billing { get; set; } = YearMonthRange.Empty; public Int32Range? MaybeSeats { get; set; } + public DateTimeOffsetRange? MaybeWindow { get; set; } + public StringSet? MaybeTags { get; set; } public RangeSet Blocks { get; set; } = RangeSet.Empty; public RangeSet Bands { get; set; } = RangeSet.Empty; @@ -42,6 +44,28 @@ public sealed class JsonDocument public InstantSet NodaStamps { get; set; } = InstantSet.Empty; public YearMonthSet Months { get; set; } = YearMonthSet.Empty; public StringSet Keys { get; set; } = StringSet.Empty; + public YearMonthSet WrappedMonths { get; set; } = YearMonthSet.Empty; +} + +public sealed class OwnedJsonRow +{ + public int Id { get; set; } + public OwnedJsonDocument Doc { get; set; } = new(); +} + +/// +/// A JSON document mapped as an owned entity rather than a complex type: EF then writes a changed +/// property on its own, with jsonb_set, instead of rewriting the document. +/// +public sealed class OwnedJsonDocument +{ + public Int32Range Seats { get; set; } = Int32Range.Empty; + public DateTimeRange WallClock { get; set; } = DateTimeRange.Empty; + public StringSet Tags { get; set; } = StringSet.Empty; + public InstantSet NodaStamps { get; set; } = InstantSet.Empty; + public Int32Range? MaybeSeats { get; set; } + public DateTimeOffsetRange? MaybeWindow { get; set; } + public StringSet? MaybeTags { get; set; } } /// @@ -84,7 +108,8 @@ public sealed class JsonColumnTests NodaDays = LocalDateSet.From(new LocalDate(2026, 1, 1)), NodaStamps = InstantSet.From(Instant.FromUtc(2026, 1, 1, 9, 30)), Months = YearMonthSet.From(new YearMonth(2026, 1), new YearMonth(2026, 6)), - Keys = StringSet.From(TextKey.Parse("users.read", null)) + Keys = StringSet.From(TextKey.Parse("users.read", null)), + WrappedMonths = YearMonthSet.From(new BillingMonth(new YearMonth(2026, 2)), new BillingMonth(new YearMonth(2026, 5))) }; /// Every property: (name, value from the sample, value from a row, server-side equality filter). @@ -116,6 +141,7 @@ public sealed class JsonColumnTests yield return ("NodaStamps", d => d.NodaStamps, r => r.Doc.NodaStamps == s.NodaStamps); yield return ("Months", d => d.Months, r => r.Doc.Months == s.Months); yield return ("Keys", d => d.Keys, r => r.Doc.Keys == s.Keys); + yield return ("WrappedMonths", d => d.WrappedMonths, r => r.Doc.WrappedMonths == s.WrappedMonths); } private static async Task Insert(int id) @@ -173,6 +199,188 @@ public async Task EveryType_IsQueryableInsideTheJsonColumn() Assert.AreEqual(0, failures.Count, string.Join(" | ", failures)); } + /// + /// A tracked document changed and saved — the path an application takes, which is not + /// ExecuteUpdate's. A range and a value set are replaced, and a nullable range, temporal + /// range and value set each go from null to a value and back, one change per save. Once every value + /// is set, and again once they are back to null, the document is read through a new context and + /// queried on the server, and every property no change touched is still the sample's. + /// A complex type's document is rewritten whole on every save (SET "Doc" = @p); the owned + /// counterpart below is the property-by-property path. + /// + [TestMethod] + public async Task TrackedChanges_AndNullTransitions_SaveAndReadBack() + { + ContainerLifecycle.RequireDatabase(); + const int id = 9004; + + var seed = Copy(Sample); + seed.MaybeSeats = null; + await using (var write = new IntegrationDbContext()) + { + write.JsonDocuments.Add(new JsonDocumentRow { Id = id, Doc = seed }); + await write.SaveChangesAsync(); + } + + var seats = Int32Range.CreateFinite(100, 200); + var tags = StringSet.From("replaced", "Tags"); + var maybe = Int32Range.CreateUnboundedStart(7); + var window = DateTimeOffsetRange.CreateFinite(new DateTimeOffset(2026, 4, 1, 8, 0, 0, TimeSpan.FromHours(2)), + new DateTimeOffset(2026, 4, 1, 9, 0, 0, TimeSpan.FromHours(2))); + var maybeTags = StringSet.From("x"); + string[] changed = ["Seats", "Tags", "MaybeSeats", "MaybeWindow", "MaybeTags"]; + + // One change per save, so each has to be detected on its own: a save rewrites the whole + // document, which would carry along a change the tracker missed next to one it saw. + Action[] toValues = + [d => d.Seats = seats, d => d.Tags = tags, d => d.MaybeSeats = maybe, d => d.MaybeWindow = window, d => d.MaybeTags = maybeTags]; + foreach (var change in toValues) await Change(id, change); + + var afterSet = await ReadBack(id); + Assert.AreEqual(seats, afterSet.Seats); + Assert.AreEqual(tags, afterSet.Tags); + Assert.AreEqual(maybe, afterSet.MaybeSeats); + Assert.AreEqual(window, afterSet.MaybeWindow); + Assert.AreEqual(maybeTags, afterSet.MaybeTags); + AssertUntouched(afterSet, changed); + Assert.AreEqual(1, await CountWhere(id, r => r.Doc.Seats == seats && r.Doc.Tags == tags && r.Doc.MaybeSeats == maybe + && r.Doc.MaybeWindow == window && r.Doc.MaybeTags == maybeTags)); + + Action[] toNull = [d => d.MaybeSeats = null, d => d.MaybeWindow = null, d => d.MaybeTags = null]; + foreach (var change in toNull) await Change(id, change); + + var afterNull = await ReadBack(id); + Assert.IsNull(afterNull.MaybeSeats); + Assert.IsNull(afterNull.MaybeWindow); + Assert.IsNull(afterNull.MaybeTags); + Assert.AreEqual(seats, afterNull.Seats); + Assert.AreEqual(tags, afterNull.Tags); + AssertUntouched(afterNull, changed); + Assert.AreEqual(1, await CountWhere(id, r => r.Doc.MaybeSeats == null && r.Doc.MaybeWindow == null && r.Doc.MaybeTags == null + && r.Doc.Seats == seats && r.Doc.Tags == tags)); + } + + /// + /// The same transitions on an owned JSON document, one property per save, which EF writes as + /// jsonb_set("Doc", '{MaybeTags}', @p) — the value alone, through the plugin's JSON writer, + /// next to siblings it must leave as they were. + /// + [TestMethod] + public async Task OwnedDocument_TrackedChanges_AndNullTransitions_SaveAndReadBack() + { + ContainerLifecycle.RequireDatabase(); + const int id = 9005; + + var wall = DateTimeRange.CreateFinite(new DateTime(2026, 1, 1, 9, 30, 0), new DateTime(2026, 1, 1, 17, 0, 0)); + var stamps = InstantSet.From(Instant.FromUtc(2026, 1, 1, 9, 30), Instant.FromUtc(2026, 6, 1, 0, 0)); + await using (var write = new IntegrationDbContext()) + { + write.OwnedJsonDocuments.Add(new OwnedJsonRow + { + Id = id, + Doc = new OwnedJsonDocument { Seats = Int32Range.CreateFinite(1, 5), WallClock = wall, Tags = StringSet.From("a"), NodaStamps = stamps } + }); + await write.SaveChangesAsync(); + } + + var seats = Int32Range.CreateFinite(100, 200); + var tags = StringSet.From("replaced", "Tags"); + var maybe = Int32Range.CreateUnboundedStart(7); + var window = DateTimeOffsetRange.CreateFinite(new DateTimeOffset(2026, 4, 1, 8, 0, 0, TimeSpan.FromHours(2)), + new DateTimeOffset(2026, 4, 1, 9, 0, 0, TimeSpan.FromHours(2))); + var maybeTags = StringSet.From("x"); + + // One change per save: EF writes a lone changed property with jsonb_set, but rewrites the + // document when several change at once. + Action[] toValues = + [d => d.Seats = seats, d => d.Tags = tags, d => d.MaybeSeats = maybe, d => d.MaybeWindow = window, d => d.MaybeTags = maybeTags]; + foreach (var change in toValues) await ChangeOwned(id, change); + + var afterSet = await ReadBackOwned(id); + Assert.AreEqual(seats, afterSet.Seats); + Assert.AreEqual(tags, afterSet.Tags); + Assert.AreEqual(maybe, afterSet.MaybeSeats); + Assert.AreEqual(window, afterSet.MaybeWindow); + Assert.AreEqual(maybeTags, afterSet.MaybeTags); + Assert.AreEqual(wall, afterSet.WallClock); + Assert.AreEqual(stamps, afterSet.NodaStamps); + Assert.AreEqual(1, await CountOwnedWhere(id, r => r.Doc.Seats == seats && r.Doc.Tags == tags && r.Doc.MaybeSeats == maybe + && r.Doc.MaybeWindow == window && r.Doc.MaybeTags == maybeTags + && r.Doc.WallClock == wall && r.Doc.NodaStamps == stamps)); + + Action[] toNull = [d => d.MaybeSeats = null, d => d.MaybeWindow = null, d => d.MaybeTags = null]; + foreach (var change in toNull) await ChangeOwned(id, change); + + var afterNull = await ReadBackOwned(id); + Assert.IsNull(afterNull.MaybeSeats); + Assert.IsNull(afterNull.MaybeWindow); + Assert.IsNull(afterNull.MaybeTags); + Assert.AreEqual(seats, afterNull.Seats); + Assert.AreEqual(tags, afterNull.Tags); + Assert.AreEqual(wall, afterNull.WallClock); + Assert.AreEqual(stamps, afterNull.NodaStamps); + Assert.AreEqual(1, await CountOwnedWhere(id, r => r.Doc.MaybeSeats == null && r.Doc.MaybeWindow == null && r.Doc.MaybeTags == null + && r.Doc.Seats == seats && r.Doc.Tags == tags + && r.Doc.WallClock == wall && r.Doc.NodaStamps == stamps)); + } + + private static async Task ChangeOwned(int id, Action change) + { + await using var ctx = new IntegrationDbContext(); + var row = await ctx.OwnedJsonDocuments.SingleAsync(r => r.Id == id); + change(row.Doc); + Assert.AreEqual(1, await ctx.SaveChangesAsync(), "the change tracker saw no change"); + } + + private static async Task ReadBackOwned(int id) + { + await using var ctx = new IntegrationDbContext(); + return (await ctx.OwnedJsonDocuments.AsNoTracking().SingleAsync(r => r.Id == id)).Doc; + } + + private static async Task CountOwnedWhere(int id, Expression> filter) + { + await using var ctx = new IntegrationDbContext(); + return await ctx.OwnedJsonDocuments.Where(r => r.Id == id).Where(filter).CountAsync(); + } + + private static async Task Change(int id, Action change) + { + await using var ctx = new IntegrationDbContext(); + var row = await ctx.JsonDocuments.SingleAsync(r => r.Id == id); + change(row.Doc); + Assert.AreEqual(1, await ctx.SaveChangesAsync(), "the change tracker saw no change"); + } + + private static async Task ReadBack(int id) + { + await using var ctx = new IntegrationDbContext(); + return (await ctx.JsonDocuments.AsNoTracking().SingleAsync(r => r.Id == id)).Doc; + } + + private static async Task CountWhere(int id, Expression> filter) + { + await using var ctx = new IntegrationDbContext(); + return await ctx.JsonDocuments.Where(r => r.Id == id).Where(filter).CountAsync(); + } + + private static void AssertUntouched(JsonDocument doc, string[] changed) + { + var wrong = typeof(JsonDocument).GetProperties() + .Where(p => !changed.Contains(p.Name) && !Equals(p.GetValue(Sample), p.GetValue(doc))) + .Select(p => $"{p.Name}: was {p.GetValue(Sample)}, now {p.GetValue(doc)}") + .ToList(); + Assert.AreEqual(0, wrong.Count, string.Join("; ", wrong)); + } + + private static JsonDocument Copy(JsonDocument source) + { + var copy = new JsonDocument(); + foreach (var property in typeof(JsonDocument).GetProperties()) + property.SetValue(copy, property.GetValue(source)); + return copy; + } + /// The document itself, as another client reading the column sees it. [TestMethod] public async Task StoredDocument_HoldsTheLiterals() @@ -183,7 +391,7 @@ public async Task StoredDocument_HoldsTheLiterals() await using var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString); await connection.OpenAsync(); await using var command = new NpgsqlCommand( - """SELECT "Doc" ->> 'Period', "Doc" ->> 'Tags', "Doc" ->> 'Billing', "Doc" ->> 'Months' FROM "JsonDocuments" WHERE "Id" = 9003""", + """SELECT "Doc" ->> 'Period', "Doc" ->> 'Tags', "Doc" ->> 'Billing', "Doc" ->> 'Months', "Doc" ->> 'WrappedMonths' FROM "JsonDocuments" WHERE "Id" = 9003""", connection); await using var reader = await command.ExecuteReaderAsync(); await reader.ReadAsync(); @@ -195,5 +403,9 @@ public async Task StoredDocument_HoldsTheLiterals() // a query into the document casts the text to daterange / date[], which only reads dates. Assert.AreEqual("[2026-01-01,2026-06-30]", reader.GetString(2)); Assert.AreEqual("{2026-01-01,2026-06-01}", reader.GetString(3)); + + // The wrapper arity too. Until 9.0.0 it was written in its element's own form, {2026-02,2026-05}, + // which the cast to date[] refuses — every query into it failed. + Assert.AreEqual("{2026-02-01,2026-05-01}", reader.GetString(4)); } } diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/TemporalStoreParityTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/TemporalStoreParityTests.cs new file mode 100644 index 0000000..b8bb1b1 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/TemporalStoreParityTests.cs @@ -0,0 +1,693 @@ +using System.Globalization; +using System.Linq.Expressions; +using CodoMetis.ValueRanges.Conventions.Tests; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using NodaTime; +using NodaTime.Text; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; + +/// The temporal values of , once as columns and once in a JSON document. +public sealed class TemporalParityRow +{ + public int Id { get; set; } + public TemporalParityValues Native { get; set; } = new(); + public TemporalParityValues Doc { get; set; } = new(); +} + +/// +/// One property per registered store conversion: every temporal range type, closed set and wrapper +/// set family, except the three over — month-aligned dates, with nothing +/// below a day to truncate — plus two of the multiranges. +/// +/// +/// A multirange has no registration of its own; it takes its range type's definition, so +/// WallMulti and NInstMulti stand for the other five. Every wrapper family is here +/// because each is a separate registration that has to pass its own truncation. +/// +public sealed class TemporalParityValues +{ + public DateTimeRange Wall { get; set; } = DateTimeRange.Empty; + public DateTimeOffsetRange Stz { get; set; } = DateTimeOffsetRange.Empty; + public TimeRange Time { get; set; } = TimeRange.Empty; + public DateRange Day { get; set; } = DateRange.Empty; + public LocalDateTimeRange NWall { get; set; } = LocalDateTimeRange.Empty; + public InstantRange NInst { get; set; } = InstantRange.Empty; + public LocalDateRange NDay { get; set; } = LocalDateRange.Empty; + public RangeSet WallMulti { get; set; } = RangeSet.Empty; + public RangeSet NInstMulti { get; set; } = RangeSet.Empty; + public DateTimeSet WallSet { get; set; } = DateTimeSet.Empty; + public DateTimeOffsetSet StzSet { get; set; } = DateTimeOffsetSet.Empty; + public TimeSet TimeSet { get; set; } = TimeSet.Empty; + public DateSet DaySet { get; set; } = DateSet.Empty; + public InstantSet NInstSet { get; set; } = InstantSet.Empty; + public LocalDateTimeSet NWallSet { get; set; } = LocalDateTimeSet.Empty; + public LocalTimeSet NTimeSet { get; set; } = LocalTimeSet.Empty; + public LocalDateSet NDaySet { get; set; } = LocalDateSet.Empty; + public DateTimeSet Audits { get; set; } = DateTimeSet.Empty; + public TimeSet Shifts { get; set; } = TimeSet.Empty; + public DateTimeOffsetSet Events { get; set; } = DateTimeOffsetSet.Empty; + public DateSet BizDays { get; set; } = DateSet.Empty; + public LocalDateTimeSet NWallMarks { get; set; } = LocalDateTimeSet.Empty; + public InstantSet NInstMarks { get; set; } = InstantSet.Empty; + public LocalTimeSet NOpenings { get; set; } = LocalTimeSet.Empty; + public LocalDateSet NCalDays { get; set; } = LocalDateSet.Empty; +} + +/// +/// A temporal value written as text — inside a JSON column, or as a SQL literal — must be the value a +/// parameter and a native column hold for it. This is the authority on that rule, against a live server. +/// +/// +/// +/// Until 9.0.0 the JSON writer and the literals sent seven fraction digits (nine for NodaTime) and let +/// PostgreSQL round them half-to-even, while Npgsql truncates a parameter to the microsecond toward +/// 2000-01-01. For a remainder above 500 ns after 2000 — below it before — the document held a +/// different value from the column: Doc.X == v missed its own row, Contains disagreed at +/// the bounds, and a range narrower than a microsecond was empty only on the server. MinValue / +/// MaxValue stayed finite in text while a parameter carried -infinity / infinity. +/// +/// +/// Each probe is one row holding every type twice, built from one instant. Per property it asserts: +/// the server reads the document as the column (CAST(doc ->> 'X' AS store) = "X"); a parameter +/// and a constant each match both copies; the two copies materialize equal; and Contains agrees +/// between the materialized value in memory and the server, on points one microsecond either side of +/// every bound. Points are whole microseconds, so the comparison is between stored values rather than +/// a test of how a probe point itself is truncated. +/// +/// +/// That last question — a sub-microsecond element operand, as a parameter and as a constant — is +/// asked separately, against an expected answer rather than only between the two paths: +/// and . +/// +/// +[TestClass] +public sealed class TemporalStoreParityTests +{ + private const long OneMicrosecond = TimeSpan.TicksPerMillisecond / 1000; + + /// The probe instants, by the rounding case each one exercises. + private static readonly (string Name, DateTime Instant)[] Probes = + [ + ("after 2000, remainder above 500 ns", new DateTime(2026, 3, 15, 10, 20, 30).AddTicks(1_234_567)), + ("after 2000, remainder below 500 ns", new DateTime(2026, 3, 15, 10, 20, 30).AddTicks(1_234_561)), + ("after 2000, exactly 500 ns, odd 6th digit", new DateTime(2026, 3, 15, 10, 20, 30).AddTicks(1_234_575)), + ("before 2000, remainder below 500 ns", new DateTime(1999, 12, 31, 22, 59, 59).AddTicks(1_234_561)), + ("before 2000, remainder above 500 ns", new DateTime(1999, 12, 31, 22, 59, 59).AddTicks(1_234_569)), + ]; + + private static TemporalParityValues Build(DateTime instant) + { + var wall = DateTime.SpecifyKind(instant, DateTimeKind.Unspecified); + var time = new TimeOnly(10, 20, 30).Add(TimeSpan.FromTicks(instant.Ticks % TimeSpan.TicksPerSecond)); + var noda = Instant.FromDateTimeUtc(DateTime.SpecifyKind(instant, DateTimeKind.Utc)).PlusNanoseconds(89); + var nwall = LocalDateTime.FromDateTime(wall).PlusNanoseconds(89); + var ntime = LocalTime.FromTicksSinceMidnight(time.Ticks).PlusNanoseconds(89); + var offset = TimeSpan.FromMinutes(330); + + return new TemporalParityValues + { + Wall = DateTimeRange.CreateFinite(wall, wall.AddHours(1).AddTicks(3)), + Stz = DateTimeOffsetRange.CreateFinite(new DateTimeOffset(wall, offset), new DateTimeOffset(wall.AddHours(1).AddTicks(3), offset)), + Time = TimeRange.CreateFinite(time, time.AddHours(1).Add(TimeSpan.FromTicks(3))), + Day = DateRange.CreateFinite(DateOnly.FromDateTime(wall), DateOnly.FromDateTime(wall).AddDays(10)), + NWall = LocalDateTimeRange.CreateFinite(nwall, nwall.PlusHours(1).PlusNanoseconds(399)), + NInst = InstantRange.CreateFinite(noda, noda.Plus(Duration.FromHours(1)).PlusNanoseconds(399)), + NDay = LocalDateRange.CreateFinite(LocalDate.FromDateTime(wall), LocalDate.FromDateTime(wall).PlusDays(10)), + WallMulti = RangeSet.From( + DateTimeRange.CreateFinite(wall, wall.AddHours(1).AddTicks(3)), + DateTimeRange.CreateFinite(wall.AddHours(2).AddTicks(7), wall.AddHours(3))), + NInstMulti = RangeSet.From( + InstantRange.CreateFinite(noda, noda.Plus(Duration.FromHours(1)).PlusNanoseconds(399)), + InstantRange.CreateFinite(noda.Plus(Duration.FromHours(2)).PlusNanoseconds(7), noda.Plus(Duration.FromHours(3)))), + + // Two elements a few hundred nanoseconds apart: one store value, so the stored array + // must hold one — the count in memory, after materialization, and on the server agree. + WallSet = DateTimeSet.From(wall, wall.AddTicks(1), wall.AddHours(5)), + StzSet = DateTimeOffsetSet.From(new DateTimeOffset(wall, offset), new DateTimeOffset(wall.AddHours(5), offset)), + TimeSet = TimeSet.From(time, time.AddHours(5)), + DaySet = DateSet.From(DateOnly.FromDateTime(wall)), + NInstSet = InstantSet.From(noda, noda.Plus(Duration.FromHours(5))), + NWallSet = LocalDateTimeSet.From(nwall, nwall.PlusHours(5)), + NTimeSet = LocalTimeSet.From(ntime, ntime.PlusHours(5)), + NDaySet = LocalDateSet.From(LocalDate.FromDateTime(wall)), + Audits = DateTimeSet.From(new AuditStamp(wall), new AuditStamp(wall.AddHours(5))), + + // Every other wrapper family has its own registration, so each is here — and the sub-day + // ones with two elements that truncate to one store value, like WallSet above. + Shifts = TimeSet.From(Shift(time), Shift(time.Add(TimeSpan.FromTicks(1))), Shift(time.AddHours(5))), + Events = DateTimeOffsetSet.From(Event(new DateTimeOffset(wall, offset)), Event(new DateTimeOffset(wall.AddTicks(1), offset))), + BizDays = DateSet.From(BizDay(DateOnly.FromDateTime(wall))), + NWallMarks = LocalDateTimeSet.From(NWallMark(nwall), NWallMark(nwall.PlusNanoseconds(1))), + NInstMarks = InstantSet.From(NInstMark(noda), NInstMark(noda.PlusNanoseconds(1)), NInstMark(noda.Plus(Duration.FromHours(5)))), + NOpenings = LocalTimeSet.From(NOpening(ntime), NOpening(ntime.PlusNanoseconds(1))), + NCalDays = LocalDateSet.From(NCalDay(LocalDate.FromDateTime(wall))) + }; + } + + // The wrapper elements' constructors are private: they are built from text, as a consumer's are. + private static ShiftTime Shift(TimeOnly value) => ShiftTime.Parse(value.ToString("O", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + private static EventStamp Event(DateTimeOffset value) => EventStamp.Parse(value.ToString("O", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + private static BusinessDate BizDay(DateOnly value) => BusinessDate.Parse(value.ToString("O", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + private static WallClockStamp NWallMark(LocalDateTime value) => WallClockStamp.Parse(LocalDateTimePattern.ExtendedIso.Format(value), null); + private static EventInstant NInstMark(Instant value) => EventInstant.Parse(InstantPattern.ExtendedIso.Format(value), null); + private static OpeningTime NOpening(LocalTime value) => OpeningTime.Parse(LocalTimePattern.ExtendedIso.Format(value), null); + private static CalendarDay NCalDay(LocalDate value) => CalendarDay.Parse(LocalDatePattern.Iso.Format(value), null); + + /// The extremes Npgsql maps to infinity, as bounds and as elements. + private static TemporalParityValues BuildExtremes() + { + var wall = new DateTime(2026, 3, 15, 10, 20, 30); + var noda = Instant.FromUtc(2026, 3, 15, 10, 20, 30); + var day = new DateOnly(2026, 3, 15); + + return new TemporalParityValues + { + Wall = DateTimeRange.CreateFinite(DateTime.MinValue, wall), + Stz = DateTimeOffsetRange.CreateFinite(new DateTimeOffset(wall, TimeSpan.Zero), DateTimeOffset.MaxValue, endInclusive: true), + Time = TimeRange.CreateFinite(TimeOnly.MinValue, TimeOnly.MaxValue, endInclusive: true), + Day = DateRange.CreateFinite(day, DateOnly.MaxValue), + NWall = LocalDateTimeRange.CreateFinite(LocalDateTime.MinIsoValue, LocalDateTime.FromDateTime(wall)), + NInst = InstantRange.CreateFinite(noda, Instant.MaxValue, endInclusive: true), + NDay = LocalDateRange.CreateFinite(LocalDate.MinIsoValue, LocalDate.FromDateTime(wall)), + WallMulti = RangeSet.From(DateTimeRange.CreateFinite(DateTime.MinValue, wall)), + NInstMulti = RangeSet.From(InstantRange.CreateFinite(noda, Instant.MaxValue, endInclusive: true)), + WallSet = DateTimeSet.From(DateTime.MinValue, wall, DateTime.MaxValue), + StzSet = DateTimeOffsetSet.From(DateTimeOffset.MinValue, DateTimeOffset.MaxValue), + TimeSet = TimeSet.From(TimeOnly.MinValue, TimeOnly.MaxValue), + DaySet = DateSet.From(DateOnly.MinValue, day, DateOnly.MaxValue), + NInstSet = InstantSet.From(Instant.MinValue, noda, Instant.MaxValue), + NWallSet = LocalDateTimeSet.From(LocalDateTime.MinIsoValue, LocalDateTime.MaxIsoValue), + NTimeSet = LocalTimeSet.From(LocalTime.MinValue, LocalTime.MaxValue), + NDaySet = LocalDateSet.From(LocalDate.MinIsoValue, LocalDate.MaxIsoValue), + Audits = DateTimeSet.From(new AuditStamp(DateTime.MinValue), new AuditStamp(DateTime.MaxValue)), + Shifts = TimeSet.From(Shift(TimeOnly.MinValue), Shift(TimeOnly.MaxValue)), + Events = DateTimeOffsetSet.From(Event(DateTimeOffset.MinValue), Event(DateTimeOffset.MaxValue)), + BizDays = DateSet.From(BizDay(DateOnly.MinValue), BizDay(DateOnly.MaxValue)), + NWallMarks = LocalDateTimeSet.From(NWallMark(LocalDateTime.MinIsoValue), NWallMark(LocalDateTime.MaxIsoValue)), + NInstMarks = InstantSet.From(NInstMark(Instant.MinValue), NInstMark(Instant.MaxValue)), + NOpenings = LocalTimeSet.From(NOpening(LocalTime.MinValue), NOpening(LocalTime.MaxValue)), + NCalDays = LocalDateSet.From(NCalDay(LocalDate.MinIsoValue), NCalDay(LocalDate.MaxIsoValue)) + }; + } + + /// A range narrower than a microsecond, which the server collapses to empty. + private static TemporalParityValues BuildNarrow() + { + var wall = new DateTime(2026, 3, 15, 10, 20, 30); + var time = new TimeOnly(10, 20, 30); + var noda = Instant.FromUtc(2026, 3, 15, 10, 20, 30); + var nwall = LocalDateTime.FromDateTime(wall); + + return new TemporalParityValues + { + Wall = DateTimeRange.CreateFinite(wall.AddTicks(1), wall.AddTicks(4)), + Stz = DateTimeOffsetRange.CreateFinite(new DateTimeOffset(wall.AddTicks(6), TimeSpan.Zero), new DateTimeOffset(wall.AddTicks(9), TimeSpan.Zero)), + Time = TimeRange.CreateFinite(time.Add(TimeSpan.FromTicks(1)), time.Add(TimeSpan.FromTicks(4))), + NWall = LocalDateTimeRange.CreateFinite(nwall.PlusNanoseconds(100), nwall.PlusNanoseconds(400)), + NInst = InstantRange.CreateFinite(noda.PlusNanoseconds(600), noda.PlusNanoseconds(900)), + WallMulti = RangeSet.From( + DateTimeRange.CreateFinite(wall, wall.AddTicks(6)), + DateTimeRange.CreateFinite(wall.AddTicks(7), wall.AddTicks(20))), + NInstMulti = RangeSet.From(InstantRange.CreateFinite(noda.PlusNanoseconds(100), noda.PlusNanoseconds(400))) + }; + } + + private static IEnumerable<(string Name, TemporalParityValues Values)> AllProbes() + { + foreach (var (name, instant) in Probes) yield return (name, Build(instant)); + yield return ("MinValue / MaxValue", BuildExtremes()); + yield return ("narrower than a microsecond", BuildNarrow()); + } + + /// Per property: name, store type, value accessor, and both copies' equality filter. + private static IEnumerable<(string Name, string StoreType, Func Get, + Func>> NativeEquals, + Func>> DocEquals)> Properties() + { + yield return ("Wall", "tsrange", v => v.Wall, v => r => r.Native.Wall == v.Wall, v => r => r.Doc.Wall == v.Wall); + yield return ("Stz", "tstzrange", v => v.Stz, v => r => r.Native.Stz == v.Stz, v => r => r.Doc.Stz == v.Stz); + yield return ("Time", "timerange", v => v.Time, v => r => r.Native.Time == v.Time, v => r => r.Doc.Time == v.Time); + yield return ("Day", "daterange", v => v.Day, v => r => r.Native.Day == v.Day, v => r => r.Doc.Day == v.Day); + yield return ("NWall", "tsrange", v => v.NWall, v => r => r.Native.NWall == v.NWall, v => r => r.Doc.NWall == v.NWall); + yield return ("NInst", "tstzrange", v => v.NInst, v => r => r.Native.NInst == v.NInst, v => r => r.Doc.NInst == v.NInst); + yield return ("NDay", "daterange", v => v.NDay, v => r => r.Native.NDay == v.NDay, v => r => r.Doc.NDay == v.NDay); + yield return ("WallMulti", "tsmultirange", v => v.WallMulti, v => r => r.Native.WallMulti == v.WallMulti, v => r => r.Doc.WallMulti == v.WallMulti); + yield return ("NInstMulti", "tstzmultirange", v => v.NInstMulti, v => r => r.Native.NInstMulti == v.NInstMulti, v => r => r.Doc.NInstMulti == v.NInstMulti); + yield return ("WallSet", "timestamp[]", v => v.WallSet, v => r => r.Native.WallSet == v.WallSet, v => r => r.Doc.WallSet == v.WallSet); + yield return ("StzSet", "timestamptz[]", v => v.StzSet, v => r => r.Native.StzSet == v.StzSet, v => r => r.Doc.StzSet == v.StzSet); + yield return ("TimeSet", "time[]", v => v.TimeSet, v => r => r.Native.TimeSet == v.TimeSet, v => r => r.Doc.TimeSet == v.TimeSet); + yield return ("DaySet", "date[]", v => v.DaySet, v => r => r.Native.DaySet == v.DaySet, v => r => r.Doc.DaySet == v.DaySet); + yield return ("NInstSet", "timestamptz[]", v => v.NInstSet, v => r => r.Native.NInstSet == v.NInstSet, v => r => r.Doc.NInstSet == v.NInstSet); + yield return ("NWallSet", "timestamp[]", v => v.NWallSet, v => r => r.Native.NWallSet == v.NWallSet, v => r => r.Doc.NWallSet == v.NWallSet); + yield return ("NTimeSet", "time[]", v => v.NTimeSet, v => r => r.Native.NTimeSet == v.NTimeSet, v => r => r.Doc.NTimeSet == v.NTimeSet); + yield return ("NDaySet", "date[]", v => v.NDaySet, v => r => r.Native.NDaySet == v.NDaySet, v => r => r.Doc.NDaySet == v.NDaySet); + yield return ("Audits", "timestamp[]", v => v.Audits, v => r => r.Native.Audits == v.Audits, v => r => r.Doc.Audits == v.Audits); + yield return ("Shifts", "time[]", v => v.Shifts, v => r => r.Native.Shifts == v.Shifts, v => r => r.Doc.Shifts == v.Shifts); + yield return ("Events", "timestamptz[]", v => v.Events, v => r => r.Native.Events == v.Events, v => r => r.Doc.Events == v.Events); + yield return ("BizDays", "date[]", v => v.BizDays, v => r => r.Native.BizDays == v.BizDays, v => r => r.Doc.BizDays == v.BizDays); + yield return ("NWallMarks", "timestamp[]", v => v.NWallMarks, v => r => r.Native.NWallMarks == v.NWallMarks, v => r => r.Doc.NWallMarks == v.NWallMarks); + yield return ("NInstMarks", "timestamptz[]", v => v.NInstMarks, v => r => r.Native.NInstMarks == v.NInstMarks, v => r => r.Doc.NInstMarks == v.NInstMarks); + yield return ("NOpenings", "time[]", v => v.NOpenings, v => r => r.Native.NOpenings == v.NOpenings, v => r => r.Doc.NOpenings == v.NOpenings); + yield return ("NCalDays", "date[]", v => v.NCalDays, v => r => r.Native.NCalDays == v.NCalDays, v => r => r.Doc.NCalDays == v.NCalDays); + } + + private static async Task Insert(int id, TemporalParityValues values) + { + await using (var write = new IntegrationDbContext()) + { + write.TemporalParityRows.Add(new TemporalParityRow { Id = id, Native = values, Doc = values }); + await write.SaveChangesAsync(); + } + + await using var read = new IntegrationDbContext(); + return await read.TemporalParityRows.AsNoTracking().SingleAsync(r => r.Id == id); + } + + private static async Task Count(int id, Expression> filter) + { + await using var ctx = new IntegrationDbContext(); + return await ctx.TemporalParityRows.Where(r => r.Id == id).Where(filter).CountAsync(); + } + + /// Columns are flattened by the complex property: Native_Wall and so on. + private static async Task ServerReadsTheDocumentAsTheColumn(int id, string property, string storeType) + { + await using var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand( + $"""SELECT CAST("Doc" ->> '{property}' AS {storeType}) = "Native_{property}" FROM "TemporalParityRows" WHERE "Id" = {id}""", + connection); + return (bool)(await command.ExecuteScalarAsync())!; + } + + [TestMethod] + public async Task EveryTemporalType_IsTheSameValue_AsColumn_Document_Parameter_AndConstant() + { + ContainerLifecycle.RequireDatabase(); + + var failures = new List(); + var id = 9500; + + foreach (var (probe, values) in AllProbes()) + { + id++; + var loaded = await Insert(id, values); + + foreach (var (name, storeType, get, nativeEquals, docEquals) in Properties()) + { + var label = $"[{probe}] {name}"; + try + { + if (!await ServerReadsTheDocumentAsTheColumn(id, name, storeType)) + failures.Add($"{label}: the server reads the document as a different value from the column."); + + if (!Equals(get(loaded.Doc), get(loaded.Native))) + failures.Add($"{label}: the document materialized {Text(get(loaded.Doc))}, the column {Text(get(loaded.Native))}."); + + // The parameter is the materialized value: what the store holds. + var stored = get(loaded.Native); + var asStored = Clone(values, name, stored); + foreach (var (path, filter) in new[] + { + ("column == parameter", nativeEquals(asStored)), + ("document == parameter", docEquals(asStored)), + ("column == parameter (as written)", nativeEquals(values)), + ("document == parameter (as written)", docEquals(values)), + }) + { + var count = await Count(id, filter); + if (count != 1) failures.Add($"{label}: {path} matched {count} rows."); + } + + foreach (var (path, matched) in await ConstantMatches(id, name, values)) + if (!matched) failures.Add($"{label}: {path} did not match its own row."); + } + catch (Exception e) + { + failures.Add($"{label}: {e.GetType().Name}: {(e.InnerException ?? e).Message.Split('\n')[0]}"); + } + } + + failures.AddRange(await ContainsDisagreements(id, probe, loaded)); + failures.AddRange(await StoredElementMisses(id, probe, values)); + failures.AddRange(await RangeElementDisagreements(id, probe, values, loaded)); + } + + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } + + /// + /// The literal path: the value inlined as a constant, against the column and the document. Before + /// 9.0.0 a constant carried seven fraction digits that the server rounded. + /// + private static async Task> ConstantMatches(int id, string name, TemporalParityValues v) + { + Expression>[] filters = name switch + { + "Wall" => [r => r.Native.Wall == EF.Constant(v.Wall), r => r.Doc.Wall == EF.Constant(v.Wall)], + "Stz" => [r => r.Native.Stz == EF.Constant(v.Stz), r => r.Doc.Stz == EF.Constant(v.Stz)], + "Time" => [r => r.Native.Time == EF.Constant(v.Time), r => r.Doc.Time == EF.Constant(v.Time)], + "Day" => [r => r.Native.Day == EF.Constant(v.Day), r => r.Doc.Day == EF.Constant(v.Day)], + "NWall" => [r => r.Native.NWall == EF.Constant(v.NWall), r => r.Doc.NWall == EF.Constant(v.NWall)], + "NInst" => [r => r.Native.NInst == EF.Constant(v.NInst), r => r.Doc.NInst == EF.Constant(v.NInst)], + "NDay" => [r => r.Native.NDay == EF.Constant(v.NDay), r => r.Doc.NDay == EF.Constant(v.NDay)], + "WallMulti" => [r => r.Native.WallMulti == EF.Constant(v.WallMulti), r => r.Doc.WallMulti == EF.Constant(v.WallMulti)], + "NInstMulti" => [r => r.Native.NInstMulti == EF.Constant(v.NInstMulti), r => r.Doc.NInstMulti == EF.Constant(v.NInstMulti)], + "WallSet" => [r => r.Native.WallSet == EF.Constant(v.WallSet), r => r.Doc.WallSet == EF.Constant(v.WallSet)], + "StzSet" => [r => r.Native.StzSet == EF.Constant(v.StzSet), r => r.Doc.StzSet == EF.Constant(v.StzSet)], + "TimeSet" => [r => r.Native.TimeSet == EF.Constant(v.TimeSet), r => r.Doc.TimeSet == EF.Constant(v.TimeSet)], + "DaySet" => [r => r.Native.DaySet == EF.Constant(v.DaySet), r => r.Doc.DaySet == EF.Constant(v.DaySet)], + "NInstSet" => [r => r.Native.NInstSet == EF.Constant(v.NInstSet), r => r.Doc.NInstSet == EF.Constant(v.NInstSet)], + "NWallSet" => [r => r.Native.NWallSet == EF.Constant(v.NWallSet), r => r.Doc.NWallSet == EF.Constant(v.NWallSet)], + "NTimeSet" => [r => r.Native.NTimeSet == EF.Constant(v.NTimeSet), r => r.Doc.NTimeSet == EF.Constant(v.NTimeSet)], + "NDaySet" => [r => r.Native.NDaySet == EF.Constant(v.NDaySet), r => r.Doc.NDaySet == EF.Constant(v.NDaySet)], + "Audits" => [r => r.Native.Audits == EF.Constant(v.Audits), r => r.Doc.Audits == EF.Constant(v.Audits)], + "Shifts" => [r => r.Native.Shifts == EF.Constant(v.Shifts), r => r.Doc.Shifts == EF.Constant(v.Shifts)], + "Events" => [r => r.Native.Events == EF.Constant(v.Events), r => r.Doc.Events == EF.Constant(v.Events)], + "BizDays" => [r => r.Native.BizDays == EF.Constant(v.BizDays), r => r.Doc.BizDays == EF.Constant(v.BizDays)], + "NWallMarks" => [r => r.Native.NWallMarks == EF.Constant(v.NWallMarks), r => r.Doc.NWallMarks == EF.Constant(v.NWallMarks)], + "NInstMarks" => [r => r.Native.NInstMarks == EF.Constant(v.NInstMarks), r => r.Doc.NInstMarks == EF.Constant(v.NInstMarks)], + "NOpenings" => [r => r.Native.NOpenings == EF.Constant(v.NOpenings), r => r.Doc.NOpenings == EF.Constant(v.NOpenings)], + "NCalDays" => [r => r.Native.NCalDays == EF.Constant(v.NCalDays), r => r.Doc.NCalDays == EF.Constant(v.NCalDays)], + _ => throw new InvalidOperationException(name) + }; + + var results = new List<(string, bool)>(); + foreach (var (filter, path) in filters.Zip(["column == constant", "document == constant"])) + results.Add((path, await Count(id, filter) == 1)); + return results; + } + + /// + /// Contains on whole-microsecond points one microsecond either side of every bound, in memory + /// on the materialized value against the server on the column and on the document. + /// + private static async Task> ContainsDisagreements(int id, string probe, TemporalParityRow loaded) + { + var failures = new List(); + + async Task Check(string name, IEnumerable points, Func inMemory, + Func>> native, + Func>> doc) + { + foreach (var point in points) + { + var expected = inMemory(point) ? 1 : 0; + var onColumn = await Count(id, native(point)); + var onDoc = await Count(id, doc(point)); + if (onColumn != expected || onDoc != expected) + failures.Add($"[{probe}] {name}.Contains({Text(point)}): in memory {expected == 1}, " + + $"column {onColumn == 1}, document {onDoc == 1}."); + } + } + + IEnumerable Around(params DateTime[] bounds) + => bounds.Where(b => b > DateTime.MinValue.AddTicks(OneMicrosecond) && b < DateTime.MaxValue.AddTicks(-OneMicrosecond)) + .SelectMany(b => new[] { b.AddTicks(-OneMicrosecond), b, b.AddTicks(OneMicrosecond) }); + + var n = loaded.Native; + if (n.Wall is DateTimeRange.Finite wall) + await Check("Wall", Around(wall.Start, wall.End), p => n.Wall.Contains(p), + p => r => r.Native.Wall.Contains(p), p => r => r.Doc.Wall.Contains(p)); + + if (n.WallMulti.Count > 0) + await Check("WallMulti", + Around([.. n.WallMulti.OfType().SelectMany(f => new[] { f.Start, f.End })]), + p => n.WallMulti.Contains(p), + p => r => r.Native.WallMulti.Contains(p), p => r => r.Doc.WallMulti.Contains(p)); + + if (n.Time is TimeRange.Finite time) + await Check("Time", + new[] { time.Start, time.End }.SelectMany(b => new[] { b.Add(TimeSpan.FromTicks(-OneMicrosecond)), b, b.Add(TimeSpan.FromTicks(OneMicrosecond)) }), + p => n.Time.Contains(p), + p => r => r.Native.Time.Contains(p), p => r => r.Doc.Time.Contains(p)); + + if (n.NInst is InstantRange.Finite inst && inst.End != Instant.MaxValue) + await Check("NInst", + new[] { inst.Start, inst.End }.SelectMany(b => new[] { b.Minus(Duration.FromNanoseconds(1000)), b, b.Plus(Duration.FromNanoseconds(1000)) }), + p => n.NInst.Contains(p), + p => r => r.Native.NInst.Contains(p), p => r => r.Doc.NInst.Contains(p)); + + foreach (var element in n.WallSet.Values.Where(e => e != DateTime.MinValue && e != DateTime.MaxValue)) + await Check("WallSet", [element], p => n.WallSet.Contains(p), + p => r => r.Native.WallSet.Contains(p), p => r => r.Doc.WallSet.Contains(p)); + + // The stored array holds the store values, deduplicated — its cardinality is the set's Count. + foreach (var (name, count, filter) in new (string, int, Expression>)[] + { + ("WallSet", n.WallSet.Count, r => r.Native.WallSet.Count), + ("Audits", n.Audits.Count, r => r.Native.Audits.Count), + ("Shifts", n.Shifts.Count, r => r.Native.Shifts.Count), + ("Events", n.Events.Count, r => r.Native.Events.Count), + ("NWallMarks", n.NWallMarks.Count, r => r.Native.NWallMarks.Count), + ("NInstMarks", n.NInstMarks.Count, r => r.Native.NInstMarks.Count), + ("NOpenings", n.NOpenings.Count, r => r.Native.NOpenings.Count), + }) + { + await using var ctx = new IntegrationDbContext(); + var onServer = await ctx.TemporalParityRows.Where(r => r.Id == id).Select(filter).SingleAsync(); + if (onServer != count) failures.Add($"[{probe}] {name}: cardinality {onServer} on the server, Count {count} in memory."); + } + + return failures; + } + + /// + /// Every element the set was built from matches its own row, as a parameter and as a constant, in + /// the column and in the document. Each one is stored — possibly as the same store value as a + /// neighbour a few hundred nanoseconds away — so the expected answer is always a match; comparing + /// the two paths only with each other would also pass if both missed. The NodaTime sets have no + /// element normalization, so their probe used to fall back to Npgsql's own element literal — nine + /// fraction digits, which the server rounds — and InstantSet.Contains(EF.Constant(x)) missed + /// the element x was stored as. + /// + private static async Task> StoredElementMisses(int id, string probe, TemporalParityValues v) + { + var failures = new List(); + + async Task Check(string name, IEnumerable elements, + Func>> asParameter, + Func>> asConstant) + { + foreach (var element in elements) + { + try + { + var parameter = await Count(id, asParameter(element)); + var constant = await Count(id, asConstant(element)); + if (parameter != 1 || constant != 1) + failures.Add($"[{probe}] {name}.Contains({Text(element)}) of a stored element: " + + $"as a parameter {parameter == 1}, as a constant {constant == 1}."); + } + catch (Exception e) + { + failures.Add($"[{probe}] {name}.Contains({Text(element)}): {e.GetType().Name}: {(e.InnerException ?? e).Message.Split('\n')[0]}"); + } + } + } + + await Check("WallSet", v.WallSet.Values, e => r => r.Native.WallSet.Contains(e), e => r => r.Native.WallSet.Contains(EF.Constant(e))); + await Check("WallSet (document)", v.WallSet.Values, e => r => r.Doc.WallSet.Contains(e), e => r => r.Doc.WallSet.Contains(EF.Constant(e))); + await Check("StzSet", v.StzSet.Values, e => r => r.Native.StzSet.Contains(e), e => r => r.Native.StzSet.Contains(EF.Constant(e))); + await Check("StzSet (document)", v.StzSet.Values, e => r => r.Doc.StzSet.Contains(e), e => r => r.Doc.StzSet.Contains(EF.Constant(e))); + await Check("TimeSet", v.TimeSet.Values, e => r => r.Native.TimeSet.Contains(e), e => r => r.Native.TimeSet.Contains(EF.Constant(e))); + await Check("TimeSet (document)", v.TimeSet.Values, e => r => r.Doc.TimeSet.Contains(e), e => r => r.Doc.TimeSet.Contains(EF.Constant(e))); + await Check("DaySet", v.DaySet.Values, e => r => r.Native.DaySet.Contains(e), e => r => r.Native.DaySet.Contains(EF.Constant(e))); + await Check("DaySet (document)", v.DaySet.Values, e => r => r.Doc.DaySet.Contains(e), e => r => r.Doc.DaySet.Contains(EF.Constant(e))); + await Check("NInstSet", v.NInstSet.Values, e => r => r.Native.NInstSet.Contains(e), e => r => r.Native.NInstSet.Contains(EF.Constant(e))); + await Check("NInstSet (document)", v.NInstSet.Values, e => r => r.Doc.NInstSet.Contains(e), e => r => r.Doc.NInstSet.Contains(EF.Constant(e))); + await Check("NWallSet", v.NWallSet.Values, e => r => r.Native.NWallSet.Contains(e), e => r => r.Native.NWallSet.Contains(EF.Constant(e))); + await Check("NWallSet (document)", v.NWallSet.Values, e => r => r.Doc.NWallSet.Contains(e), e => r => r.Doc.NWallSet.Contains(EF.Constant(e))); + await Check("NTimeSet", v.NTimeSet.Values, e => r => r.Native.NTimeSet.Contains(e), e => r => r.Native.NTimeSet.Contains(EF.Constant(e))); + await Check("NTimeSet (document)", v.NTimeSet.Values, e => r => r.Doc.NTimeSet.Contains(e), e => r => r.Doc.NTimeSet.Contains(EF.Constant(e))); + await Check("NDaySet", v.NDaySet.Values, e => r => r.Native.NDaySet.Contains(e), e => r => r.Native.NDaySet.Contains(EF.Constant(e))); + await Check("NDaySet (document)", v.NDaySet.Values, e => r => r.Doc.NDaySet.Contains(e), e => r => r.Doc.NDaySet.Contains(EF.Constant(e))); + await Check("Audits", v.Audits.Values, e => r => r.Native.Audits.Contains(e), e => r => r.Native.Audits.Contains(EF.Constant(e))); + await Check("Audits (document)", v.Audits.Values, e => r => r.Doc.Audits.Contains(e), e => r => r.Doc.Audits.Contains(EF.Constant(e))); + await Check("Shifts", v.Shifts.Values, e => r => r.Native.Shifts.Contains(e), e => r => r.Native.Shifts.Contains(EF.Constant(e))); + await Check("Shifts (document)", v.Shifts.Values, e => r => r.Doc.Shifts.Contains(e), e => r => r.Doc.Shifts.Contains(EF.Constant(e))); + await Check("Events", v.Events.Values, e => r => r.Native.Events.Contains(e), e => r => r.Native.Events.Contains(EF.Constant(e))); + await Check("Events (document)", v.Events.Values, e => r => r.Doc.Events.Contains(e), e => r => r.Doc.Events.Contains(EF.Constant(e))); + await Check("BizDays", v.BizDays.Values, e => r => r.Native.BizDays.Contains(e), e => r => r.Native.BizDays.Contains(EF.Constant(e))); + await Check("BizDays (document)", v.BizDays.Values, e => r => r.Doc.BizDays.Contains(e), e => r => r.Doc.BizDays.Contains(EF.Constant(e))); + await Check("NWallMarks", v.NWallMarks.Values, e => r => r.Native.NWallMarks.Contains(e), e => r => r.Native.NWallMarks.Contains(EF.Constant(e))); + await Check("NWallMarks (document)", v.NWallMarks.Values, e => r => r.Doc.NWallMarks.Contains(e), e => r => r.Doc.NWallMarks.Contains(EF.Constant(e))); + await Check("NInstMarks", v.NInstMarks.Values, e => r => r.Native.NInstMarks.Contains(e), e => r => r.Native.NInstMarks.Contains(EF.Constant(e))); + await Check("NInstMarks (document)", v.NInstMarks.Values, e => r => r.Doc.NInstMarks.Contains(e), e => r => r.Doc.NInstMarks.Contains(EF.Constant(e))); + await Check("NOpenings", v.NOpenings.Values, e => r => r.Native.NOpenings.Contains(e), e => r => r.Native.NOpenings.Contains(EF.Constant(e))); + await Check("NOpenings (document)", v.NOpenings.Values, e => r => r.Doc.NOpenings.Contains(e), e => r => r.Doc.NOpenings.Contains(EF.Constant(e))); + await Check("NCalDays", v.NCalDays.Values, e => r => r.Native.NCalDays.Contains(e), e => r => r.Native.NCalDays.Contains(EF.Constant(e))); + await Check("NCalDays (document)", v.NCalDays.Values, e => r => r.Doc.NCalDays.Contains(e), e => r => r.Doc.NCalDays.Contains(EF.Constant(e))); + + return failures; + } + + /// + /// A range's element operand — range @> x — probed with the original, sub-microsecond + /// bounds as a parameter and as a constant. It used to take the provider's element mapping: a + /// DateTimeOffset with a non-zero offset (every Stz probe here) threw as a + /// parameter, and a constant was Npgsql's own literal — nine digits for NodaTime, which the server + /// rounds. + /// + /// + /// The expected answer is the materialized range's Contains of the probe as stored + /// (), not the original range's: a range narrower than a microsecond + /// is legitimately empty once stored. Both paths must give that answer — agreeing with each other + /// is not enough, since both missing would agree. In the ordinary probes a start bound is inside + /// and an exclusive end outside, so each of them asserts a match and a miss. + /// + private static async Task> RangeElementDisagreements(int id, string probe, TemporalParityValues v, + TemporalParityRow loaded) + { + var failures = new List(); + + async Task Check(string name, IEnumerable points, Func expected, + Func>> asParameter, + Func>> asConstant) + { + foreach (var point in points) + { + try + { + var answer = expected(point) ? 1 : 0; + var parameter = await Count(id, asParameter(point)); + var constant = await Count(id, asConstant(point)); + if (parameter != answer || constant != answer) + failures.Add($"[{probe}] {name}.Contains({Text(point)}): the stored range holds it: {answer == 1}; " + + $"as a parameter {parameter == 1}, as a constant {constant == 1}."); + } + catch (Exception e) + { + failures.Add($"[{probe}] {name}.Contains({Text(point)}): {e.GetType().Name}: {(e.InnerException ?? e).Message.Split('\n')[0]}"); + } + } + } + + var n = loaded.Native; + var d = loaded.Doc; + + if (v.Wall is DateTimeRange.Finite wall) + { + await Check("Wall", [wall.Start, wall.End], p => n.Wall.Contains(Stored(p)), + p => r => r.Native.Wall.Contains(p), p => r => r.Native.Wall.Contains(EF.Constant(p))); + await Check("Wall (document)", [wall.Start, wall.End], p => d.Wall.Contains(Stored(p)), + p => r => r.Doc.Wall.Contains(p), p => r => r.Doc.Wall.Contains(EF.Constant(p))); + } + + if (v.Stz is DateTimeOffsetRange.Finite stz) + { + await Check("Stz", [stz.Start, stz.End], p => n.Stz.Contains(Stored(p)), + p => r => r.Native.Stz.Contains(p), p => r => r.Native.Stz.Contains(EF.Constant(p))); + await Check("Stz (document)", [stz.Start, stz.End], p => d.Stz.Contains(Stored(p)), + p => r => r.Doc.Stz.Contains(p), p => r => r.Doc.Stz.Contains(EF.Constant(p))); + } + + if (v.Time is TimeRange.Finite time) + { + await Check("Time", [time.Start, time.End], p => n.Time.Contains(Stored(p)), + p => r => r.Native.Time.Contains(p), p => r => r.Native.Time.Contains(EF.Constant(p))); + await Check("Time (document)", [time.Start, time.End], p => d.Time.Contains(Stored(p)), + p => r => r.Doc.Time.Contains(p), p => r => r.Doc.Time.Contains(EF.Constant(p))); + } + + if (v.Day is DateRange.Finite day) + { + await Check("Day", [day.Start, day.End], p => n.Day.Contains(p), + p => r => r.Native.Day.Contains(p), p => r => r.Native.Day.Contains(EF.Constant(p))); + await Check("Day (document)", [day.Start, day.End], p => d.Day.Contains(p), + p => r => r.Doc.Day.Contains(p), p => r => r.Doc.Day.Contains(EF.Constant(p))); + } + + if (v.NWall is LocalDateTimeRange.Finite nwall) + { + await Check("NWall", [nwall.Start, nwall.End], p => n.NWall.Contains(Stored(p)), + p => r => r.Native.NWall.Contains(p), p => r => r.Native.NWall.Contains(EF.Constant(p))); + await Check("NWall (document)", [nwall.Start, nwall.End], p => d.NWall.Contains(Stored(p)), + p => r => r.Doc.NWall.Contains(p), p => r => r.Doc.NWall.Contains(EF.Constant(p))); + } + + if (v.NInst is InstantRange.Finite ninst) + { + await Check("NInst", [ninst.Start, ninst.End], p => n.NInst.Contains(Stored(p)), + p => r => r.Native.NInst.Contains(p), p => r => r.Native.NInst.Contains(EF.Constant(p))); + await Check("NInst (document)", [ninst.Start, ninst.End], p => d.NInst.Contains(Stored(p)), + p => r => r.Doc.NInst.Contains(p), p => r => r.Doc.NInst.Contains(EF.Constant(p))); + } + + if (v.NDay is LocalDateRange.Finite nday) + { + await Check("NDay", [nday.Start, nday.End], p => n.NDay.Contains(p), + p => r => r.Native.NDay.Contains(p), p => r => r.Native.NDay.Contains(EF.Constant(p))); + await Check("NDay (document)", [nday.Start, nday.End], p => d.NDay.Contains(p), + p => r => r.Doc.NDay.Contains(p), p => r => r.Doc.NDay.Contains(EF.Constant(p))); + } + + // A multirange's element operand, probed at the bounds of the ranges it was built from. + var wallPoints = v.WallMulti.OfType().SelectMany(f => new[] { f.Start, f.End }).ToList(); + await Check("WallMulti", wallPoints, p => n.WallMulti.Contains(Stored(p)), + p => r => r.Native.WallMulti.Contains(p), p => r => r.Native.WallMulti.Contains(EF.Constant(p))); + await Check("WallMulti (document)", wallPoints, p => d.WallMulti.Contains(Stored(p)), + p => r => r.Doc.WallMulti.Contains(p), p => r => r.Doc.WallMulti.Contains(EF.Constant(p))); + + var instPoints = v.NInstMulti.OfType().SelectMany(f => new[] { f.Start, f.End }).ToList(); + await Check("NInstMulti", instPoints, p => n.NInstMulti.Contains(Stored(p)), + p => r => r.Native.NInstMulti.Contains(p), p => r => r.Native.NInstMulti.Contains(EF.Constant(p))); + await Check("NInstMulti (document)", instPoints, p => d.NInstMulti.Contains(Stored(p)), + p => r => r.Doc.NInstMulti.Contains(p), p => r => r.Doc.NInstMulti.Contains(EF.Constant(p))); + + return failures; + } + + // -- A probe as the server compares it -- + // + // Whole microseconds, cut toward 2000-01-01 as Npgsql binds a parameter, with the extremes + // Npgsql sends as infinity left alone. Written out here rather than borrowed from the plugin's + // StoreTemporal, so an error there cannot cancel out of the expected answer. + + private static readonly long PostgresEpochTicks = new DateTime(2000, 1, 1).Ticks; + + private static readonly Instant PostgresEpoch = Instant.FromUtc(2000, 1, 1, 0, 0); + + private static long StoredTicks(long ticks) => PostgresEpochTicks + (ticks - PostgresEpochTicks) / 10 * 10; + + private static DateTime Stored(DateTime point) + => point == DateTime.MinValue || point == DateTime.MaxValue ? point : new DateTime(StoredTicks(point.Ticks), point.Kind); + + private static DateTimeOffset Stored(DateTimeOffset point) + => point == DateTimeOffset.MinValue || point == DateTimeOffset.MaxValue + ? point + : new DateTimeOffset(StoredTicks(point.UtcTicks), TimeSpan.Zero); + + private static TimeOnly Stored(TimeOnly point) => new(point.Ticks / 10 * 10); + + private static Instant Stored(Instant point) + => point == Instant.MinValue || point == Instant.MaxValue + ? point + : PostgresEpoch + Duration.FromNanoseconds((point - PostgresEpoch).ToBigIntegerNanoseconds() / 1000 * 1000); + + private static LocalDateTime Stored(LocalDateTime point) + => point == LocalDateTime.MinIsoValue || point == LocalDateTime.MaxIsoValue + ? point + : Stored(point.InUtc().ToInstant()).InUtc().LocalDateTime; + + private static TemporalParityValues Clone(TemporalParityValues values, string name, object stored) + { + var clone = new TemporalParityValues(); + foreach (var property in typeof(TemporalParityValues).GetProperties()) + property.SetValue(clone, property.Name == name ? stored : property.GetValue(values)); + return clone; + } + + // Every fraction digit: the general format drops them, and they are what these failures are about. + private static string Text(object? value) => value switch + { + null => "null", + DateTime t => t.ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset t => t.ToString("O", CultureInfo.InvariantCulture), + TimeOnly t => t.ToString("O", CultureInfo.InvariantCulture), + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? "?" + }; +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaInlineRangeConstantTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaInlineRangeConstantTests.cs new file mode 100644 index 0000000..5c5b2b9 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaInlineRangeConstantTests.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests; + +using CodoMetis.ValueRanges; + +/// +/// A NodaTime range built inline in a query — evaluated by EF Core and inlined as a constant typed by +/// its variant — translates like any other constant. YearMonthRange has its own mapping, which +/// writes the month-aligned date form. +/// +[TestClass] +public sealed class NodaInlineRangeConstantTests +{ + private static string Where(Func> query) + { + using var context = new NodaTestDbContext(); + var sql = query(context).ToQueryString(); + return sql[sql.IndexOf("WHERE", StringComparison.Ordinal)..].Trim(); + } + + [TestMethod] + public void AnInstantRange() + => Assert.AreEqual( + "WHERE r.\"Window\" = '[2026-01-01T09:00:00Z,2026-01-01T17:00:00Z)'::tstzrange", + Where(db => db.Reservations.Where(r => r.Window == InstantRange.CreateFinite( + Instant.FromUtc(2026, 1, 1, 9, 0), Instant.FromUtc(2026, 1, 1, 17, 0))))); + + [TestMethod] + public void AYearMonthRange_InItsDateForm() + => Assert.AreEqual( + "WHERE r.\"BillingPeriod\" = '[2026-01-01,2026-06-30]'::daterange", + Where(db => db.Reservations.Where(r => r.BillingPeriod == YearMonthRange.CreateFinite(new YearMonth(2026, 1), new YearMonth(2026, 6))))); +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ExecuteUpdateGuardTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ExecuteUpdateGuardTests.cs new file mode 100644 index 0000000..37edbac --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ExecuteUpdateGuardTests.cs @@ -0,0 +1,97 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests; + +public sealed class UpdateRow +{ + public int Id { get; set; } + public StringSet Codes { get; set; } = StringSet.Empty; + public UpdateDoc Doc { get; set; } = new(); +} + +public sealed class UpdateDoc +{ + public StringSet Tags { get; set; } = StringSet.Empty; + public DateTimeRange Period { get; set; } = DateTimeRange.Empty; + public UpdateNested Nested { get; set; } = new(); +} + +public sealed class UpdateNested +{ + public StringSet Flags { get; set; } = StringSet.Empty; +} + +public sealed class UpdateTestContext : DbContext +{ + public DbSet Rows => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql( + "Host=localhost;Database=valueranges_tests;Username=postgres", + npgsql => npgsql.UseValueRanges()); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().ComplexProperty(r => r.Doc, doc => + { + doc.ToJson(); + doc.ComplexProperty(d => d.Nested); + }); +} + +/// +/// ExecuteUpdate setting a value set inside a JSON column is refused at query compilation — no +/// database needed, the refusal comes before a connection. What stays allowed is exercised against a +/// live server in ExecuteUpdateJsonTests (integration). +/// +/// +/// EF Core writes such a setter with jsonb_set(doc, path, to_jsonb(@p)), binding the set's +/// PostgreSQL array: the document then holds a JSON array where the reader expects the set's literal +/// string, the update reports success, and the row no longer loads. +/// +[TestClass] +public sealed class ExecuteUpdateGuardTests +{ + [TestMethod] + public void AValueSetInsideAJsonColumn_IsRefused() + { + using var context = new UpdateTestContext(); + + var refusal = Assert.ThrowsExactly( + () => context.Rows.ExecuteUpdate(s => s.SetProperty(r => r.Doc.Tags, StringSet.From("a")))); + + StringAssert.Contains(refusal.Message, "'Doc.Tags'"); + StringAssert.Contains(refusal.Message, "SaveChanges"); + } + + [TestMethod] + public void AValueSetInANestedComplexTypeOfAJsonColumn_IsRefused() + { + using var context = new UpdateTestContext(); + + var refusal = Assert.ThrowsExactly( + () => context.Rows.ExecuteUpdate(s => s.SetProperty(r => r.Doc.Nested.Flags, StringSet.From("a")))); + + StringAssert.Contains(refusal.Message, "'Doc.Nested.Flags'"); + } + + /// + /// The guard matches EF Core's private lowering of ExecuteUpdate by its shape. If an EF + /// upgrade changes it, the guard would silently stop recognising the call; this fails instead. + /// + [TestMethod] + public void EfCoresExecuteUpdateLowering_HasTheShapeTheGuardMatches() + { + var lowering = typeof(EntityFrameworkQueryableExtensions) + .GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Where(m => m is { Name: "ExecuteUpdate", IsGenericMethodDefinition: true }) + .Where(m => m.GetParameters() is [_, { ParameterType: var setters }] && setters == typeof(IReadOnlyList)) + .ToList(); + + Assert.AreEqual( + 1, lowering.Count, + "EF Core no longer lowers ExecuteUpdate to ExecuteUpdate(IQueryable, IReadOnlyList). " + + "Update JsonValueSetUpdateGuard in ValueRangesQueryExpressionInterceptor to the new shape."); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/InlineRangeConstantTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/InlineRangeConstantTests.cs new file mode 100644 index 0000000..16c9a1c --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/InlineRangeConstantTests.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests; + +/// +/// A range built inline in a query from values that depend on nothing in it. EF Core evaluates such a +/// call on the client and inlines the result as a constant typed by its value — the sealed variant, +/// Int32Range.Finite — for which no type mapping exists; until 9.0.0 ==, !=, +/// Equals and a conditional over one failed with "does not have a type mapping assigned". The +/// interceptor now retypes the constant to its range type. InlineRangeConstantExecutionTests +/// (integration) runs one per range type against a live server. +/// +[TestClass] +public sealed class InlineRangeConstantTests +{ + private static string Where(Func> query) + { + using var db = new TestDbContext(); + var sql = query(db).ToQueryString(); + return sql[sql.IndexOf("WHERE", StringComparison.Ordinal)..].Trim(); + } + + [TestMethod] + public void Equality_WithAFactoryCall() + => Assert.AreEqual( + "WHERE b.\"Seats\" = '[1,5]'::int4range", + Where(db => db.Bookings.Where(b => b.Seats == Int32Range.CreateFinite(1, 5)))); + + [TestMethod] + public void Inequality_AndEquals() + { + Assert.AreEqual( + "WHERE b.\"Seats\" <> '[1,5]'::int4range", + Where(db => db.Bookings.Where(b => b.Seats != Int32Range.CreateFinite(1, 5)))); + Assert.AreEqual( + "WHERE b.\"Seats\" = '[1,5]'::int4range", + Where(db => db.Bookings.Where(b => b.Seats.Equals(Int32Range.CreateFinite(1, 5))))); + } + + [TestMethod] + public void EveryShape_AndParse() + { + Assert.AreEqual( + "WHERE b.\"Seats\" = '[3,)'::int4range", + Where(db => db.Bookings.Where(b => b.Seats == Int32Range.CreateUnboundedEnd(3)))); + Assert.AreEqual( + "WHERE b.\"Seats\" = '(,3]'::int4range", + Where(db => db.Bookings.Where(b => b.Seats == Int32Range.CreateUnboundedStart(3, endInclusive: true)))); + Assert.AreEqual( + "WHERE b.\"Seats\" = '[1,5]'::int4range", + Where(db => db.Bookings.Where(b => b.Seats == Int32Range.Parse("[1,5]", null)))); + } + + [TestMethod] + public void ADateRange_FromLiteralDates() + => Assert.AreEqual( + "WHERE b.\"Period\" = '[2026-01-01,2026-01-31]'::daterange", + Where(db => db.Bookings.Where(b => b.Period == DateRange.CreateFinite(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31))))); + + /// The inlined constant goes through the store text, like any other temporal constant. + [TestMethod] + public void ATemporalRange_IsWrittenAtTheStoresResolution() + => Assert.AreEqual( + "WHERE b.\"LocalTime\" = '[2026-03-15T10:00:00.0000000,2026-03-15T11:00:00.0000000)'::tsrange", + Where(db => db.Bookings.Where(b => b.LocalTime == DateTimeRange.CreateFinite( + new DateTime(2026, 3, 15, 10, 0, 0).AddTicks(5), new DateTime(2026, 3, 15, 11, 0, 0))))); + + [TestMethod] + public void InsideAConditional() + => StringAssert.Contains( + Where(db => db.Bookings.Where(b => (b.Seats == Int32Range.CreateFinite(1, 5) ? 1 : 0) == 1)), + "b.\"Seats\" = '[1,5]'::int4range"); +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlLiteralTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlLiteralTests.cs index c38cf40..42cae03 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlLiteralTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlLiteralTests.cs @@ -53,12 +53,14 @@ public void EmptyRangeSet() => // The literal is the only place the bridge's output is observable without a database, and // the sub-second digits are the whole point: a bridge that asked its elements for their // default text form instead of the round-trip one would render 10:30:00.0000000 here and - // store a value silently truncated to the second. + // store a value silently truncated to the second. The seventh digit is 0 since 9.0.0: the + // literal carries the value at the store's microsecond, truncated exactly as Npgsql truncates + // the parameter, so a constant and a parameter for the same value agree. [TestMethod] public void WrapperTimestampSet_KeepsSubSecondPrecision() => Assert.AreEqual( - "ARRAY['2024-06-15T10:30:00.1234567']::timestamp without time zone[]", + "ARRAY['2024-06-15T10:30:00.1234560']::timestamp without time zone[]", LiteralOf(DateTimeSet.From( TestStamp.Parse("2024-06-15T10:30:00.1234567", CultureInfo.InvariantCulture)))); @@ -70,7 +72,7 @@ public void WrapperTimestampSet_KeepsSubSecondPrecision() => [TestMethod] public void WrapperTimestampSet_NormalizesKindToUnspecified() => Assert.AreEqual( - "ARRAY['2024-06-15T10:30:00.1234567']::timestamp without time zone[]", + "ARRAY['2024-06-15T10:30:00.1234560']::timestamp without time zone[]", LiteralOf(DateTimeSet.From( TestStamp.Parse("2024-06-15T10:30:00.1234567Z", CultureInfo.InvariantCulture)))); diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/TemporalStoreTextTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/TemporalStoreTextTests.cs new file mode 100644 index 0000000..e0517f1 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/TemporalStoreTextTests.cs @@ -0,0 +1,167 @@ +using System.Globalization; +using CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests; + +/// +/// The text a temporal value is written as — a SQL literal, or inside a JSON document — is the value +/// Npgsql would bind: microseconds truncated toward 2000-01-01, the extremes as infinity, the +/// range rebuilt so it collapses and merges as the server would. The live-server authority is +/// TemporalStoreParityTests (integration); this pins the text itself. +/// +[TestClass] +public sealed class TemporalStoreTextTests +{ + private static readonly DateTime After2000 = new DateTime(2026, 3, 15, 10, 20, 30).AddTicks(1_234_567); + private static readonly DateTime Before2000 = new DateTime(1999, 12, 31, 23, 59, 59).AddTicks(1_234_561); + + private static RelationalTypeMapping MappingOf() + { + using var context = new TestDbContext(); + return context.GetService().FindMapping(typeof(TValue))!; + } + + private static string LiteralOf(TValue value) where TValue : notnull + => MappingOf().GenerateSqlLiteral(value); + + private static string JsonOf(TValue value) where TValue : notnull + => MappingOf().JsonValueReaderWriter!.ToJsonString(value); + + [TestMethod] + public void RangeBounds_AreTruncatedTowardThePostgresEpoch() + { + // After 2000 the cut rounds down; before it, toward the epoch, it rounds up — as Npgsql's does. + // The second range starts before 2000 and ends after it, so its bounds go opposite ways. + Assert.AreEqual( + "'[2026-03-15T10:20:30.1234560,2026-03-15T11:20:30.1234560)'::tsrange", + LiteralOf(DateTimeRange.CreateFinite(After2000, After2000.AddHours(1)))); + Assert.AreEqual( + "'[1999-12-31T23:59:59.1234570,2000-01-01T00:59:59.1234560)'::tsrange", + LiteralOf(DateTimeRange.CreateFinite(Before2000, Before2000.AddHours(1)))); + } + + [TestMethod] + public void TheJsonDocument_HoldsTheSameText_AsTheLiteral() + { + var range = DateTimeRange.CreateFinite(After2000, After2000.AddHours(1)); + + Assert.AreEqual("\"[2026-03-15T10:20:30.1234560,2026-03-15T11:20:30.1234560)\"", JsonOf(range)); + Assert.AreEqual( + "\"{[2026-03-15T10:20:30.1234560,2026-03-15T11:20:30.1234560)}\"", + JsonOf(RangeSet.From(range))); + } + + [TestMethod] + public void TheExtremes_AreInfinity_AsNpgsqlSendsThem() + { + Assert.AreEqual( + "'[-infinity,2026-03-15T10:20:30.0000000)'::tsrange", + LiteralOf(DateTimeRange.CreateFinite(DateTime.MinValue, new DateTime(2026, 3, 15, 10, 20, 30)))); + Assert.AreEqual( + "'[2026-03-15,infinity]'::daterange", + LiteralOf(DateRange.CreateFinite(new DateOnly(2026, 3, 15), DateOnly.MaxValue, endInclusive: true))); + Assert.AreEqual( + "ARRAY['-infinity','infinity']::timestamp with time zone[]", + LiteralOf(DateTimeOffsetSet.From(DateTimeOffset.MinValue, DateTimeOffset.MaxValue))); + Assert.AreEqual("\"{-infinity,infinity}\"", JsonOf(DateSet.From(DateOnly.MinValue, DateOnly.MaxValue))); + } + + /// A time of day has no infinity; its maximum is truncated like any other value. + [TestMethod] + public void TimeOfDay_MaxValue_IsTruncated_NotRoundedToMidnight() + => Assert.AreEqual( + "'[00:00:00.0000000,23:59:59.9999990]'::timerange", + LiteralOf(TimeRange.CreateFinite(TimeOnly.MinValue, TimeOnly.MaxValue, endInclusive: true))); + + [TestMethod] + public void ARangeNarrowerThanAMicrosecond_IsEmpty_AsTheServerStoresIt() + => Assert.AreEqual( + "'empty'::tsrange", + LiteralOf(DateTimeRange.CreateFinite(After2000.AddTicks(-6), After2000.AddTicks(-3)))); + + [TestMethod] + public void AMultirange_MergesWhatTruncationMakesTouch() + => Assert.AreEqual( + "'{[2026-03-15T10:20:30.1234560,2026-03-15T10:20:30.1234580)}'::tsmultirange", + LiteralOf(RangeSet.From( + DateTimeRange.CreateFinite(After2000.AddTicks(-7), After2000.AddTicks(6)), + DateTimeRange.CreateFinite(After2000.AddTicks(7), After2000.AddTicks(13))))); + + /// + /// Two elements a few hundred nanoseconds apart are one store value: the array — literal, document + /// and parameter alike — holds it once, so cardinality on the server is the set's Count. + /// + [TestMethod] + public void SetElements_ThatTruncateToOneValue_AreStoredOnce() + { + var set = DateTimeSet.From(After2000, After2000.AddTicks(1)); + + Assert.AreEqual("ARRAY['2026-03-15T10:20:30.1234560']::timestamp without time zone[]", LiteralOf(set)); + Assert.AreEqual("\"{2026-03-15T10:20:30.1234560}\"", JsonOf(set)); + Assert.AreEqual(1, ((DateTime[])MappingOf().Converter!.ConvertToProvider(set)!).Length); + } + + /// + /// The JSON writer finds the store text through the registries on first use — no model built, no + /// mapping looked up — which is the situation of a compiled model: a wrapper set family's + /// definition is only built when something asks for it. Before, the hook had to have been set by + /// a definition's constructor, and an unresolved family silently fell back to its own literal. + /// + [TestMethod] + public void TheJsonWriter_ResolvesAWrapperSetThatNothingResolvedBefore() + { + var set = DateTimeSet.From(new NeverMappedStamp(After2000), new NeverMappedStamp(After2000.AddTicks(1))); + + Assert.AreEqual( + "\"{2026-03-15T10:20:30.1234560}\"", + LiteralJsonValueReaderWriter>.Instance.ToJsonString(set)); + } + + private static string Where(Func> query) + { + using var db = new TestDbContext(); + var sql = query(db).ToQueryString(); + return sql[sql.IndexOf("WHERE", StringComparison.Ordinal)..].Trim(); + } + + /// + /// A range's element constant is the range's own: normalized (UTC for timestamptz, no Kind for + /// timestamp), truncated, the extremes as infinity — and still self-describing. + /// + [TestMethod] + public void ARangesElementConstant_IsNormalizedAndTruncated() + { + var offset = new DateTimeOffset(After2000, TimeSpan.FromMinutes(330)); + + Assert.AreEqual( + "WHERE b.\"InstantTime\" @> TIMESTAMPTZ '2026-03-15T04:50:30.1234560+00:00'", + Where(db => db.Bookings.Where(b => b.InstantTime.Contains(EF.Constant(offset))))); + Assert.AreEqual( + "WHERE b.\"LocalTime\" @> TIMESTAMP '2026-03-15T10:20:30.1234560'", + Where(db => db.Bookings.Where(b => b.LocalTime.Contains(EF.Constant(DateTime.SpecifyKind(After2000, DateTimeKind.Utc)))))); + Assert.AreEqual( + "WHERE b.\"LocalTime\" @> TIMESTAMP '-infinity'", + Where(db => db.Bookings.Where(b => b.LocalTime.Contains(EF.Constant(DateTime.MinValue))))); + } + + /// A wrapper element no model in this project maps — see the test above. + private readonly record struct NeverMappedStamp(DateTime Value) : IFormattable, IParsable, IComparable + { + public static NeverMappedStamp Parse(string s, IFormatProvider? provider) + => new(DateTime.ParseExact(s, "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + + public static bool TryParse(string? s, IFormatProvider? provider, out NeverMappedStamp result) + { + var ok = DateTime.TryParseExact(s, "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var value); + result = new(value); + return ok; + } + + public int CompareTo(NeverMappedStamp other) => Value.CompareTo(other.Value); + + public string ToString(string? format, IFormatProvider? formatProvider) => Value.ToString(format, formatProvider); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/WrapperSetTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/WrapperSetTranslationTests.cs index 367a251..b3efbbb 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/WrapperSetTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/WrapperSetTranslationTests.cs @@ -43,6 +43,7 @@ public sealed class WrapperSetTranslationTests private static readonly TestRate Rate = TestRate.Parse("12.50", CultureInfo.InvariantCulture); private static readonly TestDay Day = new(new DateOnly(2024, 6, 15)); private static readonly TestSlot Slot = new(new TimeOnly(9, 30, 15, 250)); + // Seven fraction digits: the literal keeps the six the store can hold, truncated as Npgsql truncates. private static readonly TestStamp Stamp = TestStamp.Parse("2024-06-15T10:30:00.1234567", CultureInfo.InvariantCulture); private static readonly TestInstant Moment = new(new DateTimeOffset(2024, 6, 15, 10, 30, 0, TimeSpan.FromHours(2))); @@ -121,7 +122,7 @@ public void WrapperTimeSet_Contains_KeepsSubSecondPrecision() [TestMethod] public void WrapperDateTimeSet_Contains_KeepsSubSecondPrecision() => Assert.AreEqual( - "WHERE b.\"Audits\" @> ARRAY['2024-06-15T10:30:00.1234567']::timestamp without time zone[]", + "WHERE b.\"Audits\" @> ARRAY['2024-06-15T10:30:00.1234560']::timestamp without time zone[]", Where(db => db.Bookings.Where(b => b.Audits.Contains(EF.Constant(Stamp))))); /// @@ -167,7 +168,7 @@ public void WrapperSets_KeepThePrecisionTheElementsDefaultFormWouldDrop() "09:30:15.2500000", "09:30 — truncated to the minute"), (Where(db => db.Bookings.Where(b => b.Audits.Contains(EF.Constant(Stamp)))), - "2024-06-15T10:30:00.1234567", "06/15/2024 10:30:00 — sub-seconds and Kind dropped"), + "2024-06-15T10:30:00.1234560", "06/15/2024 10:30:00 — sub-seconds and Kind dropped"), (Where(db => db.Bookings.Where(b => b.WrappedRates.Contains(EF.Constant(Rate)))), "12.50", "12.5 — the stored scale shortened"), @@ -209,7 +210,7 @@ public void WrapperInt32Set_Remove_RendersABareElement() [TestMethod] public void WrapperDateTimeSet_Remove_RendersABareElement() => Assert.AreEqual( - "WHERE cardinality(array_remove(b.\"Audits\", '2024-06-15T10:30:00.1234567')) = 0", + "WHERE cardinality(array_remove(b.\"Audits\", '2024-06-15T10:30:00.1234560')) = 0", Where(db => db.Bookings.Where(b => b.Audits.Remove(EF.Constant(Stamp)).IsEmpty))); [TestMethod] diff --git a/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeParseFormatTests.cs b/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeParseFormatTests.cs index e38d56d..a92da5d 100644 --- a/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeParseFormatTests.cs +++ b/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeParseFormatTests.cs @@ -114,6 +114,32 @@ public void Parse_PostgresWireForm_Instant_NumericOffset() Assert.AreEqual("[2024-06-01T00:00:00Z,2024-07-01T00:00:00Z)", range.ToString()); } + /// + /// PostgreSQL writes infinity / -infinity for the values Npgsql maps from the + /// extremes of Instant, LocalDate and LocalDateTime; they read back as those + /// extremes, so text the server or to_jsonb produced parses to what Npgsql would have + /// materialized. + /// + [TestMethod] + public void Parse_PostgresInfinity_ReadsAsTheExtremeValues() + { + Assert.AreEqual( + InstantRange.CreateFinite(Instant.MinValue, Instant.MaxValue), + InstantRange.Parse("[-infinity,infinity)", null)); + Assert.AreEqual( + LocalDateRange.CreateFinite(LocalDate.MinIsoValue, LocalDate.MaxIsoValue), + LocalDateRange.Parse("[-infinity,infinity]", null)); + Assert.AreEqual( + LocalDateTimeRange.CreateFinite(LocalDateTime.MinIsoValue, LocalDateTime.MaxIsoValue), + LocalDateTimeRange.Parse("[-infinity,INFINITY)", null)); + Assert.AreEqual(InstantSet.From(Instant.MaxValue), InstantSet.Parse("{infinity}", null)); + } + + /// A time of day has no infinity, in PostgreSQL or in NodaTime. + [TestMethod] + public void Parse_Infinity_IsRefusedForATimeOfDay() + => Assert.ThrowsExactly(() => LocalTimeSet.Parse("{infinity}", null)); + [TestMethod] public void Parse_Instant_NonUtcOffset_ConvertsToInstant() { diff --git a/test/CodoMetis.ValueRanges.Tests/SetElementBridgeTests.cs b/test/CodoMetis.ValueRanges.Tests/SetElementBridgeTests.cs index 3c48a9d..50e1036 100644 --- a/test/CodoMetis.ValueRanges.Tests/SetElementBridgeTests.cs +++ b/test/CodoMetis.ValueRanges.Tests/SetElementBridgeTests.cs @@ -206,13 +206,15 @@ public void WrapperDateTimeOffsetSet_KeepsSubSecondPrecisionAndOffset() /// /// The counterexample. A wrapper that swallows the format argument answers with its own - /// default form, and the array literal it produces is a truncated value that still parses — - /// so the loss is silent here, in the pure model. It is the EF Core bridge that refuses it, - /// because that is where a truncated value would reach a column - /// (SqlLiteralTests covers the rejection). + /// default form — here the invariant culture's, which drops the sub-seconds. Until 9.0.0 that + /// literal still parsed, through the element's own lenient Parse, into a truncated value: + /// a silent loss in the pure model. The family now reads the text strictly as ISO 8601 before + /// the element sees it, so the culture form is refused, as the NodaTime twin already was + /// (WrapperLocalDateSet_ElementIgnoringTheFormat_LeaksTheCultureForm). The EF Core bridge + /// refuses it too, before a truncated value could reach a column (SqlLiteralTests). /// [TestMethod] - public void WrapperDateTimeSet_ElementIgnoringTheFormat_LosesPrecisionInTheLiteral() + public void WrapperDateTimeSet_ElementIgnoringTheFormat_IsRefusedOnParse() { var precise = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Unspecified).AddTicks(1_234_567); var set = DateTimeSet.From(new TestLossyStamp(precise)); @@ -221,9 +223,8 @@ public void WrapperDateTimeSet_ElementIgnoringTheFormat_LosesPrecisionInTheLiter // not answering with anything the store would recognize. Assert.AreEqual("{\"06/15/2024 10:30:00\"}", set.ToString()); - var reparsed = DateTimeSet.Parse(set.ToString(), CultureInfo.InvariantCulture); - - Assert.AreNotEqual(set, reparsed, "The truncation is what the contract exists to prevent."); + Assert.ThrowsExactly( + () => DateTimeSet.Parse(set.ToString(), CultureInfo.InvariantCulture)); } // ----------------------------------------------------------------------- diff --git a/test/CodoMetis.ValueRanges.Tests/SetJsonConverterTests.cs b/test/CodoMetis.ValueRanges.Tests/SetJsonConverterTests.cs index 8b9fb6b..ba6b8fc 100644 --- a/test/CodoMetis.ValueRanges.Tests/SetJsonConverterTests.cs +++ b/test/CodoMetis.ValueRanges.Tests/SetJsonConverterTests.cs @@ -383,4 +383,37 @@ public void WrapperSet_DelegatesElementSerialization() public void Deserialize_WrapperSet_InvalidElement_Throws() => Assert.ThrowsExactly( () => JsonSerializer.Deserialize>("[\"no-dot\"]", Options)); + + // ----------------------------------------------------------------------- + // Null and empty stay apart + // ----------------------------------------------------------------------- + + private sealed class NullableSets + { + public StringSet? Tags { get; set; } + public RangeSet? Blocks { get; set; } + } + + /// + /// is absent and the empty set is a value: null on one side, [] + /// for a set and "{}" for a multirange on the other, each reading back as what was written. + /// The range types pin the same (RangeJsonConverterTests.NullAndEmpty_StayDistinctAcrossARoundTrip). + /// + [TestMethod] + public void NullAndEmpty_StayDistinct_ForSetsAndMultiranges() + { + var absent = JsonSerializer.Serialize(new NullableSets()); + var empty = JsonSerializer.Serialize(new NullableSets { Tags = StringSet.Empty, Blocks = RangeSet.Empty }); + + Assert.AreEqual("""{"Tags":null,"Blocks":null}""", absent); + Assert.AreEqual("""{"Tags":[],"Blocks":"{}"}""", empty); + + var readAbsent = JsonSerializer.Deserialize(absent)!; + var readEmpty = JsonSerializer.Deserialize(empty)!; + + Assert.IsNull(readAbsent.Tags); + Assert.IsNull(readAbsent.Blocks); + Assert.AreEqual(StringSet.Empty, readEmpty.Tags); + Assert.AreEqual(RangeSet.Empty, readEmpty.Blocks); + } } diff --git a/test/CodoMetis.ValueRanges.Tests/TemporalLiteralGrammarTests.cs b/test/CodoMetis.ValueRanges.Tests/TemporalLiteralGrammarTests.cs new file mode 100644 index 0000000..875bde3 --- /dev/null +++ b/test/CodoMetis.ValueRanges.Tests/TemporalLiteralGrammarTests.cs @@ -0,0 +1,246 @@ +using System.Globalization; + +namespace CodoMetis.ValueRanges.Tests; + +/// +/// The temporal element grammar: strict ISO 8601 — the forms this library writes and the forms +/// PostgreSQL writes in its default ISO DateStyle — and nothing else. +/// +/// +/// Until 9.0.0 the BCL temporal types parsed through the lenient BCL Parse, which reads the +/// invariant culture month first: [01.02.2027,05.03.2027) was January 2 to May 2, and a +/// DateTimeOffset without an offset took the parsing machine's. Every row in the +/// Rejects_* tests below parsed before — most of them into a different value. +/// TemporalLiteralStrictnessContractTests (conventions) sweeps the rejection through every +/// range and set type and every entry point. +/// +[TestClass] +public class TemporalLiteralGrammarTests +{ + private static readonly DateTime Wall = new(2026, 1, 1, 9, 30, 0, DateTimeKind.Unspecified); + + // ── DateOnly ──────────────────────────────────────────────────────────────────────────── + + [TestMethod] + [DataRow("2026-01-01", "2026-01-01")] + [DataRow("infinity", "9999-12-31")] + [DataRow("-infinity", "0001-01-01")] + [DataRow("INFINITY", "9999-12-31")] + public void Date_Accepts(string text, string expected) + => Assert.AreEqual(DateOnly.ParseExact(expected, "yyyy-MM-dd", CultureInfo.InvariantCulture), DateRange.ParseValue(text, null)); + + [TestMethod] + [DataRow("01.02.2027")] + [DataRow("01/02/2027")] + [DataRow("Feb 1 2027")] + [DataRow("2027-2-1")] + [DataRow("27-02-01")] + [DataRow("2027-02-01T00:00:00")] + [DataRow("2027-02-01 BC")] + [DataRow("10000-01-01")] + [DataRow(" 2027-02-01")] + public void Date_Rejects(string text) + => Assert.ThrowsExactly(() => DateRange.ParseValue(text, null)); + + // ── DateTime ──────────────────────────────────────────────────────────────────────────── + + [TestMethod] + [DataRow("2026-01-01T09:30:00.1234567", 1_234_567L)] // this library ("O", Unspecified) + [DataRow("2026-01-01T09:30:00", 0L)] + [DataRow("2026-01-01 09:30:00.123456", 1_234_560L)] // PostgreSQL, ISO DateStyle + [DataRow("2026-01-01 09:30:00", 0L)] + [DataRow("2026-01-01T09:30", 0L)] // reduced precision, hand-written + [DataRow("2026-01-01 09:30", 0L)] + public void DateTime_Accepts_Unspecified(string text, long extraTicks) + { + var value = DateTimeRange.ParseValue(text, null); + + Assert.AreEqual(Wall.AddTicks(extraTicks), value); + Assert.AreEqual(DateTimeKind.Unspecified, value.Kind); + } + + [TestMethod] + public void DateTime_Accepts_Utc_AndLocal_WithRoundTripKind() + { + var utc = DateTimeRange.ParseValue("2026-01-01T09:30:00.0000000Z", null); + var local = DateTimeRange.ParseValue("2026-01-01T09:30:00.0000000+01:00", null); + + Assert.AreEqual(DateTimeKind.Utc, utc.Kind); + Assert.AreEqual(DateTime.SpecifyKind(Wall, DateTimeKind.Utc), utc); + Assert.AreEqual(DateTimeKind.Local, local.Kind); + Assert.AreEqual(DateTime.SpecifyKind(Wall.AddHours(-1), DateTimeKind.Utc), local.ToUniversalTime()); + } + + /// + /// ISO 8601 puts no limit on fraction digits; Go and Java write nine. They are truncated to the + /// 100 ns the BCL types hold, as System.Text.Json truncates them. + /// + [TestMethod] + [DataRow("2026-01-01T09:30:00.123456789")] + [DataRow("2026-01-01T09:30:00.1234567890123456")] + public void DateTime_Accepts_MoreThanSevenFractionDigits_Truncated(string text) + => Assert.AreEqual(Wall.AddTicks(1_234_567), DateTimeRange.ParseValue(text, null)); + + [TestMethod] + public void DateTime_Accepts_AShortOffset_AsLocal() + => Assert.AreEqual( + DateTime.SpecifyKind(Wall.AddHours(-1), DateTimeKind.Utc), + DateTimeRange.ParseValue("2026-01-01T09:30:00+01", null).ToUniversalTime()); + + [TestMethod] + public void DateTime_Accepts_Infinity() + { + Assert.AreEqual(DateTime.MaxValue, DateTimeRange.ParseValue("infinity", null)); + Assert.AreEqual(DateTime.MinValue, DateTimeRange.ParseValue("-infinity", null)); + } + + [TestMethod] + [DataRow("01.02.2027 09:30:00")] + [DataRow("01/02/2027 09:30:00")] + [DataRow("Feb 1 2027 09:30")] + [DataRow("2027-02-01")] // date-only for a date-time + [DataRow("2027-02-01T09")] + [DataRow("2027-02-01T09:30:00,5")] // comma fraction + [DataRow("2027-02-01T25:00:00")] + [DataRow("2027-02-01T09:30:00 BC")] + public void DateTime_Rejects(string text) + => Assert.ThrowsExactly(() => DateTimeRange.ParseValue(text, null)); + + // ── DateTimeOffset ────────────────────────────────────────────────────────────────────── + + [TestMethod] + [DataRow("2026-01-01T09:30:00.0000000+00:00", 0)] // this library ("O") + [DataRow("2026-01-01T09:30:00.0000000+05:30", 330)] + [DataRow("2026-01-01T09:30:00Z", 0)] + [DataRow("2026-01-01 09:30:00+00", 0)] // PostgreSQL, ISO DateStyle + [DataRow("2026-01-01 09:30:00.123456+05:30", 330)] + [DataRow("2026-01-01 09:30:00-03", -180)] + [DataRow("2026-01-01T09:30+01:00", 60)] + public void DateTimeOffset_Accepts(string text, int offsetMinutes) + { + var value = DateTimeOffsetRange.ParseValue(text, null); + + Assert.AreEqual(TimeSpan.FromMinutes(offsetMinutes), value.Offset); + Assert.AreEqual(new DateTime(2026, 1, 1, 9, 30, 0), value.DateTime.AddTicks(-(value.DateTime.Ticks % TimeSpan.TicksPerSecond))); + } + + /// + /// PostgreSQL writes a historical local-mean-time offset with seconds; DateTimeOffset cannot + /// hold one, so the instant is kept at offset zero rather than refused. + /// + [TestMethod] + public void DateTimeOffset_Accepts_PostgresSecondsOffset_AsTheInstantAtUtc() + { + var value = DateTimeOffsetRange.ParseValue("1850-01-01 00:00:00+05:53:28", null); + + Assert.AreEqual(TimeSpan.Zero, value.Offset); + Assert.AreEqual(new DateTime(1849, 12, 31, 18, 6, 32), value.UtcDateTime); + Assert.AreEqual(new DateTimeOffset(1850, 1, 1, 5, 53, 28, TimeSpan.Zero), + DateTimeOffsetRange.ParseValue("1850-01-01T00:00:00-05:53:28", null)); + } + + [TestMethod] + public void DateTimeOffset_Accepts_NineFractionDigits_Truncated() + => Assert.AreEqual( + new DateTimeOffset(Wall.AddTicks(1_234_567), TimeSpan.Zero), + DateTimeOffsetRange.ParseValue("2026-01-01T09:30:00.123456789Z", null)); + + [TestMethod] + public void DateTimeOffset_Accepts_Infinity() + { + Assert.AreEqual(DateTimeOffset.MaxValue, DateTimeOffsetRange.ParseValue("infinity", null)); + Assert.AreEqual(DateTimeOffset.MinValue, DateTimeOffsetRange.ParseValue("-infinity", null)); + } + + [TestMethod] + [DataRow("2026-01-01T09:30:00")] // no offset: used to take the machine's + [DataRow("2026-01-01 09:30:00")] + [DataRow("01.01.2026 09:30:00 +01:00")] + [DataRow("2026-01-01")] + [DataRow("2026-01-01T09:30:00+15:00:00")] // no such offset + [DataRow("2026-01-01T09:30:00+05:30:60")] + public void DateTimeOffset_Rejects(string text) + => Assert.ThrowsExactly(() => DateTimeOffsetRange.ParseValue(text, null)); + + // ── TimeOnly ──────────────────────────────────────────────────────────────────────────── + + [TestMethod] + [DataRow("09:30", 0L)] + [DataRow("09:30:00", 0L)] + [DataRow("09:30:00.1234567", 1_234_567L)] + [DataRow("09:30:00.123456", 1_234_560L)] + [DataRow("09:30:00.123456789", 1_234_567L)] + public void Time_Accepts(string text, long extraTicks) + => Assert.AreEqual(new TimeOnly(9, 30).Add(TimeSpan.FromTicks(extraTicks)), TimeRange.ParseValue(text, null)); + + [TestMethod] + [DataRow("9:30 AM")] + [DataRow("9:30")] + [DataRow("24:00:00")] // PostgreSQL's end of day: unrepresentable + [DataRow("09.30")] + [DataRow("09:30:00,5")] + [DataRow("T09:30")] + [DataRow("infinity")] // time has no infinity in PostgreSQL either + public void Time_Rejects(string text) + => Assert.ThrowsExactly(() => TimeRange.ParseValue(text, null)); + + // ── Through the literals ──────────────────────────────────────────────────────────────── + + /// The motivating case: dotted dates used to parse, month first. + [TestMethod] + public void DottedDateRange_IsRefused_NotReadMonthFirst() + { + Assert.ThrowsExactly(() => DateRange.Parse("[01.02.2027,05.03.2027)", CultureInfo.InvariantCulture)); + Assert.IsFalse(DateRange.TryParse("[01.02.2027,05.03.2027)", CultureInfo.InvariantCulture, out _)); + Assert.ThrowsExactly(() => DateSet.Parse("{01.02.2027}", CultureInfo.InvariantCulture)); + Assert.ThrowsExactly(() => DateSet.Parse("{01.02.2027}", CultureInfo.InvariantCulture)); + } + + /// + /// PostgreSQL's own range output — quoted, space-separated, short offsets, infinity — reads + /// back, so text copied from psql or stored by to_jsonb parses. + /// + [TestMethod] + public void PostgresRangeOutput_Parses() + { + Assert.AreEqual( + DateTimeRange.CreateFinite(Wall, Wall.AddHours(7).AddTicks(1_234_560)), + DateTimeRange.Parse("[\"2026-01-01 09:30:00\",\"2026-01-01 16:30:00.123456\")", CultureInfo.InvariantCulture)); + Assert.AreEqual( + DateTimeOffsetRange.CreateFinite(new DateTimeOffset(Wall, TimeSpan.Zero), new DateTimeOffset(Wall.AddHours(1), TimeSpan.Zero)), + DateTimeOffsetRange.Parse("[\"2026-01-01 09:30:00+00\",\"2026-01-01 11:30:00+01\")", CultureInfo.InvariantCulture)); + Assert.AreEqual( + DateRange.CreateFinite(new DateOnly(2026, 1, 1), DateOnly.MaxValue, endInclusive: false), + DateRange.Parse("[2026-01-01,infinity)", CultureInfo.InvariantCulture)); + } + + /// Infinity reads as the extreme value; formatting is unchanged, so the value round-trips. + [TestMethod] + public void InfinityBound_RoundTripsAsTheExtremeValue() + { + var parsed = DateTimeRange.Parse("[-infinity,2026-01-01T00:00:00)", CultureInfo.InvariantCulture); + + Assert.AreEqual(DateTimeRange.CreateFinite(DateTime.MinValue, new DateTime(2026, 1, 1)), parsed); + Assert.AreEqual(parsed, DateTimeRange.Parse(parsed.ToString(), CultureInfo.InvariantCulture)); + } + + /// A hand-written opening-hours literal without seconds. + [TestMethod] + public void TimeRange_WithoutSeconds_Parses() + => Assert.AreEqual( + TimeRange.CreateFinite(new TimeOnly(9, 0), new TimeOnly(17, 0)), + TimeRange.Parse("[09:00,17:00)", CultureInfo.InvariantCulture)); + + /// + /// A wrapper arity reads the text as its primitive first, so a lenient element Parse never + /// sees non-ISO text, and the element still re-validates the canonical form. + /// + [TestMethod] + public void WrapperArity_ReadsPostgresForm_ThroughTheStrictPrimitive() + { + var set = DateTimeSet.Parse("{\"2026-01-01 09:30:00.123456\"}", CultureInfo.InvariantCulture); + + Assert.AreEqual(DateTimeSet.From(TestStamp.Parse(Wall.AddTicks(1_234_560).ToString("O", CultureInfo.InvariantCulture), null)), set); + Assert.ThrowsExactly(() => DateTimeSet.Parse("{\"01/01/2026 09:30:00\"}", CultureInfo.InvariantCulture)); + } +} diff --git a/test/CodoMetis.ValueRanges.Tests/TemporalSetJsonTests.cs b/test/CodoMetis.ValueRanges.Tests/TemporalSetJsonTests.cs new file mode 100644 index 0000000..daa0d27 --- /dev/null +++ b/test/CodoMetis.ValueRanges.Tests/TemporalSetJsonTests.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodoMetis.ValueRanges.Tests; + +/// +/// A closed temporal set reads its JSON elements with the grammar its literal uses — unless a converter +/// is registered for the element type, which still wins. +/// +/// +/// The elements used to go through System.Text.Json's own converters, which are ISO 8601 too, with one +/// silent exception: a without an offset, or a bare date, was read in the +/// parsing machine's offset, so the same payload held different instants on different servers while +/// the set's literal refused the text. The write side is System.Text.Json's, unchanged. +/// +[TestClass] +public class TemporalSetJsonTests +{ + [TestMethod] + [DataRow("2027-02-01T09:30:00")] + [DataRow("2027-02-01")] + public void ADateTimeOffsetWithoutAnOffset_IsRefused(string element) + => Assert.ThrowsExactly( + () => JsonSerializer.Deserialize($"[\"{element}\"]")); + + [TestMethod] + public void ADateOnlyDateTime_AndAnUnpaddedTime_AreRefused() + { + Assert.ThrowsExactly(() => JsonSerializer.Deserialize("[\"2027-02-01\"]")); + Assert.ThrowsExactly(() => JsonSerializer.Deserialize("[\"9:30\"]")); + } + + [TestMethod] + public void ANonStringElement_IsRefused() + => Assert.ThrowsExactly(() => JsonSerializer.Deserialize("[20270201]")); + + /// What Go and Java write: nine fraction digits, truncated to 100 ns as System.Text.Json did. + [TestMethod] + public void NineFractionDigits_AreRead_Truncated() + => Assert.AreEqual( + DateTimeOffsetSet.From(new DateTimeOffset(2027, 2, 1, 9, 30, 0, TimeSpan.Zero).AddTicks(1_234_567)), + JsonSerializer.Deserialize("[\"2027-02-01T09:30:00.123456789Z\"]")); + + [TestMethod] + public void TheWrittenForm_IsSystemTextJsons_AndReadsBack() + { + var set = DateTimeOffsetSet.From(new DateTimeOffset(2026, 1, 1, 9, 30, 0, TimeSpan.FromHours(1))); + + var json = JsonSerializer.Serialize(set); + + Assert.AreEqual("[\"2026-01-01T09:30:00+01:00\"]", json); + Assert.AreEqual(set, JsonSerializer.Deserialize(json)); + } + + /// A converter registered for the element type is authoritative, as for every element type. + [TestMethod] + public void ARegisteredElementConverter_StillReads() + { + var options = new JsonSerializerOptions { Converters = { new OffsetlessIsUtcConverter() } }; + + Assert.AreEqual( + DateTimeOffsetSet.From(new DateTimeOffset(2027, 2, 1, 9, 30, 0, TimeSpan.Zero)), + JsonSerializer.Deserialize("[\"2027-02-01T09:30:00\"]", options)); + } + + /// A source-generated context hands over System.Text.Json's own converter: still strict. + [TestMethod] + public void ASourceGeneratedContext_IsStrictToo() + { + Assert.ThrowsExactly( + () => JsonSerializer.Deserialize("[\"2027-02-01T09:30:00\"]", TemporalSetJsonContext.Default.DateTimeOffsetSet)); + Assert.AreEqual( + DateTimeOffsetSet.From(new DateTimeOffset(2027, 2, 1, 9, 30, 0, TimeSpan.FromHours(2))), + JsonSerializer.Deserialize("[\"2027-02-01T09:30:00+02:00\"]", TemporalSetJsonContext.Default.DateTimeOffsetSet)); + } + + private sealed class OffsetlessIsUtcConverter : JsonConverter + { + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => DateTimeOffset.Parse(reader.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture)); + } +} + +[JsonSerializable(typeof(DateTimeOffsetSet))] +[JsonSerializable(typeof(DateTimeOffset))] +internal sealed partial class TemporalSetJsonContext : JsonSerializerContext; diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index 43878aa..a7e87de 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -228,6 +228,10 @@ Case("aot-stringset", () => }); // Through object: the serializer resolves the runtime type (the variant) from the context. Case("aot-object", () => JsonSerializer.Serialize(h1, ctx.Object)); +// A closed temporal set reads its elements with the literal grammar: an offset is required. +Case("aot-dtoset", () => JsonSerializer.Serialize( + JsonSerializer.Deserialize("[\"2026-01-01T09:30:00+01:00\"]", ctx.DateTimeOffsetSet)!, ctx.DateTimeOffsetSet)); +Case("aot-dtoset-offsetless", () => JsonSerializer.Deserialize("[\"2026-01-01T09:30:00\"]", ctx.DateTimeOffsetSet)!.ToString()); #pragma warning disable IL2026 // TypeDescriptor is not trim-safe in general; the converter lookup is what is tested. Case("aot-typeconverter", () => TypeDescriptor.GetConverter(typeof(RangeSet)).ConvertFromInvariantString("{[1,5]}")!.ToString()!); #pragma warning restore IL2026 @@ -252,6 +256,8 @@ public readonly record struct Key(string Value) : IFormattable, IParsable [JsonSerializable(typeof(StringSet))] [JsonSerializable(typeof(string))] [JsonSerializable(typeof(object))] +[JsonSerializable(typeof(DateTimeOffsetSet))] +[JsonSerializable(typeof(DateTimeOffset))] internal partial class SmokeJson : JsonSerializerContext; public readonly record struct Code(int Value) : IFormattable, IParsable, IComparable @@ -300,6 +306,8 @@ assert_contains 'aot-wrapper=["users.read"] roundtrip' "$work/aot.native assert_contains 'aot-wrapper-read={1,2}' "$work/aot.native.out" "AOT: a validated-wrapper set the program never constructs deserializes" assert_contains 'aot-stringset=["alpha","beta"] roundtrip' "$work/aot.native.out" "AOT: a primitive set round-trips through the documented minimal context" assert_contains 'aot-object="[2026-01-01,2026-06-30]"' "$work/aot.native.out" "AOT: a range serializes through object" +assert_contains 'aot-dtoset=["2026-01-01T09:30:00+01:00"]' "$work/aot.native.out" "AOT: a DateTimeOffsetSet round-trips" +assert_contains 'aot-dtoset-offsetless=FAILED JsonException' "$work/aot.native.out" "AOT: a DateTimeOffsetSet element without an offset is refused" assert_contains 'aot-typeconverter={[1,5]}' "$work/aot.native.out" "AOT: TypeConverter for a RangeSet the program never constructs" # ── EF Core + NodaTime satellite: the whole dependency chain ──────────────────────────────────────