diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4aeabb5..01d83f3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -36,6 +36,9 @@ updates: ignore: - dependency-name: "Microsoft.EntityFrameworkCore*" - dependency-name: "Npgsql*" + # The test projects reach NodaTime only through their reference to the shipping satellite, + # so an update here is an edit to src/: 9.0.0 nearly shipped a raised floor that way. + - dependency-name: "NodaTime" # The ASP.NET Core packages version with the shared framework: 11.x needs net11.0. - dependency-name: "Microsoft.AspNetCore*" update-types: [ "version-update:semver-major" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd9ec9..767cb14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,11 +42,26 @@ must not pick any of these up silently. 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. +7. **EF Core queries composing on a server-computed value set `Union` throw where they answered + wrongly** — equality in a projection or on a projected union, a conditional or `??` around one, + `GroupBy`, `Distinct`, `OrderBy`, set operations, `Count` through `??`, and `ExecuteUpdate` to a + union. Each returned rows that disagreed with memory, or stored a non-canonical array. +8. **The literal grammar follows PostgreSQL's.** A quoted-empty or whitespace-only range bound is + refused instead of read as unbounded; a backslash escapes in unquoted array elements and range + bounds (`{a\,b}` is one element); `{{a,b}}`, `{a}b}` and `{a"b}` are refused; only ASCII + whitespace is skipped. An empty format string writes the literal, as `null` does. +9. **A `DateTime` literal with an offset keeps its wall clock.** `2026-01-01T09:30+05:30` is 09:30 + (`Local`) on every machine; it was converted into the parsing machine's zone. +10. **JSON precedence and coverage changed in three places.** A union converter registered on the + options now reaches values serialized through `object` or as a variant; a property declared as + a range or set interface writes the literal (it wrote `{}`); and a NodaTime date in a non-ISO + calendar — bare, or as a wrapper-set element — is refused, where it was written thirteen days + off. 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 OpenAPI transformer to copy. The -[migration guide](docs/migration.md#migration-from-v8x) has the call-site edits. +[migration guide](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#migration-from-v8x) has the call-site edits. ### Changed @@ -79,8 +94,12 @@ OpenAPI transformer to copy. The 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 + 100 ns; `T` or a space between them; for a `DateTime` an optional `Z` (`Utc`) or `±HH`, + `±HH:mm` or `±HH:mm:ss` (`Local`) with the wall clock kept as written — **the offset is no longer + applied**: it was converted into the parsing machine's zone, so `2026-01-01T09:30+05:30` was + 05:00 in Berlin and 04:00 on a UTC server, and a `tsrange` stores the wall clock; PostgreSQL's own + `timestamp` input drops the offset the same way, and a local value now round-trips exactly, even + across a DST gap — 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 @@ -96,6 +115,12 @@ OpenAPI transformer to copy. The grammar; `TemporalLiteralStrictnessContractTests` (conventions) refuses a non-ISO element through every entry point of all 26 temporal range and set types. +- **The range interfaces are declared not to be an extension point.** `IRange`, the shape + interfaces and `IRangeFactory` 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. Nothing changes for code that consumes them. The XML docs and `docs/architecture.md` + state the policy. + ### Fixed - **⚠️ Interpolating a `DecimalRange` under a comma-decimal culture produced a different range.** @@ -116,7 +141,74 @@ OpenAPI transformer to copy. The explicit comma-decimal provider misread silently: `DecimalRange.Parse("[1.5,2.5)", de-DE)` was `[15,25)`. They now parse with `NumberStyles.Float` — sign, digits, point, exponent — and those inputs are a `FormatException`. Integer types already used `NumberStyles.Integer`. **If you - relied on the lenient forms, they now fail.** + relied on the lenient forms, they now fail.** The wrapper arities are covered too: `DecimalSet` + and the integer arities handed the raw element text to the element's own `Parse`, and a generated + one (`decimal.Parse(s, provider)`, `NumberStyles.Number`) accepts group separators, so + `DecimalSet.Parse("{\"1,5\"}")` was `{15}` — through `TryParse`, configuration binding and + a JSON string too — while `DecimalSet` refused it. They now parse with the family's grammar first + and hand the element its invariant text, so its validation still runs and scale is kept; the + decimal JSON writer and the EF Core bridge read element text the same way. + `WrapperGrammarContractTests` (conventions) holds every wrapper family to its primitive sibling. + +- **⚠️ An empty format string wrote a culture-formatted literal.** .NET reads `""` as the general + format, exactly like `null`, but the literal checked only for `null`: `ToString("", de-DE)` on + `DecimalRange [1.5,2)` wrote `[1,5,2)`, a `DateRange` with `("", th-TH)` Buddhist-era short + dates. An empty format now writes the invariant literal everywhere `null` does. + `LiteralProviderContractTests` and `LiteralCultureContractTests` sweep `""` alongside `null`. + +- **⚠️ A quoted empty range bound read as an unbounded one.** `Int32Range.Parse("[\"\",5)")` was + `(,4]` and `("","")` was `(,)`, silently, where PostgreSQL answers `invalid input syntax`; a + whitespace-only bound (`[ ,5)`) was trimmed to nothing the same way. Bounds now follow PostgreSQL's + `range_in`: only an absent bound is infinite, quotes may wrap any part of a bound, and a backslash + escapes the next character inside quotes or out. Applies to every range type and multirange. + +- **⚠️ Array literals departed from PostgreSQL's `array_in`.** `StringSet.Parse(@"{a\,b}")` was two + elements, `a\` and `b`, where PostgreSQL reads the one element `a,b`; `{{a,b}}` was the set + `{"{a","b}"}` where PostgreSQL reads a two-dimensional array; `{a}b}` was `{"a}b"}` where + PostgreSQL errors. Unquoted elements now honour backslash escapes, an unescaped `{`, `}` or `"` + inside one is a `FormatException`, and whitespace inside an element is kept. Both literal parsers + skip only the ASCII whitespace PostgreSQL skips. `LiteralGrammarParityTests` (integration) asks a + live server for the same answer. + +- **The NodaTime types refused `HH:mm`.** `TimeRange.Parse("[09:00,17:00)")` parsed but + `LocalTimeSet.Parse("{09:00}")` threw. `HH:mm` is now accepted wherever the BCL types accept it — + a `LocalTime`, the time part of a `LocalDateTime`, an `Instant` with its offset or `Z`. + +- **⚠️ A converter registered on the options for a range union reached union-typed declarations + only.** System.Text.Json resolves a converter by the exact type, and a range reached through + `object` or declared as a variant is handed over as its variant, whose own `[JsonConverter]` + wrote the literal — so with a custom `Int32Range` converter, `Serialize(range)` wrote the custom + form and `Serialize(range)` wrote `"[1,2]"`. The variant converter now hands values and + dictionary keys, on both legs, to whatever System.Text.Json would choose for the union from + `Converters`, unless that is this package's own; a read goes through the union and narrows. + **Payload change** for anyone who registered a union converter and serialized ranges through + `object`. + +- **⚠️ A property declared as a range or set interface was written as a property dump.** + `IRange` wrote `{}` and `IValueSet` `{"Values":["a"]}`, with no error, and neither + read back. The interfaces now carry `InterfaceDeclaredJsonConverter`, which writes through the + runtime type's contract (the literal, or a converter registered for that type) and refuses a read + with `NotSupportedException` naming the remedy: declare the concrete type. + +- **⚠️ NodaTime: `AddNodaTimeRangeConverters()` shadowed `ConfigureForNodaTime`, and wrote bare + non-ISO values in their own calendar's fields.** Registered first, its element converters won for + all five NodaTime types, contrary to the documented "either order"; and a Julian + `LocalDate(2024, 1, 1)` — ISO 2024-01-14 — was written `"2024-01-01"` and read back thirteen days + off. Any other converter registered for the type now wins wherever it sits, and a bare + `LocalDate`, `LocalDateTime` or `YearMonth` in a non-ISO calendar is refused on write with + `ArgumentException`, as NodaTime's own converter refuses it. **Convert with + `WithCalendar(CalendarSystem.Iso)` first.** + +- **⚠️ NodaTime wrapper sets stored an element in another calendar as a different date.** + NodaTime's ISO patterns format a value in its own calendar's fields, so a `LocalDateSet` over a + Julian `LocalDate(2024, 1, 1)` — ISO 2024-01-14 — wrote `{2024-01-01}` and `["2024-01-01"]`, and + the EF Core bridge bound, inlined and stored ISO 2024-01-01: thirteen days off, with no error and + an unequal round trip. `LocalDateTimeSet` and `YearMonthSet` likewise; against live + PostgreSQL a Julian-January parameter matched a row holding ISO January. The closed sets convert + such a value to ISO; a wrapper arity can only build its element by parsing, so it now **refuses** + it with `ArgumentException` — in `From`, collection expressions and reads, and as the probe of + `Contains`/`Add`/`Remove` — and the EF Core bridge refuses one as a bare operand. **If you wrapped + non-ISO values, convert them with `WithCalendar(CalendarSystem.Iso)` first.** - **⚠️ A culture provider no longer changes the literal.** `Parse(s, provider)` handed the provider to the element parser, and `ToString(null, provider)` to the element formatter. `th-TH` uses the @@ -150,9 +242,11 @@ OpenAPI transformer to copy. The 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. + - **`ExecuteUpdate` of a value set inside the document is refused**: EF Core writes it with + `to_jsonb`, storing a JSON array the document cannot hold, after which the row no longer loads. + The refusal reads the translated `UPDATE`, so a join or `EF.Property` cannot reach past it. + Ranges, multiranges, a whole complex property holding a set, and the whole JSON property can be + set. - `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 @@ -188,6 +282,24 @@ OpenAPI transformer to copy. The store text like any other constant. `Contains`, `Overlaps`, captured variables and `EF.Constant` were never affected. `InlineRangeConstantExecutionTests` (integration) runs one per range type. +- **⚠️ A server-computed value set `Union` is refused wherever it would answer wrongly (EF Core).** + `Union` translates to `array_cat`, which concatenates; 8.0.0's LINQ-level refusal caught only + `==`/`Equals` in predicates and `Count` over a bare union, and its claim that a projection is + client-evaluated was false. Against live PostgreSQL, with `{a,c} ∪ {a,b}` and target `{a,b,c}`: a + projected comparison answered `false`, a ternary or `??` around the union matched 0 rows, + `GroupBy` made 2 groups where memory makes 1, `Distinct().Count()` said 2, and `ExecuteUpdate` + stored `{a,c,a,b}`. The refusal now runs on the final SQL tree, through a decorator around the + provider's `IRelationalParameterBasedSqlProcessorFactory`, and follows a union through + `array_remove`, `CASE`, `COALESCE` and subquery columns. It may still feed `@>`, `<@`, `&&`, + `Remove`, `IsEmpty`, or the outermost projection, which materializes canonical. + `UnionCanonicalityTests` (integration) executes every shape and the legal compositions. + +- **A NodaTime range's element operand in another calendar was compared by its own fields (EF + Core).** `LocalDateRange` and `LocalDateTimeRange` passed no ISO normalization for an operand, so + `Contains(EF.Constant(julianDate))` rendered the Julian year, month and day as if they were ISO + and missed its row, and a `LocalDate` parameter threw inside Npgsql. The operand is now converted + to ISO, as the bounds are and as the NodaTime sets already did. + ### Added - **A `TypeConverter` on every range, variant, multirange and value set.** None had one, so diff --git a/CLAUDE.md b/CLAUDE.md index 2c0e158..2038d04 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,10 +61,10 @@ repo-wide. - **Do NOT** add new range types without verifying the generic engines cover them — a new type must implement `IRange` + `IRangeFactory` with the five sealed variants; the engines in `Internals/` dispatch per shape through the structural interfaces - **Ranges — NEVER decide a binary operation by switching on the receiver's shape.** A binary relation is a function of the *pair* of shapes: read the bounds it actually compares, or switch on `(left, right)`. Switching on the receiver and handling the operand's shapes in an inner switch has now produced the same bug four times — `IsAdjacentTo` (fixed 6.2.1), `IsStrictlyLeftOf` and `Except` (both 7.0.0), and `RangeSet.Except(TRange)` with an infinity operand (8.0.0). The tell is an inner switch with a `_` fallback: it answers `false`, or returns the receiver unchanged, for exactly the operand shapes nobody wrote an arm for. **This applies to value-producing operations, not just predicates** — `Except` returned a well-formed range holding the wrong values, which is the harder one to notice. It hides well because the EF translation is correct, so the disagreement is between memory and the database rather than inside either. `ShapeMatrixParityTests` asks PostgreSQL for every ordered shape pair and is the check that catches it - **Ranges — a shape dispatch's `_` arm MUST throw, never produce a value.** C# cannot prove a switch over interface patterns exhaustive, so the discard arm cannot be removed — the point is that it stops answering. Since 8.0.0 the three engines have one entry point each taking `IRange` on both sides, switch over `(left, right)` with one arm per accepted pair, and throw `ShapePair.Unreachable` naming the pair for the rest, so an unhandled pair is a missing *line* and a loud failure rather than a plausible value. `EngineDispatchConventionTests` parses `src/**/Internals/` and enforces both halves — throwing discards, and entry points that are not typed by one operand's shape -- **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 +- **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. The range and set interfaces carry `InterfaceDeclaredJsonConverter` (write through the runtime type, refuse a read), and a variant converter defers to a union converter registered on the options — both so a value's wire form never depends on how it was declared - **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 +- **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`. A `DateTime` offset is accepted and **never applied** — the wall clock is kept, as `Local`, which is PostgreSQL's `timestamp` input rule; converting it made one literal a different `tsrange` value on every machine. 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 @@ -76,4 +76,4 @@ repo-wide. - **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 — 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 +- **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. Since 9.0.0 everything else is refused on the **final SQL tree** by `ValueSetSqlGuard`, run from a decorator around the provider's `IRelationalParameterBasedSqlProcessorFactory` (every query, `ExecuteUpdate` and `ExecuteDelete` passes through it). It **fails closed**: the translator emits the placeholder `ValueSetSqlGuard.UnguardedUnion` and only the guard renames it to `array_cat`, so a `ReplaceService` that drops the decorator makes every server-side `Union` fail on the server instead of silently skipping the refusals. Never emit `array_cat` directly, and never match it by name before the guard has run. It follows values *derived* from `array_cat` — through `array_remove` (which preserves canonical form rather than establishing it), `CASE`, `COALESCE` and subquery/set-operation columns — and allows them only under `@>`/`<@`/`&&`, `cardinality(…) = 0`, a null test, or in the outermost projection (materialization canonicalizes); equality, `cardinality`, `GROUP BY`, `DISTINCT`, `ORDER BY`, deduplicating set operations, `IN` and `UPDATE` setters throw. Never go back to recognizing these in LINQ: the 8.0.0 guard matched `==` inside a list of operators and a projection, a ternary, a coalesce, `GroupBy`, `Distinct` and `ExecuteUpdate` all translated and answered wrongly. `ValueSetsMemberTranslator` still *declines* `Count` over a visible union, which is what gives a projection its client fallback. The same guard refuses `to_jsonb()` in an `UPDATE` setter — `ExecuteUpdate` of a value set inside a JSON column, which writes a JSON array where the document holds the literal, after which the row no longer loads — keyed off the translated setter, so a join or `EF.Property` cannot bypass it. `UnionCanonicalityTests` and `ExecuteUpdateJsonTests` (integration) assert results, not SQL. 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/CONTRIBUTING.md b/CONTRIBUTING.md index 4b14e23..702bd40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,14 +15,14 @@ Shipping projects live under `src/`, test projects under `test/`. and [docs/testing.md](docs/testing.md) behind it — worth reading before changing a range type, a value set family, or the EF plugin. -The integration suite spins up a real PostgreSQL container via Testcontainers. Without Docker it -goes inconclusive locally, so skip it if you need to: +The two integration projects (`test/*IntegrationTests/`) spin up a real PostgreSQL container via +Testcontainers. Without Docker they go inconclusive locally, so skip them if you need to: ```bash -dotnet test test/CodoMetis.ValueRanges.Tests +dotnet test --filter "FullyQualifiedName!~IntegrationTests" ``` -On CI the same suite **fails** rather than skipping. An unreachable container must not quietly +On CI they **fail** rather than skip. An unreachable container must not quietly retire the layer that is the authority on PostgreSQL semantics while the build reports green. ## The quality bar diff --git a/docs/architecture.md b/docs/architecture.md index f256d1b..4dae76e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -191,8 +191,11 @@ of them; one that swallows it is rejected at the persistence boundary. failure modes are a wrong answer, a leaked NodaTime exception, or a non-ISO element smuggled into an empty set. The EF satellite mirrors it through the definition's `normalizeValue`, which now also backs an `ElementTypeMapping` so the same probe normalizes on the server. -- `Internals/SetFormat` implements PostgreSQL array-literal parse/format (quoting, escaping, - unquoted `NULL` rejected — sets never contain null; reads throw on corrupt data). +- `Internals/SetFormat` implements PostgreSQL array-literal parse/format with `array_in`'s grammar + (quoting; backslash escapes in quoted and unquoted elements; an unescaped brace or quote inside + an unquoted element, or a nested `{`, refused — sets are one-dimensional; ASCII whitespace only + around elements; unquoted `NULL` rejected — sets never contain null; reads throw on corrupt data). + `Internals/RangeFormat` reads bounds with `range_in`'s: only an absent bound is infinite. ## JSON Serialization (`Serialization/`) @@ -224,8 +227,8 @@ what each attribute names: directly with no reflection. - **`RangeSet` and the wrapper arities name `RangeJsonConverterFactory`.** An attribute cannot name an open generic converter (`typeof(RangeSetJsonConverter<,>)` fails at runtime with - `ContainsGenericParameters`), so these go through the factory's `MakeGenericType`, as a registered - factory always did. + `ContainsGenericParameters`), so these name the factory, which takes the closed converter from the + type's own `GenericConverters` registration (see *Native AOT* below) — never from `MakeGenericType`. `[TypeConverter]` follows the same three rules with `RangeTypeConverter`, `RangeVariantTypeConverter`, `ValueSetTypeConverter` and, for the generic types, `LiteralTypeConverter` — which `TypeDescriptor` @@ -244,14 +247,32 @@ System.Text.Json resolves converters by the type it is handed, so `Serialize.CanConvert(typeof(Int32Range.Finite))` is false — so +the variant's attribute would answer with the literal while union-typed declarations got the custom +form. `RangeVariantJsonConverter` therefore looks up what System.Text.Json would choose for the union +from `Converters` (first match, a factory expanded through its own `CreateConverter`) and delegates to +it, values and keys, unless that is a converter from this package. The lookup is cached per options +instance once the options are read-only. Reads go through the union and narrow, as the literal path +does. Nothing is built by reflection. + Since 9.0.0 `RangeJsonConverterFactory` builds nothing at runtime. It reads the type's own `[JsonConverter]`: a non-generic type names a closed converter, which the factory creates from the attribute (whose `ConverterType` is annotated for its constructor, so this is trim-safe); a generic type names the factory itself, and the closed converter comes from `GenericConverters` (below). `CanConvert` is "names a converter of this package", so the factory no longer walks interfaces. -A property declared as the `IRange`/`IValueSet` *interface* is not handled — the interface -carries no factory to parse back through, so it falls to System.Text.Json's default handling. +A value declared as one of the *interfaces* — `IRange`, the five shape interfaces, `IValueSet` — +resolves the interface, whose attribute is not inherited from anything and would otherwise be absent +(System.Text.Json then dumps the interface's members). Each carries +`[JsonConverter(typeof(InterfaceDeclaredJsonConverter))]`, one non-generic `JsonConverter` +that System.Text.Json casts to the declared interface: it writes through +`options.GetTypeInfo(value.GetType())` — the runtime type's own resolution, reflection-based or +source-generated, so no generic converter is closed at runtime — and refuses a read with +`NotSupportedException`, since an interface does not say which type to construct. +`JsonDefaultContractTests` discovers the core-namespace interfaces each variant implements and checks +both legs for every shape. The factory interfaces are excluded: their static abstract members rule +them out as a declared type. ### Native AOT and `GenericConverters` @@ -301,14 +322,17 @@ asserted rather than fixed (`SetJsonConverterTests`) — matching the native wri would mean reproducing System.Text.Json's temporal formatting for an element known only through `IFormattable`, and the round-trip form is the one the array literal and the EF bridge share. The NodaTime satellite defines -one per set (`Serialization/NodaTimeElementJsonConverter.cs`, namespace +one per element type (`Serialization/NodaElementJsonConverters.cs`, namespace `CodoMetis.ValueRanges.Serialization`), each reusing the family's own `ParseValue`/`FormatValue` so -JSON, array literals and the wire form share one text form. The satellite additionally exposes -`AddNodaTimeRangeConverters()`, which puts those same converters on the options — the hook only -reaches set elements, so this is what covers bare NodaTime properties alongside a set. It skips -element types an existing converter already claims, which makes it idempotent and -order-independent against `ConfigureForNodaTime`. Range types are unaffected throughout — they -format themselves. +JSON, array literals and the wire form share one text form; the three calendar-bearing ones refuse a +non-ISO value on write, as NodaTime's own converter does (a set has already normalized it). The +satellite additionally exposes `AddNodaTimeRangeConverters()`, which puts those same converters on +the options behind a factory — the hook only reaches set elements, so this is what covers bare +NodaTime properties alongside a set. The factory is resolved only once the options are in use, so it +looks past itself and hands over any other converter registered for the type, wherever it sits in +`Converters`: that is what keeps `ConfigureForNodaTime` authoritative in either call order (a plain +converter list is first-match, which made the order decide). Range types are unaffected throughout — +they format themselves. ## EF Core PostgreSQL (`src/CodoMetis.ValueRanges.EFCore.PostgreSQL/`) @@ -323,7 +347,8 @@ format themselves. - **Element mappings** (`BridgedElementTypeMapping`): a bare element parameter in `col @> ARRAY[@p]` binds as its backing primitive — the same definition-supplied converting-element-mapping seam `YearMonth` uses. Two producers: the wrapper families (text-form contract fails loudly here with an error naming it), and any `SetTypeDefinition` carrying a `normalizeValue`, which gets an element mapping applying that same normalization (the server-side half of `NormalizeElement`) - **`BridgedSetTypeDefinition`** carries the wrapper bridge itself: an element format plus parse/format delegates over the primitive, rather than the element's default text form and `IParsable`. Both halves are load-bearing. The format is what keeps a temporal element from being truncated on the way to the column. The delegates exist because `IParsable` is too narrow twice over — `DateTime.Parse` reached through it cannot ask for `DateTimeStyles.RoundtripKind`, so a UTC element arrives as `DateTimeKind.Local`, and NodaTime's value types do not implement `IParsable` at all. The temporal families parse strictly (`ParseExact`), which turns "the wrapper ignored the format specifier" from silent truncation into the contract error - **Translators**: `ValueSetsMethodCallTranslator` (Contains → `@>` `ARRAY[value]` unconditionally — always GIN-servable; Overlaps/IsSubsetOf/IsSupersetOf → `&&`/`<@`/`@>`; IsProperSubsetOf/IsProperSupersetOf → the operator paired with its **negated converse** rather than `<>`, keeping both halves multiplicity-insensitive; Remove → `array_remove`; Union → `array_cat`) and `ValueSetsMemberTranslator` (`Count`/`IsEmpty` → `cardinality`). `Intersect`/`Except`/`Add` have no translation: PostgreSQL's array type has only 9 operators (`@> <@ && = <> < <= > >=`) against the range type's 17 — there is no array intersection or difference, and no sorted insert (`array_sort` orders by collation, not ordinal). They stay in the core library because it has no EF dependency and is an in-memory type family first. Set `==` is translated by EF itself as `col = @p` and assumes canonical writers; all package-translated operators are order-insensitive and stay correct against non-canonical rows - - **Composing on a translated `Union`**: `array_cat` concatenates rather than canonicalizing. That is invisible to the operators above and to materialization (reads route through `From`), but `cardinality` would double-count shared elements — so `Count` over an `array_cat` operand is **refused** by the member translator (EF reports the query as untranslatable, which beats a quietly inflated number), while `IsEmpty` stays translated (a concatenation is empty exactly when both sides are). **Equality over a union is wrong and cannot be intercepted** — EF emits the `=` itself. Canonicalizing server-side is not available: PostgreSQL has no array-distinct function (verified against 18.4 — `array_sort`/`array_reverse`/`array_shuffle` exist, nothing that deduplicates; `intarray.uniq` is `int[]`-only and not installed by default), so it would need a `SELECT DISTINCT … ORDER BY` subquery whose ordering could not match CLR canonical order anyway (`text` orders by database collation, not ordinal; `uuid` orders byte-wise where `Guid.CompareTo` orders field-wise) + - **Composing on a translated `Union`**: `array_cat` concatenates rather than canonicalizing. That is invisible to the operators above and to materialization (reads route through `From`), but not to anything sensitive to order or multiplicity. Since 9.0.0 that is refused on the **final SQL tree** by `ValueSetSqlGuard`, run from `ValueRangesParameterBasedSqlProcessorFactory` — a decorator around the provider's `IRelationalParameterBasedSqlProcessorFactory`, the last stage before SQL generation and the one every query, `ExecuteUpdate` and `ExecuteDelete` passes through (EF has no plugin seam there, so the registration wraps Npgsql's rather than replacing it). The guard fails closed: the translator emits `Union` as `ValueSetSqlGuard.UnguardedUnion` (`valueranges_union_without_sql_guard`, a function PostgreSQL does not have) and only `Check` renames it to `array_cat`, so a later registration that *replaces* the factory — dropping the decorator — makes every server-side `Union` fail on the server rather than skip the guard; one that decorates around it still reaches it. Detecting the decorator instead would refuse that legitimate wrapper. The guard tracks values *derived* from `array_cat` — through `array_remove`, `CASE`, `COALESCE`, and subquery or set-operation columns — and allows them only under `@>`/`<@`/`&&`, `cardinality(…) = 0` (`IsEmpty`), a null test, or in the outermost projection; equality, `cardinality`, `GROUP BY`, `DISTINCT`, `ORDER BY`, deduplicating set operations, `IN` and `UPDATE` setters are refused. `ValueSetsMemberTranslator` additionally *declines* `Count` over a union it can see (via `ValueSetSqlGuard.MayBeConcatenation`), which is what lets EF client-evaluate it in a projection. The 8.0.0 LINQ-level guard it replaced matched `==`/`Equals` inside a list of operators and missed every other shape. Canonicalizing server-side is not available: PostgreSQL has no array-distinct function (verified against 18.4 — `array_sort`/`array_reverse`/`array_shuffle` exist, nothing that deduplicates; `intarray.uniq` is `int[]`-only and not installed by default), so it would need a `SELECT DISTINCT … ORDER BY` subquery whose ordering could not match CLR canonical order anyway (`text` orders by database collation, not ordinal; `uuid` orders byte-wise where `Guid.CompareTo` orders field-wise) + - **`ExecuteUpdate` into a JSON column**: Npgsql writes a scalar setter inside a document as `jsonb_set(doc, path, to_jsonb(value))`, which for a value set stores a JSON array where the document holds the literal — the row then no longer loads. The same guard refuses any `to_jsonb`/`to_json` of a value set inside an `UPDATE` setter, naming the column and path, so it holds however the setter reached the property (join, `EF.Property`, nested complex type); a whole complex value is serialized by EF through the plugin's writer and stays allowed - `YearMonthSet` gets a hand-written `YearMonthSetTypeDefinition` (month-aligned `date[]`, reads throw on non-aligned dates), reusing the range family's `YearMonthTypeMapping` as its element mapping - **Enable**: `options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges());` — or `npgsql.UseValueRangesNodaTime()` from the NodaTime satellite, which implies it diff --git a/docs/discard-triage.md b/docs/discard-triage.md index eddbbca..80cf4e3 100644 --- a/docs/discard-triage.md +++ b/docs/discard-triage.md @@ -59,7 +59,7 @@ because an empty or infinity operand genuinely reaches them and genuinely has an `ValueRangesAggregateMethodCallTranslator`, `ValueRangesQueryExpressionInterceptor`, `ValueSetsMemberTranslator` (×2). Returning `null` from a translator means "not mine, try the next one" — the discard is how EF composition works, not a gap. `DerivesFromUnion`'s `_ => false` -terminates a recursion. +terminates a recursion (since 9.0.0 it is `ValueSetSqlGuard.MayBeConcatenation`). ## Fixed (2) diff --git a/docs/efcore.md b/docs/efcore.md index 7114409..73d9ffe 100644 --- a/docs/efcore.md +++ b/docs/efcore.md @@ -168,21 +168,45 @@ bookings.Where(b => b.Tags.Union(more).Contains(tag)); `Union` is the one translated operation whose result is **not** canonical — `array_cat` concatenates. That is invisible to the operators above (all duplicate-insensitive) and to materialization (reads re-canonicalize), but anything sensitive to order or multiplicity is -**refused** rather than translated: `Count`, and since 8.0.0 `==`/`!=`/`Equals`. Array equality -is sensitive to both, and the ordering half is the one that surprises — on the server -`{a,c} ∪ {a,b} = {a,b,c}` is false for the repeated element, and `{a,c} ∪ {b} = {a,b,c}` is false -for the ordering alone, where nothing repeats. Both are true in memory. Compare with -`IsSubsetOf`/`IsSupersetOf` instead, which mean the same thing for canonical sets and ignore both, -or materialize first with `AsEnumerable()` and let the in-memory algebra answer. - -The refusals cover only the contexts EF must translate in full — `Where`, `Any`, `All`, the -ordering and grouping keys, and the predicate overloads. **In a projection both still work**, because -EF falls back to client evaluation there and computes against the materialized set: +**refused** rather than translated. Array equality is sensitive to both, and the ordering half is +the one that surprises — on the server `{a,c} ∪ {a,b} = {a,b,c}` is false for the repeated +element, and `{a,c} ∪ {b} = {a,b,c}` is false for the ordering alone, where nothing repeats. Both +are true in memory. Compare with `IsSubsetOf`/`IsSupersetOf` instead, which mean the same thing for +canonical sets and ignore both, or materialize first with `AsEnumerable()` and let the in-memory +algebra answer. + +Since 9.0.0 the refusal is made on the **translated SQL**, just before it is sent, so it does not +depend on how the query is written. A value derived from a union — `array_cat` itself, +`array_remove` over one, a conditional or `??` that may yield one, a subquery or `Concat` column +projecting one — may only feed `@>`, `<@`, `&&` (`Contains`, `Overlaps`, the subset and superset +family), `Remove`, `IsEmpty`, a null test, or the outermost projection, which is materialized. It is +refused under equality, `Count`, `GroupBy`, `Distinct`, `OrderBy`, `Union`/`Intersect`/`Except` of +queries, `Contains` over a sequence of sets, and as an `ExecuteUpdate` value — which would store +the concatenation, after which equality and `Count` on that row are wrong for good. (Before 9.0.0 a +LINQ-level pattern caught only `==`/`Equals` in predicates and `Count` over a bare union; a +projection, a conditional, a coalesce, a grouping key, `Distinct` and `ExecuteUpdate` all ran and +answered wrongly.) + +The check runs from a decorator around the provider's `IRelationalParameterBasedSqlProcessorFactory`, +and it **fails closed**: a translated `Union` carries the placeholder name +`valueranges_union_without_sql_guard` until the check renames it to `array_cat`. If something +replaces that service after `UseValueRanges()` — a `ReplaceService` call, or another extension that +replaces rather than decorates it — every query with a server-side `Union` fails with PostgreSQL's +`function valueranges_union_without_sql_guard(…) does not exist` instead of answering as if the +check were not there. Decorate the registered factory rather than replacing it, and both keep +working. + +`Count` in a **projection** still works: its translator declines a union, so EF evaluates it on the +client against the materialized set. A comparison does not get that fallback — EF translates `==` +itself — so it is refused in a projection too: ```csharp -bookings.Where(b => b.Tags.Union(more).Count > 2); // throws — must translate -bookings.Select(b => b.Tags.Union(more).Count); // fine — client-evaluated, correct -bookings.AsEnumerable().Where(b => b.Tags.Union(more).Count > 2); // fine — in memory +bookings.Select(b => b.Tags.Union(more)); // fine — materialized canonical +bookings.Select(b => b.Tags.Union(more).Count); // fine — client-evaluated, correct +bookings.Where(b => b.Tags.Union(more).IsSupersetOf(need)); // fine — @> ignores order and duplicates +bookings.Where(b => b.Tags.Union(more).Count > 2); // throws +bookings.Select(b => b.Tags.Union(more) == expected); // throws +bookings.AsEnumerable().Where(b => b.Tags.Union(more) == expected); // fine — in memory ``` There is no package-specific opt-in for the in-memory form, deliberately: `AsEnumerable()` is the @@ -267,7 +291,8 @@ hot, keep the value in a plain column of its own as well. | `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` 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. Recognized in the translated `UPDATE`, so it holds however the setter reaches the property (a join, `EF.Property`, a nested complex type) | +| `ExecuteUpdate` of a whole complex property that contains a value set | ✓ — EF serializes the complex value through the plugin's writer | | `ExecuteUpdate` setting a property to `null` | fails in Npgsql (`42804`), for every type | | `ExecuteUpdate` on an owned JSON type | refused by EF Core | diff --git a/docs/migration.md b/docs/migration.md index 199ab41..f23326a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -161,12 +161,11 @@ relied on this comparison it was already returning wrong rows.** Your options: `Contains`, `Overlaps`, `IsSubsetOf`, `IsSupersetOf`, `IsEmpty`, and the proper subset/superset pair. - Materialize first with `AsEnumerable()` to get the in-memory answer deliberately. -- Keep it in a **projection**, where it still works — EF falls back to client evaluation there and - computes against the materialized set. -The refusal covers only the contexts EF must translate in full: `Where`, `Any`, `All`, the predicate -overloads, and the ordering and grouping keys. It matches how `Count` over a union has been refused -since 6.2.0. +The 8.0 refusal covered only `Where`, `Any`, `All`, the predicate overloads, and the ordering and +grouping keys, on the claim that EF evaluates a comparison in a projection on the client. It does +not — it translates it, and the projection answered wrongly. 9.0.0 closes that and every other +shape; see [the v8.x section](#a-server-computed-value-set-union-is-refused-wherever-it-would-answer-wrongly). ### `RangeSet.Except` with an infinity operand, and `Complement()` @@ -197,8 +196,22 @@ throws. ## Migration from v8.x -v9.0.0 changes what default-options `System.Text.Json` serialization writes, and takes the culture -out of the literal. No signature changed. +No signature changed. What changed is what existing calls write, accept and translate — check these, +in order of how likely they are to reach you: + +- **Your JSON payloads change shape** if you never registered `AddRangeConverters()`: every type now + writes its literal by default ([details](#ranges-multiranges-and-value-sets-serialize-as-literals-without-registration)). +- **Input that parsed now throws `FormatException`**: non-ISO temporal text, an offset-less + `DateTimeOffset`, culture-formatted numbers, and literals PostgreSQL itself refuses + ([culture](#the-literal-ignores-the-culture), [ISO 8601](#temporal-literals-are-strict-iso-8601)). +- **Queries that ran now throw `InvalidOperationException`**: anything over a server-computed value set + `Union` other than projecting it or testing containment and overlap — those queries were already + returning wrong rows ([details](#a-server-computed-value-set-union-is-refused-wherever-it-would-answer-wrongly)). +- **A `DateTime` with an offset keeps its wall clock** instead of converting into the machine's zone, + and NodaTime values in a non-ISO calendar are refused on write ([ISO 8601](#temporal-literals-are-strict-iso-8601), + [JSON](#three-json-precedence-and-coverage-changes)). +- **Temporal constants and JSON-column values reach the database truncated to the microsecond**, as a + parameter always did ([details](#temporal-values-reach-the-database-as-text-at-its-resolution)). ### Ranges, multiranges and value sets serialize as literals without registration @@ -223,8 +236,9 @@ type's attribute. ### The literal ignores the culture `Parse(s, provider)` and `TryParse(s, provider, …)` now read the invariant literal and ignore the -provider, and `ToString(null, provider)` — or interpolation, or `string.Format` — always writes the -invariant literal. A provider still applies with an explicit format string. +provider, and `ToString(null, provider)` — or `ToString("", provider)`, interpolation, or +`string.Format` — always writes the invariant literal. A provider still applies with an explicit, +non-empty format string. Before, the provider reached each bound: `DateRange.Parse(s, th-TH)` was 543 years early, `DecimalRange.Parse("[1.5,2.5)", de-DE)` was `[15,25)`, and `$"{decimalRange}"` under `de-DE` wrote @@ -237,7 +251,11 @@ 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. +symbols are a `FormatException` rather than 1000, −5 and 5. That includes the wrapper arities: +`DecimalSet` and the integer ones read the element text with the primitive family's grammar before +the element's own `Parse` sees it, so a generated `Parse` that accepts group separators no longer +turns `{"1,5"}` into fifteen, and `DecimalSet` now reads exponent notation (`{1e3}`, a JSON +`1.5e1`) as `DecimalSet` does. ### Temporal literals are strict ISO 8601 @@ -253,10 +271,29 @@ whatever the invariant culture could, month first. These are now a `FormatExcept | `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 | +A `DateTime` bound or element with an offset — `2026-01-01T09:30+05:30` — now keeps the wall clock +as written, with `DateTimeKind.Local`, as PostgreSQL's `timestamp` input does. It used to be +converted into the parsing machine's zone: 05:00 in Berlin, 04:00 on a UTC server, and a `tsrange` +stores that wall clock. If a payload carries offsets because it means instants, the type it wants is +`DateTimeOffsetRange` (`tstzrange`); convert explicitly (`ToUniversalTime()`) before building a +`DateTimeRange`. Offsets are `±HH`, `±HH:mm` or `±HH:mm:ss`; `+0530` and `+5:30` are refused. + +A range bound that is a quoted `""` or only whitespace — `["",5)`, `( , )` — is now a `FormatException` +rather than an infinite bound, as in PostgreSQL; only an absent bound (`(,5)`) is infinite. A backslash +outside quotes now escapes the next character, in a range bound and in an array element alike, as +it does in PostgreSQL: `StringSet.Parse(@"{a\,b}")` is the one element `a,b`, where it was the two +elements `a\` and `b`. An array literal PostgreSQL refuses or reads as multi-dimensional — `{{a,b}}`, +`{a}b}`, `{a"b}` — is a `FormatException`; `{{a,b}}` used to be the set `{"{a","b}"}`. Only ASCII +whitespace is skipped around elements and bounds; a no-break space is now kept as element text (and +refused inside a range bound), where it was trimmed. + `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. +read instead of truncating silently. The NodaTime types were already strict; they now also accept +`HH:mm` wherever the BCL types do — a `LocalTime`, the time part of a `LocalDateTime`, and an +`Instant` written with its offset or `Z` — so `LocalTimeSet.Parse("{09:00}")` reads like +`TimeRange.Parse("[09:00,17:00)")` instead of throwing. ### Temporal values reach the database as text at its resolution @@ -266,7 +303,53 @@ the value Npgsql would bind: truncated to the microsecond toward 2000-01-01, wit 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). +column is refused, however the setter reaches the property (it would have written a document the +row no longer loads from); setting a whole complex property that contains the set still works. + +### A server-computed value set `Union` is refused wherever it would answer wrongly + +`Union` translates to `array_cat`, a concatenation. 8.0 refused `==`/`Equals` over it in predicates +and `Count` over a bare union; everything else ran and answered differently from memory. These now +throw `InvalidOperationException` naming the remedy: + +```csharp +db.Rows.Select(r => r.Tags.Union(x) == target); // was: false where memory says true +db.Rows.Where(r => (r.Id > 0 ? r.Tags.Union(x) : r.Tags) == target); // and the ?? form: was 0 rows +db.Rows.GroupBy(r => r.Tags.Union(x)); // was: one group per operand order +db.Rows.Select(r => r.Tags.Union(x)).Distinct(); // was: duplicates kept +db.Rows.OrderBy(r => r.Tags.Union(x)); +db.Rows.Where(r => (r.OptionalTags ?? r.Tags.Union(x)).Count == 3); // was: duplicates counted +db.Rows.ExecuteUpdate(s => s.SetProperty(r => r.Tags, r => r.Tags.Union(x))); // was: stored {a,c,a,b} +``` + +**A query that ran into one of these was already returning wrong rows**, and an `ExecuteUpdate` of +a union left rows that compare and count wrongly on the server until rewritten — load and save them +through `SaveChanges` to canonicalize. Use `IsSubsetOf`/`IsSupersetOf` (both together are set +equality), or materialize first. Projecting the union itself, and composing it with `Contains`, +`Overlaps`, the subset/superset family, `Remove` and `IsEmpty`, keep working. + +**If you replace `IRelationalParameterBasedSqlProcessorFactory`** after `UseValueRanges()` — with +`ReplaceService`, or through another library — every query with a server-side `Union` now fails with +`function valueranges_union_without_sql_guard(…) does not exist`. The check runs from that service, +and a translated `Union` only becomes `array_cat` once the check has run. Wrap the registered factory +instead of replacing it. + +### Three JSON precedence and coverage changes + +- **A converter you register on the options for a range type now reaches its variants.** A range + serialized through `object`, an `object` collection or a variant-typed property used to bypass it + and write the literal; it now goes through your converter, so one payload no longer mixes two + wire forms. If you relied on the mixed form, declare those properties as a type your converter + does not claim. +- **A property declared as `IRange`, a shape interface or `IValueSet`** writes the literal (or + array) of its runtime type, where it wrote `{}` or a property dump. Reading one still cannot work — + the concrete type is unknown — and now throws `NotSupportedException`; declare the concrete type. +- **NodaTime:** `AddNodaTimeRangeConverters()` no longer outranks `ConfigureForNodaTime` when + registered first, and a bare `LocalDate`, `LocalDateTime` or `YearMonth` in a non-ISO calendar is + refused on write (`ArgumentException`), where it was written in its own calendar's fields and read + back days off. The same holds for an element of `LocalDateSet`, `LocalDateTimeSet` or + `YearMonthSet`, in memory and through EF Core. Convert with `WithCalendar(CalendarSystem.Iso)` + first. ### Ranges can be dictionary keys diff --git a/docs/serialization.md b/docs/serialization.md index ff125b3..2f92337 100644 --- a/docs/serialization.md +++ b/docs/serialization.md @@ -42,7 +42,7 @@ The optional `format` parameter is forwarded to the element type, so you can con 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: +**The literal never depends on a culture.** Without a format string — `ToString()`, `ToString(null, provider)`, `ToString("", provider)` (an empty format is the general format, as everywhere in .NET), 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 var thai = CultureInfo.GetCultureInfo("th-TH"); @@ -79,7 +79,7 @@ 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. +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` (read as `Utc`) or `±HH`, `±HH:mm` or `±HH:mm:ss` (read as `Local`), with the wall clock kept as written and the offset never applied — PostgreSQL's rule for `timestamp` input, and what a `tsrange` stores, so one literal is one value on every machine and a local value round-trips with its kind; 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. The NodaTime types follow the same grammar, `HH:mm` included (an `Instant` still needs its offset or `Z`). 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: @@ -118,7 +118,9 @@ PostgreSQL allows quoting individual bounds to embed commas, brackets, or other Int32Range.Parse("[\"1\",\"10\"]", null); // [1, 10] ``` -Inside quotes, `\"` is unescaped to `"` and `\\` to `\`, matching PostgreSQL's quoted-bound syntax. The no-quote fast path stays allocation-free; unescaping only runs when a backslash is actually present inside the quotes. +A bound is read as PostgreSQL's `range_in` reads it. A double quote opens or closes a quoted section anywhere in a bound (`"1"2` is `12`), a doubled quote inside one is a literal `"`, and a backslash takes the next character literally, inside quotes or out (`[\1,5)` is `[1,5)`). Whitespace around the unquoted part of a bound is dropped. + +**Only an absent bound is infinite.** `(,5)` is unbounded below; `["",5)` and `[ ,5)` are not — their lower bound is present and empty, which no element type reads, so they are a `FormatException`, as PostgreSQL answers `invalid input syntax for type integer: ""`. Before 9.0, `["",5)` read as `(,4]` and `("","")` as `(,)`. ### TypeConverter and configuration binding @@ -152,7 +154,13 @@ JsonSerializer.Serialize(DateRange.CreateFinite(new DateOnly(2026, 1, 1), > Before 9.0 the converters were opt-in. Without the registration System.Text.Json fell back to dumping public properties — `DateRange` wrote `{"Length":181}` — and could not read any of it back. -The attribute is the lowest-precedence way System.Text.Json picks a converter, so one you register on the options or put on a property still wins. `AddRangeConverters()` and `RangeJsonConverterFactory` remain and produce the same output; registering them is harmless and now redundant: +The attribute is the lowest-precedence way System.Text.Json picks a converter, so one you register on the options or put on a property still wins. + +A converter registered on the options for a range union also reaches the union's variants. System.Text.Json resolves a converter by the exact type it is handed, and a range reached through `object` — a boxed value, an `object` collection, a dictionary of `object` — or declared as a variant (`Int32Range.Finite`) is handed over as its variant, whose own attribute would answer with the literal. So the variant converter looks for the converter System.Text.Json would choose for the union from `Converters` (the first that handles it, a `JsonConverter` or a factory producing one) and, unless that is this package's own, hands the value to it: values and dictionary keys, on write and on read. A read goes through the union and narrows, so a variant-typed declaration still refuses a value of another shape with `JsonException`. One payload therefore has one wire form per type, however its values are declared. A converter on a *property* stays scoped to that property, and one registered for a variant type itself wins for that variant, as for any type. + +> Before 9.0 a converter on the options for `Int32Range` reached union-typed declarations only: `Serialize(range)` used it, while `Serialize(range)`, a `List` and a variant-typed property wrote the literal. + +`AddRangeConverters()` and `RangeJsonConverterFactory` remain and produce the same output; registering them is harmless and now redundant: ```csharp using CodoMetis.ValueRanges.Serialization; @@ -184,7 +192,7 @@ var set = RangeSet.From([ ]); string setJson = JsonSerializer.Serialize(set); // "\"{[1,5],[7,10]}\"" -// Works with all six range types and their multirange counterparts +// Works with every range type and its multirange counterpart var dates = JsonSerializer.Serialize( DateRange.CreateFinite(new DateOnly(2025, 1, 1), new DateOnly(2025, 12, 31))); // "\"[2025-01-01,2025-12-31]\"" @@ -201,7 +209,19 @@ JsonSerializer.Serialize(Int32Range.CreateFinite(1, 5)); // "\"[1,5]\" JsonSerializer.Serialize(new List { range, dateRange }); // ["[1,5]","[2024-01-01,2024-03-01]"] ``` -Reading into a variant-typed declaration works too, and refuses a literal of the wrong shape: `"empty"` is not an `Int32Range.Finite`, so it throws `JsonException` rather than widening. A property declared as the `IRange` interface is *not* covered — the interface carries no factory to parse back through; declare it as the union type. +Reading into a variant-typed declaration works too, and refuses a literal of the wrong shape: `"empty"` is not an `Int32Range.Finite`, so it throws `JsonException` rather than widening. + +A value declared as one of the package's interfaces — `IRange`, a shape interface such as `IFiniteRange`, or `IValueSet` — is written through its runtime type's converter, so it is the same literal or array as when it is declared as that type (and a converter you registered for that type applies). Reading into such a declaration throws `NotSupportedException`: the interface does not say which type to construct, and the remedy is to declare the concrete type, which reads the same JSON. `null` round-trips as for any reference type. Under a source-generated context the runtime type must be listed, or the write fails naming it. + +```csharp +record Booking(IRange Seats, IValueSet Tags); + +JsonSerializer.Serialize(new Booking(Int32Range.CreateFinite(1, 4), StringSet.From("a"))); +// {"Seats":"[1,4]","Tags":["a"]} +JsonSerializer.Deserialize(json); // NotSupportedException: declare the concrete type +``` + +> Before 9.0 the interfaces carried no converter, so System.Text.Json dumped the interface's members: `IRange` wrote `{}` and `IValueSet` wrote `{"Values":["a"]}`, with no error, and neither read back. ### Dictionary keys @@ -242,7 +262,7 @@ It is consulted last — only when System.Text.Json has no scalar converter for JsonSerializer.Serialize(LocalDateSet.From(new LocalDate(2024, 1, 1))); // ["2024-01-01"] ``` -The satellite also ships `AddNodaTimeRangeConverters()`, which registers the same element converters on the options. That extends the ISO 8601 form to bare NodaTime properties sitting *alongside* a set, which the fallback does not reach — see the [satellite README](../src/CodoMetis.ValueRanges.NodaTime/README.md#json). +The satellite also ships `AddNodaTimeRangeConverters()`, which registers the same element converters on the options. That extends the ISO 8601 form to bare NodaTime properties sitting *alongside* a set, which the fallback does not reach. Those converters are a fallback too — any other converter on the options for the same type wins, in either registration order, so NodaTime.Serialization.SystemTextJson stays authoritative — and a bare `LocalDate`, `LocalDateTime` or `YearMonth` in a calendar other than ISO is refused on write with an `ArgumentException`, as NodaTime's converter refuses it. See the [satellite README](../src/CodoMetis.ValueRanges.NodaTime/README.md#json). ## Native AOT diff --git a/docs/testing.md b/docs/testing.md index a70d30f..48f74ea 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -53,17 +53,19 @@ Value set tests (v6) follow the same one-file-per-concern pattern with a `Set` p Coverage of the arities *as a family* lives in the conventions suite, because it is discovery-driven: `ValueSetContractTests` closes every one over a representative element from `WrapperElements.cs` and runs the same three contract assertions it runs on the closed types, so an arity added without a probe or without honouring `NormalizeElement`/`CanonicalOrder`/`ElementJsonConverter` fails there. -`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. +`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 — a union converter on the options reaching values declared as a variant, through `object`, in an `object` collection or dictionary and as variant-typed keys, and following first match — and a source-generated context. `InterfaceDeclaredJsonTests.cs` covers values declared as `IRange`, the shape interfaces and `IValueSet`: written through the runtime type, reads refused naming the remedy, null, a registered converter, 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, every range shape under a custom union converter on the options, and every shape and set family declared as each package interface it implements — 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. 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. +`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 — and accepts `HH:mm` uniformly wherever a time of day appears, NodaTime included. + +The literal grammars themselves follow PostgreSQL's `range_in` and `array_in`. `RangeLiteralGrammarContractTests` (conventions) sweeps every range type: only an absent bound is infinite (a quoted `""` or a whitespace-only bound is refused), quotes and backslash escapes work anywhere in a bound, and only ASCII whitespace is skipped. `SetParseFormatTests` pins the array grammar case by case (escapes in unquoted elements, nested braces and stray quotes refused) plus a round-trip sweep over every structural character. `LiteralGrammarParityTests` (integration) hands the same text to PostgreSQL and requires the same answer — refused by both, or the same value. `WrapperGrammarContractTests` (conventions) holds every wrapper arity, closed over a deliberately lenient element and over its representative one, to its primitive sibling's element grammar through every entry point. `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`. -`test/CodoMetis.ValueRanges.NodaTime.Tests/` covers what is new in the NodaTime satellite — type wiring, the one-day and one-month discrete steps, ISO calendar normalization (and `YearMonthRange`'s non-ISO rejection), wire-form parsing, `Interval`/`DateInterval`/month-alignment interop — with representative shape coverage; the engines themselves are exhaustively covered by the core tests. `NodaTimeSetTests.cs`/`NodaTimeSetJsonTests.cs` do the same for the five set types (calendar normalization, wire-form parsing, JSON with `ConfigureForNodaTime`). `NodaTimeSetElementBridgeTests.cs` covers the five NodaTime arities — ISO literal round-trips, ordering, algebra, JSON, and `CultureBoundDay`, an element that ignores the format argument and so leaks NodaTime's culture long form into the literal. `NodaTimeConverterRegistrationTests.cs` covers both JSON entry points: the `ElementJsonConverter` hook (all five sets round-tripping under plain `AddRangeConverters()` and under a bare factory, a registered converter still winning, identical output either way), and `AddNodaTimeRangeConverters()` (ISO 8601 element forms, all four ranges and all five sets, bare elements alongside a set, idempotence and order-independence against `AddRangeConverters()` and `ConfigureForNodaTime`). +`test/CodoMetis.ValueRanges.NodaTime.Tests/` covers what is new in the NodaTime satellite — type wiring, the one-day and one-month discrete steps, ISO calendar normalization (and `YearMonthRange`'s non-ISO rejection), wire-form parsing, `Interval`/`DateInterval`/month-alignment interop — with representative shape coverage; the engines themselves are exhaustively covered by the core tests. `NodaTimeSetTests.cs`/`NodaTimeSetJsonTests.cs` do the same for the five set types (calendar normalization, wire-form parsing, JSON with `ConfigureForNodaTime`). `NodaTimeSetElementBridgeTests.cs` covers the five NodaTime arities — ISO literal round-trips, ordering, algebra, JSON, the refusal of an element backed by a non-ISO value (construction, collection expressions, a registered element converter, and the `Contains`/`Add`/`Remove` probe against the empty set, where nothing is compared), and `CultureBoundDay`, an element that ignores the format argument and so leaks NodaTime's culture long form into the literal. `NodaTimeConverterRegistrationTests.cs` covers both JSON entry points: the `ElementJsonConverter` hook (all five sets round-tripping under plain `AddRangeConverters()` and under a bare factory, a registered converter still winning, identical output either way), and `AddNodaTimeRangeConverters()` (ISO 8601 element forms, all four ranges and all five sets, bare elements alongside a set, idempotence and order-independence against `AddRangeConverters()` and `ConfigureForNodaTime` — measured through `GetConverter` and a non-ISO date, since a round trip of ISO values passes whichever converter wins — and the refusal of bare non-ISO values on write). EF Core translation tests live in `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/` — they assert generated SQL via `ToQueryString()` without a database. `SetModelMappingTests.cs`/`SetQueryTranslationTests.cs` cover the value set columns: every operator in constant, parameter, and column form, the `string[]`-coexistence and store-name negatives, the without-`UseValueRanges` model-build failure, and the wrapper element binding. `WrapperArityIntegrationTests.cs` closes the live-database half of the same gap: twelve of the fifteen arities had never touched PostgreSQL, so everything known about them was a claim about SQL text. It round-trips each one, asserts the stored column text (a bridge that coarsens *consistently on both legs* round-trips perfectly and stores the wrong thing, which is how the format-pinning defect hides), and runs `Contains` server-side both for a stored element and for a near miss one unit of precision away. Seeding the unpinned-`TimeSet<>` defect fails the round trip, the stored text and the near miss — but *not* the positive `Contains`, since probe and column truncate alike. Its element types are linked from `WrapperElements.cs` in the conventions project rather than copied, so the values that round-trip are the ones whose semantics `ValueSetContractTests` checks. `SqlLiteralTests.cs` pins what the wrapper bridge actually emits — the sub-second digits of a `DateTimeSet` element, its `Kind` normalization, and a `DecimalSet` element's scale — which is the only place that conversion is observable without a database. `WrapperSetTranslationTests.cs` does for the ten core arities what `NodaWrapperSetTranslationTests.cs` (below) does for the five NodaTime ones: the `Contains` literal per arity, the two set-operand forms, equality, cardinality, the `Union` refusal, and the `array_remove` shape — the only operand position where the element carries no array cast to type it. Its precision sweep is stated as *survival of a value*, not absence of a lossy shape, because an arity that stops pinning its format still emits a well-formed ISO literal: unpinning `TimeSet<>` yields `ARRAY['09:30:00.0000000']`, seven fraction digits and fifteen seconds short. `DateTime` and `DateOnly` cannot fail this way — their default form is rejected by the bridge's `ParseExact` — so `TimeOnly` and `decimal` are the two cases with no exception to catch. `QueryTranslationTests.cs` also pins the shape predicates on a single range column, including the two PostgreSQL has no function for — `IsInfinity` as `lower_inf AND upper_inf`, `IsFinite` as the negation of both plus `NOT isempty`. `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/` does the same for the NodaTime types, proving every translation path (operators, functions, factories, aggregates, multiranges, sets, BCL coexistence). `NodaWrapperSetTranslationTests.cs` covers the NodaTime arities specifically — the highest-risk corner, since they carry the most custom bridge code and reach the registry through `RegisterFamily` rather than as closed definitions. Its operands go through `EF.Constant` on purpose: a captured variable is parameterized, and a parameter binds the converted primitive natively, which hides the entire literal path. That is not hypothetical — writing these tests is what caught `LocalDateSet` rendering `ARRAY['Saturday, 15 June 2024']::date[]`, because its family registration had left `literalText` at the default. @@ -81,6 +83,8 @@ Three further parity sweeps close the gaps the 8.0.0 audit left, each the struct - `RangeAggregateParityTests.cs` (group blocks 30000/40000) sweeps `RangeAgg`/`RangeIntersectAgg` against `range_agg`/`range_intersect_agg` over every subset of an eight-range universe that includes `Empty` and `Infinite` — inputs neither aggregate had ever been fed, on either side. The empty and infinity results are also stated as literals rather than compared, because parity alone cannot say the two implementations agree on the *right* answer; the zero-row divergence (`NULL` on the server, the empty set in memory) is executed rather than only documented, via raw SQL, since LINQ cannot express a group with no rows. The expected values come from the shipped aggregates, handed in as delegates because the overloads are per range type: the first draft re-derived the fold in the test, and a seeded defect in `RangeIntersectAgg` walked through it untouched. - `ContinuousMultirangeParityTests.cs` sweeps the continuous multirange algebra against `nummultirange` — every subset of six fragments and every ordered pair through `+`, `-`, `*` and the complement. This is the half of the multirange surface no in-memory oracle can reach (see **The Multirange Oracle** below). The fragments differ only in inclusivity at each meeting point, which is the one thing that decides a merge without a step, and one of them is a single-value range whose only job is to be absorbed or left standing. Worth knowing about the seeds: a blunt break of continuous adjacency fails nine worked examples too, so it proves only that the sweep runs. The seed that shows what it *adds* is an inclusivity-blind lower-bound comparer — the whole core suite notices that only through the comparer's own unit test, while its consequence, unions that stop merging, is caught nowhere at the value level. +`UnionCanonicalityTests.cs` (Id block 12xxx) and the second half of `ExecuteUpdateJsonTests.cs` (96xx) hold the two refusals `ValueSetSqlGuard` makes on the final SQL. Every refusal is asserted by *executing* the query — the 8.0.0 projection test only called `ToQueryString()` and asserted nothing, which is how a comparison that answered `false` against a live server was documented as working. The union data is the probe that found the gaps (`{a,c}` and `{b,c}`, each unioned with `{a,b}`: equal in memory, different concatenations on the server), and the positive half executes every legal composition and compares with memory. Several shapes compose over a `UNION ALL` (`Concatenated`) on purpose: EF inlines a projected union into a later predicate wherever it can, so only a derived table EF cannot see through proves that the guard follows a column back to its `array_cat` — disabling that tracking fails exactly those tests. The JSON half uses `UpdateGuardRow`, whose complex type is mapped both inside a document and as plain columns, so a refusal keyed off the CLR type rather than the translated setter would fail `TheSameComplexTypeOutsideAJsonColumn_IsStillUpdatable`. + `test/CodoMetis.ValueRanges.Conventions.Tests/` covers what the compiler cannot: defects that build, test and pack clean and only surface once a consumer installs the package. Five groups — **changelog consistency** (the shipped `Version` is documented at the root and in every package's changelog, no file runs ahead of the version property, per-package versions are a subset of the root's, sections newest-first), **packaging** (every package declares a `PackageReadmeFile` and packs a README from *its own* directory rather than a shared one, carries a description and tags; the shared props ship symbols and Source Link with `ContinuousIntegrationBuild` gated on CI), **value set contracts** (the `NormalizeElement`, `CanonicalOrder` and `ElementJsonConverter` rules from CLAUDE.md, asserted behaviourally: a set that breaks any of them fails to find an element it was built from, or writes objects where scalars belong), and **mapping parity** (every range and set type in both shipping assemblies resolves through `IRelationalTypeMappingSource` — including each wrapper arity closed over its representative element, which is a second way to be missing from the registry, since the families are registered by open generic definition rather than as closed definitions; plain arrays keep their native provider mapping), and **the default JSON contract** (`JsonDefaultContractTests`: every range shape — through the union, through `object`, inside a `RangeSet`, as a dictionary key — and every set family serializes unregistered exactly as under `AddRangeConverters()`, which is what fails when a new type or variant lacks its own `[JsonConverter]`). Everything is discovered — projects by globbing `src/`, types by reflection — so a new package or type is covered without editing the tests. Two consequences worth knowing: diff --git a/docs/value-sets.md b/docs/value-sets.md index 7197b01..f0334b9 100644 --- a/docs/value-sets.md +++ b/docs/value-sets.md @@ -114,6 +114,8 @@ public string ToString(string? format, IFormatProvider? provider) A wrapper that swallows the argument and returns its own form is rejected rather than silently truncated. +The reverse direction belongs to the family, not the element: every arity reads element text with its primitive sibling's grammar first — PostgreSQL's `numeric` grammar for `DecimalSet`, a signed integer for the integer arities, strict ISO 8601 for the temporal and NodaTime ones — and only then hands the element the parsed primitive's invariant text, so its `Parse` still validates. Whatever a generated `Parse` accepts (`decimal.Parse` with `NumberStyles.Number` takes group separators and no exponent), `DecimalSet` reads `{"1,5"}` as a `FormatException` and `{1e3}` as 1000, exactly as `DecimalSet` does — through `Parse`, `TryParse`, JSON and configuration binding. + ### Ordering and JSON String-backed wrappers sort ordinal over their text form — deliberately not the element's own `IComparable`, whose generated implementations typically delegate to culture-sensitive string comparison. @@ -141,8 +143,12 @@ The same vetting as [for ranges](why.md#why-these-element-types) applies, with o StringSet.From("a b", "plain").ToString(); // {"a b",plain} Int32Set.Parse("{2,1,2}", null); // {1,2} — normalizes on parse StringSet.Parse("{a,NULL}", null); // FormatException — sets never contain null +StringSet.Parse(@"{a\,b}", null); // {"a,b"} — a backslash escapes, quoted or not +StringSet.Parse("{{a,b}}", null); // FormatException — a two-dimensional array is not a set ``` +The grammar is PostgreSQL's `array_in`: whitespace around an unquoted element is dropped and whitespace inside it kept (`{ a b }` is `{"a b"}`); an unescaped `{`, `}` or `"` inside an unquoted element is refused, as is text after a quoted one; only ASCII whitespace counts, so a no-break space is element text, as it is on the server. + JSON serialization needs no registration — every set type carries its converter, as the ranges do — and produces plain JSON arrays (`["alpha","beta"]`), delegating element serialization to System.Text.Json — element converters apply. Reads normalize and reject null elements. Element types the serializer does not know natively are covered by the family's own [element converter](serialization.md#element-converters). ## Converting between sets and ranges diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md index a388e70..c64d7df 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md @@ -14,7 +14,7 @@ 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. The wrapper `YearMonthSet` is written in the date form too; its own + compiled models (`dotnet ef dbcontext optimize`; `--nativeaot` is not supported yet). 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 @@ -25,6 +25,13 @@ covers all four packages, which share one version number and release together. 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. +- **A `LocalDateRange` or `LocalDateTimeRange` element operand in another calendar was compared by + its own fields.** `Contains(EF.Constant(julianDate))` rendered the Julian year, month and day as + if they were ISO and missed its row, and a `LocalDate` parameter threw inside Npgsql. The operand + is now converted to ISO, as the bounds are and as the NodaTime sets already did. +- **⚠️ A non-ISO element of `LocalDateSet`, `LocalDateTimeSet` or `YearMonthSet` is + refused as a bare operand** instead of being bound, inlined or written as the ISO date its own + calendar's fields spell (a Julian January parameter matched rows holding ISO January). ## [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 21ae6e4..7b20182 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Infrastructure/NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions.cs @@ -25,13 +25,20 @@ public static class NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions private static readonly IRangeTypeDefinition[] Definitions = [ // 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. + // and an Instant is an instant. The calendar-bearing types normalize to ISO, as their + // factories do with the bounds — a no-op for a bound, but it backs the element mapping + // of a bare operand (`range @> @p`), where a non-ISO value would otherwise bind its own + // calendar's year/month/day as if they were ISO. 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), + "daterange", "datemultirange", "date", + normalizeValue: static value => value.Calendar == CalendarSystem.Iso ? value : value.WithCalendar(CalendarSystem.Iso), + infinityText: NodaStoreTemporal.InfinityText), new RangeTypeDefinition( "tsrange", "tsmultirange", "timestamp without time zone", + normalizeValue: static value => value.Calendar == CalendarSystem.Iso ? value : value.WithCalendar(CalendarSystem.Iso), storeValue: NodaStoreTemporal.Truncate, infinityText: NodaStoreTemporal.InfinityText), new RangeTypeDefinition( "tstzrange", "tstzmultirange", "timestamp with time zone", @@ -89,6 +96,11 @@ public static class NpgsqlValueRangesNodaTimeDbContextOptionsBuilderExtensions // output is the culture's form. The element's own ToString(format, provider) is what // produces it, so a wrapper that forwards its format argument — which is what the // generators emit — needs no configuration. + // + // The three calendar-bearing families also set requireRoundTrip. The ISO pattern formats a + // value in its own calendar's fields, so an element backed by a Julian 2024-01-01 (ISO + // 2024-01-14) would bind, inline and store as ISO 2024-01-01. The sets refuse such an element + // at construction; requireRoundTrip refuses it as a bare operand (`column @> ARRAY[@p]`) too. private static readonly (Type Family, Func Factory)[] SetFamilies = [ // literalText is passed explicitly on every one of these, for the same reason the @@ -102,12 +114,12 @@ private static readonly (Type Family, Func Factory)[] (typeof(LocalDateSet<>), SetTypeRegistry.Bridged( "date", LocalDatePattern.Iso.PatternText, ParseWith(LocalDatePattern.Iso), LocalDatePattern.Iso.Format, - NodaStoreTemporal.Literal, storeText: true)), + NodaStoreTemporal.Literal, storeText: true, requireRoundTrip: true)), (typeof(LocalDateTimeSet<>), SetTypeRegistry.Bridged( "timestamp without time zone", LocalDateTimePattern.ExtendedIso.PatternText, ParseWith(LocalDateTimePattern.ExtendedIso), LocalDateTimePattern.ExtendedIso.Format, - NodaStoreTemporal.Literal, NodaStoreTemporal.Truncate, storeText: true)), + NodaStoreTemporal.Literal, NodaStoreTemporal.Truncate, storeText: true, requireRoundTrip: true)), (typeof(InstantSet<>), SetTypeRegistry.Bridged( "timestamp with time zone", InstantPattern.ExtendedIso.PatternText, @@ -131,7 +143,8 @@ private static readonly (Type Family, Func Factory)[] static date => YearMonthPattern.Iso.Format(YearMonthOf(date)), LocalDatePattern.Iso.Format, storeText: true, - parseStoreText: static text => LocalDateSet.Parse(text, null).Values)) + parseStoreText: static text => LocalDateSet.Parse(text, null).Values, + requireRoundTrip: true)) ]; /// diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/README.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/README.md index 8b6d186..b163f6d 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/README.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/README.md @@ -67,9 +67,9 @@ reservations.Where(r => r.Holidays.Remove(day).IsEmpty); // cardinality(arra - **No normalization rules.** The base package documents `DateTimeKind` reinterpretation for `tsrange` and UTC offset normalization for `tstzrange`. Neither applies here: `LocalDateTime` is wall-clock time by construction and `Instant` is an instant by construction — the value written is the value stored. - **Discrete canonicalization** is compensated exactly as for `DateRange`: `LocalDateRange.UpperBound()` translates to `upper(x) - 1`, so server results always equal the in-memory results (verified against live PostgreSQL). -- **Calendar normalization reaches bare operands.** `LocalDateSet`/`LocalDateTimeSet` hold ISO elements, so a probe in another calendar is normalized before it is bound — `Holidays.Contains(copticDate)` queries the ISO date it denotes, not that calendar's year/month/day read as if they were ISO. `YearMonthSet` and `YearMonthRange` reject a non-ISO operand outright, since a non-ISO year-month has no lossless ISO equivalent. The same rule applies in memory (see the core NodaTime README). +- **Calendar normalization reaches bare operands.** `LocalDateSet`/`LocalDateTimeSet` hold ISO elements, so a probe in another calendar is normalized before it is bound — `Holidays.Contains(copticDate)` queries the ISO date it denotes, not that calendar's year/month/day read as if they were ISO. `YearMonthSet` and `YearMonthRange` reject a non-ISO operand outright, since a non-ISO year-month has no lossless ISO equivalent. The same rule applies in memory (see the core NodaTime README). The wrapper arities (`LocalDateSet`, `LocalDateTimeSet`, `YearMonthSet`) cannot convert their element, so a non-ISO one is refused with `ArgumentException` as a bare operand — parameter, inlined constant or JSON document — exactly as the set refuses it at construction; before 9.0 it was bound as the ISO date its own calendar's fields spell. - **`YearMonthSet` (v6)** persists **first-of-month dates** in a `date[]`, exactly as `YearMonthRange` does for its bounds; reads validate alignment and throw on a partial-month date rather than silently shifting it to its month. -- **`Union` is the one set operation whose SQL result is not canonical** — it translates to `array_cat`, which concatenates. Harmless inside the operators above (all duplicate-insensitive) and on materialization (reads re-canonicalize), but `Count` over a union is refused rather than counting duplicates. `Remove` has no such caveat: `array_remove` leaves the array sorted and deduplicated. +- **`Union` is the one set operation whose SQL result is not canonical** — it translates to `array_cat`, which concatenates. Harmless inside the operators above (all duplicate-insensitive) and on materialization (reads re-canonicalize); anything sensitive to order or duplicates — equality, `Count`, `GroupBy`, `Distinct`, `OrderBy`, an `ExecuteUpdate` value — is refused rather than answering differently from memory. `Remove` has no such caveat: `array_remove` leaves the array sorted and deduplicated. - **`YearMonthRange` (v5)** is stored as a **month-aligned `daterange`**: `[2025-01, 2025-03]` becomes `[2025-01-01, 2025-04-01)`, so no custom database type is needed and every operator works server-side. Bound elements convert through first-of-month dates (`Contains(yearMonth)` → `@> DATE 'yyyy-MM-01'`); `UpperBound()`'s `upper(x) - 1` lands on the last day of the end month, which reads back as that month. Reads validate month alignment — a partial-month `daterange` throws instead of silently shifting. The factories cannot be constructed *in SQL from column values* (months are coarser than the `date` subtype); constant and parameter ranges work as usual. - **Precision**: NodaTime carries nanoseconds, PostgreSQL stores microseconds — sub-microsecond precision is reduced at the database boundary. - **`Instant.MinValue`/`MaxValue`** map to PostgreSQL `-infinity`/`infinity` by default (Npgsql rule): a finite bound that happens to be infinite, distinct from an unbounded side (`upper_inf` stays `false`). diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md index dc96d51..69cbed3 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md @@ -15,14 +15,19 @@ covers all four packages, which share one version number and release together. into the document needs — EF extracts the value as text and casts it, `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. + because a compiled model re-creates it; a model generated by `dotnet ef dbcontext optimize` + round-trips and queries these columns. `--nativeaot` generation is not supported yet — it fails + at design time on the plugin's type mappings. - 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. + - `ExecuteUpdate` of a value set inside a JSON column is refused: EF Core writes it with + `to_jsonb`, a JSON array the document cannot hold, after which the row no longer loads. The + refusal reads the translated `UPDATE`, so it holds however the setter reaches the property — a + join, `EF.Property`, a nested complex type, a computed value. Ranges, multiranges, a whole + complex property holding a set, 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 @@ -37,6 +42,23 @@ covers all four packages, which share one version number and release together. 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. +- **⚠️ A server-computed value set `Union` is refused wherever it would answer differently from + memory.** `Union` translates to `array_cat`, a concatenation. 8.0.0 refused `==`/`Equals` over it + only inside predicates, and `Count` only over a bare union; a comparison in a projection or on a + projected union, a ternary or `??` around one, `GroupBy`, `Distinct`, `OrderBy`, a deduplicating + set operation, `Count` through `??`, and `ExecuteUpdate` to a union all translated and answered — + or stored — something different (`{a,c,a,b}` in the row). The check now runs on the final SQL + tree, through a decorator around the provider's `IRelationalParameterBasedSqlProcessorFactory`, + and follows a union through `array_remove`, `CASE`, `COALESCE` and subquery columns. It may still + feed `@>`, `<@`, `&&`, `Remove`, `IsEmpty`, or the outermost projection, which materializes + canonical. `Count` in a projection still falls back to the client. The check fails closed: a + translated `Union` is named `valueranges_union_without_sql_guard` until the check renames it to + `array_cat`, so if a later `ReplaceService` (or another extension) replaces that factory instead + of decorating it, every server-side `Union` fails on the server instead of skipping the check. +- **The numeric wrapper arities' bridge reads element text with the family's grammar**, so an + element formatting in the current culture (`1,5` under `de-DE`) is refused instead of bound as 15. +- **A `DateTimeSet` element keeps its wall clock** on the way to the column, even one the + machine's zone skips: the bridge drops the offset of its `"O"` text rather than converting it. 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/Infrastructure/NpgsqlValueRangesServiceCollectionExtensions.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Infrastructure/NpgsqlValueRangesServiceCollectionExtensions.cs index e864c41..ead8fe3 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Infrastructure/NpgsqlValueRangesServiceCollectionExtensions.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Infrastructure/NpgsqlValueRangesServiceCollectionExtensions.cs @@ -35,6 +35,10 @@ public static IServiceCollection AddEntityFrameworkNpgsqlValueRanges(this IServi .TryAdd() .TryAdd(); + // No plugin seam exists after translation, so the final-SQL check decorates the provider's + // parameter-based processor rather than replacing it. + ValueRangesParameterBasedSqlProcessorFactory.Decorate(serviceCollection); + return serviceCollection; } } diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeDefinition.cs index 724c8ad..d81a489 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeDefinition.cs @@ -155,6 +155,7 @@ internal sealed class BridgedSetTypeDefinition : ISe private readonly Func _parsePrimitive; private readonly Func _formatPrimitive; private readonly Func? _storeValue; + private readonly bool _requireRoundTrip; /// The PostgreSQL type of one element, e.g. integer. /// @@ -179,6 +180,14 @@ internal sealed class BridgedSetTypeDefinition : ISe /// 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. /// + /// + /// Whether an element whose primitive does not read back as that element is refused, rather than + /// bound, written or inlined as the value its text happens to spell. The NodaTime satellite sets + /// it for LocalDateSet<T>, LocalDateTimeSet<T> and + /// YearMonthSet<T>: the ISO pattern formats a non-ISO value in its own calendar's + /// fields, so a Julian 2024-01-01 — ISO 2024-01-14 — would query for ISO 2024-01-01. The sets + /// refuse such an element at construction; this is the same rule for a bare element operand. + /// public BridgedSetTypeDefinition( string elementStoreType, string? elementFormat, @@ -187,9 +196,11 @@ public BridgedSetTypeDefinition( Func? literalText = null, Func? storeValue = null, bool storeText = false, - Func>? parseStoreText = null + Func>? parseStoreText = null, + bool requireRoundTrip = false ) { + _requireRoundTrip = requireRoundTrip; _elementFormat = elementFormat; _parsePrimitive = parsePrimitive; _formatPrimitive = formatPrimitive; @@ -224,6 +235,15 @@ private TPrimitive ToPrimitive(TElement element) + "backing primitive's text form.", ex); } + // Compared before the store's truncation, which is a documented, deliberate loss. + if (_requireRoundTrip && !FromPrimitive(primitive).Equals(element)) + throw new ArgumentException( + $"The '{typeof(TElement)}' element formats as '{text}', which reads back as a different value " + + $"through '{typeof(TPrimitive)}' — what a NodaTime value in a calendar other than ISO does, since " + + "its own fields are written as if they were ISO. Convert the wrapped value with " + + "WithCalendar(CalendarSystem.Iso) before wrapping it.", + nameof(element)); + return _storeValue is null ? primitive : _storeValue(primitive); } diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeRegistry.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeRegistry.cs index 29387f9..67a19ad 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeRegistry.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/SetTypeRegistry.cs @@ -47,17 +47,19 @@ FrozenDictionary ByElementType // The element format is null wherever the element's own default text form already // round-trips through the primitive. That is true of Guid and the integers, and of // decimal — and false of every temporal below, whose default form silently drops - // sub-seconds (and, for DateTime, the Kind). + // sub-seconds (and, for DateTime, the Kind). The numeric families read the element's text + // with their own element parser, so a group separator is refused here exactly as it is by + // DecimalSet.Parse: NumberStyles.Number read a culture-leaking "1,5" as fifteen. [typeof(GuidSet<>)] = Bridged( "uuid", null, static text => Guid.Parse(text, Invariant), Text), [typeof(Int16Set<>)] = Bridged( - "smallint", null, static text => short.Parse(text, Invariant), Text), + "smallint", null, static text => Int16Set.ParseValue(text, Invariant), Text), [typeof(Int32Set<>)] = Bridged( - "integer", null, static text => int.Parse(text, Invariant), Text), + "integer", null, static text => Int32Set.ParseValue(text, Invariant), Text), [typeof(Int64Set<>)] = Bridged( - "bigint", null, static text => long.Parse(text, Invariant), Text), + "bigint", null, static text => Int64Set.ParseValue(text, Invariant), Text), [typeof(DecimalSet<>)] = Bridged( - "numeric", null, static text => decimal.Parse(text, NumberStyles.Number, Invariant), Text), + "numeric", null, static text => DecimalSet.ParseValue(text, Invariant), Text), // ParseExact, not Parse: an element that ignores the format specifier and hands back // its own default form is a contract violation, and the strict parse turns it into @@ -80,13 +82,10 @@ FrozenDictionary ByElementType // The same store-side normalization the closed siblings apply: PostgreSQL // `timestamp` has no time zone, so elements are written as wall-clock time with // DateTimeKind.Unspecified; `timestamptz` stores an instant, so they normalize to - // UTC. RoundtripKind is what makes the first one reproduce SpecifyKind on the - // original value instead of reinterpreting a UTC element as local. + // UTC. The wall clock is read as written (ParseRoundTripWallClock). [typeof(DateTimeSet<>)] = Bridged( "timestamp without time zone", "O", - static text => DateTime.SpecifyKind( - DateTime.ParseExact(text, "O", Invariant, DateTimeStyles.RoundtripKind), - DateTimeKind.Unspecified), + ParseRoundTripWallClock, static value => value.ToString("O", Invariant), storeValue: StoreTemporal.Truncate, storeText: true), [typeof(DateTimeOffsetSet<>)] = Bridged( @@ -100,6 +99,32 @@ FrozenDictionary ByElementType private static CultureInfo Invariant => CultureInfo.InvariantCulture; + /// + /// The wall clock of a "O" text, as : the fixed-width + /// date and time before its kind suffix — nothing, Z or ±HH:mm — with the suffix + /// checked but never applied. + /// + /// + /// timestamp stores no zone, and the core grammar keeps a local value's wall clock rather + /// than converting its offset (PostgreSQL's rule for timestamp input). Reading the text + /// with would convert it into this machine's zone, + /// which moves a wall clock the zone skips or repeats. + /// + private static DateTime ParseRoundTripWallClock(string text) + { + const int wallClockLength = 27; // yyyy-MM-ddTHH:mm:ss.fffffff + var suffix = text.Length >= wallClockLength ? text.AsSpan(wallClockLength) : default; + var validSuffix = suffix.IsEmpty + || suffix is "Z" + || suffix.Length == 6 && suffix[0] is '+' or '-' && suffix[3] == ':' + && char.IsAsciiDigit(suffix[1]) && char.IsAsciiDigit(suffix[2]) + && char.IsAsciiDigit(suffix[4]) && char.IsAsciiDigit(suffix[5]); + + return text.Length >= wallClockLength && validSuffix + ? DateTime.ParseExact(text.AsSpan(0, wallClockLength), "yyyy-MM-dd'T'HH:mm:ss.fffffff", Invariant, DateTimeStyles.None) + : throw new FormatException($"'{text}' is not a round-trip (\"O\") DateTime."); + } + /// The invariant text form of a primitive whose default already round-trips. private static string Text(TPrimitive value) where TPrimitive : IFormattable => value.ToString(null, Invariant); @@ -121,6 +146,10 @@ private static string Text(TPrimitive value) where TPrimitive : IFor /// 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. + /// + /// Whether an element whose primitive does not read back as that element is refused — the + /// calendar-bearing NodaTime families, whose ISO pattern spells a non-ISO value's own fields. + /// internal static Func Bridged( string elementStoreType, string? elementFormat, @@ -129,7 +158,8 @@ internal static Func Bridged( Func? literalText = null, Func? storeValue = null, bool storeText = false, - Func>? parseStoreText = null + Func>? parseStoreText = null, + bool requireRoundTrip = false ) where TPrimitive : struct => closed => @@ -139,7 +169,8 @@ internal static Func Bridged( return (ISetTypeDefinition)Activator.CreateInstance( definitionType, - [elementStoreType, elementFormat, parsePrimitive, formatPrimitive, literalText, storeValue, storeText, parseStoreText])!; + [elementStoreType, elementFormat, parsePrimitive, formatPrimitive, literalText, storeValue, storeText, parseStoreText, + requireRoundTrip])!; }; private static volatile Snapshot Current = BuildSnapshot( diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesParameterBasedSqlProcessorFactory.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesParameterBasedSqlProcessorFactory.cs new file mode 100644 index 0000000..628324d --- /dev/null +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesParameterBasedSqlProcessorFactory.cs @@ -0,0 +1,80 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.DependencyInjection; + +namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Query; + +/// +/// Wraps the provider's so that every +/// processor it creates runs over its result — which is also what +/// turns a translated Union into array_cat, so a query that bypasses this decorator +/// fails on the server instead of skipping the guard. +/// +/// +/// +/// The parameter-based processor is the last step before SQL generation, and the only one every +/// command passes through: queries, ExecuteUpdate and ExecuteDelete each build a +/// RelationalCommandCache that calls it. The tree it returns is final — projections applied, +/// null semantics expanded — so the guard judges exactly what will be sent. +/// +/// +/// EF Core offers no plugin seam at this stage, so the provider's registration is decorated rather +/// than replaced (see ): the provider's own processing runs unchanged, and the +/// guard only reads its output. No EF-internal API is involved — the overridden +/// Process(Expression, ParametersCacheDecorator) is the public extension point. +/// +/// +internal sealed class ValueRangesParameterBasedSqlProcessorFactory( + IRelationalParameterBasedSqlProcessorFactory inner, + RelationalParameterBasedSqlProcessorDependencies dependencies +) : IRelationalParameterBasedSqlProcessorFactory +{ + /// + public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) + => new GuardedProcessor(inner.Create(parameters), dependencies, parameters); + + /// + /// Replaces the registered with this + /// decorator around it. Idempotent; registers the Npgsql provider services first when they are + /// not there yet, so there is always a provider factory to wrap. + /// + internal static void Decorate(IServiceCollection services) + { + var existing = services.LastOrDefault(d => d.ServiceType == typeof(IRelationalParameterBasedSqlProcessorFactory)); + if (existing is null) + { + services.AddEntityFrameworkNpgsql(); + existing = services.Last(d => d.ServiceType == typeof(IRelationalParameterBasedSqlProcessorFactory)); + } + + if (existing.ImplementationFactory?.Target is Decoration) return; + + services.Remove(existing); + services.Add(new ServiceDescriptor( + typeof(IRelationalParameterBasedSqlProcessorFactory), + new Decoration(existing).Create, + existing.Lifetime)); + } + + /// Builds the decorator around whatever the replaced descriptor would have produced. + private sealed class Decoration(ServiceDescriptor inner) + { + public object Create(IServiceProvider provider) + => new ValueRangesParameterBasedSqlProcessorFactory( + (IRelationalParameterBasedSqlProcessorFactory) ( + inner.ImplementationInstance + ?? inner.ImplementationFactory?.Invoke(provider) + ?? ActivatorUtilities.CreateInstance(provider, inner.ImplementationType!)), + provider.GetRequiredService()); + } + + private sealed class GuardedProcessor( + RelationalParameterBasedSqlProcessor inner, + RelationalParameterBasedSqlProcessorDependencies dependencies, + RelationalParameterBasedSqlProcessorParameters parameters + ) : RelationalParameterBasedSqlProcessor(dependencies, parameters) + { + public override Expression Process(Expression queryExpression, ParametersCacheDecorator parametersDecorator) + => ValueSetSqlGuard.Check(inner.Process(queryExpression, parametersDecorator)); + } +} diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs index f66e2c6..d52b2c1 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs @@ -1,14 +1,11 @@ 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; /// -/// Pre-translation passes over the query: two rewrites and two refusals. +/// Pre-translation rewrites of the query. /// /// /// @@ -21,227 +18,18 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Query; /// . /// /// -/// The refusals reject comparing a server-computed value set Union for equality — see -/// — and ExecuteUpdate setting a value set inside a JSON -/// column — see . +/// The value set refusals — a server-computed Union consumed by an order- or +/// multiplicity-sensitive operation, and ExecuteUpdate writing a value set into a JSON +/// column — are not made here but on the translated SQL, by : at the +/// LINQ level they can only be recognized by how the query is written, and every pattern list +/// missed a shape that translated to the same SQL. /// /// public sealed class ValueRangesQueryExpressionInterceptor : IQueryExpressionInterceptor { /// public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData) - { - UnionEqualityGuard.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; - } - - /// - /// Refuses ==, != and Equals where either side is a value set - /// Union computed on the server. - /// - /// - /// - /// Union translates to array_cat, which concatenates: the result carries - /// duplicates and keeps each operand's ordering. The membership operators the sets translate - /// to — @>, <@, && — ignore both, which is why a union - /// composes safely with them and with IsProperSubsetOf/IsProperSupersetOf, - /// written as <@ AND NOT @> for exactly this reason. Array equality ignores - /// neither. - /// - /// - /// Both halves bite, and the ordering half is the one that surprises: over - /// text[] the server answers for - /// {a,c} ∪ {a,b} = {a,b,c} because of the repeated a, and equally - /// for {a,c} ∪ {b} = {a,b,c}, where nothing repeats and only - /// the order differs. In memory both are . Nothing throws and the row - /// simply does not match. - /// - /// - /// PostgreSQL has no array_distinct to canonicalize into, and sorting inside the query would - /// order text by the database collation rather than ordinally — silently disagreeing - /// with the client's canonical order, which is the same defect one layer down. So this is - /// refused rather than translated, matching how Count over a union is refused in - /// . Materialize first, or compare with - /// IsSubsetOf/IsSupersetOf, which mean the same thing for canonical sets and - /// translate correctly. - /// - /// - private sealed class UnionEqualityGuard : ExpressionVisitor - { - public static readonly UnionEqualityGuard Instance = new(); - - /// - /// The operators whose lambdas must translate in full. Everything else — a projection above - /// all — is left alone, because EF falls back to client evaluation there and computes the - /// comparison correctly against the materialized set. - /// - /// - /// This is what keeps the refusal in step with how Count over a union behaves. - /// That one returns from a translator, so EF decides: a predicate - /// fails, a projection degrades to client evaluation. Equality has no translator seam and - /// is refused a step earlier, so the contexts have to be named rather than inferred — - /// otherwise the refusal is stricter than the defect and takes a working projection with - /// it. - /// - private static readonly HashSet MustTranslate = new(StringComparer.Ordinal) - { - "Where", "Any", "All", "Count", "LongCount", - "First", "FirstOrDefault", "Single", "SingleOrDefault", "Last", "LastOrDefault", - "SkipWhile", "TakeWhile", - "OrderBy", "OrderByDescending", "ThenBy", "ThenByDescending", "GroupBy" - }; - - protected override Expression VisitMethodCall(MethodCallExpression node) - { - // Only scan inside the lambdas of operators EF must translate server-side. `Queryable` - // rather than `Enumerable`: past an AsEnumerable the comparison runs in memory and is - // correct, which is the documented way to ask for it. - if (node.Method.DeclaringType == typeof(Queryable) && MustTranslate.Contains(node.Method.Name)) - { - foreach (var argument in node.Arguments.Skip(1)) - ComparisonScan.Instance.Visit(argument); - } - - return base.VisitMethodCall(node); - } - } - - /// Finds an equality comparison against a server-computed union. - private sealed class ComparisonScan : ExpressionVisitor - { - public static readonly ComparisonScan Instance = new(); - - protected override Expression VisitBinary(BinaryExpression node) - { - if (node.NodeType is ExpressionType.Equal or ExpressionType.NotEqual) - Refuse(node.Left, node.Right, node.NodeType == ExpressionType.Equal ? "==" : "!="); - - return base.VisitBinary(node); - } - - protected override Expression VisitMethodCall(MethodCallExpression node) - { - if (node.Method.Name == nameof(object.Equals)) - { - if (node.Object is { } instance && node.Arguments.Count == 1) - Refuse(instance, node.Arguments[0], "Equals"); - else if (node.Object is null && node.Arguments.Count == 2) - Refuse(node.Arguments[0], node.Arguments[1], "Equals"); - } - - return base.VisitMethodCall(node); - } - - private static void Refuse(Expression left, Expression right, string comparison) - { - if (!DerivesFromUnion(left) && !DerivesFromUnion(right)) return; - - throw new InvalidOperationException( - $"'{comparison}' cannot be translated when either side is a value set Union computed " - + "on the server. Union translates to array_cat, which concatenates rather than " - + "canonicalizes, so the result carries duplicates and keeps each operand's ordering " - + "while array equality is sensitive to both — the comparison would answer false for " - + "sets that are equal in memory. Compare with IsSubsetOf/IsSupersetOf, which mean the " - + "same thing for canonical sets and ignore order and multiplicity, or materialize the " - + "union first and compare the value."); - } - - /// - /// Whether the operand is a value set Union, or something built on one that did not - /// restore canonical form — the LINQ counterpart of the same test - /// applies to the translated SQL. - /// - private static bool DerivesFromUnion(Expression expression) => - expression is MethodCallExpression { Method: { DeclaringType: { } declaringType } method } - && declaringType == typeof(ValueSetExtensions) - && method.Name switch - { - nameof(ValueSetExtensions.Union) => true, - - // array_remove preserves whatever canonical form its input had, which for a - // concatenation is none — the same reason the Count refusal looks through it. - nameof(ValueSetExtensions.Remove) when ((MethodCallExpression) expression).Arguments.Count > 0 - => DerivesFromUnion(((MethodCallExpression) expression).Arguments[0]), - - _ => false - }; - } + => RangeVariantConstantRetyper.Instance.Visit(RangeSetOperatorRewriter.Instance.Visit(queryExpression)); /// /// Retypes a range constant that EF Core inlined from the runtime type of its value — the sealed diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs new file mode 100644 index 0000000..f024bc8 --- /dev/null +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetSqlGuard.cs @@ -0,0 +1,434 @@ +using System.Linq.Expressions; +using CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; + +namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Query; +// PgBinaryExpression is Npgsql-internal (EF1001): it is the node Npgsql builds for @>, <@ and &&, +// the only operators a server-computed Union may feed, and nothing public identifies them in the +// SQL tree. Acknowledged here, at the usage, rather than suppressed repo-wide. +#pragma warning disable EF1001 + +/// +/// Refuses, on the final SQL tree, the two value set shapes that translate without error and then +/// answer — or write — something different from what the LINQ said. +/// +/// +/// +/// Runs from , after the provider's own +/// parameter-based processing and immediately before SQL generation, for queries and for +/// ExecuteUpdate/ExecuteDelete alike. Working on the SQL tree rather than the LINQ is +/// what makes both checks independent of how the query was written: by then a projection, a +/// conditional, a coalesce, a join or EF.Property have all been reduced to the same few +/// nodes. +/// +/// +/// A server-computed Union is not canonical. It translates to array_cat, +/// which concatenates: the result carries duplicates and keeps each operand's order. An +/// expression derived from one — array_cat itself, array_remove over one +/// (which preserves canonical form rather than establishing it), a CASE or +/// COALESCE that may yield one, a subquery column projecting one — may only be +/// +/// an operand of @>, <@ or &&, which ignore order and +/// multiplicity (this includes the <@ AND NOT @> proper-containment form); +/// the input of array_remove, array_cat, CASE or COALESCE, +/// whose result is then derived in turn; +/// tested with IS NULL, or by cardinality(…) = 0 — IsEmpty: a +/// concatenation is empty exactly when both operands are; +/// a column of the outermost SELECT, which is materialized, and materialization +/// canonicalizes through the set's From. +/// +/// Everything else is refused: equality, cardinality, GROUP BY, DISTINCT, +/// ORDER BY, a deduplicating set operation, IN, and an UPDATE setter — which +/// would store the concatenation, after which server-side equality and Count on that row are +/// wrong for good. PostgreSQL has no array-distinct to canonicalize into, and sorting in SQL would +/// order text by the database collation rather than ordinally, which is the same defect one +/// layer down. +/// +/// +/// A value set inside a JSON column must not be written by to_jsonb. Npgsql +/// translates an ExecuteUpdate setter targeting a scalar inside a JSON document to +/// jsonb_set(doc, path, to_jsonb(value)). For a value set, value is the PostgreSQL +/// array, so the document receives a JSON array where it holds the set's literal string: the +/// update reports success and the row no longer loads. Detecting it here, by what will be +/// written rather than by how the setter lambda named the property, covers every route to the +/// property — a join, EF.Property, a nested complex type. +/// +/// +/// The guard fails closed. It runs from a decorated service, and a later +/// ReplaceService — or another extension replacing rather than decorating the same service — +/// would drop it without a trace, after which every refused shape answers wrongly again. So +/// does not emit array_cat: it emits +/// , a function PostgreSQL does not have, and only +/// renames it. Without the guard a query over a server-side Union fails on the server, naming +/// the placeholder; a decorator around the guard still reaches it and changes nothing. +/// +/// +internal static class ValueSetSqlGuard +{ + /// + /// The function name a translated Union carries until renames it to + /// array_cat. It reaches PostgreSQL — as function … does not exist — only when the + /// guard did not run, which is the point. + /// + public const string UnguardedUnion = "valueranges_union_without_sql_guard"; + + /// + /// Checks a query, update or delete expression and throws when it holds a refused shape; returns it + /// with every renamed to array_cat. + /// + public static Expression Check(Expression queryExpression) + { + new Walker().Root(queryExpression); + return UnionNamer.Instance.Visit(queryExpression); + } + + /// + /// Whether an expression may evaluate to a server-computed union, judged from the expression + /// alone. asks this to decline Count, so that EF + /// falls back to client evaluation in a projection; it sees no enclosing query, so a column + /// projected by a subquery is left to , which follows it. + /// + public static bool MayBeConcatenation(SqlExpression? expression) => expression switch + { + SqlFunctionExpression { Name: UnguardedUnion } when IsSet(expression) => true, + SqlFunctionExpression { Name: "array_remove", Arguments: [var input, _] } when IsSet(expression) => MayBeConcatenation(input), + SqlFunctionExpression { Name: "COALESCE", Arguments: { } arguments } => arguments.Any(MayBeConcatenation), + CaseExpression conditional => conditional.WhenClauses.Any(c => MayBeConcatenation(c.Result)) || MayBeConcatenation(conditional.ElseResult), + SqlUnaryExpression { OperatorType: ExpressionType.Convert } convert when IsSet(convert) => MayBeConcatenation(convert.Operand), + _ => false + }; + + private static bool IsSet(SqlExpression expression) => SetTypeRegistry.TryGetByClrType(expression.Type, out _); + + /// The operations a derived value is refused under, each with its own message. + private enum Use { Other, Equality, Count, GroupBy, Distinct, OrderBy, SetOperation } + + private sealed class Walker + { + /// Subquery and set-operation columns that project a derived value, by table alias and column name. + private readonly HashSet<(string Table, string Column)> _derivedColumns = []; + + public void Root(Expression expression) + { + switch (expression) + { + // The outermost projection is read by the shaper, through the set's From, so a derived + // value is canonical again by the time anyone sees it. A subquery's projection is only + // as safe as whatever consumes its columns, which Derived(ColumnExpression) decides. + case SelectExpression select: + Select(select); + break; + + case UpdateExpression update: + Select(update.SelectExpression); + foreach (var setter in update.ColumnValueSetters) + { + JsonSetter(setter.Column, setter.Value, path: null); + if (Derived(setter.Value)) throw Refusal.Setter(setter.Column.Name); + } + + break; + + case DeleteExpression delete: + Select(delete.SelectExpression); + break; + + default: + Consume(expression); + break; + } + } + + /// Walks a SELECT and reports, per projection, whether it projects a derived value. + private bool[] Select(SelectExpression select) + { + foreach (var table in select.Tables) Table(table); + + var derived = new bool[select.Projection.Count]; + for (var i = 0; i < derived.Length; i++) + { + derived[i] = Derived(select.Projection[i].Expression); + if (derived[i] && select.IsDistinct) throw Refusal.For(Use.Distinct); + } + + Consume(select.Predicate); + foreach (var grouping in select.GroupBy) Consume(grouping, Use.GroupBy); + Consume(select.Having); + foreach (var ordering in select.Orderings) Consume(ordering.Expression, Use.OrderBy); + Consume(select.Offset); + Consume(select.Limit); + + return derived; + } + + private void Table(TableExpressionBase table) + { + switch (table) + { + case SelectExpression subquery: + Register(subquery.Alias, subquery, Select(subquery)); + break; + + case SetOperationBase setOperation: + var either = Select(setOperation.Source1).Zip(Select(setOperation.Source2), (a, b) => a || b).ToArray(); + if (setOperation.IsDistinct && either.Contains(true)) throw Refusal.For(Use.SetOperation); + + Register(setOperation.Alias, setOperation.Source1, either); + break; + + case PredicateJoinExpressionBase join: + Table(join.Table); + Consume(join.JoinPredicate); + break; + + case JoinExpressionBase join: + Table(join.Table); + break; + + case TableExpression or FromSqlExpression: + break; + + default: + // Table-valued functions (unnest, …), VALUES and provider-specific sources: nothing + // derived may flow into them. + foreach (var child in ChildCollector.Of(table)) Consume(child); + break; + } + } + + private void Register(string? alias, SelectExpression source, bool[] derived) + { + if (alias is null) return; + for (var i = 0; i < derived.Length; i++) + if (derived[i]) _derivedColumns.Add((alias, source.Projection[i].Alias)); + } + + /// Refuses a derived value in a position that does not tolerate one. + private void Consume(Expression? expression, Use use = Use.Other) + { + switch (expression) + { + case null: + return; + + case SqlExpression sql: + if (Derived(sql)) throw Refusal.For(use); + return; + + case SelectExpression select: + if (Select(select).Contains(true)) throw Refusal.For(use); + return; + + default: + if (expression.NodeType == ExpressionType.Extension) + foreach (var child in ChildCollector.Of(expression)) Consume(child, use); + return; + } + } + + /// + /// Whether the expression's value may be a concatenation. Throws when one is consumed inside it + /// by an operator that is sensitive to order or multiplicity. + /// + private bool Derived(SqlExpression? expression) + { + switch (expression) + { + case null: + return false; + + case ColumnExpression column: + return _derivedColumns.Contains((column.TableAlias, column.Name)); + + case SqlFunctionExpression { Name: UnguardedUnion, Arguments: { } arguments } when IsSet(expression): + foreach (var argument in arguments) Derived(argument); + return true; + + // array_remove preserves canonical form rather than establishing it. + case SqlFunctionExpression { Name: "array_remove", Arguments: [var input, var element] } when IsSet(expression): + Consume(element); + return Derived(input); + + case SqlFunctionExpression { Name: "COALESCE", Arguments: { } arguments }: + return arguments.Aggregate(false, (any, argument) => Derived(argument) | any); + + case CaseExpression conditional: + Consume(conditional.Operand); + var anyResult = false; + foreach (var clause in conditional.WhenClauses) + { + Consume(clause.Test); + anyResult |= Derived(clause.Result); + } + + return Derived(conditional.ElseResult) | anyResult; + + case SqlUnaryExpression { OperatorType: ExpressionType.Convert } convert when IsSet(convert): + return Derived(convert.Operand); + + // IS NULL / IS NOT NULL: a concatenation is NULL exactly when an operand is. + case SqlUnaryExpression { OperatorType: ExpressionType.Equal or ExpressionType.NotEqual } nullTest: + Derived(nullTest.Operand); + return false; + + // IsEmpty, cardinality(x) = 0: a concatenation is empty exactly when both operands are. + case SqlBinaryExpression { OperatorType: ExpressionType.Equal or ExpressionType.NotEqual } comparison + when CardinalityComparedWithZero(comparison) is { } counted: + Derived(counted); + return false; + + case SqlBinaryExpression { OperatorType: ExpressionType.Equal or ExpressionType.NotEqual } equality: + Consume(equality.Left, Use.Equality); + Consume(equality.Right, Use.Equality); + return false; + + case SqlFunctionExpression { Name: "cardinality", Arguments: [var counted] }: + Consume(counted, Use.Count); + return false; + + // @>, <@ and && ignore order and multiplicity: the reason array_cat is a valid + // translation of Union at all. + case PgBinaryExpression { OperatorType: PgExpressionType.Contains or PgExpressionType.ContainedBy or PgExpressionType.Overlaps } containment: + Derived(containment.Left); + Derived(containment.Right); + return false; + + case ScalarSubqueryExpression scalar: + return Select(scalar.Subquery) is [true, ..]; + + case ExistsExpression exists: + Select(exists.Subquery); + return false; + + case InExpression membership: + Consume(membership.Item, Use.Equality); + Consume(membership.Subquery, Use.Equality); + foreach (var value in membership.Values ?? []) Consume(value, Use.Equality); + return false; + + default: + foreach (var child in ChildCollector.Of(expression)) Consume(child); + return false; + } + } + + private static SqlExpression? CardinalityComparedWithZero(SqlBinaryExpression comparison) + => (comparison.Left, comparison.Right) switch + { + (SqlFunctionExpression { Name: "cardinality", Arguments: [var operand] }, SqlConstantExpression { Value: 0 }) => operand, + (SqlConstantExpression { Value: 0 }, SqlFunctionExpression { Name: "cardinality", Arguments: [var operand] }) => operand, + _ => null + }; + + /// + /// Refuses to_jsonb(set) (or to_json) inside an UPDATE setter, naming the + /// JSON path from the enclosing jsonb_set where there is one. + /// + private static void JsonSetter(ColumnExpression column, SqlExpression? value, string? path) + { + switch (value) + { + case null: + break; + + case SqlFunctionExpression { Name: "jsonb_set" or "json_set", Arguments: [var document, SqlConstantExpression { Value: var segments }, var written, ..] }: + JsonSetter(column, document, path); + JsonSetter(column, written, segments is IEnumerable named ? string.Join('.', named) : path); + break; + + case SqlFunctionExpression { Name: "to_jsonb" or "to_json", Arguments: [var serialized] } when IsSet(Unwrap(serialized)): + throw Refusal.JsonSetter(column.Name, path); + + default: + foreach (var child in ChildCollector.Of(value)) + if (child is SqlExpression sql) JsonSetter(column, sql, path); + break; + } + } + + private static SqlExpression Unwrap(SqlExpression expression) + => expression is SqlUnaryExpression { OperatorType: ExpressionType.Convert } convert ? Unwrap(convert.Operand) : expression; + } + + /// Gives every checked the name PostgreSQL knows. + private sealed class UnionNamer : ExpressionVisitor + { + public static readonly UnionNamer Instance = new(); + + protected override Expression VisitExtension(Expression node) + { + var visited = base.VisitExtension(node); + return visited is SqlFunctionExpression { Name: UnguardedUnion, Arguments: { } arguments } union + ? new SqlFunctionExpression( + "array_cat", arguments, union.IsNullable, union.ArgumentsPropagateNullability!, union.Type, union.TypeMapping) + : visited; + } + } + + /// Collects an extension node's direct children without descending into them. + private sealed class ChildCollector : ExpressionVisitor + { + private readonly List _children = []; + + public static List Of(Expression node) + { + var collector = new ChildCollector(); + collector.Collect(node); + return collector._children; + } + + private void Collect(Expression node) => base.VisitExtension(node); + + public override Expression? Visit(Expression? node) + { + if (node is not null) _children.Add(node); + return node; + } + } + + private static class Refusal + { + private const string Remedy = + "Compose a server-side Union only with Contains, Overlaps, IsSubsetOf/IsSupersetOf, " + + "IsProperSubsetOf/IsProperSupersetOf, Remove or IsEmpty, which ignore order and multiplicity, or materialize " + + "it first (project it, or switch to AsEnumerable()) and work with the value."; + + public static InvalidOperationException For(Use use) => use switch + { + Use.Equality => Create( + "Value set equality ('==', '!=', 'Equals', or Contains over a sequence of sets)", "array equality", + "Compare with IsSubsetOf/IsSupersetOf, which mean the same thing for canonical sets and ignore order and " + + "multiplicity (a.IsSubsetOf(b) && a.IsSupersetOf(b) is set equality), or materialize the union first and " + + "compare the value."), + Use.Count => Create("'Count'", "cardinality", Remedy), + Use.GroupBy => Create("Grouping by it ('GroupBy')", "GROUP BY", Remedy), + Use.Distinct => Create("'Distinct' over it", "DISTINCT", Remedy), + Use.OrderBy => Create("Ordering by it ('OrderBy')", "ORDER BY", Remedy), + Use.SetOperation => Create("A deduplicating set operation over it ('Union', 'Intersect' or 'Except' of two queries)", + "the set operation's row comparison", Remedy), + _ => Create("This use of it", "the operation", Remedy) + }; + + private static InvalidOperationException Create(string subject, string operation, string remedy) + => new($"{subject} cannot be translated over a value set Union computed on the server. Union translates to " + + "array_cat, which concatenates rather than canonicalizes: the server-side result carries duplicates and " + + $"keeps each operand's order, and {operation} is sensitive to both, so the query would answer differently " + + $"from the same expression in memory. {remedy}"); + + public static InvalidOperationException Setter(string column) => new( + $"'ExecuteUpdate' cannot set the value set column '{column}' to a value set Union computed on the server. " + + "Union translates to array_cat, which concatenates rather than canonicalizes, so the row would store " + + "duplicates in operand order, and from then on server-side equality and Count on that row are wrong. " + + "Compute the union in memory and set the value, or load the entities and save them with SaveChanges."); + + public static InvalidOperationException JsonSetter(string column, string? path) => new( + $"'ExecuteUpdate' cannot set the value set '{column}{(path is null ? "" : "." + 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.{column}, …)) " + + "or a whole complex property that contains the set."); + } +} diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetsMemberTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetsMemberTranslator.cs index 1517ea8..571839a 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetsMemberTranslator.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetsMemberTranslator.cs @@ -30,10 +30,15 @@ internal sealed class ValueSetsMemberTranslator(ISqlExpressionFactory sqlExpress // Union translates to array_cat, whose result is concatenated rather than // deduplicated. cardinality would then count shared elements twice — {a,c} unioned // with {a,b} is 4 on the server and 3 in memory. PostgreSQL has no array_distinct to - // wrap it in, so Count over a server-computed union is refused: EF reports the query - // as untranslatable, which beats returning a number that is quietly too large. - // IsEmpty stays translatable — a concatenation is empty exactly when both sides are. - if (member.Name == nameof(StringSet.Count) && DerivesFromUnion(instance)) + // wrap it in, so Count over a server-computed union is declined: in a projection EF + // then evaluates it on the client against the materialized (canonical) set, and in a + // predicate it reports the query as untranslatable, which beats filtering on a number + // that is quietly too large. Declining looks through array_remove (which preserves a + // concatenation rather than canonicalizing it), CASE and COALESCE. A union reaching + // cardinality through a subquery column is invisible from here and is refused on the + // final SQL by ValueSetSqlGuard instead. IsEmpty stays translatable — a concatenation + // is empty exactly when both sides are. + if (member.Name == nameof(StringSet.Count) && ValueSetSqlGuard.MayBeConcatenation(instance)) return null; var set = sqlExpressionFactory.ApplyTypeMapping(instance, definition.SetTypeMapping); @@ -46,29 +51,6 @@ internal sealed class ValueSetsMemberTranslator(ISqlExpressionFactory sqlExpress }; } - /// - /// Whether the operand is a union, or something built on one that did not restore canonical - /// form. - /// - /// - /// Matching only the outermost call let the refusal be side-stepped by anything wrapping the - /// union. array_remove is the case that exists today: it preserves whatever canonical - /// form its input had, which for a concatenation is none, so - /// Tags.Union(Other).Remove(x).Count reached cardinality and counted shared - /// elements twice — 4 against live PostgreSQL where the in-memory expression is 3. - /// Canonicality-preserving wrappers therefore have to be looked through rather than treated - /// as a fresh start. - /// - private static bool DerivesFromUnion(SqlExpression expression) => expression switch - { - SqlFunctionExpression { Name: "array_cat" } => true, - - SqlFunctionExpression { Name: "array_remove", Arguments.Count: > 0 } remove - => DerivesFromUnion(remove.Arguments[0]), - - _ => false - }; - private SqlExpression Cardinality(SqlExpression set) => sqlExpressionFactory.Function( "cardinality", diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetsMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetsMethodCallTranslator.cs index ada9134..b76d055 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetsMethodCallTranslator.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueSetsMethodCallTranslator.cs @@ -44,14 +44,14 @@ namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Query; /// Composing on a translated Union. array_cat concatenates rather than /// canonicalizing, which is invisible to the operators above (all order- and /// multiplicity-insensitive) and to materialization (reads route through From), but not -/// to everything: Count over a union is refused by ValueSetsMemberTranslator -/// rather than counting duplicates — including through a wrapping Remove, which -/// preserves the concatenation rather than canonicalizing it — and equality over a union is -/// wrong and cannot be intercepted — EF emits the = itself, so -/// Tags.Union(x) == y compares a concatenated array against a canonical one and is false -/// even where the in-memory union equals y. The same applies to -/// Tags.Union(x).Remove(e) == y, for the same reason and with the same remedy. Canonicalizing server-side is not an option: PostgreSQL has no -/// array-distinct function (verified against 18.4), so it needs a +/// to everything: equality, Count, grouping, ordering, DISTINCT and an +/// ExecuteUpdate value all see the duplicates and the operand order — including through a +/// wrapping Remove, which preserves the concatenation rather than canonicalizing it. EF +/// emits several of those itself (the = of Tags.Union(x) == y, a GROUP BY +/// key), so no translator can intercept them; refuses them on the +/// final SQL instead, and ValueSetsMemberTranslator declines Count so that a +/// projection falls back to the client. Canonicalizing server-side is not an option: PostgreSQL +/// has no array-distinct function (verified against 18.4), so it needs a /// SELECT DISTINCT … ORDER BY subquery, and the ordering could not match CLR canonical /// order anyway (text orders by database collation, not ordinal; uuid orders /// byte-wise where orders field-wise). Materialize the @@ -124,9 +124,10 @@ SqlExpression Set(int index) case nameof(ValueSetExtensions.Union) when arguments.Count == 2: // The server-side result is unsorted and undeduplicated — fine inside // predicates (the operators above ignore both), and a materialized result - // re-canonicalizes on read. + // re-canonicalizes on read. Emitted under a placeholder name that only + // ValueSetSqlGuard renames to array_cat, so the guard cannot be skipped silently. return sqlExpressionFactory.Function( - "array_cat", + ValueSetSqlGuard.UnguardedUnion, [Set(0), Set(1)], nullable: true, argumentsPropagateNullability: [true, true], diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md index 40939ec..4fdf19e 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/README.md @@ -113,8 +113,9 @@ tabulates both halves together. `HasPostgresRange` to create the type and `EnableUnmappedTypes` on the data source. - **`Union` is the one set operation whose SQL result is not canonical** — it translates to `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. + operators and on materialization (reads re-canonicalize); anything sensitive to order or + duplicates — equality, `Count`, `GroupBy`, `Distinct`, `OrderBy`, an `ExecuteUpdate` value — is + refused rather than answering differently from memory, checked on the translated SQL. - **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 diff --git a/src/CodoMetis.ValueRanges.NodaTime/CHANGELOG.md b/src/CodoMetis.ValueRanges.NodaTime/CHANGELOG.md index 993cffc..bd823b9 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.NodaTime/CHANGELOG.md @@ -22,6 +22,25 @@ covers all four packages, which share one version number and release together. bounds and elements also read PostgreSQL's `infinity` / `-infinity`, as the extremes Npgsql maps them from (`MaxValue` / `MinValue`, `MaxIsoValue` / `MinIsoValue`). +### Fixed + +- **⚠️ `AddNodaTimeRangeConverters()` no longer shadows `ConfigureForNodaTime`, and refuses bare + non-ISO values.** Registered first, its element converters won for all five types; now any other + converter registered for the type wins, in either order. A bare `LocalDate`, `LocalDateTime` or + `YearMonth` in a non-ISO calendar was written in that calendar's fields — a Julian 2024-01-01 + read back as ISO 2024-01-01, thirteen days off — and is now refused with `ArgumentException`, as + NodaTime's converter does. Sets and ranges are unaffected. +- **⚠️ `LocalDateSet`, `LocalDateTimeSet` and `YearMonthSet` refuse an element backed by + a non-ISO value.** Its ISO text spelled the other calendar's fields — a Julian 2024-01-01 was + written, and read back, as ISO 2024-01-01 — silently. The element is refused with + `ArgumentException` in `From`, collection expressions and reads, and as the + `Contains`/`Add`/`Remove` probe. +- **`HH:mm` is accepted** for `LocalTime`, the time part of `LocalDateTime`, and an `Instant` with + its offset or `Z`, as the core types accept it; it was a `FormatException`. +- The core fixes to literal grammar (empty format string, quoted empty bounds, array escapes) and + to JSON precedence (union converters reaching variants, interface-declared properties) apply to + the NodaTime types too. See `CodoMetis.ValueRanges` 9.0.0. + ### Added - **`Deconstruct` on every `Finite` variant, and on the unbounded variants of `LocalDateRange`, diff --git a/src/CodoMetis.ValueRanges.NodaTime/CodoMetis.ValueRanges.NodaTime.csproj b/src/CodoMetis.ValueRanges.NodaTime/CodoMetis.ValueRanges.NodaTime.csproj index 852c005..545b41e 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/CodoMetis.ValueRanges.NodaTime.csproj +++ b/src/CodoMetis.ValueRanges.NodaTime/CodoMetis.ValueRanges.NodaTime.csproj @@ -35,7 +35,7 @@ v6 adds the NodaTime value sets — LocalDateSet (date[]), LocalDateTimeSet (tim - + diff --git a/src/CodoMetis.ValueRanges.NodaTime/InstantRange.cs b/src/CodoMetis.ValueRanges.NodaTime/InstantRange.cs index ddd8a27..7d64f1b 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/InstantRange.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/InstantRange.cs @@ -204,7 +204,7 @@ public static Instant ParseValue(ReadOnlySpan s, IFormatProvider? provider /// (uuuu-MM-ddTHH:mm:ss.FFFFFFFFFZ, subsecond digits only when present) by default. /// public static string FormatValue(Instant value, string? format, IFormatProvider? provider) - => format is null + => string.IsNullOrEmpty(format) ? NodaPatterns.Instant.Format(value) : value.ToString(format, provider ?? CultureInfo.InvariantCulture); diff --git a/src/CodoMetis.ValueRanges.NodaTime/Internals/IsoWrapperElement.cs b/src/CodoMetis.ValueRanges.NodaTime/Internals/IsoWrapperElement.cs new file mode 100644 index 0000000..1d772f1 --- /dev/null +++ b/src/CodoMetis.ValueRanges.NodaTime/Internals/IsoWrapperElement.cs @@ -0,0 +1,58 @@ +using System.Globalization; +using CodoMetis.ValueRanges.Core; + +namespace CodoMetis.ValueRanges.Internals; + +/// +/// The construction-time check the calendar-bearing wrapper arities — LocalDateSet<T>, +/// LocalDateTimeSet<T>, YearMonthSet<T> — apply to every element: its ISO +/// text form must read back as that same element. +/// +/// +/// +/// NodaTime's ISO patterns format a value in its own calendar's fields. A wrapper over a Julian +/// LocalDate(2024, 1, 1) — ISO 2024-01-14 — therefore wrote 2024-01-01 into the array +/// literal, the JSON array and the EF Core bridge, all of which read it back as ISO 2024-01-01: +/// thirteen days off, silently. The closed sets convert such a value with WithCalendar; a +/// wrapper arity cannot, because the only way to build a wrapper element from an ISO +/// primitive is to parse it, and the element's calendar is not visible through the BCL interfaces +/// it is constrained on. So the element is refused, as a bare non-ISO value is refused on the JSON +/// write path. +/// +/// +/// The test is the round trip itself — format with the family's pinned pattern, parse back through +/// the family's ParseValue, compare — so it asks exactly the question that matters (would +/// the stored text be this element?) without knowing what makes the element differ. An element +/// whose text does not parse at all (one that ignores the format argument) is left alone here: it +/// already fails loudly at every text boundary, which its own tests pin. +/// +/// +internal static class IsoWrapperElement +{ + internal static TElement Require(TElement value) + where TSet : IValueSetFactory, IValueSet + where TElement : IEquatable + { + var text = TSet.FormatValue(value, null, CultureInfo.InvariantCulture); + + TElement readBack; + try + { + readBack = TSet.ParseValue(text.AsSpan(), CultureInfo.InvariantCulture); + } + catch (Exception ex) when (ex is FormatException or ArgumentException or OverflowException) + { + return value; + } + + return readBack.Equals(value) + ? value + : throw new ArgumentException( + $"A {typeof(TSet).Name.Split('`')[0]}<{typeof(TElement).Name}> element must be backed by a value in " + + $"the ISO calendar. This one formats as '{text}' in the family's ISO 8601 text form, which reads " + + "back as a different value — what a NodaTime value in another calendar does, since its own " + + "fields are written as if they were ISO. Convert the wrapped value with " + + "WithCalendar(CalendarSystem.Iso) before wrapping it.", + nameof(value)); + } +} diff --git a/src/CodoMetis.ValueRanges.NodaTime/Internals/NodaPatterns.cs b/src/CodoMetis.ValueRanges.NodaTime/Internals/NodaPatterns.cs index 32c63c9..2522614 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Internals/NodaPatterns.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Internals/NodaPatterns.cs @@ -14,6 +14,13 @@ namespace CodoMetis.ValueRanges.Internals; /// — (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. +/// +/// A time of day may also be written HH:mm — on its own, as the time part of a +/// , and before an instant's offset — exactly where the core's +/// IsoTemporal accepts it. It is valid ISO 8601, cannot be misread, and is what a hand-written +/// [09:00,17:00) uses; before 9.0.0 the BCL types read it and these threw. Nothing else is +/// widened: the hour is still two digits, and an instant still needs its offset or Z. +/// /// internal static class NodaPatterns { @@ -34,6 +41,18 @@ internal static class NodaPatterns /// Also the PostgreSQL wire form. internal static readonly LocalTimePattern Time = LocalTimePattern.ExtendedIso; + /// ISO 8601 time of day without seconds: 12:30. Parsing only. + private static readonly LocalTimePattern TimeHoursMinutes = + LocalTimePattern.CreateWithInvariantCulture("HH':'mm"); + + /// Date and time without seconds, T-separated: 2024-06-01T12:30. Parsing only. + private static readonly LocalDateTimePattern DateTimeHoursMinutes = + LocalDateTimePattern.CreateWithInvariantCulture("uuuu'-'MM'-'dd'T'HH':'mm"); + + /// Date and time without seconds, space-separated: 2024-06-01 12:30. Parsing only. + private static readonly LocalDateTimePattern DateTimeSpaceHoursMinutes = + LocalDateTimePattern.CreateWithInvariantCulture("uuuu'-'MM'-'dd' 'HH':'mm"); + /// ISO 8601 UTC instant: 2024-06-01T12:30:00.123456789Z. internal static readonly InstantPattern Instant = InstantPattern.ExtendedIso; @@ -45,6 +64,14 @@ internal static class NodaPatterns private static readonly OffsetDateTimePattern InstantOffsetSpace = OffsetDateTimePattern.CreateWithInvariantCulture("uuuu'-'MM'-'dd' 'HH':'mm':'ss;FFFFFFFFFo"); + /// Without seconds, with an offset or Z: 2024-06-01T14:30+02:00. Parsing only. + private static readonly OffsetDateTimePattern InstantOffsetTHoursMinutes = + OffsetDateTimePattern.CreateWithInvariantCulture("uuuu'-'MM'-'dd'T'HH':'mmo"); + + /// Without seconds, space-separated, with an offset or Z: 2024-06-01 14:30+02. Parsing only. + private static readonly OffsetDateTimePattern InstantOffsetSpaceHoursMinutes = + OffsetDateTimePattern.CreateWithInvariantCulture("uuuu'-'MM'-'dd' 'HH':'mmo"); + internal static LocalDate ParseDate(string text) => IsInfinity(text, out var positive) ? positive ? LocalDate.MaxIsoValue : LocalDate.MinIsoValue @@ -54,7 +81,13 @@ internal static YearMonth ParseYearMonth(string text) => YearMonth.Parse(text).GetValueOrThrow(); internal static LocalTime ParseTime(string text) - => Time.Parse(text).GetValueOrThrow(); + { + var iso = Time.Parse(text); + if (iso.Success) return iso.Value; + + var hoursMinutes = TimeHoursMinutes.Parse(text); + return hoursMinutes.Success ? hoursMinutes.Value : iso.GetValueOrThrow(); + } internal static LocalDateTime ParseDateTime(string text) { @@ -63,8 +96,13 @@ internal static LocalDateTime ParseDateTime(string text) var iso = DateTime.Parse(text); if (iso.Success) return iso.Value; - var space = DateTimeSpace.Parse(text); - return space.Success ? space.Value : iso.GetValueOrThrow(); + foreach (var pattern in (ReadOnlySpan)[DateTimeSpace, DateTimeHoursMinutes, DateTimeSpaceHoursMinutes]) + { + var result = pattern.Parse(text); + if (result.Success) return result.Value; + } + + return iso.GetValueOrThrow(); } internal static Instant ParseInstant(string text) @@ -74,11 +112,14 @@ internal static Instant ParseInstant(string text) var iso = Instant.Parse(text); if (iso.Success) return iso.Value; - var offsetT = InstantOffsetT.Parse(text); - if (offsetT.Success) return offsetT.Value.ToInstant(); + foreach (var pattern in (ReadOnlySpan) + [InstantOffsetT, InstantOffsetSpace, InstantOffsetTHoursMinutes, InstantOffsetSpaceHoursMinutes]) + { + var result = pattern.Parse(text); + if (result.Success) return result.Value.ToInstant(); + } - var offsetSpace = InstantOffsetSpace.Parse(text); - return offsetSpace.Success ? offsetSpace.Value.ToInstant() : iso.GetValueOrThrow(); + return iso.GetValueOrThrow(); } private static bool IsInfinity(string text, out bool positive) diff --git a/src/CodoMetis.ValueRanges.NodaTime/LocalDateRange.cs b/src/CodoMetis.ValueRanges.NodaTime/LocalDateRange.cs index b30db82..6b83ea4 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/LocalDateRange.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/LocalDateRange.cs @@ -279,7 +279,7 @@ public static LocalDate ParseValue(ReadOnlySpan s, IFormatProvider? provid /// Formats a value using ISO 8601 (uuuu-MM-dd) by default. /// public static string FormatValue(LocalDate value, string? format, IFormatProvider? provider) - => format is null + => string.IsNullOrEmpty(format) ? NodaPatterns.Date.Format(value) : value.ToString(format, provider ?? CultureInfo.InvariantCulture); diff --git a/src/CodoMetis.ValueRanges.NodaTime/LocalDateTimeRange.cs b/src/CodoMetis.ValueRanges.NodaTime/LocalDateTimeRange.cs index 1111220..d3e8e4e 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/LocalDateTimeRange.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/LocalDateTimeRange.cs @@ -270,7 +270,7 @@ public static LocalDateTime ParseValue(ReadOnlySpan s, IFormatProvider? pr /// (uuuu-MM-ddTHH:mm:ss.FFFFFFFFF, subsecond digits only when present) by default. /// public static string FormatValue(LocalDateTime value, string? format, IFormatProvider? provider) - => format is null + => string.IsNullOrEmpty(format) ? NodaPatterns.DateTime.Format(value) : value.ToString(format, provider ?? CultureInfo.InvariantCulture); diff --git a/src/CodoMetis.ValueRanges.NodaTime/README.md b/src/CodoMetis.ValueRanges.NodaTime/README.md index 2b6f2b9..894f35e 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/README.md +++ b/src/CodoMetis.ValueRanges.NodaTime/README.md @@ -27,7 +27,7 @@ A range says *"every moment between these two"*; a set says *"exactly these mome | `LocalTimeSet` | `LocalTime` | `time[]` | | `YearMonthSet` | `YearMonth` | `date[]` (month-aligned) | -The whole core set algebra applies: `Contains`, `Overlaps`, `IsSubsetOf`/`IsSupersetOf` and their proper variants, `Union`, `Remove`, `Count`, `IsEmpty`, plus client-side `Intersect`/`Except`/`Add` — along with array-literal parsing/formatting, JSON ([one call to set up](#json)), and collection expressions. +The whole core set algebra applies: `Contains`, `Overlaps`, `IsSubsetOf`/`IsSupersetOf` and their proper variants, `Union`, `Remove`, `Count`, `IsEmpty`, plus client-side `Intersect`/`Except`/`Add` — along with array-literal parsing/formatting, JSON ([no setup needed](#json)), and collection expressions. ```csharp using CodoMetis.ValueRanges; @@ -88,7 +88,7 @@ JsonSerializer.Serialize(new { Day = new LocalDate(2025, 1, 1), Days = holidays // {"Day":"2025-01-01","Days":["2025-01-01","2025-12-26"]} ``` -It registers the converter factory plus the same five element converters on the options, so a `LocalDate` property outside a set gets the ISO 8601 form too. Element types already claimed by a registered converter are left alone, which makes it idempotent and order-independent against NodaTime.Serialization.SystemTextJson: +It registers the converter factory plus the same five element converters on the options, so a `LocalDate` property outside a set gets the ISO 8601 form too. The element converters are a fallback: any other converter on the same options that handles one of the five types is used instead, wherever it was registered. That makes the call idempotent, and NodaTime.Serialization.SystemTextJson stays authoritative in either order: ```csharp var options = new JsonSerializerOptions() @@ -98,6 +98,10 @@ var options = new JsonSerializerOptions() Reach for that package when the payload carries NodaTime types beyond these five. +A bare `LocalDate`, `LocalDateTime` or `YearMonth` in a calendar other than ISO is refused on write with an `ArgumentException`, as NodaTime's own converter refuses it. Its ISO 8601 text would spell the other calendar's fields, so a Julian `LocalDate(2024, 1, 1)` (ISO 2024-01-14) would read back as ISO 2024-01-01. Convert it with `WithCalendar(CalendarSystem.Iso)` first. Sets and ranges never get that far: they convert a non-ISO value to ISO at construction, or reject it (`YearMonth`). + +> Before 9.0 the element converters claimed a type by being registered first, so `AddNodaTimeRangeConverters().ConfigureForNodaTime(…)` shadowed NodaTime's converters, and a bare non-ISO value was written in its own calendar's fields with no error. + ## Two documented caveats, dissolved The core package documents two reinterpretation rules at the database boundary for the BCL-based types. With NodaTime they do not arise, because the types cannot express the ambiguity in the first place: @@ -122,6 +126,7 @@ The core package restricts its element types to domains with a total order that - **Defaults match the core conventions.** `LocalDateRange.CreateFinite` is closed `[start, end]` (discrete); `LocalDateTimeRange`/`InstantRange` default to half-open `[start, end)` (continuous timestamp convention). Discrete canonicalization applies: `(2025-01-01, 2025-01-31)` normalizes to `[2025-01-02, 2025-01-30]`. - **The ISO calendar is a construction rule.** `LocalDate.CompareTo` is only defined between dates of the same calendar system, and PostgreSQL `date`/`timestamp` are proleptic Gregorian. `LocalDateRange` and `LocalDateTimeRange` therefore normalize bounds to the ISO calendar at construction (`WithCalendar(CalendarSystem.Iso)` — same day on the timeline, ISO representation). Ranges never hold mixed-calendar bounds, so comparisons cannot throw. A date outside the ISO calendar's year range (far-future non-ISO dates) throws `ArgumentOutOfRangeException` at construction. `YearMonthRange` is stricter: a non-ISO year-month spans parts of two ISO months and has no lossless equivalent, so it **rejects** non-ISO bounds with `ArgumentException` instead of reinterpreting them. - **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. +- **The wrapper arities refuse a non-ISO element instead.** `LocalDateSet`, `LocalDateTimeSet` and `YearMonthSet` cannot convert an element to ISO — the only way to build a `T` from an ISO value is to parse it — so an element backed by a value in another calendar is refused with `ArgumentException`: in `From`, in a collection expression, when reading (JSON through a registered element converter), and as the probe of `Contains`/`Add`/`Remove`. The test is the round trip: the element's ISO text must read back as that element. Before 9.0 a wrapper over a Julian 2024-01-01 — ISO 2024-01-14 — was written as `2024-01-01` to the literal, JSON and the database, and read back thirteen days off. The EF Core package refuses one as a bare query operand too. - **`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, 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. @@ -145,7 +150,9 @@ InstantRange range = interval.ToInstantRange(); // [start, end) Finite Interval back = range.ToInterval(); // round-trips if (LocalDateRange.Parse("[2025-01-01,2025-01-31]", null) is LocalDateRange.Finite finite) +{ DateInterval dates = finite.ToDateInterval(); +} ``` What the range types add over `Interval`/`DateInterval`: the empty range, unbounded date ranges, all four bound-inclusiveness combinations, the full set algebra (`Union`/`Except`/`Intersect`/`Merge`/`Complement`), multiranges, PostgreSQL literals, and LINQ-to-SQL translation. diff --git a/src/CodoMetis.ValueRanges.NodaTime/Serialization/NodaElementJsonConverters.cs b/src/CodoMetis.ValueRanges.NodaTime/Serialization/NodaElementJsonConverters.cs new file mode 100644 index 0000000..c01e930 --- /dev/null +++ b/src/CodoMetis.ValueRanges.NodaTime/Serialization/NodaElementJsonConverters.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CodoMetis.ValueRanges.Core; +using NodaTime; + +namespace CodoMetis.ValueRanges.Serialization; + +/// +/// The one ISO 8601 element converter per NodaTime element type — what the satellite's sets hand +/// to and what +/// AddNodaTimeRangeConverters() uses for bare values. +/// +internal static class NodaElementJson +{ + internal static readonly JsonConverter LocalDate = + new IsoCalendarElementJsonConverter(static value => value.Calendar); + + internal static readonly JsonConverter LocalDateTime = + new IsoCalendarElementJsonConverter(static value => value.Calendar); + + internal static readonly JsonConverter YearMonth = + new IsoCalendarElementJsonConverter(static value => value.Calendar); + + // No calendar to check: a time of day and an instant are the same in every calendar. + internal static readonly JsonConverter LocalTime = ValueSetTextElementJsonConverter.Instance; + + internal static readonly JsonConverter Instant = ValueSetTextElementJsonConverter.Instance; +} + +/// +/// The ISO 8601 text form of a calendar-bearing NodaTime value, refusing — on write — a value in +/// any calendar but ISO. +/// +/// +/// +/// The ISO patterns format a value in its own calendar's fields: a Julian LocalDate(2024, 1, 1), +/// which is ISO 2024-01-14, formats as 2024-01-01 and reads back as ISO 2024-01-01 — thirteen +/// days off, silently. A Coptic date formats as 1740-13-05, which at least fails on the way +/// back in. +/// +/// +/// Refused rather than converted, for two reasons. A converter that converted would hand back a +/// value unequal to the one written (LocalDate equality includes the calendar), which is the +/// silent kind of change this package does not make on a wire boundary. And NodaTime's own +/// converter refuses with the same exception type, so a payload behaves the same whichever of the +/// two converters ends up serving it. Normalizing belongs to construction: the satellite's sets and +/// ranges convert a non-ISO date to ISO there (or reject one, for YearMonth), so what they +/// hand this converter is always ISO. +/// +/// +internal sealed class IsoCalendarElementJsonConverter(Func calendarOf) : JsonConverter + where TSet : IValueSetFactory, IValueSet + where T : IEquatable +{ + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => ValueSetTextElementJsonConverter.Instance.Read(ref reader, typeToConvert, options); + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var calendar = calendarOf(value); + if (calendar != CalendarSystem.Iso) + throw new ArgumentException( + $"{typeof(T).Name} values must use the ISO calendar to be serialized; got {value} in the {calendar.Id} " + + "calendar. Its ISO 8601 text would spell the fields of that calendar and read back as a different " + + $"{typeof(T).Name}. Convert it first with WithCalendar(CalendarSystem.Iso).", + nameof(value)); + + ValueSetTextElementJsonConverter.Instance.Write(writer, value, options); + } +} + +/// +/// Serves the five NodaTime element types for AddNodaTimeRangeConverters(), as a fallback: +/// any other converter on the same options that handles the type is used instead, wherever it sits +/// in . +/// +/// +/// +/// System.Text.Json takes the first converter in that +/// can handle a type, so registering plain converters made the call order decide: with +/// AddNodaTimeRangeConverters() first, its converters shadowed NodaTime.Serialization's for +/// all five types. A factory is asked for its converter only when the options are first used, by +/// which time every registration has been made — so it can look past itself and hand over the +/// converter that would otherwise have been shadowed. +/// +/// +/// Nothing is built by reflection: the fallbacks are closed instances in , +/// and another factory's converter is obtained through its own . +/// +/// +internal sealed class NodaElementJsonConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) => Fallback(typeToConvert) is not null; + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + => RegisteredElsewhere(typeToConvert, options) ?? Fallback(typeToConvert); + + private static JsonConverter? RegisteredElsewhere(Type typeToConvert, JsonSerializerOptions options) + { + foreach (var candidate in options.Converters) + { + if (candidate is NodaElementJsonConverterFactory || !candidate.CanConvert(typeToConvert)) + continue; + + return candidate is JsonConverterFactory factory + ? factory.CreateConverter(typeToConvert, options) + : candidate; + } + + return null; + } + + private static JsonConverter? Fallback(Type type) + => type == typeof(LocalDate) ? NodaElementJson.LocalDate + : type == typeof(LocalDateTime) ? NodaElementJson.LocalDateTime + : type == typeof(LocalTime) ? NodaElementJson.LocalTime + : type == typeof(Instant) ? NodaElementJson.Instant + : type == typeof(YearMonth) ? NodaElementJson.YearMonth + : null; +} diff --git a/src/CodoMetis.ValueRanges.NodaTime/Serialization/NodaTimeJsonSerializerOptionsExtensions.cs b/src/CodoMetis.ValueRanges.NodaTime/Serialization/NodaTimeJsonSerializerOptionsExtensions.cs index a4e4b0b..6d0d67a 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Serialization/NodaTimeJsonSerializerOptionsExtensions.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Serialization/NodaTimeJsonSerializerOptionsExtensions.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using CodoMetis.ValueRanges.Core; using NodaTime; namespace CodoMetis.ValueRanges.Serialization; @@ -26,11 +25,20 @@ public static class NodaTimeRangeJsonSerializerOptionsExtensions /// The range types are unaffected either way; they format themselves. /// /// - /// Composes with NodaTime.Serialization.SystemTextJson in either registration order: an - /// element type already handled by a registered converter is left alone, so - /// ConfigureForNodaTime stays authoritative where it is used. Reach for that package - /// when the payload also carries NodaTime types beyond these five - /// (Duration, Period, ZonedDateTime, …). + /// The element converters are a fallback. Any other converter on the same options that handles + /// one of the five types is used instead, wherever it sits in + /// — so this composes with + /// NodaTime.Serialization.SystemTextJson in either registration order, and ConfigureForNodaTime + /// stays authoritative for the types it covers. Reach for that package when the payload also + /// carries NodaTime types beyond these five (Duration, Period, ZonedDateTime, …). + /// + /// + /// A bare , or in a + /// calendar other than ISO is refused on write with an , as + /// NodaTime's own converter refuses it: its ISO 8601 text would spell the other calendar's fields + /// and read back as a different value (a Julian 2024-01-01 is ISO 2024-01-14). Convert it with + /// WithCalendar(CalendarSystem.Iso) first. Sets and ranges are unaffected — they convert a + /// non-ISO value to ISO at construction, or reject it. /// /// /// @@ -48,24 +56,9 @@ public static JsonSerializerOptions AddNodaTimeRangeConverters(this JsonSerializ if (!options.Converters.Any(static c => c is RangeJsonConverterFactory)) options.Converters.Add(new RangeJsonConverterFactory()); - AddElementConverter(options); - AddElementConverter(options); - AddElementConverter(options); - AddElementConverter(options); - AddElementConverter(options); + if (!options.Converters.Any(static c => c is NodaElementJsonConverterFactory)) + options.Converters.Add(new NodaElementJsonConverterFactory()); return options; } - - private static void AddElementConverter(JsonSerializerOptions options) - where TSet : IValueSetFactory, IValueSet - where T : IEquatable - { - // Whoever claimed the element type first wins — this is what makes the method - // idempotent and order-independent against ConfigureForNodaTime. - if (options.Converters.Any(static c => c.CanConvert(typeof(T)))) - return; - - options.Converters.Add(ValueSetTextElementJsonConverter.Instance); - } } diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/InstantSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/InstantSet.cs index 6ae007d..c98e964 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/InstantSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/InstantSet.cs @@ -92,7 +92,7 @@ public static Instant ParseValue(ReadOnlySpan s, IFormatProvider? provider /// Formats a value using ISO 8601 (uuuu-MM-ddTHH:mm:ssZ with optional subseconds) by default. /// public static string FormatValue(Instant value, string? format, IFormatProvider? provider) - => format is null + => string.IsNullOrEmpty(format) ? NodaPatterns.Instant.Format(value) : value.ToString(format, provider ?? CultureInfo.InvariantCulture); @@ -103,7 +103,7 @@ public static string FormatValue(Instant value, string? format, IFormatProvider? /// . /// static JsonConverter? IValueSetFactory.ElementJsonConverter - => ValueSetTextElementJsonConverter.Instance; + => NodaElementJson.Instant; /// /// Parses a PostgreSQL array literal (e.g. {2024-06-01T08:00:00Z}, {}) into a @@ -243,7 +243,7 @@ public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats an element using extended ISO 8601 /// (uuuu-MM-ddTHH:mm:ss.FFFFFFFFFZ) by default — see the class remarks. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) - => value.ToString(format ?? RoundTripFormat, provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? RoundTripFormat : format, provider ?? CultureInfo.InvariantCulture); /// /// The JSON fallback for elements that carry no converter of their own: a JSON string diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateSet.cs index 505adbc..bd6ae9a 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateSet.cs @@ -108,7 +108,7 @@ public static LocalDate ParseValue(ReadOnlySpan s, IFormatProvider? provid /// Formats a value using ISO 8601 (uuuu-MM-dd) by default. /// public static string FormatValue(LocalDate value, string? format, IFormatProvider? provider) - => format is null + => string.IsNullOrEmpty(format) ? NodaPatterns.Date.Format(value) : value.ToString(format, provider ?? CultureInfo.InvariantCulture); @@ -119,7 +119,7 @@ public static string FormatValue(LocalDate value, string? format, IFormatProvide /// . /// static JsonConverter? IValueSetFactory.ElementJsonConverter - => ValueSetTextElementJsonConverter.Instance; + => NodaElementJson.LocalDate; /// /// Parses a PostgreSQL array literal (e.g. {2024-01-01,2024-12-24}, {}) into a @@ -186,6 +186,14 @@ public bool Equals(LocalDateSet? other) /// Canonical order is the element's own , which for generated /// wrappers delegates to the backing . /// +/// +/// The element must be backed by a value in the ISO calendar. The closed +/// converts a non-ISO value; this arity cannot build its element from an ISO value except by parsing +/// it, so an element whose ISO text reads back as a different element — what a non-ISO value does, +/// since the pattern spells its own calendar's fields — is refused with +/// in From, in a collection expression, on read, and as the +/// probe of Contains, Add and Remove. +/// /// /// The validated element type backed by a . [DebuggerDisplay("{ToString(),nq}")] @@ -216,13 +224,29 @@ public sealed class LocalDateSet : /// public static LocalDateSet Empty { get; } = new([]); + // An element backed by a calendar other than ISO is refused rather than stored as the ISO value + // its fields spell; the closed set converts one, which a wrapper cannot. See IsoWrapperElement. + private static TElement RequireIso(TElement value) => IsoWrapperElement.Require, TElement>(value); + + // Contains, Add and Remove refuse the probe From would refuse: against the empty set nothing is + // compared, so a non-ISO probe answered false and Add stored it. + TElement IValueSet.NormalizeElement(TElement value) => RequireIso(value); + /// + /// An element is backed by a value in a calendar other than ISO. public static LocalDateSet From(IEnumerable values) - => FromTrusted(ValueSetCore.Canonicalize(values, Comparer.Default)); + { + ArgumentNullException.ThrowIfNull(values); + return FromTrusted(ValueSetCore.Canonicalize(values.Select(RequireIso), Comparer.Default)); + } /// + /// An element is backed by a value in a calendar other than ISO. public static LocalDateSet From(params ReadOnlySpan values) - => FromTrusted(ValueSetCore.Canonicalize(values, Comparer.Default)); + { + foreach (var value in values) RequireIso(value); + return FromTrusted(ValueSetCore.Canonicalize(values, Comparer.Default)); + } internal static LocalDateSet FromTrusted(ImmutableArray elements) => elements.IsEmpty ? Empty : new(elements); @@ -258,7 +282,7 @@ public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats an element using ISO 8601 (uuuu-MM-dd) by default — see the /// class remarks. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) - => value.ToString(format ?? RoundTripFormat, provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? RoundTripFormat : format, provider ?? CultureInfo.InvariantCulture); /// /// The JSON fallback for elements that carry no converter of their own: a JSON string diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateTimeSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateTimeSet.cs index 5fca234..1ee0744 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateTimeSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalDateTimeSet.cs @@ -107,7 +107,7 @@ public static LocalDateTime ParseValue(ReadOnlySpan s, IFormatProvider? pr /// Formats a value using ISO 8601 (uuuu-MM-ddTHH:mm:ss with optional subseconds) by default. /// public static string FormatValue(LocalDateTime value, string? format, IFormatProvider? provider) - => format is null + => string.IsNullOrEmpty(format) ? NodaPatterns.DateTime.Format(value) : value.ToString(format, provider ?? CultureInfo.InvariantCulture); @@ -118,7 +118,7 @@ public static string FormatValue(LocalDateTime value, string? format, IFormatPro /// . /// static JsonConverter? IValueSetFactory.ElementJsonConverter - => ValueSetTextElementJsonConverter.Instance; + => NodaElementJson.LocalDateTime; /// /// Parses a PostgreSQL array literal (e.g. {2024-06-01T08:00:00,2024-06-02T08:00:00}, {}) into a @@ -185,6 +185,14 @@ public bool Equals(LocalDateTimeSet? other) /// Canonical order is the element's own , which for generated /// wrappers delegates to the backing . /// +/// +/// The element must be backed by a value in the ISO calendar. The closed +/// converts a non-ISO value; this arity cannot build its element from an ISO value except by parsing +/// it, so an element whose ISO text reads back as a different element — what a non-ISO value does, +/// since the pattern spells its own calendar's fields — is refused with +/// in From, in a collection expression, on read, and as the +/// probe of Contains, Add and Remove. +/// /// /// The validated element type backed by a . [DebuggerDisplay("{ToString(),nq}")] @@ -216,13 +224,29 @@ public sealed class LocalDateTimeSet : /// public static LocalDateTimeSet Empty { get; } = new([]); + // An element backed by a calendar other than ISO is refused rather than stored as the ISO value + // its fields spell; the closed set converts one, which a wrapper cannot. See IsoWrapperElement. + private static TElement RequireIso(TElement value) => IsoWrapperElement.Require, TElement>(value); + + // Contains, Add and Remove refuse the probe From would refuse: against the empty set nothing is + // compared, so a non-ISO probe answered false and Add stored it. + TElement IValueSet.NormalizeElement(TElement value) => RequireIso(value); + /// + /// An element is backed by a value in a calendar other than ISO. public static LocalDateTimeSet From(IEnumerable values) - => FromTrusted(ValueSetCore.Canonicalize(values, Comparer.Default)); + { + ArgumentNullException.ThrowIfNull(values); + return FromTrusted(ValueSetCore.Canonicalize(values.Select(RequireIso), Comparer.Default)); + } /// + /// An element is backed by a value in a calendar other than ISO. public static LocalDateTimeSet From(params ReadOnlySpan values) - => FromTrusted(ValueSetCore.Canonicalize(values, Comparer.Default)); + { + foreach (var value in values) RequireIso(value); + return FromTrusted(ValueSetCore.Canonicalize(values, Comparer.Default)); + } internal static LocalDateTimeSet FromTrusted(ImmutableArray elements) => elements.IsEmpty ? Empty : new(elements); @@ -258,7 +282,7 @@ public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats an element using extended ISO 8601 /// (uuuu-MM-ddTHH:mm:ss.FFFFFFFFF) by default — see the class remarks. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) - => value.ToString(format ?? RoundTripFormat, provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? RoundTripFormat : format, provider ?? CultureInfo.InvariantCulture); /// /// The JSON fallback for elements that carry no converter of their own: a JSON string diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalTimeSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalTimeSet.cs index 3c06001..cc03200 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalTimeSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/LocalTimeSet.cs @@ -92,7 +92,7 @@ public static LocalTime ParseValue(ReadOnlySpan s, IFormatProvider? provid /// Formats a value using ISO 8601 (HH:mm:ss with optional subseconds) by default. /// public static string FormatValue(LocalTime value, string? format, IFormatProvider? provider) - => format is null + => string.IsNullOrEmpty(format) ? NodaPatterns.Time.Format(value) : value.ToString(format, provider ?? CultureInfo.InvariantCulture); @@ -103,7 +103,7 @@ public static string FormatValue(LocalTime value, string? format, IFormatProvide /// . /// static JsonConverter? IValueSetFactory.ElementJsonConverter - => ValueSetTextElementJsonConverter.Instance; + => NodaElementJson.LocalTime; /// /// Parses a PostgreSQL array literal (e.g. {09:00:00,17:30:00}, {}) into a @@ -243,7 +243,7 @@ public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats an element using extended ISO 8601 (HH:mm:ss.FFFFFFFFF) by /// default — see the class remarks. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) - => value.ToString(format ?? RoundTripFormat, provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? RoundTripFormat : format, provider ?? CultureInfo.InvariantCulture); /// /// The JSON fallback for elements that carry no converter of their own: a JSON string diff --git a/src/CodoMetis.ValueRanges.NodaTime/Sets/YearMonthSet.cs b/src/CodoMetis.ValueRanges.NodaTime/Sets/YearMonthSet.cs index 7de8b9d..6355b5a 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/Sets/YearMonthSet.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/Sets/YearMonthSet.cs @@ -114,7 +114,7 @@ public static YearMonth ParseValue(ReadOnlySpan s, IFormatProvider? provid /// Formats a value using ISO 8601 (uuuu-MM) by default. /// public static string FormatValue(YearMonth value, string? format, IFormatProvider? provider) - => format is null + => string.IsNullOrEmpty(format) ? NodaPatterns.YearMonth.Format(value) : value.ToString(format, provider ?? CultureInfo.InvariantCulture); @@ -125,7 +125,7 @@ public static string FormatValue(YearMonth value, string? format, IFormatProvide /// . /// static JsonConverter? IValueSetFactory.ElementJsonConverter - => ValueSetTextElementJsonConverter.Instance; + => NodaElementJson.YearMonth; /// /// Parses a PostgreSQL array literal (e.g. {2024-01,2024-06}, {}) into a @@ -197,6 +197,14 @@ public bool Equals(YearMonthSet? other) /// Canonical order is the element's own , which for generated /// wrappers delegates to the backing . /// +/// +/// The element must be backed by a value in the ISO calendar. The closed +/// converts a non-ISO value; this arity cannot build its element from an ISO value except by parsing +/// it, so an element whose ISO text reads back as a different element — what a non-ISO value does, +/// since the pattern spells its own calendar's fields — is refused with +/// in From, in a collection expression, on read, and as the +/// probe of Contains, Add and Remove. +/// /// /// The validated element type backed by a . [DebuggerDisplay("{ToString(),nq}")] @@ -227,13 +235,29 @@ public sealed class YearMonthSet : /// public static YearMonthSet Empty { get; } = new([]); + // An element backed by a calendar other than ISO is refused rather than stored as the ISO value + // its fields spell; the closed set converts one, which a wrapper cannot. See IsoWrapperElement. + private static TElement RequireIso(TElement value) => IsoWrapperElement.Require, TElement>(value); + + // Contains, Add and Remove refuse the probe From would refuse: against the empty set nothing is + // compared, so a non-ISO probe answered false and Add stored it. + TElement IValueSet.NormalizeElement(TElement value) => RequireIso(value); + /// + /// An element is backed by a value in a calendar other than ISO. public static YearMonthSet From(IEnumerable values) - => FromTrusted(ValueSetCore.Canonicalize(values, Comparer.Default)); + { + ArgumentNullException.ThrowIfNull(values); + return FromTrusted(ValueSetCore.Canonicalize(values.Select(RequireIso), Comparer.Default)); + } /// + /// An element is backed by a value in a calendar other than ISO. public static YearMonthSet From(params ReadOnlySpan values) - => FromTrusted(ValueSetCore.Canonicalize(values, Comparer.Default)); + { + foreach (var value in values) RequireIso(value); + return FromTrusted(ValueSetCore.Canonicalize(values, Comparer.Default)); + } internal static YearMonthSet FromTrusted(ImmutableArray elements) => elements.IsEmpty ? Empty : new(elements); @@ -269,7 +293,7 @@ public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats an element using ISO 8601 (uuuu-MM) by default — see the class /// remarks. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) - => value.ToString(format ?? RoundTripFormat, provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? RoundTripFormat : format, provider ?? CultureInfo.InvariantCulture); /// /// The JSON fallback for elements that carry no converter of their own: a JSON string diff --git a/src/CodoMetis.ValueRanges.NodaTime/YearMonthRange.cs b/src/CodoMetis.ValueRanges.NodaTime/YearMonthRange.cs index 67cd378..848a0aa 100644 --- a/src/CodoMetis.ValueRanges.NodaTime/YearMonthRange.cs +++ b/src/CodoMetis.ValueRanges.NodaTime/YearMonthRange.cs @@ -288,7 +288,7 @@ public static YearMonth ParseValue(ReadOnlySpan s, IFormatProvider? provid /// Formats a value using ISO 8601 (uuuu-MM) by default. /// public static string FormatValue(YearMonth value, string? format, IFormatProvider? provider) - => format is null + => string.IsNullOrEmpty(format) ? NodaPatterns.YearMonth.Format(value) : value.ToString(format, provider ?? CultureInfo.InvariantCulture); diff --git a/src/CodoMetis.ValueRanges/CHANGELOG.md b/src/CodoMetis.ValueRanges/CHANGELOG.md index e408b81..0d86c39 100644 --- a/src/CodoMetis.ValueRanges/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges/CHANGELOG.md @@ -26,6 +26,13 @@ covers all four packages, which share one version number and release together. 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. + **A `DateTime` with an offset keeps its wall clock** (`Local`), as PostgreSQL's `timestamp` input + does: `2026-01-01T09:30+05:30` was converted into the parsing machine's zone, a different value + on every server, and a local value inside a DST gap came back an hour off. + +- **The range interfaces are declared not to be an extension point.** Implementing `IRange`, + the shape interfaces or `IRangeFactory` outside the library is unsupported, and they + may gain members in a minor version. Consuming them is unaffected. ### Fixed @@ -37,6 +44,32 @@ covers all four packages, which share one version number and release together. - **⚠️ Decimal bounds and elements parse with PostgreSQL's numeric grammar** (`NumberStyles.Float` instead of `Any`): `"1,000"`, `"(5)"` and currency symbols are a `FormatException` rather than 1000, −5 and 5, and `DecimalRange.Parse("[1.5,2.5)", de-DE)` fails instead of returning `[15,25)`. + The wrapper arities read element text with the same grammar before the element's own `Parse` + sees it: `DecimalSet.Parse("{\"1,5\"}")` was `{15}` through a generated + `NumberStyles.Number` `Parse`; the integer arities likewise. + +- **⚠️ `ToString("", provider)` wrote a culture-formatted literal** (`[1,5,2)` under `de-DE`, + Buddhist-era dates under `th-TH`): `""` is the general format, as `null` is, and now writes the + invariant literal. + +- **⚠️ A quoted empty range bound read as unbounded** — `["",5)` was `(,4]`, `("","")` was `(,)`, + and `[ ,5)` the same. Bounds follow PostgreSQL's `range_in`: only an absent bound is infinite, + and a backslash escapes the next character outside quotes too. + +- **⚠️ Array literals follow PostgreSQL's `array_in`**: `{a\,b}` is the one element `a,b` (was `a\` + and `b`), and `{{a,b}}`, `{a}b}` and `{a"b}` are a `FormatException` (`{{a,b}}` was + `{"{a","b}"}`). Only ASCII whitespace is skipped around elements. + +- **⚠️ A union converter on the options now reaches the union's variants.** A range serialized + through `object`, an `object` collection or a variant-typed declaration wrote the literal while a + union-typed declaration used the registered converter. The variant converter now delegates to the + converter System.Text.Json would choose for the union, narrowing on read. **Payload change** for + anyone relying on the mixed form. + +- **⚠️ Interface-declared ranges and sets no longer write a property dump.** `IRange` wrote + `{}` and `IValueSet` `{"Values":["a"]}`. The interfaces carry the new + `InterfaceDeclaredJsonConverter`: writes go through the runtime type's converter, reads throw + `NotSupportedException` — declare the concrete type. - **⚠️ A culture provider no longer changes the literal.** `Parse(s, th-TH)` read `2026-01-01` as a Buddhist-era date (Gregorian 1483) and `ToString(null, th-TH)` wrote `2569`; diff --git a/src/CodoMetis.ValueRanges/Core/IEmptyRange.cs b/src/CodoMetis.ValueRanges/Core/IEmptyRange.cs index c54d04b..10778a6 100644 --- a/src/CodoMetis.ValueRanges/Core/IEmptyRange.cs +++ b/src/CodoMetis.ValueRanges/Core/IEmptyRange.cs @@ -1,7 +1,10 @@ +using System.Text.Json.Serialization; +using CodoMetis.ValueRanges.Serialization; namespace CodoMetis.ValueRanges.Core; /// /// Marker interface for the empty range — a range that contains no values. /// /// The element type of the range. +[JsonConverter(typeof(InterfaceDeclaredJsonConverter))] public interface IEmptyRange : IRange where T : struct, IComparable, IEquatable; \ No newline at end of file diff --git a/src/CodoMetis.ValueRanges/Core/IFiniteRange.cs b/src/CodoMetis.ValueRanges/Core/IFiniteRange.cs index e1fed9e..04f3651 100644 --- a/src/CodoMetis.ValueRanges/Core/IFiniteRange.cs +++ b/src/CodoMetis.ValueRanges/Core/IFiniteRange.cs @@ -1,4 +1,6 @@ +using System.Text.Json.Serialization; using CodoMetis.ValueRanges.Internals; +using CodoMetis.ValueRanges.Serialization; namespace CodoMetis.ValueRanges.Core; @@ -6,6 +8,7 @@ namespace CodoMetis.ValueRanges.Core; /// Represents a range that is bounded on both sides. /// /// The element type of the range. +[JsonConverter(typeof(InterfaceDeclaredJsonConverter))] public interface IFiniteRange : IRange where T : struct, IComparable, IEquatable { /// Gets the lower (left) bound of the range. diff --git a/src/CodoMetis.ValueRanges/Core/IInfinityRange.cs b/src/CodoMetis.ValueRanges/Core/IInfinityRange.cs index 961ebb1..75a5c30 100644 --- a/src/CodoMetis.ValueRanges/Core/IInfinityRange.cs +++ b/src/CodoMetis.ValueRanges/Core/IInfinityRange.cs @@ -1,4 +1,6 @@ +using System.Text.Json.Serialization; using CodoMetis.ValueRanges.Internals; +using CodoMetis.ValueRanges.Serialization; namespace CodoMetis.ValueRanges.Core; @@ -7,6 +9,7 @@ namespace CodoMetis.ValueRanges.Core; /// An infinity range contains every value of the element type. /// /// The element type of the range. +[JsonConverter(typeof(InterfaceDeclaredJsonConverter))] public interface IInfinityRange : IRange where T : struct, IComparable, IEquatable { TRange IRange.IntersectWith(IRange other) => RangeBoundHelpers.RecreateAs(other); diff --git a/src/CodoMetis.ValueRanges/Core/IRange.cs b/src/CodoMetis.ValueRanges/Core/IRange.cs index aa50d82..8dc3ff8 100644 --- a/src/CodoMetis.ValueRanges/Core/IRange.cs +++ b/src/CodoMetis.ValueRanges/Core/IRange.cs @@ -1,9 +1,12 @@ +using System.Text.Json.Serialization; +using CodoMetis.ValueRanges.Serialization; namespace CodoMetis.ValueRanges.Core; /// /// Marker interface for all range types over a comparable, equatable value type. /// /// The element type of the range. +[JsonConverter(typeof(InterfaceDeclaredJsonConverter))] public interface IRange where T : struct, IComparable, IEquatable { internal TRange IntersectWith(IRange other) diff --git a/src/CodoMetis.ValueRanges/Core/IRangeFactory.cs b/src/CodoMetis.ValueRanges/Core/IRangeFactory.cs index 6565f8b..831b15b 100644 --- a/src/CodoMetis.ValueRanges/Core/IRangeFactory.cs +++ b/src/CodoMetis.ValueRanges/Core/IRangeFactory.cs @@ -139,7 +139,7 @@ static bool ISpanParsable.TryParse(ReadOnlySpan s, IFormatProvider /// for each bound value. /// /// - /// A format string forwarded to the bound value formatter, or + /// A format string forwarded to the bound value formatter, or or empty /// for the literal — the type-specific default (ISO 8601 for date/time types). /// /// @@ -150,6 +150,9 @@ static bool ISpanParsable.TryParse(ReadOnlySpan s, IFormatProvider /// string IFormattable.ToString(string? format, IFormatProvider? provider) { + // An empty format is the general format, exactly as null is — .NET reads the two alike — so + // it asks for the literal too. Normalized here so FormatValue sees one spelling of "none". + if (string.IsNullOrEmpty(format)) format = null; var effective = format is null ? CultureInfo.InvariantCulture : provider; string Fmt(T v) => TRange.FormatValue(v, format, effective); return this switch diff --git a/src/CodoMetis.ValueRanges/Core/IUnboundedEndRange.cs b/src/CodoMetis.ValueRanges/Core/IUnboundedEndRange.cs index 49849ac..ff41a20 100644 --- a/src/CodoMetis.ValueRanges/Core/IUnboundedEndRange.cs +++ b/src/CodoMetis.ValueRanges/Core/IUnboundedEndRange.cs @@ -1,4 +1,6 @@ +using System.Text.Json.Serialization; using CodoMetis.ValueRanges.Internals; +using CodoMetis.ValueRanges.Serialization; namespace CodoMetis.ValueRanges.Core; @@ -6,6 +8,7 @@ namespace CodoMetis.ValueRanges.Core; /// Represents a range that is unbounded on the right: [Start, +∞) or (Start, +∞). /// /// The element type of the range. +[JsonConverter(typeof(InterfaceDeclaredJsonConverter))] public interface IUnboundedEndRange : IRange where T : struct, IComparable, IEquatable { /// Gets the lower (left) bound of the range. diff --git a/src/CodoMetis.ValueRanges/Core/IUnboundedStartRange.cs b/src/CodoMetis.ValueRanges/Core/IUnboundedStartRange.cs index b313e55..ad28d9e 100644 --- a/src/CodoMetis.ValueRanges/Core/IUnboundedStartRange.cs +++ b/src/CodoMetis.ValueRanges/Core/IUnboundedStartRange.cs @@ -1,4 +1,6 @@ +using System.Text.Json.Serialization; using CodoMetis.ValueRanges.Internals; +using CodoMetis.ValueRanges.Serialization; namespace CodoMetis.ValueRanges.Core; @@ -6,6 +8,7 @@ namespace CodoMetis.ValueRanges.Core; /// Represents a range that is unbounded on the left: (-∞, End] or (-∞, End). /// /// The element type of the range. +[JsonConverter(typeof(InterfaceDeclaredJsonConverter))] public interface IUnboundedStartRange : IRange where T : struct, IComparable, IEquatable { /// Gets the upper (right) bound of the range. diff --git a/src/CodoMetis.ValueRanges/Core/IValueSet.cs b/src/CodoMetis.ValueRanges/Core/IValueSet.cs index 4c1e3d6..12c00ad 100644 --- a/src/CodoMetis.ValueRanges/Core/IValueSet.cs +++ b/src/CodoMetis.ValueRanges/Core/IValueSet.cs @@ -1,4 +1,6 @@ using System.Collections.Immutable; +using System.Text.Json.Serialization; +using CodoMetis.ValueRanges.Serialization; namespace CodoMetis.ValueRanges.Core; @@ -12,6 +14,7 @@ namespace CodoMetis.ValueRanges.Core; /// closed-world guarantee the same way the private range constructors do. /// /// The element type of the set. +[JsonConverter(typeof(InterfaceDeclaredJsonConverter))] public interface IValueSet where T : IEquatable { /// The canonical elements: deduplicated, sorted, never containing . diff --git a/src/CodoMetis.ValueRanges/Core/IValueSetFactory.cs b/src/CodoMetis.ValueRanges/Core/IValueSetFactory.cs index af6e8bb..b4766a4 100644 --- a/src/CodoMetis.ValueRanges/Core/IValueSetFactory.cs +++ b/src/CodoMetis.ValueRanges/Core/IValueSetFactory.cs @@ -126,12 +126,13 @@ static bool ISpanParsable.TryParse(ReadOnlySpan s, IFormatProvider? /// for each element. /// /// - /// A format string forwarded to the element formatter, or - /// to use the type-specific default (ISO 8601 for date/time types). + /// A format string forwarded to the element formatter, or or empty + /// for the literal — the type-specific default (ISO 8601 for date/time types). /// /// - /// An forwarded to the element formatter; - /// defaults to the invariant culture. + /// An forwarded to the element formatter alongside an explicit + /// . Without one it is ignored: the literal is a wire format and is + /// always invariant. /// string IFormattable.ToString(string? format, IFormatProvider? provider) => SetFormat.Format((IValueSet)this, format, provider); diff --git a/src/CodoMetis.ValueRanges/DateRange.cs b/src/CodoMetis.ValueRanges/DateRange.cs index 4014fd8..99c105e 100644 --- a/src/CodoMetis.ValueRanges/DateRange.cs +++ b/src/CodoMetis.ValueRanges/DateRange.cs @@ -229,7 +229,7 @@ public static DateOnly ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats a value using ISO 8601 (yyyy-MM-dd) by default. /// public static string FormatValue(DateOnly value, string? format, IFormatProvider? provider) - => value.ToString(format ?? "yyyy-MM-dd", provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? "yyyy-MM-dd" : format, provider ?? CultureInfo.InvariantCulture); /// /// Parses a PostgreSQL range literal (e.g. [2024-01-01,2024-12-31], empty, (,)) diff --git a/src/CodoMetis.ValueRanges/DateTimeOffsetRange.cs b/src/CodoMetis.ValueRanges/DateTimeOffsetRange.cs index 8890e1f..964e366 100644 --- a/src/CodoMetis.ValueRanges/DateTimeOffsetRange.cs +++ b/src/CodoMetis.ValueRanges/DateTimeOffsetRange.cs @@ -203,7 +203,7 @@ public static DateTimeOffset ParseValue(ReadOnlySpan s, IFormatProvider? p /// preserving full precision and UTC offset. /// public static string FormatValue(DateTimeOffset value, string? format, IFormatProvider? provider) - => value.ToString(format ?? "O", provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? "O" : format, provider ?? CultureInfo.InvariantCulture); /// /// Parses a PostgreSQL range literal into a . diff --git a/src/CodoMetis.ValueRanges/DateTimeRange.cs b/src/CodoMetis.ValueRanges/DateTimeRange.cs index 88784f5..1f7e39e 100644 --- a/src/CodoMetis.ValueRanges/DateTimeRange.cs +++ b/src/CodoMetis.ValueRanges/DateTimeRange.cs @@ -202,7 +202,7 @@ public static DateTime ParseValue(ReadOnlySpan s, IFormatProvider? provide /// preserving full precision and . /// public static string FormatValue(DateTime value, string? format, IFormatProvider? provider) - => value.ToString(format ?? "O", provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? "O" : format, provider ?? CultureInfo.InvariantCulture); /// /// Parses a PostgreSQL range literal (e.g. [2024-01-01T00:00:00,2024-12-31T23:59:59), diff --git a/src/CodoMetis.ValueRanges/Internals/IsoTemporal.cs b/src/CodoMetis.ValueRanges/Internals/IsoTemporal.cs index a0b9249..be4e9da 100644 --- a/src/CodoMetis.ValueRanges/Internals/IsoTemporal.cs +++ b/src/CodoMetis.ValueRanges/Internals/IsoTemporal.cs @@ -18,8 +18,9 @@ namespace CodoMetis.ValueRanges.Internals; /// 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 +/// space (PostgreSQL); for a DateTime an optional Z () or +/// ±HH, ±HH:mm or ±HH:mm:ss () suffix, with the wall +/// clock kept as written and the offset not applied; 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 @@ -38,16 +39,6 @@ internal static class IsoTemporal 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 = [ @@ -73,17 +64,65 @@ internal static TimeOnly ParseTime(ReadOnlySpan s) ? value : throw new FormatException("Expected an ISO 8601 time of day, HH:mm or HH:mm:ss with up to seven fraction digits."); + /// + /// The offset is not applied. A DateTime range is a tsrange, which stores a wall + /// clock and no zone, and PostgreSQL's own timestamp input keeps the wall clock and drops + /// the offset ('2026-01-01 09:30+05:30'::timestamp is 09:30). Until 9.0.0 the offset was + /// converted into the parsing machine's zone, so one literal stored a different wall clock on + /// every server. A value with an offset is , so a local value — + /// which "O" writes with its offset — round-trips with its kind, and no zone rule is ever + /// consulted, not even for a wall clock the machine's zone skips or repeats. + /// 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 + var text = TruncateFraction(s); + var kind = DateTimeKind.Unspecified; + if (text.Length > 0 && text[^1] == 'Z') + { + kind = DateTimeKind.Utc; + text = text[..^1]; + } + else if (OffsetLength(text) is > 0 and var length) + { + kind = DateTimeKind.Local; + text = text[..^length]; + } + + return DateTime.TryParseExact(text, WallClockFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var value) + ? DateTime.SpecifyKind(value, kind) : 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."); + + "optionally followed by Z or ±HH[:mm[:ss]], or infinity / -infinity."); } + /// + /// The length of a trailing ±HH, ±HH:mm or ±HH:mm:ss offset of at most 14 hours, + /// or 0 — only after the time, so the date's own hyphens are never mistaken for one. + /// + private static int OffsetLength(ReadOnlySpan s) + { + foreach (var length in (ReadOnlySpan) [3, 6, 9]) + { + if (s.Length < 16 + length) continue; + + var tail = s[^length..]; + if (tail[0] is not ('+' or '-') + || !TryTwoDigits(tail[1..3], 14, out _) + || length >= 6 && (tail[3] != ':' || !TryTwoDigits(tail[4..6], 59, out _)) + || length == 9 && (tail[6] != ':' || !TryTwoDigits(tail[7..9], 59, out _))) + continue; + + return length; + } + + return 0; + } + + private static bool TryTwoDigits(ReadOnlySpan s, int max, out int value) + => int.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out value) && value <= max; + internal static DateTimeOffset ParseDateTimeOffset(ReadOnlySpan s) { if (TryParseInfinity(s, out var positive)) return positive ? DateTimeOffset.MaxValue : DateTimeOffset.MinValue; diff --git a/src/CodoMetis.ValueRanges/Internals/LiteralWhiteSpace.cs b/src/CodoMetis.ValueRanges/Internals/LiteralWhiteSpace.cs new file mode 100644 index 0000000..59e441c --- /dev/null +++ b/src/CodoMetis.ValueRanges/Internals/LiteralWhiteSpace.cs @@ -0,0 +1,25 @@ +namespace CodoMetis.ValueRanges.Internals; + +/// +/// The whitespace PostgreSQL's literal parsers skip — array_in around elements, range_in +/// around the literal, and the element input functions behind the built-in types around a bound: +/// ASCII space, tab, newline, carriage return, vertical tab and form feed. +/// +/// +/// Not the rest of Unicode's whitespace, which is text there: a leading no-break space is part of a +/// text[] element and makes an integer bound invalid. would +/// drop it — silently a different element, or a bound PostgreSQL refuses. +/// +internal static class LiteralWhiteSpace +{ + internal static bool Is(char c) => c is ' ' or '\t' or '\n' or '\r' or '\v' or '\f'; + + internal static ReadOnlySpan Trim(ReadOnlySpan s) + { + var start = 0; + var end = s.Length; + while (start < end && Is(s[start])) start++; + while (end > start && Is(s[end - 1])) end--; + return s[start..end]; + } +} diff --git a/src/CodoMetis.ValueRanges/Internals/RangeFormat.cs b/src/CodoMetis.ValueRanges/Internals/RangeFormat.cs index 56dc023..6eabbdf 100644 --- a/src/CodoMetis.ValueRanges/Internals/RangeFormat.cs +++ b/src/CodoMetis.ValueRanges/Internals/RangeFormat.cs @@ -12,7 +12,7 @@ internal static TRange Parse(ReadOnlySpan s, IFormatProvider? p where TRange : IRangeFactory where T : struct, IComparable, IEquatable { - s = s.Trim(); + s = LiteralWhiteSpace.Trim(s); if (s.Equals("empty", StringComparison.OrdinalIgnoreCase)) return TRange.Empty; @@ -32,23 +32,23 @@ internal static TRange Parse(ReadOnlySpan s, IFormatProvider? p var endInclusive = endBracket == ']'; var inner = s[1..^1]; - var commaIdx = FindComma(inner); + var position = 0; - if (commaIdx < 0) - throw new FormatException($"The input string '{LiteralExcerpt.Of(s)}' is not a valid range literal."); - - var startPart = UnquoteValue(inner[..commaIdx].Trim()); - var endPart = UnquoteValue(inner[(commaIdx + 1)..].Trim()); + var startPart = ScanBound(inner, ref position, s); + if (position == inner.Length || inner[position] != ',') + throw new FormatException($"The input string '{LiteralExcerpt.Of(s)}' is not a valid range literal: missing comma after the lower bound."); - var hasStart = startPart.Length > 0; - var hasEnd = endPart.Length > 0; + position++; + var endPart = ScanBound(inner, ref position, s); + if (position != inner.Length) + throw new FormatException($"The input string '{LiteralExcerpt.Of(s)}' is not a valid range literal: unexpected '{inner[position]}' in the upper bound."); - return (hasStart, hasEnd) switch + return (startPart, endPart) switch { - (false, false) => TRange.Infinite, - (false, true) => TRange.CreateUnboundedStart(ParseBound(endPart, s), endInclusive), - (true, false) => TRange.CreateUnboundedEnd (ParseBound(startPart, s), startInclusive), - (true, true) => TRange.CreateFinite( + (null, null) => TRange.Infinite, + (null, _) => TRange.CreateUnboundedStart(ParseBound(endPart, s), endInclusive), + (_, null) => TRange.CreateUnboundedEnd (ParseBound(startPart, s), startInclusive), + _ => TRange.CreateFinite( ParseBound(startPart, s), ParseBound(endPart, s), startInclusive, @@ -56,6 +56,86 @@ internal static TRange Parse(ReadOnlySpan s, IFormatProvider? p }; } + /// + /// One bound, read as PostgreSQL's range_in reads it: when the bound + /// is absent — the next character is the comma or the end — and its text otherwise. + /// + /// + /// + /// Only an absent bound is infinite. A quoted "", or a bound of only whitespace, is a + /// present bound whose text is empty, which the element parser refuses for every type here — as + /// PostgreSQL does (invalid input syntax for type integer: ""). Until 9.0.0 the bound was + /// unquoted first and then tested for emptiness, so ["",5) read as (,4], silently. + /// + /// + /// Within a bound, a double quote opens or closes a quoted section anywhere ("1"2 is + /// 12), a doubled quote inside one is a literal quote, and a backslash takes the next + /// character literally, inside quotes or out. An unquoted, unescaped comma, ) or ] + /// ends the bound. Whitespace around the unquoted part is dropped: PostgreSQL hands it to the + /// element's input function, and every input function behind these types ignores it. That is + /// ASCII whitespace only (): a no-break space stays in the bound, + /// where the element parser refuses it as PostgreSQL's does. + /// + /// + private static string? ScanBound(ReadOnlySpan inner, ref int position, ReadOnlySpan literal) + { + var start = position; + var sb = new StringBuilder(); + + // The length of the text up to its last character that is not unquoted whitespace. + var significant = 0; + var inQuotes = false; + + while (position < inner.Length && (inQuotes || inner[position] is not (',' or ')' or ']'))) + { + var c = inner[position++]; + + if (c == '\\') + { + if (position == inner.Length) + throw new FormatException($"The input string '{LiteralExcerpt.Of(literal)}' is not a valid range literal: unterminated escape."); + + sb.Append(inner[position++]); + significant = sb.Length; + } + else if (c == '"') + { + if (!inQuotes) + { + inQuotes = true; + significant = sb.Length; + } + else if (position < inner.Length && inner[position] == '"') + { + sb.Append('"'); + significant = sb.Length; + position++; + } + else + { + inQuotes = false; + } + } + else if (inQuotes || !LiteralWhiteSpace.Is(c)) + { + sb.Append(c); + significant = sb.Length; + } + else if (sb.Length > 0 || significant > 0) + { + sb.Append(c); // inner whitespace; dropped below if nothing significant follows + } + } + + if (inQuotes) + throw new FormatException($"The input string '{LiteralExcerpt.Of(literal)}' is not a valid range literal: unterminated quoted bound."); + + if (position == start) return null; + + sb.Length = significant; + return sb.ToString(); + } + /// /// A bound through the element parser, with its failure re-thrown as a bounded message. The /// element parser is not chained as the inner exception: the BCL's format error embeds the @@ -110,91 +190,46 @@ internal static bool TryParse( /// internal static IReadOnlyList SplitSetLiterals(ReadOnlySpan s) { - s = s.Trim(); + s = LiteralWhiteSpace.Trim(s); if (s.Length < 2 || s[0] != '{' || s[^1] != '}') throw new FormatException($"The input string '{LiteralExcerpt.Of(s)}' is not a valid range set literal."); - var inner = s[1..^1].Trim(); + var inner = LiteralWhiteSpace.Trim(s[1..^1]); var result = new List(); if (inner.IsEmpty) return result; - var depth = 0; - var start = 0; + var depth = 0; + var start = 0; + var inQuotes = false; + // Brackets and commas inside a quoted or escaped bound are bound text, not structure. for (var i = 0; i < inner.Length; i++) { switch (inner[i]) { - case '[' or '(': + case '\\': + i++; + break; + case '"': + inQuotes = !inQuotes; + break; + case '[' or '(' when !inQuotes: depth++; break; - case ']' or ')': + case ']' or ')' when !inQuotes: depth--; break; - case ',' when depth == 0: - result.Add(inner[start..i].Trim().ToString()); + case ',' when depth == 0 && !inQuotes: + result.Add(LiteralWhiteSpace.Trim(inner[start..i]).ToString()); start = i + 1; break; } } - result.Add(inner[start..].Trim().ToString()); + result.Add(LiteralWhiteSpace.Trim(inner[start..]).ToString()); return result; } - - private static int FindComma(ReadOnlySpan s) - { - var inQuotes = false; - for (var i = 0; i < s.Length; i++) - { - switch (s[i]) - { - case '"': - inQuotes = !inQuotes; - break; - case '\\' when inQuotes: - i++; // skip escaped char inside quotes - break; - case ',' when !inQuotes: - return i; - } - } - return -1; - } - - private static ReadOnlySpan UnquoteValue(ReadOnlySpan s) - { - if (s.Length < 2 || s[0] != '"' || s[^1] != '"') return s; - var inner = s[1..^1]; - // No backslash escapes inside the quotes — return a slice of the input directly. - if (!inner.Contains('\\')) return inner; - // PostgreSQL quoted bounds escape `\"` as `"` and `\\` as `\`. Unescape them so - // ParseValue receives the literal value the user intended. This path is rare for - // the built-in element types (int, decimal, DateOnly, …) but matters for custom - // range types whose bound stringification can contain quotes or backslashes. - return UnescapeQuoted(inner); - } - - private static string UnescapeQuoted(ReadOnlySpan s) - { - var sb = new StringBuilder(s.Length); - for (int i = 0; i < s.Length; i++) - { - if (s[i] == '\\' && i + 1 < s.Length) - { - char next = s[i + 1]; - if (next == '"' || next == '\\') - { - sb.Append(next); - i++; // consume the escaped char - continue; - } - } - sb.Append(s[i]); - } - return sb.ToString(); - } } diff --git a/src/CodoMetis.ValueRanges/Internals/SetFormat.cs b/src/CodoMetis.ValueRanges/Internals/SetFormat.cs index 0ce9839..2bcea7f 100644 --- a/src/CodoMetis.ValueRanges/Internals/SetFormat.cs +++ b/src/CodoMetis.ValueRanges/Internals/SetFormat.cs @@ -18,7 +18,9 @@ internal static string Format(IValueSet set, string? format, IFormat if (elements.IsEmpty) return "{}"; // No format means the literal, which is invariant whatever provider is passed; a provider - // applies only alongside an explicit format, which asks for display text. + // applies only alongside an explicit format, which asks for display text. An empty format + // is no format — .NET reads "" as the general format, exactly like null. + if (string.IsNullOrEmpty(format)) format = null; provider = format is null ? CultureInfo.InvariantCulture : provider ?? CultureInfo.InvariantCulture; var sb = new StringBuilder(elements.Length * 8).Append('{'); @@ -37,13 +39,14 @@ internal static TSet Parse(ReadOnlySpan s, IFormatProvider? provi where TSet : IValueSetFactory, IValueSet where T : IEquatable { - s = s.Trim(); + s = LiteralWhiteSpace.Trim(s); if (s.Length < 2 || s[0] != '{' || s[^1] != '}') throw new FormatException($"The input string '{LiteralExcerpt.Of(s)}' is not a valid array literal."); - var inner = s[1..^1].Trim(); - if (inner.IsEmpty) return TSet.Empty; + // Not trimmed: a trailing escaped space ({a\ }) belongs to the last element. + var inner = s[1..^1]; + if (LiteralWhiteSpace.Trim(inner).IsEmpty) return TSet.Empty; var values = new List(); var position = 0; @@ -52,7 +55,7 @@ internal static TSet Parse(ReadOnlySpan s, IFormatProvider? provi { values.Add(ParseElement(inner, ref position)); - while (position < inner.Length && char.IsWhiteSpace(inner[position])) position++; + while (position < inner.Length && LiteralWhiteSpace.Is(inner[position])) position++; if (position == inner.Length) break; @@ -86,15 +89,17 @@ private static T ParseElement(ReadOnlySpan inner, ref int positio where TSet : IValueSetFactory, IValueSet where T : IEquatable { - while (position < inner.Length && char.IsWhiteSpace(inner[position])) position++; + while (position < inner.Length && LiteralWhiteSpace.Is(inner[position])) position++; if (position == inner.Length) throw new FormatException($"The input string '{{{LiteralExcerpt.Of(inner)}}}' is not a valid array literal."); if (inner[position] == '"') { - // Quoted element: `\"` and `\\` unescape; the text is taken verbatim otherwise, - // so a quoted "NULL" is the four-character value, not the null marker. + // Quoted element: a backslash takes the next character literally; the text is taken + // verbatim otherwise, so a quoted "NULL" is the four-character value, not the null + // marker. Only whitespace may follow the closing quote (the caller then requires the + // delimiter or the end), so {"a"b} is refused, as PostgreSQL refuses it. position++; var sb = new StringBuilder(); while (true) @@ -120,24 +125,53 @@ private static T ParseElement(ReadOnlySpan inner, ref int positio } } - var start = position; - while (position < inner.Length && inner[position] is not ',' and not '"') + if (inner[position] == '{') + throw new FormatException( + $"The input string '{{{LiteralExcerpt.Of(inner)}}}' is a multi-dimensional array literal; a value set is one-dimensional."); + + // Unquoted element, as PostgreSQL's array_in reads it: a backslash takes the next character + // literally; an unescaped brace or quote is malformed; whitespace around the element is + // dropped and whitespace inside it kept. Only a bare, unescaped NULL is the null marker. + var text = new StringBuilder(); + var significant = 0; + var escaped = false; + + while (position < inner.Length && inner[position] != ',') { - position++; - } + var c = inner[position++]; - if (position < inner.Length && inner[position] == '"') - throw new FormatException($"The input string '{{{LiteralExcerpt.Of(inner)}}}' is not a valid array literal."); + if (c == '\\') + { + if (position == inner.Length) + throw new FormatException($"The input string '{{{LiteralExcerpt.Of(inner)}}}' has an unterminated escape sequence."); - var text = inner[start..position].Trim(); + text.Append(inner[position++]); + significant = text.Length; + escaped = true; + } + else if (c is '{' or '}' or '"') + { + throw new FormatException( + $"The input string '{{{LiteralExcerpt.Of(inner)}}}' is not a valid array literal: unexpected '{c}' in an unquoted element."); + } + else + { + text.Append(c); + if (!LiteralWhiteSpace.Is(c)) significant = text.Length; + } + } - if (text.IsEmpty) + text.Length = significant; + + if (text.Length == 0) throw new FormatException($"The input string '{{{LiteralExcerpt.Of(inner)}}}' is not a valid array literal."); - if (text.Equals("NULL", StringComparison.OrdinalIgnoreCase)) + var value = text.ToString(); + + if (!escaped && value.Equals("NULL", StringComparison.OrdinalIgnoreCase)) throw new FormatException("Value sets cannot contain null elements."); - return ParseElementValue(text, inner); + return ParseElementValue(value, inner); } /// diff --git a/src/CodoMetis.ValueRanges/README.md b/src/CodoMetis.ValueRanges/README.md index efc2465..8da3983 100644 --- a/src/CodoMetis.ValueRanges/README.md +++ b/src/CodoMetis.ValueRanges/README.md @@ -110,18 +110,18 @@ containing null, with structural equality. It relates to a PostgreSQL array colu `RangeSet` relates to `datemultirange`: the CLR type models the domain concept, the column is its storage encoding. -| .NET type | Element type | PostgreSQL column | Wrapper arity | -|---------------------|------------------|-------------------|----------------------| -| `StringSet` | `string` | `text[]` | `StringSet` | -| `GuidSet` | `Guid` | `uuid[]` | `GuidSet` | -| `Int16Set` | `short` | `smallint[]` | — | -| `Int32Set` | `int` | `integer[]` | `Int32Set` | -| `Int64Set` | `long` | `bigint[]` | `Int64Set` | -| `DecimalSet` | `decimal` | `numeric[]` | — | -| `DateSet` | `DateOnly` | `date[]` | — | -| `TimeSet` | `TimeOnly` | `time[]` | — | -| `DateTimeSet` | `DateTime` | `timestamp[]` | — | -| `DateTimeOffsetSet` | `DateTimeOffset` | `timestamptz[]` | — | +| .NET type | Element type | PostgreSQL column | Wrapper arity | +|---------------------|------------------|-------------------|-------------------------------| +| `StringSet` | `string` | `text[]` | `StringSet` | +| `GuidSet` | `Guid` | `uuid[]` | `GuidSet` | +| `Int16Set` | `short` | `smallint[]` | `Int16Set` | +| `Int32Set` | `int` | `integer[]` | `Int32Set` | +| `Int64Set` | `long` | `bigint[]` | `Int64Set` | +| `DecimalSet` | `decimal` | `numeric[]` | `DecimalSet` | +| `DateSet` | `DateOnly` | `date[]` | `DateSet` | +| `TimeSet` | `TimeOnly` | `time[]` | `TimeSet` | +| `DateTimeSet` | `DateTime` | `timestamp[]` | `DateTimeSet` | +| `DateTimeOffsetSet` | `DateTimeOffset` | `timestamptz[]` | `DateTimeOffsetSet` | ```csharp var tags = StringSet.From("beta", "alpha", "beta"); // {alpha,beta} — deduplicated, sorted diff --git a/src/CodoMetis.ValueRanges/Serialization/InterfaceDeclaredJsonConverter.cs b/src/CodoMetis.ValueRanges/Serialization/InterfaceDeclaredJsonConverter.cs new file mode 100644 index 0000000..bf4150e --- /dev/null +++ b/src/CodoMetis.ValueRanges/Serialization/InterfaceDeclaredJsonConverter.cs @@ -0,0 +1,73 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CodoMetis.ValueRanges.Core; + +namespace CodoMetis.ValueRanges.Serialization; + +/// +/// Serializes a value declared as one of the range or value set interfaces — +/// , its five shape interfaces, or — through the +/// converter of its runtime type, so it writes the same literal or array as when it is declared as +/// that type. Reading into such a declaration is refused with . +/// +/// +/// +/// System.Text.Json resolves the declared type, and before 9.0.0 the interfaces carried no +/// converter: a property of type IRange<int> was written as {} and one of type +/// IValueSet<string> as {"Values":["a"]} — a dump of the interface's members, +/// with no error, and nothing that read back. +/// +/// +/// The write asks the options for the runtime type's contract, so whatever would serialize that +/// type — this package's converter, or one registered on the options — serializes it here too, +/// reflection-based or source-generated alike. Under a source-generated context the runtime type +/// must be listed there; if it is not, System.Text.Json throws a +/// naming it. +/// +/// +/// A read cannot be served: an interface does not say which type to construct, and the same +/// literal is valid for more than one of them. The remedy is to declare the concrete type, which +/// reads the very JSON this converter writes. is left to +/// System.Text.Json in both directions, as for any reference type. +/// +/// +public sealed class InterfaceDeclaredJsonConverter : JsonConverter +{ + /// + public override bool CanConvert(Type typeToConvert) + => typeToConvert is { IsInterface: true, IsGenericType: true } + && IsHandled(typeToConvert.GetGenericTypeDefinition()); + + /// Always throws: an interface declaration does not say which type to construct. + /// Always. + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var declared = Display(typeToConvert); + var concrete = typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(IValueSet<>) + ? "the value set type (StringSet, Int32Set, …)" + : "the range type (Int32Range, DateRange, …), one of its variants, or RangeSet"; + + throw new NotSupportedException( + $"A value declared as {declared} cannot be read from JSON: the interface does not say which type to " + + $"construct. Declare the property as the concrete type — {concrete} — which reads the JSON this " + + "package writes for it."); + } + + /// + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, options.GetTypeInfo(value.GetType())); + + private static bool IsHandled(Type definition) + => definition == typeof(IRange<>) + || definition == typeof(IFiniteRange<>) + || definition == typeof(IUnboundedStartRange<>) + || definition == typeof(IUnboundedEndRange<>) + || definition == typeof(IEmptyRange<>) + || definition == typeof(IInfinityRange<>) + || definition == typeof(IValueSet<>); + + private static string Display(Type type) + => type.IsGenericType + ? $"{type.Name[..type.Name.IndexOf('`')]}<{string.Join(", ", type.GetGenericArguments().Select(Display))}>" + : type.Name; +} diff --git a/src/CodoMetis.ValueRanges/Serialization/RangeJsonConverter.cs b/src/CodoMetis.ValueRanges/Serialization/RangeJsonConverter.cs index e24ef40..e45f079 100644 --- a/src/CodoMetis.ValueRanges/Serialization/RangeJsonConverter.cs +++ b/src/CodoMetis.ValueRanges/Serialization/RangeJsonConverter.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; using CodoMetis.ValueRanges.Core; @@ -92,6 +93,15 @@ public override void Write(Utf8JsonWriter writer, TRange value, JsonSerializerOp /// A literal that parses to a different variant is a read error, not a silent widening: /// "empty" cannot be read into a declaration of type Finite. /// +/// +/// A converter for the union registered on +/// reaches the variants too: when the first converter there that handles +/// is not this package's, this converter hands every value and every dictionary key to it — reading +/// a union and narrowing it as above. Without that, System.Text.Json would apply the custom converter +/// to union-typed declarations only, and one payload would carry two wire forms for the same type. +/// A converter placed on a property, and one registered for the variant type itself, are chosen by +/// System.Text.Json before this one is consulted, as for any type. +/// /// /// The sealed variant type being converted. /// The range union that declares the variant. @@ -104,6 +114,9 @@ public class RangeVariantJsonConverter : JsonConverter public override TVariant Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (UnionConverterOn(options) is { } union) + return Narrow(union.Read(ref reader, typeof(TRange), options)); + var s = reader.GetString() ?? throw new JsonException($"Expected a non-null JSON string for a {typeof(TVariant).Name} value."); @@ -112,11 +125,18 @@ public override TVariant Read(ref Utf8JsonReader reader, Type typeToConvert, Jso /// public override TVariant ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => Parse(reader.GetString()!); + => UnionConverterOn(options) is { } union + ? Narrow(union.ReadAsPropertyName(ref reader, typeof(TRange), options)) + : Parse(reader.GetString()!); /// public override void WriteAsPropertyName(Utf8JsonWriter writer, TVariant value, JsonSerializerOptions options) - => writer.WritePropertyName(((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); + { + if (UnionConverterOn(options) is { } union) + union.WriteAsPropertyName(writer, value, options); + else + writer.WritePropertyName(((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); + } private static TVariant Parse(string s) { @@ -131,6 +151,16 @@ private static TVariant Parse(string s) return variant; } + private static TVariant Narrow(TRange? parsed) + => parsed switch + { + null => default!, + TVariant variant => variant, + _ => throw new JsonException( + $"Cannot read a {typeof(TRange).Name}.{parsed.GetType().Name} as " + + $"{typeof(TRange).Name}.{typeof(TVariant).Name}.") + }; + /// public override void Write(Utf8JsonWriter writer, TVariant value, JsonSerializerOptions options) { @@ -140,8 +170,50 @@ public override void Write(Utf8JsonWriter writer, TVariant value, JsonSerializer return; } + if (UnionConverterOn(options) is { } union) + { + union.Write(writer, value, options); + return; + } + writer.WriteStringValue(((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); } + + // Keyed by the options instance. Once System.Text.Json has used an options instance it is read-only, + // so what its Converters resolve to cannot change; an instance still being configured is not cached. + private static readonly ConditionalWeakTable?>> UnionConverters = new(); + + /// + /// The converter System.Text.Json would choose for from + /// — the first one that can handle it — unless that + /// is this package's own, which writes the same literal this converter does. + /// + private static JsonConverter? UnionConverterOn(JsonSerializerOptions options) + => options.IsReadOnly + ? UnionConverters.GetValue(options, static o => new StrongBox?>(FindUnionConverter(o))).Value + : FindUnionConverter(options); + + private static JsonConverter? FindUnionConverter(JsonSerializerOptions options) + { + foreach (var candidate in options.Converters) + { + if (!candidate.CanConvert(typeof(TRange))) + continue; + + var converter = candidate is JsonConverterFactory factory + ? factory.CreateConverter(typeof(TRange), options) + : candidate; + + // First match decides, as it does for the union itself: if that is the package's own + // converter, the variant keeps writing the literal. + return converter is JsonConverter union + && converter.GetType().Assembly != typeof(RangeJsonConverter<,>).Assembly + ? union + : null; + } + + return null; + } } /// diff --git a/src/CodoMetis.ValueRanges/Serialization/RangeJsonConverterFactory.cs b/src/CodoMetis.ValueRanges/Serialization/RangeJsonConverterFactory.cs index 446a651..d7df7f9 100644 --- a/src/CodoMetis.ValueRanges/Serialization/RangeJsonConverterFactory.cs +++ b/src/CodoMetis.ValueRanges/Serialization/RangeJsonConverterFactory.cs @@ -7,7 +7,8 @@ namespace CodoMetis.ValueRanges.Serialization; /// -/// A for every range, range set and value set type. Since 9.0.0 +/// A for every range, range set and value set type, and the range and set +/// interfaces (see ). Since 9.0.0 /// each of those types carries its converter as a [JsonConverter] attribute, so registering /// this factory is no longer needed; it remains valid and produces the same converters. /// diff --git a/src/CodoMetis.ValueRanges/Serialization/ValueSetElementJsonConverter.cs b/src/CodoMetis.ValueRanges/Serialization/ValueSetElementJsonConverter.cs index c359768..24d138c 100644 --- a/src/CodoMetis.ValueRanges/Serialization/ValueSetElementJsonConverter.cs +++ b/src/CodoMetis.ValueRanges/Serialization/ValueSetElementJsonConverter.cs @@ -176,7 +176,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions { var text = TSet.FormatValue(value, null, CultureInfo.InvariantCulture); - if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var number)) + if (!decimal.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) throw new JsonException( $"{typeof(T).Name} formats as '{text}', which is not a decimal number. An element of " + $"{typeof(TSet).Name} must format as exactly the text form of the primitive it wraps."); diff --git a/src/CodoMetis.ValueRanges/Sets/DateSet.cs b/src/CodoMetis.ValueRanges/Sets/DateSet.cs index 6f92a54..17514d8 100644 --- a/src/CodoMetis.ValueRanges/Sets/DateSet.cs +++ b/src/CodoMetis.ValueRanges/Sets/DateSet.cs @@ -75,7 +75,7 @@ public static DateOnly ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats a value using ISO 8601 (yyyy-MM-dd) by default. public static string FormatValue(DateOnly value, string? format, IFormatProvider? provider) - => value.ToString(format ?? "yyyy-MM-dd", provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? "yyyy-MM-dd" : format, provider ?? CultureInfo.InvariantCulture); /// /// Parses a PostgreSQL array literal (e.g. {2024-01-01,2024-12-24}, {}) into a @@ -214,7 +214,7 @@ public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats an element using ISO 8601 (yyyy-MM-dd) by default — see the /// class remarks. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) - => value.ToString(format ?? RoundTripFormat, provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? RoundTripFormat : format, provider ?? CultureInfo.InvariantCulture); /// /// The JSON fallback for elements that carry no converter of their own: a JSON string diff --git a/src/CodoMetis.ValueRanges/Sets/DateTimeOffsetSet.cs b/src/CodoMetis.ValueRanges/Sets/DateTimeOffsetSet.cs index 2745bb6..386b9bf 100644 --- a/src/CodoMetis.ValueRanges/Sets/DateTimeOffsetSet.cs +++ b/src/CodoMetis.ValueRanges/Sets/DateTimeOffsetSet.cs @@ -78,7 +78,7 @@ public static DateTimeOffset ParseValue(ReadOnlySpan s, IFormatProvider? p /// 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) - => value.ToString(format ?? "O", provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? "O" : format, provider ?? CultureInfo.InvariantCulture); /// /// Parses a PostgreSQL array literal (e.g. {2024-01-01T08:00:00+00:00}, {}) into a @@ -218,7 +218,7 @@ public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats an element using the round-trip format specifier (O) by default /// — see the class remarks. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) - => value.ToString(format ?? RoundTripFormat, provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? RoundTripFormat : format, provider ?? CultureInfo.InvariantCulture); /// /// The JSON fallback for elements that carry no converter of their own: a JSON string diff --git a/src/CodoMetis.ValueRanges/Sets/DateTimeSet.cs b/src/CodoMetis.ValueRanges/Sets/DateTimeSet.cs index 2162dfd..6b0eeae 100644 --- a/src/CodoMetis.ValueRanges/Sets/DateTimeSet.cs +++ b/src/CodoMetis.ValueRanges/Sets/DateTimeSet.cs @@ -78,7 +78,7 @@ public static DateTime ParseValue(ReadOnlySpan s, IFormatProvider? provide /// 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) - => value.ToString(format ?? "O", provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? "O" : format, provider ?? CultureInfo.InvariantCulture); /// /// Parses a PostgreSQL array literal (e.g. {2024-01-01T08:00:00,2024-01-02T08:00:00}, {}) into a @@ -219,7 +219,7 @@ public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats an element using the round-trip format specifier (O) by default /// — see the class remarks. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) - => value.ToString(format ?? RoundTripFormat, provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? RoundTripFormat : format, provider ?? CultureInfo.InvariantCulture); /// /// The JSON fallback for elements that carry no converter of their own: a JSON string diff --git a/src/CodoMetis.ValueRanges/Sets/DecimalSet.cs b/src/CodoMetis.ValueRanges/Sets/DecimalSet.cs index ee82c15..a722445 100644 --- a/src/CodoMetis.ValueRanges/Sets/DecimalSet.cs +++ b/src/CodoMetis.ValueRanges/Sets/DecimalSet.cs @@ -191,11 +191,21 @@ static DecimalSet 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 with the family's grammar — PostgreSQL's numeric grammar (, invariant, no group separators; scale is kept), exactly as + /// reads it — and hands the element the parsed + /// 's invariant text via , which re-runs + /// its validation: corrupt data throws rather than materializing. /// + /// + /// The element's own Parse never sees the raw text. A generated wrapper's + /// Parse is typically the BCL default or wider, and would otherwise decide the grammar + /// — before 9.0.0 a group separator made "1,5" fifteen here while the primitive family + /// refused it. + /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse( + DecimalSet.ParseValue(s, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture); /// Formats an element via — its backing text /// form. diff --git a/src/CodoMetis.ValueRanges/Sets/Int16Set.cs b/src/CodoMetis.ValueRanges/Sets/Int16Set.cs index e7505f1..c31e189 100644 --- a/src/CodoMetis.ValueRanges/Sets/Int16Set.cs +++ b/src/CodoMetis.ValueRanges/Sets/Int16Set.cs @@ -186,11 +186,21 @@ static Int16Set IValueSetFactory, TElement>.FromTru 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 with the family's grammar — an optionally signed integer (, invariant), exactly as + /// reads it — and hands the element the parsed + /// 's invariant text via , which re-runs + /// its validation: corrupt data throws rather than materializing. /// + /// + /// The element's own Parse never sees the raw text. A generated wrapper's + /// Parse is typically the BCL default or wider, and would otherwise decide the grammar + /// — before 9.0.0 a group separator made "1,5" fifteen here while the primitive family + /// refused it. + /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse( + Int16Set.ParseValue(s, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture); /// Formats an element via — its backing text /// form. diff --git a/src/CodoMetis.ValueRanges/Sets/Int32Set.cs b/src/CodoMetis.ValueRanges/Sets/Int32Set.cs index f03995a..b5a960f 100644 --- a/src/CodoMetis.ValueRanges/Sets/Int32Set.cs +++ b/src/CodoMetis.ValueRanges/Sets/Int32Set.cs @@ -181,11 +181,21 @@ static Int32Set IValueSetFactory, TElement>.FromTru 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 with the family's grammar — an optionally signed integer (, invariant), exactly as + /// reads it — and hands the element the parsed + /// 's invariant text via , which re-runs + /// its validation: corrupt data throws rather than materializing. /// + /// + /// The element's own Parse never sees the raw text. A generated wrapper's + /// Parse is typically the BCL default or wider, and would otherwise decide the grammar + /// — before 9.0.0 a group separator made "1,5" fifteen here while the primitive family + /// refused it. + /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse( + Int32Set.ParseValue(s, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture); /// Formats an element via — its backing text form. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) diff --git a/src/CodoMetis.ValueRanges/Sets/Int64Set.cs b/src/CodoMetis.ValueRanges/Sets/Int64Set.cs index b1e3892..210f5a0 100644 --- a/src/CodoMetis.ValueRanges/Sets/Int64Set.cs +++ b/src/CodoMetis.ValueRanges/Sets/Int64Set.cs @@ -181,11 +181,21 @@ static Int64Set IValueSetFactory, TElement>.FromTru 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 with the family's grammar — an optionally signed integer (, invariant), exactly as + /// reads it — and hands the element the parsed + /// 's invariant text via , which re-runs + /// its validation: corrupt data throws rather than materializing. /// + /// + /// The element's own Parse never sees the raw text. A generated wrapper's + /// Parse is typically the BCL default or wider, and would otherwise decide the grammar + /// — before 9.0.0 a group separator made "1,5" fifteen here while the primitive family + /// refused it. + /// public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provider) - => TElement.Parse(s.ToString(), provider ?? CultureInfo.InvariantCulture); + => TElement.Parse( + Int64Set.ParseValue(s, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), + CultureInfo.InvariantCulture); /// Formats an element via — its backing text form. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) diff --git a/src/CodoMetis.ValueRanges/Sets/TimeSet.cs b/src/CodoMetis.ValueRanges/Sets/TimeSet.cs index b0161ff..87f1bc6 100644 --- a/src/CodoMetis.ValueRanges/Sets/TimeSet.cs +++ b/src/CodoMetis.ValueRanges/Sets/TimeSet.cs @@ -77,7 +77,7 @@ public static TimeOnly ParseValue(ReadOnlySpan s, IFormatProvider? provide /// 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) - => value.ToString(format ?? "O", provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? "O" : format, provider ?? CultureInfo.InvariantCulture); /// /// Parses a PostgreSQL array literal (e.g. {09:00:00,17:00:00}, {}) into a @@ -215,7 +215,7 @@ public static TElement ParseValue(ReadOnlySpan s, IFormatProvider? provide /// Formats an element using the round-trip format specifier (O) by default /// — see the class remarks. public static string FormatValue(TElement value, string? format, IFormatProvider? provider) - => value.ToString(format ?? RoundTripFormat, provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? RoundTripFormat : format, provider ?? CultureInfo.InvariantCulture); /// /// The JSON fallback for elements that carry no converter of their own: a JSON string diff --git a/src/CodoMetis.ValueRanges/TimeRange.cs b/src/CodoMetis.ValueRanges/TimeRange.cs index 0ed81de..7370988 100644 --- a/src/CodoMetis.ValueRanges/TimeRange.cs +++ b/src/CodoMetis.ValueRanges/TimeRange.cs @@ -211,7 +211,7 @@ public static TimeOnly ParseValue(ReadOnlySpan s, IFormatProvider? provide /// preserving full precision. /// public static string FormatValue(TimeOnly value, string? format, IFormatProvider? provider) - => value.ToString(format ?? "O", provider ?? CultureInfo.InvariantCulture); + => value.ToString(string.IsNullOrEmpty(format) ? "O" : format, provider ?? CultureInfo.InvariantCulture); /// /// Parses a PostgreSQL range literal (e.g. [09:00:00,17:00:00), empty, (,)) diff --git a/test/CodoMetis.ValueRanges.Conventions.Tests/JsonDefaultContractTests.cs b/test/CodoMetis.ValueRanges.Conventions.Tests/JsonDefaultContractTests.cs index a31ed75..b324e97 100644 --- a/test/CodoMetis.ValueRanges.Conventions.Tests/JsonDefaultContractTests.cs +++ b/test/CodoMetis.ValueRanges.Conventions.Tests/JsonDefaultContractTests.cs @@ -99,6 +99,20 @@ private static void AssertRangeShapes() Assert.AreEqual(range, JsonSerializer.Deserialize(expected, variant, Unregistered), $"{label}: did not read back through its variant."); + // Declared as an interface — IRange and the shape interface — the write goes through + // the runtime type's converter, and a read is refused rather than property-dumped. + var interfaces = DeclarableInterfacesOf(variant); + Assert.IsTrue(interfaces.Count >= 2, + $"{label}: found {interfaces.Count} declarable interfaces, expected IRange and a shape interface."); + foreach (var declared in interfaces) + { + Assert.AreEqual(expected, JsonSerializer.Serialize(range, declared, Unregistered), + $"{label}: declared as {declared.Name}, the wire form differs. Is the interface missing " + + "[JsonConverter(typeof(InterfaceDeclaredJsonConverter))]?"); + Assert.ThrowsExactly(() => JsonSerializer.Deserialize(expected, declared, Unregistered), + $"{label}: declared as {declared.Name}, a read was not refused."); + } + var set = RangeSet.From([range]); var expectedSet = JsonSerializer.Serialize(set, Registered); Assert.AreEqual(expectedSet, JsonSerializer.Serialize(set, Unregistered), @@ -119,6 +133,83 @@ private static void AssertRangeShapes() } } + /// + /// A converter registered on the options for a union reaches every one of its shapes, however + /// the value is declared — the union, its variant, , an + /// object collection, a dictionary value or a variant-typed key. Before 9.0.0 it reached + /// only union-typed declarations: the rest resolved to the variant's own attribute and wrote the + /// literal, so one payload mixed two wire forms. + /// + [TestMethod] + public void EveryRangeShape_HonoursAUnionConverterOnTheOptions() + { + foreach (var (rangeType, elementType) in RangeProbes.AllRangeTypes()) + Reflect.InvokeGeneric(typeof(JsonDefaultContractTests), nameof(AssertUnionConverterReachesShapes), rangeType, elementType); + } + + private static void AssertUnionConverterReachesShapes() + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + { + var options = new JsonSerializerOptions { Converters = { new TaggedRangeConverter() } }; + + foreach (var range in RangeProbes.Shapes()) + { + var variant = range.GetType(); + var label = $"{typeof(TRange).Name}.{variant.Name} {range}"; + var expected = JsonSerializer.Serialize(range, options); + + Assert.StartsWith("\"tag:", expected, $"{label}: declared as the union, the registered converter was not used."); + Assert.AreEqual(expected, JsonSerializer.Serialize(range, variant, options), $"{label}: declared as its variant."); + Assert.AreEqual(expected, JsonSerializer.Serialize(range, options), $"{label}: through object."); + Assert.AreEqual($"[{expected}]", JsonSerializer.Serialize(new List { range }, options), $"{label}: in a List."); + Assert.AreEqual($"{{\"v\":{expected}}}", + JsonSerializer.Serialize(new Dictionary { ["v"] = range }, options), $"{label}: as a dictionary value."); + + Assert.AreEqual(range, JsonSerializer.Deserialize(expected, variant, options), $"{label}: did not read back through its variant."); + + var byVariant = (IDictionary)Activator.CreateInstance(typeof(Dictionary<,>).MakeGenericType(variant, typeof(int)))!; + byVariant.Add(range, 1); + var variantJson = JsonSerializer.Serialize(byVariant, byVariant.GetType(), options); + Assert.AreEqual($"{{{expected}:1}}", variantJson, $"{label}: as a variant-typed key."); + Assert.AreEqual(range, ((IDictionary)JsonSerializer.Deserialize(variantJson, byVariant.GetType(), options)!).Keys.Cast().Single(), + $"{label}: as a variant-typed key, did not read back."); + } + } + + /// A custom wire form for any range type: its literal behind a prefix, keys included. + private sealed class TaggedRangeConverter : System.Text.Json.Serialization.JsonConverter + where TRange : IRangeFactory + where T : struct, IComparable, IEquatable + { + public override TRange Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Parse(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, TRange value, JsonSerializerOptions options) + => writer.WriteStringValue($"tag:{value}"); + + public override TRange ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Parse(reader.GetString()!); + + public override void WriteAsPropertyName(Utf8JsonWriter writer, TRange value, JsonSerializerOptions options) + => writer.WritePropertyName($"tag:{value}"); + + private static TRange Parse(string s) + => s.StartsWith("tag:", StringComparison.Ordinal) + ? TRange.Parse(s.AsSpan("tag:".Length), null) + : throw new JsonException($"Expected a tagged literal, got '{s}'."); + } + + /// + /// The package's interfaces a value can be declared as: those in the core namespace a variant + /// implements, minus the factory interfaces, whose static abstract members rule them out as a + /// declared type. Discovered, so a new shape interface is checked with no edit here. + /// + private static List DeclarableInterfacesOf(Type variant) + => [.. variant.GetInterfaces() + .Where(i => i.Namespace == typeof(IRange<>).Namespace + && !i.GetMethods().Any(m => m is { IsStatic: true, IsAbstract: true }))]; + /// /// As a dictionary key the value is the same literal as its value form, and reads back. /// @@ -157,6 +248,11 @@ private static void AssertSet() $"{label}: the unregistered wire form differs. Is the set type missing its [JsonConverter]?"); Assert.AreEqual(set, JsonSerializer.Deserialize(expected, Unregistered), $"{label}: did not read back."); + + Assert.AreEqual(expected, JsonSerializer.Serialize>(set, Unregistered), + $"{label}: declared as IValueSet, the wire form differs."); + Assert.ThrowsExactly(() => JsonSerializer.Deserialize>(expected, Unregistered), + $"{label}: declared as IValueSet, a read was not refused."); } } } diff --git a/test/CodoMetis.ValueRanges.Conventions.Tests/LiteralCultureContractTests.cs b/test/CodoMetis.ValueRanges.Conventions.Tests/LiteralCultureContractTests.cs index c2806f9..949a4c9 100644 --- a/test/CodoMetis.ValueRanges.Conventions.Tests/LiteralCultureContractTests.cs +++ b/test/CodoMetis.ValueRanges.Conventions.Tests/LiteralCultureContractTests.cs @@ -13,7 +13,8 @@ namespace CodoMetis.ValueRanges.Conventions.Tests; /// Int64Range, DecimalRange) formatted a null provider in the current culture. /// Under de-DE, $"{DecimalRange [1.5,2)}" was [1,5,2), which the parser reads /// back as [1,52) — a different range, silently. Under sv-SE a negative bound took -/// U+2212 as its minus sign and no longer parsed. +/// U+2212 as its minus sign and no longer parsed. An empty format string is swept alongside +/// : .NET treats the two identically, so the literal must as well. /// [TestClass] public sealed class LiteralCultureContractTests @@ -66,6 +67,9 @@ private static void AssertCultureFree(string label, IFormattable value) + "invariant literal. A FormatValue is formatting a null provider in the current culture."); Assert.AreEqual(literal, value.ToString(), $"{label} under {culture}: ToString() is not the invariant literal."); + Assert.AreEqual(literal, value.ToString("", null), + $"{label} under {culture}: ToString(\"\", null) is not the invariant literal. An empty " + + "format is the general format, exactly like null, and must not count as an explicit one."); } finally { diff --git a/test/CodoMetis.ValueRanges.Conventions.Tests/LiteralProviderContractTests.cs b/test/CodoMetis.ValueRanges.Conventions.Tests/LiteralProviderContractTests.cs index 1f0e696..b1af206 100644 --- a/test/CodoMetis.ValueRanges.Conventions.Tests/LiteralProviderContractTests.cs +++ b/test/CodoMetis.ValueRanges.Conventions.Tests/LiteralProviderContractTests.cs @@ -15,6 +15,14 @@ namespace CodoMetis.ValueRanges.Conventions.Tests; /// [1483-01-01,1483-06-30] and ToString(null, th-TH) wrote 2569; an explicit /// de-DE provider read the decimal 1.5 as fifteen. None of it threw. A provider still /// applies alongside an explicit format string, which asks for display text rather than a literal. +/// +/// An empty format string is not an explicit one: .NET treats "" exactly as +/// (the general format), so both are swept. The first cut of this rule checked only +/// format is null, and ToString("", de-DE) on DecimalRange [1.5,2) wrote +/// [1,5,2) — the pre-9.0 bug, reachable through any API that passes an empty format rather +/// than none — while DateRange's format ?? "yyyy-MM-dd" let "" through to +/// the short-date pattern. +/// /// [TestClass] public sealed class LiteralProviderContractTests @@ -22,6 +30,9 @@ public sealed class LiteralProviderContractTests private static readonly CultureInfo[] Providers = [.. new[] { "de-DE", "sv-SE", "fa-IR", "th-TH", "ar-SA" }.Select(CultureInfo.GetCultureInfo)]; + /// The two spellings of "no format" — both must write the literal. + private static readonly string?[] NoFormat = [null, ""]; + [TestMethod] public void EveryRangeShape_IgnoresTheProvider_ForTheLiteral() { @@ -42,8 +53,14 @@ private static void AssertRangeShapes() foreach (var provider in Providers) { - Assert.AreEqual(literal, ((IFormattable)range).ToString(null, provider), - $"{label}: ToString(null, {provider.Name}) is not the invariant literal."); + foreach (var format in NoFormat) + { + Assert.AreEqual(literal, ((IFormattable)range).ToString(format, provider), + $"{label}: ToString({Show(format)}, {provider.Name}) is not the invariant literal."); + Assert.AreEqual(setText, set.ToString(format, provider), + $"{label} as RangeSet: ToString({Show(format)}, {provider.Name}) is not the invariant literal."); + } + Assert.AreEqual(range, TRange.Parse(literal, provider), $"{label}: Parse(literal, {provider.Name}) read a different range."); Assert.IsTrue(TRange.TryParse(literal, provider, out var tried) && Equals(tried, range), @@ -51,8 +68,6 @@ private static void AssertRangeShapes() Assert.AreEqual(range, TRange.Parse(literal.AsSpan(), provider), $"{label}: Parse(span, {provider.Name}) read a different range."); - Assert.AreEqual(setText, set.ToString(null, provider), - $"{label} as RangeSet: ToString(null, {provider.Name}) is not the invariant literal."); Assert.AreEqual(set, RangeSet.Parse(setText, provider), $"{label} as RangeSet: Parse(literal, {provider.Name}) read a different set."); } @@ -75,10 +90,76 @@ private static void AssertSet() foreach (var provider in Providers) { - Assert.AreEqual(literal, set.ToString(null, provider), - $"{typeof(TSet).Name}: ToString(null, {provider.Name}) is not the invariant literal."); + foreach (var format in NoFormat) + { + Assert.AreEqual(literal, set.ToString(format, provider), + $"{typeof(TSet).Name}: ToString({Show(format)}, {provider.Name}) is not the invariant literal."); + } + Assert.AreEqual(set, TSet.Parse(literal, provider), $"{typeof(TSet).Name}: Parse(literal, {provider.Name}) read a different set."); } } + + /// + /// FormatValue is public and reached directly as well as through the literal, so it must + /// read "" as its own default too — not hand it to the element, where a + /// format ?? "yyyy-MM-dd" lets it through to the culture's short-date pattern. + /// + [TestMethod] + public void EveryFormatValue_ReadsAnEmptyFormat_AsItsDefault() + { + var swept = 0; + var failures = new List(); + + foreach (var (rangeType, elementType) in RangeProbes.AllRangeTypes()) + { + failures.AddRange(Reflect.InvokeGeneric>( + typeof(LiteralProviderContractTests), nameof(RangeFormatValueFailures), rangeType, elementType)); + swept++; + } + + foreach (var (setType, elementType) in SetProbes.AllSetTypes()) + { + failures.AddRange(Reflect.InvokeGeneric>( + typeof(LiteralProviderContractTests), nameof(SetFormatValueFailures), setType, elementType)); + swept++; + } + + Assert.IsTrue(swept >= 30, $"The sweep discovered only {swept} types — too few to mean anything."); + Assert.IsEmpty(failures, + "FormatValue reads an empty format as an explicit one:\n" + string.Join("\n", failures)); + } + + private static IEnumerable RangeFormatValueFailures() + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + => FormatValueFailures( + typeof(TRange).Name, + [(T)RangeProbes.Bounds[typeof(T)].Low, (T)RangeProbes.Bounds[typeof(T)].High], + TRange.FormatValue); + + private static IEnumerable SetFormatValueFailures() + where TSet : IValueSetFactory, IValueSet + where T : IEquatable + => FormatValueFailures(typeof(TSet).Name, SetProbes.For(), TSet.FormatValue); + + private static List FormatValueFailures( + string label, IEnumerable values, Func formatValue) + { + var failures = new List(); + + foreach (var value in values) + { + var none = formatValue(value, null, CultureInfo.InvariantCulture); + var empty = formatValue(value, "", CultureInfo.InvariantCulture); + + if (none != empty) + failures.Add($" {label}: null -> '{none}', \"\" -> '{empty}'"); + } + + return failures; + } + + private static string Show(string? format) => format is null ? "null" : $"\"{format}\""; } diff --git a/test/CodoMetis.ValueRanges.Conventions.Tests/RangeLiteralGrammarContractTests.cs b/test/CodoMetis.ValueRanges.Conventions.Tests/RangeLiteralGrammarContractTests.cs new file mode 100644 index 0000000..2ad3423 --- /dev/null +++ b/test/CodoMetis.ValueRanges.Conventions.Tests/RangeLiteralGrammarContractTests.cs @@ -0,0 +1,151 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using CodoMetis.ValueRanges.Core; + +namespace CodoMetis.ValueRanges.Conventions.Tests; + +/// +/// Every range type reads a bound as PostgreSQL's range_in does: only an absent bound +/// is infinite; a quoted "" or a bound of only whitespace is a value — one no element type here +/// accepts; quotes may wrap any part of a bound and a backslash escapes the next character, inside +/// quotes or out. Swept through Parse, TryParse, the span overload, JSON, the +/// TypeConverter and the multirange literal. +/// +/// +/// +/// Until 9.0.0 the parser unquoted each bound and then asked whether it was empty, so +/// Int32Range.Parse("[\"\",5)") was (,4] and ("","") was (,) — silently, +/// where PostgreSQL answers invalid input syntax for type integer: "". A bound of whitespace +/// was trimmed to nothing the same way. Accepting those later and then refusing them would be a +/// breaking change, which is why they are refused from the first release that reads literals strictly. +/// +/// +/// The behaviour mirrors the probes run against PostgreSQL 17 (["",5), [ ,5), +/// [1, ) refused; [\1,5) and ["1"2,5) read as 1 and 12); +/// LiteralGrammarParityTests in the integration project asks a live server. +/// +/// +[TestClass] +public sealed class RangeLiteralGrammarContractTests +{ + /// 11 range types as of 9.0.0 (7 core, 4 NodaTime). + private const int KnownRangeTypes = 11; + + [TestMethod] + public void EveryRangeType_ReadsBoundsWithPostgresRangeGrammar() + { + var swept = new List(); + var failures = new List(); + + foreach (var (rangeType, elementType) in RangeProbes.AllRangeTypes()) + { + failures.AddRange(Reflect.InvokeGeneric>( + typeof(RangeLiteralGrammarContractTests), nameof(Failures), rangeType, elementType)); + swept.Add(rangeType.Name); + } + + Assert.IsTrue(swept.Count >= KnownRangeTypes, + $"Only {swept.Count} range types were swept, fewer than the {KnownRangeTypes} known: {string.Join(", ", swept)}."); + Assert.IsEmpty(failures, "A range literal is read differently from PostgreSQL's range_in:\n" + string.Join("\n", failures)); + } + + private static IEnumerable Failures() + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + { + var failures = new List(); + var (low, high) = ((T)RangeProbes.Bounds[typeof(T)].Low, (T)RangeProbes.Bounds[typeof(T)].High); + var lo = TRange.FormatValue(low, null, CultureInfo.InvariantCulture); + var hi = TRange.FormatValue(high, null, CultureInfo.InvariantCulture); + + // A quoted empty bound, or one of only whitespace, is present — and no element type here reads it. + foreach (var literal in new[] + { + $"[\"\",{hi})", $"[{lo},\"\")", "(\"\",\"\")", $"[ ,{hi})", $"[{lo}, )", "( , )", "(\"\",)", "(,\"\")" + }) + { + Refused(literal); + } + + // Only an absent bound is infinite — a guard against refusing too much. + Reads($"(,{hi})", TRange.CreateUnboundedStart(high, endInclusive: false)); + Reads($"[{lo},)", TRange.CreateUnboundedEnd(low, startInclusive: true)); + Reads("(,)", TRange.Infinite); + + // Backslash escapes the next character outside quotes too, and quotes may wrap part of a bound. + var finite = TRange.CreateFinite(low, high, startInclusive: true, endInclusive: false); + Reads($"[{EscapeEach(lo)},{EscapeEach(hi)})", finite); + Reads($"[\"{lo[..1]}\"{lo[1..]},{hi[..1]}\"{hi[1..]}\")", finite); + Reads($"[\"{EscapeEach(lo)}\",\"{hi}\")", finite); + + // Whitespace around an unquoted bound is not part of it for any element type here. + Reads($"[ {lo} , {hi} )", finite); + + // Structure PostgreSQL refuses: a second comma, a closing bracket inside a bound, an + // unterminated quote or escape, Unicode whitespace where only ASCII whitespace is skipped. + foreach (var literal in new[] + { + $"[{lo},{hi},{hi})", $"[{lo}),{hi})", $"[{lo}],{hi})", $"[\"{lo},{hi})", $"[{lo},{hi}\\)", + // Only ASCII whitespace is skipped, as in PostgreSQL: a no-break space is bound text. + $"[\u00A0{lo},{hi})", $"\u00A0[{lo},{hi})" + }) + { + Refused(literal); + } + + return failures; + + void Refused(string literal) + { + var label = $"{typeof(TRange).Name} {literal}"; + + Expect(label, "Parse", () => TRange.Parse(literal, CultureInfo.InvariantCulture)); + Expect(label, "Parse(span)", () => TRange.Parse(literal.AsSpan(), CultureInfo.InvariantCulture)); + if (TRange.TryParse(literal, CultureInfo.InvariantCulture, out var tried)) + failures.Add($" {label}: TryParse read {tried}."); + Expect(label, "JSON", () => JsonSerializer.Deserialize(JsonSerializer.Serialize(literal))!); + Expect(label, "TypeConverter", + () => (TRange)TypeDescriptor.GetConverter(typeof(TRange)).ConvertFromInvariantString(literal)!); + Expect(label, "RangeSet.Parse", + () => RangeSet.Parse($"{{[{lo},{hi}),{literal}}}", CultureInfo.InvariantCulture)); + } + + void Expect(string label, string path, Func read) where TException : Exception + { + try + { + failures.Add($" {label}: {path} read {read()}."); + } + catch (TException) + { + } + catch (Exception ex) + { + failures.Add($" {label}: {path} threw {ex.GetType().Name}, not {typeof(TException).Name}: {ex.Message}"); + } + } + + void Reads(string literal, TRange expected) + { + var label = $"{typeof(TRange).Name} {literal}"; + + try + { + var actual = TRange.Parse(literal, CultureInfo.InvariantCulture); + if (!Equals(actual, expected)) + failures.Add($" {label}: Parse read {actual}, expected {expected}."); + + var set = RangeSet.Parse($"{{{literal}}}", CultureInfo.InvariantCulture); + if (!Equals(set, RangeSet.From([expected]))) + failures.Add($" {label}: RangeSet.Parse read {set}, expected {{{expected}}}."); + } + catch (FormatException ex) + { + failures.Add($" {label}: refused, expected {expected}: {ex.Message}"); + } + } + } + + private static string EscapeEach(string text) => string.Concat(text.Select(c => $"\\{c}")); +} diff --git a/test/CodoMetis.ValueRanges.Conventions.Tests/TemporalLiteralStrictnessContractTests.cs b/test/CodoMetis.ValueRanges.Conventions.Tests/TemporalLiteralStrictnessContractTests.cs index 6b764c1..dd06099 100644 --- a/test/CodoMetis.ValueRanges.Conventions.Tests/TemporalLiteralStrictnessContractTests.cs +++ b/test/CodoMetis.ValueRanges.Conventions.Tests/TemporalLiteralStrictnessContractTests.cs @@ -37,12 +37,138 @@ public sealed class TemporalLiteralStrictnessContractTests [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(LocalDateTime)] = ["01.02.2027 09:30:00", "2027-02-01", "2027-02-01T9:30"], + [typeof(Instant)] = ["01.02.2027 09:30:00 +01:00", "2027-02-01T09:30:00", "2027-02-01", + "2027-02-01T09:30"], + [typeof(LocalTime)] = ["9:30 AM", "9:30"], [typeof(YearMonth)] = ["02.2027"] }; + /// + /// HH:mm elements, paired with the full form each must read as. Valid ISO 8601, never + /// misread, and what a hand-written [09:00,17:00) uses — accepted uniformly wherever a + /// time of day appears. An instant still needs its offset or Z (see ). + /// + /// + /// Until 9.0.0 the BCL types accepted it and the NodaTime ones did not: TimeRange.Parse("[09:00,17:00)") + /// parsed while LocalTimeSet.Parse("{09:00}") threw. + /// + private static readonly Dictionary HoursMinutes = new() + { + [typeof(TimeOnly)] = [("09:30", "09:30:00")], + [typeof(DateTime)] = [("2027-02-01T09:30", "2027-02-01T09:30:00"), ("2027-02-01 09:30", "2027-02-01T09:30:00")], + [typeof(DateTimeOffset)] = [("2027-02-01T09:30+01:00", "2027-02-01T09:30:00+01:00"), + ("2027-02-01 09:30+01", "2027-02-01T09:30:00+01:00"), + ("2027-02-01T09:30Z", "2027-02-01T09:30:00Z")], + [typeof(LocalTime)] = [("09:30", "09:30:00")], + [typeof(LocalDateTime)] = [("2027-02-01T09:30", "2027-02-01T09:30:00"), ("2027-02-01 09:30", "2027-02-01T09:30:00")], + [typeof(Instant)] = [("2027-02-01T09:30+01:00", "2027-02-01T09:30:00+01:00"), + ("2027-02-01 09:30+01", "2027-02-01T09:30:00+01:00"), + ("2027-02-01T09:30Z", "2027-02-01T09:30:00Z")] + }; + + /// 5 range types and 12 set types (6 closed, 6 wrapper arities) with a time of day, as of 9.0.0. + private const int KnownTimeOfDayTypes = 17; + + [TestMethod] + public void EveryTimeOfDayRangeAndSet_AcceptsHoursAndMinutes_OnEveryEntryPoint() + { + var checkedTypes = new List(); + var failures = new List(); + + foreach (var (rangeType, elementType) in RangeProbes.AllRangeTypes()) + { + if (!HoursMinutes.ContainsKey(elementType)) continue; + + failures.AddRange(Reflect.InvokeGeneric>( + typeof(TemporalLiteralStrictnessContractTests), nameof(RangeHoursMinutesFailures), 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()) + { + var primitive = primitives.GetValueOrDefault(setType.Name.Split('`')[0]) ?? elementType; + if (!HoursMinutes.ContainsKey(primitive)) continue; + + failures.AddRange(Reflect.InvokeGeneric>( + typeof(TemporalLiteralStrictnessContractTests), nameof(SetHoursMinutesFailures), setType, elementType, primitive)); + checkedTypes.Add(setType.IsGenericType ? $"{setType.Name.Split('`')[0]}<{elementType.Name}>" : setType.Name); + } + + Assert.IsTrue( + checkedTypes.Count >= KnownTimeOfDayTypes, + $"Only {checkedTypes.Count} time-of-day range and set types were checked, fewer than the " + + $"{KnownTimeOfDayTypes} known to exist: {string.Join(", ", checkedTypes)}."); + Assert.IsEmpty(failures, "HH:mm is not accepted uniformly:\n" + string.Join("\n", failures)); + } + + private static IEnumerable RangeHoursMinutesFailures() + where TRange : IRangeFactory, IRange + where T : struct, IComparable, IEquatable + { + var failures = new List(); + + foreach (var (shortForm, fullForm) in HoursMinutes[typeof(T)]) + { + var literal = $"[\"{shortForm}\",\"{shortForm}\"]"; + var expected = TRange.Parse($"[\"{fullForm}\",\"{fullForm}\"]", CultureInfo.InvariantCulture); + var label = $"{typeof(TRange).Name} {literal}"; + + Check(failures, label, "Parse", expected, () => TRange.Parse(literal, CultureInfo.InvariantCulture)); + Check(failures, label, "Parse(span)", expected, () => TRange.Parse(literal.AsSpan(), CultureInfo.InvariantCulture)); + Check(failures, label, "TryParse", expected, + () => TRange.TryParse(literal, CultureInfo.InvariantCulture, out var tried) ? tried : throw new FormatException("TryParse returned false.")); + Check(failures, label, "JSON", expected, () => JsonSerializer.Deserialize(JsonSerializer.Serialize(literal))!); + Check(failures, label, "TypeConverter", expected, + () => (TRange)TypeDescriptor.GetConverter(typeof(TRange)).ConvertFromInvariantString(literal)!); + Check(failures, label, "RangeSet.Parse", RangeSet.From([expected]), + () => RangeSet.Parse($"{{{literal}}}", CultureInfo.InvariantCulture)); + } + + return failures; + } + + private static IEnumerable SetHoursMinutesFailures() + where TSet : IValueSetFactory, IValueSet + where T : IEquatable + { + var failures = new List(); + + foreach (var (shortForm, fullForm) in HoursMinutes[typeof(TPrimitive)]) + { + var literal = $"{{\"{shortForm}\"}}"; + var expected = TSet.Parse($"{{\"{fullForm}\"}}", CultureInfo.InvariantCulture); + var label = $"{(typeof(TSet).IsGenericType ? $"{typeof(TSet).Name.Split('`')[0]}<{typeof(T).Name}>" : typeof(TSet).Name)} {literal}"; + + Check(failures, label, "Parse", expected, () => TSet.Parse(literal, CultureInfo.InvariantCulture)); + Check(failures, label, "TryParse", expected, + () => TSet.TryParse(literal, CultureInfo.InvariantCulture, out var tried) ? tried : throw new FormatException("TryParse returned false.")); + Check(failures, label, "JSON", expected, () => JsonSerializer.Deserialize($"[{JsonSerializer.Serialize(shortForm)}]")!); + Check(failures, label, "TypeConverter", expected, + () => (TSet)TypeDescriptor.GetConverter(typeof(TSet)).ConvertFromInvariantString(literal)!); + } + + return failures; + } + + private static void Check(List failures, string label, string path, TValue expected, Func read) + { + try + { + var actual = read(); + if (!Equals(actual, expected)) + failures.Add($" {label}: {path} read {actual}, expected {expected}."); + } + catch (Exception ex) when (ex is FormatException or JsonException or NotSupportedException or ArgumentException) + { + failures.Add($" {label}: {path} refused it ({ex.GetType().Name})."); + } + } + /// 8 temporal range types and 18 temporal set types (9 closed, 9 wrapper arities), as of 9.0.0. private const int KnownTemporalTypes = 26; diff --git a/test/CodoMetis.ValueRanges.Conventions.Tests/WrapperGrammarContractTests.cs b/test/CodoMetis.ValueRanges.Conventions.Tests/WrapperGrammarContractTests.cs new file mode 100644 index 0000000..2e61847 --- /dev/null +++ b/test/CodoMetis.ValueRanges.Conventions.Tests/WrapperGrammarContractTests.cs @@ -0,0 +1,311 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using CodoMetis.ValueRanges.Core; +using NodaTime; + +namespace CodoMetis.ValueRanges.Conventions.Tests; + +/// +/// A wrapper arity reads element text with its family's grammar, not its element's: for every +/// discovered wrapper family, closed over a deliberately lenient element, every probe text is +/// accepted or refused exactly as the primitive sibling accepts or refuses it, and an accepted one +/// reads the same value — through Parse, TryParse, the span overload, the +/// TypeConverter and JSON. +/// +/// +/// +/// Until 9.0.0 the numeric arities handed the raw element text to TElement.Parse. A generated +/// wrapper — new(decimal.Parse(s, provider)), which is NumberStyles.Number — accepts +/// group separators, so DecimalSet.Parse("{\"1,5\"}") threw while +/// DecimalSet<Price>.Parse("{\"1,5\"}") was {15}, silently, and the same through +/// configuration binding and a JSON string. In the other direction {1e3} — PostgreSQL numeric +/// grammar — was refused by the wrapper and read by the primitive. The temporal arities already +/// parsed with the family grammar first and handed the element its round-trip text; the numeric ones +/// now do the same. +/// +/// +/// Each numeric family is swept twice: over a deliberately lenient element (NumberStyles.Any), +/// which would widen the grammar, and over the representative one (NumberStyles.Number for +/// , which has no exponent), which would narrow it. An element that parses exactly +/// like the primitive would make the two agree by construction, and the sweep would pass with the +/// defect in place. +/// +/// +[TestClass] +public sealed class WrapperGrammarContractTests +{ + /// + /// Probe element texts per primitive. Each one is placed in a quoted array literal (so leading + /// and trailing whitespace reaches the element parser) and in a JSON string. + /// + private static readonly Dictionary TextProbes = new() + { + [typeof(decimal)] = [ + "1.5", "1.50", "-1.5", "+1.5", " 1.5", "1.5 ", ".5", "1e3", "1.5E-1", "12.500e1", + "1,5", "1,000", "1,000.5", "1 000", "(1)", "1-", "¤1", "0x10", "NaN", "Infinity", "" + ], + [typeof(int)] = Integers("2147483648"), + [typeof(short)] = Integers("32768"), + [typeof(long)] = Integers("9223372036854775808"), + [typeof(Guid)] = [ + "6f9619ff-8b86-d011-b42d-00c04fc964ff", "6f9619ff8b86d011b42d00c04fc964ff", + "{6f9619ff-8b86-d011-b42d-00c04fc964ff}", "(6f9619ff-8b86-d011-b42d-00c04fc964ff)", + " 6f9619ff-8b86-d011-b42d-00c04fc964ff ", "6f9619ff-8b86-d011-b42d", "" + ], + [typeof(DateOnly)] = ["2024-06-15", "infinity", "01.02.2027", "06/15/2024", "2024-06-15T00:00:00", ""], + [typeof(TimeOnly)] = ["09:30", "09:30:15.25", "23:59:59.9999999", "9:30 AM", "9:30", ""], + [typeof(DateTime)] = [ + "2024-06-15T10:30:00", "2024-06-15 10:30:00.1234567", "2024-06-15T10:30:00Z", "-infinity", + "01.02.2027 09:30:00", "2027-02-01", "" + ], + [typeof(DateTimeOffset)] = [ + "2024-06-15T10:30:00+02:00", "2024-06-15 10:30:00.123456+02", "2024-06-15T10:30:00Z", + "2027-02-01T09:30:00", "01.02.2027 09:30:00 +01:00", "" + ], + [typeof(LocalDate)] = ["2024-06-15", "01.02.2027", ""], + [typeof(LocalDateTime)] = ["2024-06-15T10:30:00.123456789", "2024-06-15 10:30:00", "2027-02-01", ""], + [typeof(Instant)] = ["2024-06-15T10:30:00Z", "2024-06-15 10:30:00+02", "2027-02-01T09:30:00", ""], + [typeof(LocalTime)] = ["09:30:15.123456789", "23:59:59", "9:30 AM", ""], + [typeof(YearMonth)] = ["2024-06", "02.2027", ""] + }; + + /// + /// Probe JSON number tokens per numeric primitive, read by the wrapper's element converter and + /// compared with what System.Text.Json reads for the primitive sibling. + /// + private static readonly Dictionary NumberProbes = new() + { + [typeof(decimal)] = ["1.5", "1.50", "-0.1", "1e3", "1.5e1", "12.50E-1"], + [typeof(int)] = ["42", "-7", "1e3", "42.0", "2147483648"], + [typeof(short)] = ["42", "-7", "1e3", "42.0", "32768"], + [typeof(long)] = ["42", "-7", "1e3", "42.0", "9223372036854775808"] + }; + + /// + /// Families with no grammar to widen: a string element's text is its value, and a wrapper that + /// trims or validates it ( does) is exercising its own validation. + /// + private static readonly HashSet NoGrammar = [typeof(string)]; + + /// 15 wrapper families as of 9.0.0, less the string one, plus a lenient element for each of the four numeric ones. + private const int KnownClosures = 18; + + private static string[] Integers(string overflow) => + ["42", "-7", "+42", " 42", "42 ", "1,000", "1e3", "42.0", "(1)", "1-", "¤1", "0x2A", "4 2", overflow, ""]; + + [TestMethod] + public void EveryWrapperArity_ReadsElementsWithItsFamilyGrammar() + { + var swept = new List(); + var failures = new List(); + + foreach (var (closed, _) in SetProbes.AllSetTypes().Where(pair => pair.Set.IsGenericType)) + { + var family = closed.GetGenericTypeDefinition(); + var sibling = family.Assembly.GetType(family.FullName!.Split('`')[0]) + ?? throw new InvalidOperationException($"{family.Name} has no primitive sibling."); + var primitive = ElementTypeOf(sibling); + + if (NoGrammar.Contains(primitive)) continue; + + // Both directions: the lenient element would widen the grammar, the representative one + // (Money is NumberStyles.Number, which has no exponent) would narrow it. + foreach (var element in new[] { LenientElementFor(primitive), WrapperElements.For(family) }.OfType()) + { + var wrapper = family.MakeGenericType(element); + + failures.AddRange(Reflect.InvokeGeneric>( + typeof(WrapperGrammarContractTests), nameof(Disagreements), wrapper, element, sibling, primitive)); + swept.Add($"{family.Name.Split('`')[0]}<{element.Name}>"); + } + } + + Assert.IsTrue(swept.Count >= KnownClosures, + $"Only {swept.Count} wrapper closures were swept, fewer than the {KnownClosures} expected: " + + string.Join(", ", swept)); + Assert.IsEmpty(failures, + "A wrapper arity reads element text differently from its primitive sibling:\n" + string.Join("\n", failures)); + } + + private static Type? LenientElementFor(Type primitive) => primitive.Name switch + { + nameof(Decimal) => typeof(LenientAmount), + nameof(Int32) => typeof(LenientCount), + nameof(Int16) => typeof(LenientTinyCount), + nameof(Int64) => typeof(LenientLargeCount), + _ => null + }; + + private static IEnumerable Disagreements() + where TWrapperSet : IValueSetFactory, IValueSet + where TElement : IEquatable + where TPrimitiveSet : IValueSetFactory, IValueSet + where TPrimitive : IEquatable + { + var label = $"{typeof(TWrapperSet).Name.Split('`')[0]}<{typeof(TElement).Name}>"; + var failures = new List(); + + if (!TextProbes.TryGetValue(typeof(TPrimitive), out var texts)) + { + failures.Add($" {label}: no probe texts for {typeof(TPrimitive).Name} — add them to TextProbes."); + return failures; + } + + foreach (var text in texts) + { + var literal = $"{{\"{text.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"}}"; + var expected = Outcome(() => TPrimitiveSet.Parse(literal, CultureInfo.InvariantCulture)); + + Compare("Parse", Outcome(() => TWrapperSet.Parse(literal, CultureInfo.InvariantCulture))); + Compare("Parse(span)", Outcome(() => TWrapperSet.Parse(literal.AsSpan(), CultureInfo.InvariantCulture))); + Compare("TryParse", TWrapperSet.TryParse(literal, CultureInfo.InvariantCulture, out var tried) + ? tried.ToString(null, CultureInfo.InvariantCulture) + : Refused); + Compare("TypeConverter", Outcome(() => (TWrapperSet)TypeDescriptor.GetConverter(typeof(TWrapperSet)) + .ConvertFromInvariantString(literal)!)); + Compare("JSON string", Outcome(() => JsonSerializer.Deserialize( + $"[{JsonSerializer.Serialize(text)}]")!)); + + void Compare(string path, string actual) + { + if (actual != expected) + failures.Add($" {label} {path} of {literal}: {actual}, but {typeof(TPrimitiveSet).Name} reads {expected}"); + } + } + + foreach (var number in NumberProbes.GetValueOrDefault(typeof(TPrimitive)) ?? []) + { + var json = $"[{number}]"; + var expected = Outcome(() => JsonSerializer.Deserialize(json)!); + var actual = Outcome(() => JsonSerializer.Deserialize(json)!); + + if (actual != expected) + failures.Add($" {label} JSON number {json}: {actual}, but {typeof(TPrimitiveSet).Name} reads {expected}"); + } + + return failures; + } + + private const string Refused = "refused"; + + private static string Outcome(Func read) where TSet : IFormattable + { + try + { + return read().ToString(null, CultureInfo.InvariantCulture); + } + catch (Exception ex) when (ex is FormatException or JsonException or OverflowException or ArgumentException + or NotSupportedException) + { + return Refused; + } + } + + private static Type ElementTypeOf(Type setType) => + setType.GetInterfaces() + .First(@interface => @interface.IsGenericType + && @interface.GetGenericTypeDefinition() == typeof(IValueSet<>)) + .GetGenericArguments()[0]; +} + +// Deliberately lenient numeric wrappers: NumberStyles.Any accepts group separators, parentheses, +// a trailing sign, a currency symbol, an exponent and an integral value with a zero fraction. A +// generated wrapper is usually NumberStyles.Number or the BCL default; Any is the widest it could be, +// which is the point — the family grammar must win whatever the element accepts. + +public readonly record struct LenientAmount : IFormattable, IParsable, IComparable +{ + private readonly decimal _value; + + private LenientAmount(decimal value) => _value = value; + + public static LenientAmount Parse(string s, IFormatProvider? provider) + => new(decimal.Parse(s, NumberStyles.Any, provider ?? CultureInfo.InvariantCulture)); + + public static bool TryParse(string? s, IFormatProvider? provider, out LenientAmount result) + { + var parsed = decimal.TryParse(s, NumberStyles.Any, provider ?? CultureInfo.InvariantCulture, out var value); + result = parsed ? new LenientAmount(value) : default; + return parsed; + } + + public int CompareTo(LenientAmount other) => _value.CompareTo(other._value); + + public string ToString(string? format, IFormatProvider? formatProvider) + => _value.ToString(format, formatProvider ?? CultureInfo.InvariantCulture); + + public override string ToString() => _value.ToString(CultureInfo.InvariantCulture); +} + +public readonly record struct LenientCount : IFormattable, IParsable, IComparable +{ + private readonly int _value; + + private LenientCount(int value) => _value = value; + + public static LenientCount Parse(string s, IFormatProvider? provider) + => new(int.Parse(s, NumberStyles.Any, provider ?? CultureInfo.InvariantCulture)); + + public static bool TryParse(string? s, IFormatProvider? provider, out LenientCount result) + { + var parsed = int.TryParse(s, NumberStyles.Any, provider ?? CultureInfo.InvariantCulture, out var value); + result = parsed ? new LenientCount(value) : default; + return parsed; + } + + public int CompareTo(LenientCount other) => _value.CompareTo(other._value); + + public string ToString(string? format, IFormatProvider? formatProvider) + => _value.ToString(format, formatProvider ?? CultureInfo.InvariantCulture); + + public override string ToString() => _value.ToString(CultureInfo.InvariantCulture); +} + +public readonly record struct LenientTinyCount : IFormattable, IParsable, IComparable +{ + private readonly short _value; + + private LenientTinyCount(short value) => _value = value; + + public static LenientTinyCount Parse(string s, IFormatProvider? provider) + => new(short.Parse(s, NumberStyles.Any, provider ?? CultureInfo.InvariantCulture)); + + public static bool TryParse(string? s, IFormatProvider? provider, out LenientTinyCount result) + { + var parsed = short.TryParse(s, NumberStyles.Any, provider ?? CultureInfo.InvariantCulture, out var value); + result = parsed ? new LenientTinyCount(value) : default; + return parsed; + } + + public int CompareTo(LenientTinyCount other) => _value.CompareTo(other._value); + + public string ToString(string? format, IFormatProvider? formatProvider) + => _value.ToString(format, formatProvider ?? CultureInfo.InvariantCulture); + + public override string ToString() => _value.ToString(CultureInfo.InvariantCulture); +} + +public readonly record struct LenientLargeCount : IFormattable, IParsable, IComparable +{ + private readonly long _value; + + private LenientLargeCount(long value) => _value = value; + + public static LenientLargeCount Parse(string s, IFormatProvider? provider) + => new(long.Parse(s, NumberStyles.Any, provider ?? CultureInfo.InvariantCulture)); + + public static bool TryParse(string? s, IFormatProvider? provider, out LenientLargeCount result) + { + var parsed = long.TryParse(s, NumberStyles.Any, provider ?? CultureInfo.InvariantCulture, out var value); + result = parsed ? new LenientLargeCount(value) : default; + return parsed; + } + + public int CompareTo(LenientLargeCount other) => _value.CompareTo(other._value); + + public string ToString(string? format, IFormatProvider? formatProvider) + => _value.ToString(format, formatProvider ?? CultureInfo.InvariantCulture); + + public override string ToString() => _value.ToString(CultureInfo.InvariantCulture); +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecuteUpdateJsonTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecuteUpdateJsonTests.cs index 6066574..97d5b41 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecuteUpdateJsonTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ExecuteUpdateJsonTests.cs @@ -3,6 +3,30 @@ namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; +public sealed class UpdateGuardRow +{ + public int Id { get; set; } + public UpdateGuardDoc Doc { get; set; } = new(); + public UpdateGuardDoc Plain { get; set; } = new(); +} + +public sealed class UpdateGuardDoc +{ + public StringSet Tags { get; set; } = StringSet.Empty; + public UpdateGuardInner Inner { get; set; } = new(); +} + +public sealed class UpdateGuardInner +{ + public StringSet Tags { get; set; } = StringSet.Empty; +} + +public sealed class UpdateGuardNote +{ + public int Id { get; set; } + public int RowId { get; set; } +} + /// /// 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. @@ -109,4 +133,134 @@ public async Task TheWholeDocument_AndANativeSetColumn_AreStillUpdatable() await using var read = new IntegrationDbContext(); Assert.AreEqual(permissions, (await read.Reservations.AsNoTracking().SingleAsync(r => r.Id == 9604)).Permissions); } + + // -- The refusal keys off what the UPDATE writes, not off how the setter names the property -- + // + // Until 9.0.0 the refusal matched the setter lambda: a parameter of an entity type followed by a + // plain member chain. A join and EF.Property reached the same jsonb_set(…, to_jsonb(@p)) without + // being refused, and the row was corrupted. The refusal now reads the translated setter itself. + + private static readonly StringSet Seeded = StringSet.From("seed"); + + private static async Task SeedGuardRow(int id) + { + await using var write = new IntegrationDbContext(); + write.UpdateGuardRows.Add(new UpdateGuardRow + { + Id = id, + Doc = new UpdateGuardDoc { Tags = Seeded, Inner = new UpdateGuardInner { Tags = Seeded } }, + Plain = new UpdateGuardDoc { Tags = Seeded, Inner = new UpdateGuardInner { Tags = Seeded } } + }); + write.UpdateGuardNotes.Add(new UpdateGuardNote { Id = id, RowId = id }); + await write.SaveChangesAsync(); + } + + /// Loads the row in full — that this still works is the assertion that matters. + private static async Task ReloadGuardRow(int id) + { + await using var read = new IntegrationDbContext(); + return await read.UpdateGuardRows.AsNoTracking().SingleAsync(r => r.Id == id); + } + + private static async Task AssertRefusedAndIntact(int id, Func update, string path) + { + await using (var context = new IntegrationDbContext()) + { + var refusal = await Assert.ThrowsExactlyAsync(() => update(context)); + StringAssert.Contains(refusal.Message, $"'{path}'"); + StringAssert.Contains(refusal.Message, "to_jsonb"); + } + + var row = await ReloadGuardRow(id); + Assert.AreEqual(Seeded, row.Doc.Tags); + Assert.AreEqual(Seeded, row.Doc.Inner.Tags); + } + + [TestMethod] + public async Task AValueSetReachedThroughAJoin_IsRefused_AndTheRowStillLoads() + { + ContainerLifecycle.RequireDatabase(); + await SeedGuardRow(9611); + + await AssertRefusedAndIntact(9611, context => context.UpdateGuardRows + .Join(context.UpdateGuardNotes, r => r.Id, n => n.RowId, (r, n) => new { r, n }) + .Where(x => x.r.Id == 9611) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.r.Doc.Tags, StringSet.From("joined"))), "Doc.Tags"); + } + + [TestMethod] + public async Task AValueSetNamedThroughEFProperty_IsRefused_AndTheRowStillLoads() + { + ContainerLifecycle.RequireDatabase(); + await SeedGuardRow(9612); + + await AssertRefusedAndIntact(9612, context => context.UpdateGuardRows + .Where(r => r.Id == 9612) + .ExecuteUpdateAsync(s => s.SetProperty(r => EF.Property(r.Doc, "Tags"), StringSet.From("named"))), "Doc.Tags"); + } + + [TestMethod] + public async Task ANestedValueSetInsideTheDocument_IsRefused_AndTheRowStillLoads() + { + ContainerLifecycle.RequireDatabase(); + await SeedGuardRow(9613); + + await AssertRefusedAndIntact(9613, context => context.UpdateGuardRows + .Where(r => r.Id == 9613) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.Doc.Inner.Tags, StringSet.From("nested"))), "Doc.Inner.Tags"); + } + + [TestMethod] + public async Task AComputedValueSetSetter_IsRefused_AndTheRowStillLoads() + { + ContainerLifecycle.RequireDatabase(); + await SeedGuardRow(9614); + + // A column rather than a parameter: to_jsonb of the native array, the same JSON array. + await AssertRefusedAndIntact(9614, context => context.UpdateGuardRows + .Where(r => r.Id == 9614) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.Doc.Tags, r => r.Plain.Tags)), "Doc.Tags"); + } + + [TestMethod] + public async Task AWholeComplexPropertyContainingASet_IsStillUpdatable() + { + ContainerLifecycle.RequireDatabase(); + await SeedGuardRow(9615); + + // EF serializes a complex value itself, through the plugin's JSON writer — the set arrives as + // its literal string, which is what the document holds. + var inner = new UpdateGuardInner { Tags = StringSet.From("b", "a") }; + await using (var update = new IntegrationDbContext()) + await update.UpdateGuardRows.Where(r => r.Id == 9615).ExecuteUpdateAsync(s => s.SetProperty(r => r.Doc.Inner, inner)); + + var row = await ReloadGuardRow(9615); + Assert.AreEqual(StringSet.From("a", "b"), row.Doc.Inner.Tags); + Assert.AreEqual(Seeded, row.Doc.Tags); + + await using var query = new IntegrationDbContext(); + Assert.AreEqual(1, await query.UpdateGuardRows.CountAsync(r => r.Id == 9615 && r.Doc.Inner.Tags.Contains("b"))); + } + + [TestMethod] + public async Task TheSameComplexTypeOutsideAJsonColumn_IsStillUpdatable() + { + ContainerLifecycle.RequireDatabase(); + await SeedGuardRow(9616); + + // UpdateGuardDoc is also mapped inside the document. A refusal resolved by CLR type would take + // these setters with it; one reading the translated setter sees plain array columns. + var tags = StringSet.From("y", "x"); + await using (var update = new IntegrationDbContext()) + { + await update.UpdateGuardRows.Where(r => r.Id == 9616).ExecuteUpdateAsync(s => s + .SetProperty(r => r.Plain.Tags, tags) + .SetProperty(r => r.Plain.Inner.Tags, r => r.Plain.Tags.Remove("seed"))); + } + + var row = await ReloadGuardRow(9616); + Assert.AreEqual(tags, row.Plain.Tags); + Assert.AreEqual(StringSet.Empty, row.Plain.Inner.Tags); + Assert.AreEqual(Seeded, row.Doc.Tags); + } } diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/IntegrationDbContext.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/IntegrationDbContext.cs index d66b961..22e5728 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/IntegrationDbContext.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/IntegrationDbContext.cs @@ -182,6 +182,10 @@ public sealed class IntegrationDbContext : DbContext public DbSet TemporalParityRows => Set(); + public DbSet UpdateGuardRows => Set(); + + public DbSet UpdateGuardNotes => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseNpgsql( ContainerLifecycle.ConnectionString!, @@ -217,6 +221,21 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) row.ComplexProperty(r => r.Doc, doc => doc.ToJson()); }); + // One complex CLR type used twice: inside a jsonb document and as table-split columns, so the + // ExecuteUpdate JSON refusal is shown to key off what is written, not off the CLR type + // (ExecuteUpdateJsonTests). The notes table exists to reach the row through a join. + modelBuilder.Entity(row => + { + row.Property(r => r.Id).ValueGeneratedNever(); + row.ComplexProperty(r => r.Doc, doc => + { + doc.ToJson(); + doc.ComplexProperty(d => d.Inner); + }); + row.ComplexProperty(r => r.Plain, plain => plain.ComplexProperty(d => d.Inner)); + }); + modelBuilder.Entity().Property(n => n.Id).ValueGeneratedNever(); + // 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/LiteralGrammarParityTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/LiteralGrammarParityTests.cs new file mode 100644 index 0000000..c6f35cc --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/LiteralGrammarParityTests.cs @@ -0,0 +1,159 @@ +using System.Globalization; +using Npgsql; +using NpgsqlTypes; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; + +/// +/// Hands the same literal text to PostgreSQL's array_in / range_in and to this library's +/// parsers, and requires the same answer: both refuse it, or both read the same value. +/// +/// +/// +/// The literal grammars are where memory and the database can disagree without either looking +/// wrong. Until 9.0.0 StringSet.Parse("{a\,b}") was two elements where PostgreSQL reads one, +/// {{a,b}} was a one-dimensional set of "{a" and "b}", and +/// Int32Range.Parse("[\"\",5)") was (,4] where PostgreSQL refuses an empty integer. +/// The unit tests pin each answer; this asks the server they were taken from. +/// +/// +/// A literal PostgreSQL reads into something a value set cannot hold — a multi-dimensional array, a +/// NULL element — counts as one the set must refuse. +/// +/// +[TestClass] +public sealed class LiteralGrammarParityTests +{ + private static readonly string[] ArrayLiterals = + [ + "{a,b}", "{}", "{ }", "{ a b }", "{\"a\" , \"b\"}", "{\"\"}", "{\"NULL\"}", + @"{a\,b}", @"{a\\b}", @"{""a\""b""}", @"{\""a}", @"{a\ }", @"{\ a}", @"{a b\ }", @"{N\ULL}", @"{a\}b}", @"{\{a}", + "{ a}", "{a }", "{\ta\n}", + "{NULL}", "{null}", "{a,NULL}", + "{{a,b}}", "{{a},{b}}", "{a}b}", "{a{b}", "{a\"b}", "{a\"}", "{a\"b\"c}", "{\"a\"b}", "{\"x\" y}", + @"{a\}", "{a,}", "{,a}", "{a,,b}", "{\"a\",}", "{\"a", "a,b", "{a,b" + ]; + + private static readonly string[] Int4RangeLiterals = + [ + "[1,5)", "(,5)", "[1,)", "(,)", "empty", " [1,5) ", "[ 1 , 5 )", "[\"1\",\"5\")", @"[\1,5)", "[\"1\"2,20)", + "[1\"\",5)", "[\" 1 \",5)", "[\"1\",\"\"\"\")", + "[\"\",5)", "[1,\"\")", "(\"\",\"\")", "[ ,5)", "[1, )", "( , )", + "[1,5]x", "[1,5,6)", "[1),5)", "[1(,5)", "[\"1,5)", @"[1,5\)", "[ 1,5)", " [1,5)" + ]; + + private static readonly string[] DateRangeLiterals = + [ + "[2024-01-01,2024-02-01)", "[\"2024-01-01\",\"2024-02-01\")", "[\"\",2024-02-01)", "[ ,2024-02-01)", + @"[2024\-01\-01,2024-02-01)", "(,infinity)", "[-infinity,2024-01-01)", + // Whitespace around an unquoted bound is the range grammar's to skip. Inside quotes it reaches + // the element, where PostgreSQL's date_in trims it and the strict ISO grammar refuses it — + // deliberately, and loudly (TemporalLiteralGrammarTests.Date_Rejects), so it is not listed. + "[ 2024-01-01 , 2024-02-01 )", " [2024-01-01,2024-02-01) " + ]; + + private static readonly string[] Int4MultirangeLiterals = + [ + "{[1,5),[7,9)}", "{}", "{ [1,5) , [7,9) }", "{[\"\",5)}", "{[1,5),[\"\",9)}", "{[\"1\",\"5\")}", "{ [1,5)}" + ]; + + [TestMethod] + public async Task ArrayLiterals_ReadAsPostgresReadsThem() + { + ContainerLifecycle.RequireDatabase(); + await using var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString); + await connection.OpenAsync(); + + var failures = new List(); + + foreach (var literal in ArrayLiterals) + { + string server; + try + { + var ndims = await ScalarAsync(connection, "SELECT array_ndims($1::text[])", literal); + var elements = new List(); + await using (var command = Command(connection, "SELECT e FROM unnest($1::text[]) AS e", literal)) + await using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + elements.Add(reader.IsDBNull(0) ? null : reader.GetString(0)); + } + + server = ndims > 1 || elements.Contains(null) + ? "refused" + : StringSet.From(elements!).ToString(); + } + catch (PostgresException) + { + server = "refused"; + } + + var client = StringSet.TryParse(literal, CultureInfo.InvariantCulture, out var parsed) ? parsed.ToString() : "refused"; + + if (client != server) + failures.Add($" {Show(literal)}: StringSet {client}, PostgreSQL {server}"); + } + + Assert.IsEmpty(failures, "text[] literals read differently:\n" + string.Join("\n", failures)); + } + + [TestMethod] + public async Task RangeLiterals_ReadAsPostgresReadsThem() + { + ContainerLifecycle.RequireDatabase(); + await using var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString); + await connection.OpenAsync(); + + var failures = new List(); + + await Compare("int4range", Int4RangeLiterals, + literal => Int32Range.TryParse(literal, CultureInfo.InvariantCulture, out var r) ? r.ToString() : "refused"); + await Compare("daterange", DateRangeLiterals, + literal => DateRange.TryParse(literal, CultureInfo.InvariantCulture, out var r) ? r.ToString() : "refused"); + await Compare("int4multirange", Int4MultirangeLiterals, + literal => RangeSet.TryParse(literal, CultureInfo.InvariantCulture, out var r) ? r.ToString() : "refused"); + + Assert.IsEmpty(failures, "Range literals read differently:\n" + string.Join("\n", failures)); + + async Task Compare(string type, string[] literals, Func client) + { + foreach (var literal in literals) + { + string server; + try + { + // Compared as values, not text: the library writes a discrete range closed + // ([1,4] for PostgreSQL's [1,5)) and an infinite date as MaxValue, so the + // server's canonical output goes through the same parser. + server = client((await ScalarAsync(connection, $"SELECT $1::{type}::text", literal))!); + } + catch (PostgresException) + { + server = "refused"; + } + + var mine = client(literal); + if (mine != server) + failures.Add($" {type} {Show(literal)}: library {mine}, PostgreSQL {server}"); + } + } + } + + private static NpgsqlCommand Command(NpgsqlConnection connection, string sql, string literal) + { + var command = new NpgsqlCommand(sql, connection); + command.Parameters.Add(new NpgsqlParameter { Value = literal, NpgsqlDbType = NpgsqlDbType.Text }); + return command; + } + + private static async Task ScalarAsync(NpgsqlConnection connection, string sql, string literal) + { + await using var command = Command(connection, sql, literal); + var value = await command.ExecuteScalarAsync(); + return value is null or DBNull ? default : (T)value; + } + + private static string Show(string literal) + => string.Concat(literal.Select(c => c < 0x20 || c > 0x7E ? $"\\u{(int)c:X4}" : c.ToString())); +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NodaTimeIntegrationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NodaTimeIntegrationTests.cs index 8afdd36..9deae07 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NodaTimeIntegrationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/NodaTimeIntegrationTests.cs @@ -266,6 +266,34 @@ public async Task MultirangeOperations_ServerEqualsInMemory() Assert.AreEqual(set.Union(operand), serverUnion); } + [TestMethod] + public async Task ElementOperand_InAnotherCalendar_IsComparedAsItsIsoDate() + { + ContainerLifecycle.RequireDatabase(); + + // Julian 2026-01-10 is ISO 2026-01-23, inside both stored ranges. A non-ISO element must + // reach the server as its ISO date, as the range bounds do — not as its own calendar's + // year/month/day written as if they were ISO, which is 2026-01-10 and outside. + await Seed(new Reservation + { + Id = 6041, + NodaPeriod = LocalDateRange.CreateFinite(D(2026, 1, 20), D(2026, 1, 25)), + NodaWallClock = LocalDateTimeRange.CreateFinite( + new LocalDateTime(2026, 1, 20, 0, 0), new LocalDateTime(2026, 1, 25, 0, 0)) + }); + + var day = new LocalDate(2026, 1, 10, CalendarSystem.Julian); + var clock = new LocalDateTime(2026, 1, 10, 12, 0, CalendarSystem.Julian); + + await using var context = new IntegrationDbContext(); + var rows = context.Reservations.Where(r => r.Id == 6041); + + Assert.AreEqual(1, await rows.CountAsync(r => r.NodaPeriod.Contains(day)), "LocalDate parameter"); + Assert.AreEqual(1, await rows.CountAsync(r => r.NodaPeriod.Contains(EF.Constant(day))), "LocalDate constant"); + Assert.AreEqual(1, await rows.CountAsync(r => r.NodaWallClock.Contains(clock)), "LocalDateTime parameter"); + Assert.AreEqual(1, await rows.CountAsync(r => r.NodaWallClock.Contains(EF.Constant(clock))), "LocalDateTime constant"); + } + private static async Task Seed(params Reservation[] rows) { await using var context = new IntegrationDbContext(); diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/UnionCanonicalityTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/UnionCanonicalityTests.cs new file mode 100644 index 0000000..e60a3ce --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/UnionCanonicalityTests.cs @@ -0,0 +1,381 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Npgsql; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; + +// NpgsqlParameterBasedSqlProcessorFactory is Npgsql-internal (EF1001): replacing the service with +// the provider's own factory is the realistic way a third-party registration drops the guard. +#pragma warning disable EF1001 + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; + +/// +/// A value set Union computed on the server is array_cat — a concatenation, with +/// duplicates and in operand order. Each test here either executes a composition that is correct +/// for a concatenation and compares the answer with the same expression in memory, or executes one +/// that is not and asserts it is refused — by running the query, never by inspecting its SQL. +/// +/// +/// +/// The data is the probe that found the gaps: {a,c} ∪ {a,b} is {a,c,a,b} on the +/// server and {a,b,c} in memory, and {b,c} ∪ {a,b} is {b,c,a,b} — so the two +/// rows have equal unions in memory and different ones on the server. +/// +/// +/// Up to 9.0.0 the refusal was a LINQ pattern match on ==/Equals inside a list of +/// operators, plus Count over a bare union. Every shape in the refused group below translated +/// and answered wrongly; the refusal now runs on the final SQL tree (ValueSetSqlGuard). +/// +/// +[TestClass] +public sealed class UnionCanonicalityTests +{ + private const int First = 12001; + private const int Second = 12002; + + private static readonly StringSet Operand = StringSet.From("a", "b"); + private static readonly StringSet Target = StringSet.From("a", "b", "c"); + + private static readonly SemaphoreSlim SeedLock = new(1, 1); + private static bool _seeded; + + /// Seeds the two rows once; every test only reads them (or fails to write them). + private static async Task Context() + { + ContainerLifecycle.RequireDatabase(); + + await SeedLock.WaitAsync(); + try + { + if (!_seeded) + { + await using var write = new IntegrationDbContext(); + write.Reservations.AddRange( + new Reservation { Id = First, Tags = StringSet.From("a", "c") }, + new Reservation { Id = Second, Tags = StringSet.From("b", "c") }); + await write.SaveChangesAsync(); + _seeded = true; + } + } + finally + { + SeedLock.Release(); + } + + return new IntegrationDbContext(); + } + + private static IQueryable Rows(IntegrationDbContext context) + => context.Reservations.Where(r => r.Id == First || r.Id == Second); + + private static IQueryable FirstRow(IntegrationDbContext context) + => context.Reservations.Where(r => r.Id == First); + + /// + /// Both rows' unions as a column of a UNION ALL — a derived table EF cannot inline, so + /// whatever composes on U reads a column rather than the array_cat itself. + /// + private static IQueryable Concatenated(IntegrationDbContext context) + => FirstRow(context).Select(r => new UnionRow { Id = r.Id, U = r.Tags.Union(Operand) }) + .Concat(context.Reservations.Where(r => r.Id == Second) + .Select(r => new UnionRow { Id = r.Id, U = r.Tags.Union(Operand) })); + + /// Member-initialized rather than positional: a constructor call is a client projection. + private sealed class UnionRow + { + public int Id { get; init; } + public StringSet U { get; init; } = StringSet.Empty; + } + + private static async Task AssertRefused(Func query, string mentioning) + { + var refusal = await Assert.ThrowsExactlyAsync(query); + StringAssert.Contains(refusal.Message, mentioning); + } + + [TestMethod] + public void TheData_IsWhatTheTestsAssume() + { + Assert.AreEqual(Target, StringSet.From("a", "c").Union(Operand)); + Assert.AreEqual(Target, StringSet.From("b", "c").Union(Operand)); + } + + // -- Refused: each translated, and answered differently from memory, before 9.0.0 -- + + [TestMethod] + public async Task EqualityInAProjection_IsRefused() + { + await using var context = await Context(); + + // Was: SELECT array_cat(r."Tags", @x) = @target → false, where memory says true. + await AssertRefused(() => FirstRow(context).Select(r => r.Tags.Union(Operand) == Target).SingleAsync(), "equality"); + await AssertRefused(() => FirstRow(context).Select(r => new { r.Id, Same = r.Tags.Union(Operand).Equals(Target) }).SingleAsync(), "equality"); + } + + [TestMethod] + public async Task EqualityOnAProjectedUnion_IsRefused() + { + await using var context = await Context(); + + // Was: 0 rows, where memory finds 1. + await AssertRefused( + () => FirstRow(context).Select(r => new { r.Id, U = r.Tags.Union(Operand) }).Where(a => a.U == Target).CountAsync(), + "equality"); + + // The same over a UNION ALL, which EF cannot inline: the predicate reads a column of the + // derived table, so the refusal has to follow the column to the array_cat that fills it. + await AssertRefused( + () => Concatenated(context).Where(a => a.U != Target).CountAsync(), + "equality"); + } + + [TestMethod] + public async Task EqualityOnAConditionalOrCoalescedUnion_IsRefused() + { + await using var context = await Context(); + + // Was: CASE … = @target and COALESCE(…) = @target, 0 rows each. + await AssertRefused( + () => FirstRow(context).CountAsync(r => (r.Id > 0 ? r.Tags.Union(Operand) : r.Tags) == Target), "equality"); + await AssertRefused( + () => FirstRow(context).CountAsync(r => (r.OptionalTags ?? r.Tags.Union(Operand)) == Target), "equality"); + } + + [TestMethod] + public async Task EqualityOnARemovedUnion_IsRefused() + { + await using var context = await Context(); + + await AssertRefused(() => FirstRow(context).CountAsync(r => r.Tags.Union(Operand).Remove("z") == Target), "equality"); + } + + [TestMethod] + public async Task MembershipAmongUnions_IsRefused() + { + await using var context = await Context(); + + // Membership is equality against each candidate: the first row's union is among the second + // row's in memory, and {a,c,a,b} is not among {b,c,a,b} on the server. + Assert.AreEqual(StringSet.From("b", "c").Union(Operand), StringSet.From("a", "c").Union(Operand)); + await AssertRefused( + () => FirstRow(context).CountAsync(r => context.Reservations.Where(o => o.Id == Second) + .Select(o => o.Tags.Union(Operand)) + .Contains(r.Tags.Union(Operand))), + "equality"); + } + + [TestMethod] + public async Task GroupingByAUnion_IsRefused() + { + await using var context = await Context(); + + // Was: 2 groups, where memory forms 1 group of 2. + await AssertRefused( + () => Rows(context).GroupBy(r => r.Tags.Union(Operand)).Select(g => g.Count()).ToListAsync(), "GROUP BY"); + } + + [TestMethod] + public async Task DistinctOverAUnion_IsRefused() + { + await using var context = await Context(); + + // Was: 2, where memory counts 1. + await AssertRefused(() => Rows(context).Select(r => r.Tags.Union(Operand)).Distinct().CountAsync(), "DISTINCT"); + await AssertRefused(() => Rows(context).Select(r => r.Tags.Union(Operand)).Distinct().ToListAsync(), "DISTINCT"); + } + + [TestMethod] + public async Task OrderingByAUnion_IsRefused() + { + await using var context = await Context(); + + await AssertRefused(() => Rows(context).OrderBy(r => r.Tags.Union(Operand)).Select(r => r.Id).ToListAsync(), "ORDER BY"); + } + + [TestMethod] + public async Task ADeduplicatingSetOperationOverAUnion_IsRefused() + { + await using var context = await Context(); + + await AssertRefused( + () => FirstRow(context).Select(r => r.Tags.Union(Operand)) + .Union(context.Reservations.Where(r => r.Id == Second).Select(r => r.Tags.Union(Operand))) + .ToListAsync(), + "set operation"); + } + + [TestMethod] + public async Task CountOverACoalescedUnion_IsRefused() + { + await using var context = await Context(); + + // Was: cardinality(COALESCE(…, array_cat(…))) = 3 → 0 rows. The translator now declines it, + // so EF reports the predicate untranslatable. + await Assert.ThrowsExactlyAsync( + () => FirstRow(context).CountAsync(r => (r.OptionalTags ?? r.Tags.Union(Operand)).Count == 3)); + + // Through a column of a derived table the translator cannot see the union; the SQL check can. + await AssertRefused(() => Concatenated(context).CountAsync(a => a.U.Count == 3), "'Count'"); + } + + [TestMethod] + public async Task ExecuteUpdateToAUnion_IsRefused_AndTheRowIsUnchanged() + { + // A row of its own, so that a regression corrupts nothing the other tests read. + const int updated = 12003; + await using (var write = await Context()) + { + write.Reservations.Add(new Reservation { Id = updated, Tags = StringSet.From("a", "c") }); + await write.SaveChangesAsync(); + } + + // Was: the row stored {a,c,a,b}, after which server-side equality and Count on it were wrong. + await using (var context = new IntegrationDbContext()) + { + await AssertRefused( + () => context.Reservations.Where(r => r.Id == updated) + .ExecuteUpdateAsync(s => s.SetProperty(r => r.Tags, r => r.Tags.Union(Operand))), + "ExecuteUpdate"); + } + + var original = StringSet.From("a", "c"); + await using var read = new IntegrationDbContext(); + Assert.AreEqual(1, await read.Reservations.CountAsync(r => r.Id == updated && r.Tags == original && r.Tags.Count == 2)); + } + + // -- Still translated, and agreeing with memory -- + + [TestMethod] + public async Task OrderAndMultiplicityInsensitiveOperators_ComposeOnAUnion() + { + await using var context = await Context(); + var probe = StringSet.From("a", "b", "c", "d"); + + // Each answer is checked against the same expression evaluated in memory over both rows. + var rows = await Rows(context).Select(r => r.Tags).ToListAsync(); + int Expected(Func predicate) => rows.Count(predicate); + + Assert.AreEqual(Expected(t => t.Union(Operand).Contains("c")), await Rows(context).CountAsync(r => r.Tags.Union(Operand).Contains("c"))); + Assert.AreEqual(Expected(t => t.Union(Operand).IsSubsetOf(Target)), await Rows(context).CountAsync(r => r.Tags.Union(Operand).IsSubsetOf(Target))); + Assert.AreEqual(Expected(t => t.Union(Operand).IsSupersetOf(Target)), await Rows(context).CountAsync(r => r.Tags.Union(Operand).IsSupersetOf(Target))); + Assert.AreEqual(Expected(t => t.Union(Operand).Overlaps(Target)), await Rows(context).CountAsync(r => r.Tags.Union(Operand).Overlaps(Target))); + Assert.AreEqual(Expected(t => t.Union(Operand).IsProperSubsetOf(probe)), await Rows(context).CountAsync(r => r.Tags.Union(Operand).IsProperSubsetOf(probe))); + Assert.AreEqual(Expected(t => t.Union(Operand).IsProperSubsetOf(Target)), await Rows(context).CountAsync(r => r.Tags.Union(Operand).IsProperSubsetOf(Target))); + Assert.AreEqual(Expected(t => t.Union(Operand).IsProperSupersetOf(Operand)), await Rows(context).CountAsync(r => r.Tags.Union(Operand).IsProperSupersetOf(Operand))); + Assert.AreEqual(Expected(t => Target.IsSubsetOf(t.Union(Operand))), await Rows(context).CountAsync(r => Target.IsSubsetOf(r.Tags.Union(Operand)))); + Assert.AreEqual(Expected(t => t.Union(Operand).Remove("c").IsSupersetOf(Operand)), + await Rows(context).CountAsync(r => r.Tags.Union(Operand).Remove("c").IsSupersetOf(Operand))); + Assert.AreEqual(Expected(t => t.Union(Operand).IsEmpty), await Rows(context).CountAsync(r => r.Tags.Union(Operand).IsEmpty)); + + // Set equality spelled as mutual containment — the remedy the equality refusal names. + Assert.AreEqual(2, await Rows(context).CountAsync(r => r.Tags.Union(Operand).IsSubsetOf(Target) && r.Tags.Union(Operand).IsSupersetOf(Target))); + Assert.AreEqual(2, Expected(t => t.Union(Operand) == Target)); + } + + [TestMethod] + public async Task AProjectedUnion_IsMaterializedCanonical() + { + await using var context = await Context(); + + // Reading through the set's From canonicalizes, so the concatenation never reaches the caller. + var bare = await FirstRow(context).Select(r => r.Tags.Union(Operand)).SingleAsync(); + Assert.AreEqual(Target, bare); + Assert.AreEqual(3, bare.Count); + + var anonymous = await FirstRow(context).Select(r => new { r.Id, U = r.Tags.Union(Operand) }).SingleAsync(); + Assert.AreEqual(Target, anonymous.U); + + var dto = await FirstRow(context).Select(r => new UnionDto(r.Id, r.Tags.Union(Operand).Remove("z"))).SingleAsync(); + Assert.AreEqual(Target, dto.Union); + + var conditional = await FirstRow(context).Select(r => r.Id > 0 ? r.Tags.Union(Operand) : r.Tags).SingleAsync(); + Assert.AreEqual(Target, conditional); + + // A derived table's column, read by the outer SELECT — and its insensitive compositions. + var throughDerivedTable = await Concatenated(context).OrderBy(a => a.Id).Select(a => a.U).ToListAsync(); + CollectionAssert.AreEqual(new[] { Target, Target }, throughDerivedTable); + Assert.AreEqual(2, await Concatenated(context).CountAsync(a => a.U.IsSupersetOf(Target) && !a.U.IsEmpty)); + + // A nested collection: its columns are part of the outermost projection too. + var nested = await FirstRow(context) + .Select(r => new + { + r.Id, + Unions = context.Reservations.Where(o => o.Id == First || o.Id == Second) + .OrderBy(o => o.Id) + .Select(o => o.Tags.Union(Operand)) + .ToList() + }) + .SingleAsync(); + CollectionAssert.AreEqual(new[] { Target, Target }, nested.Unions); + + // UNION ALL keeps rows rather than comparing them. + var concatenated = await FirstRow(context).Select(r => r.Tags.Union(Operand)) + .Concat(context.Reservations.Where(r => r.Id == Second).Select(r => r.Tags.Union(Operand))) + .ToListAsync(); + CollectionAssert.AreEqual(new[] { Target, Target }, concatenated); + + // Count in a projection falls back to the client, against the materialized set. + Assert.AreEqual(3, await FirstRow(context).Select(r => r.Tags.Union(Operand).Count).SingleAsync()); + Assert.AreEqual(3, await FirstRow(context).Select(r => (r.OptionalTags ?? r.Tags.Union(Operand)).Count).SingleAsync()); + } + + // -- Fail-closed: the guard replaced away -- + + /// + /// A later ReplaceService of the processor factory drops the decorator the guard runs from. + /// Before the final 9.0.0 review the refused shapes then ran silently and answered as in 8.x — here, + /// no rows where memory finds both. Every server-side Union now fails instead, harmless + /// compositions included: only the guard turns the translator's placeholder into array_cat. + /// + [TestMethod] + public async Task WithTheProcessorFactoryReplaced_AUnionFailsInsteadOfAnsweringWrongly() + { + await (await Context()).DisposeAsync(); + await using var context = new ReplacedProcessorContext(); + Assert.IsInstanceOfType(context.GetService()); + + var inMemory = new[] { StringSet.From("a", "c"), StringSet.From("b", "c") }.Count(tags => tags.Union(Operand) == Target); + Assert.AreEqual(2, inMemory); + + foreach (var query in new Func[] + { + () => context.Reservations.Where(r => (r.Id == First || r.Id == Second) && r.Tags.Union(Operand) == Target) + .Select(r => r.Id).ToListAsync(), + () => context.Reservations.Where(r => r.Id == First && r.Tags.Union(Operand).Contains("a")) + .Select(r => r.Id).ToListAsync() + }) + { + var refusal = await Assert.ThrowsExactlyAsync(query); + Assert.AreEqual(PostgresErrorCodes.UndefinedFunction, refusal.SqlState); + StringAssert.Contains(refusal.MessageText, "valueranges_union_without_sql_guard"); + } + } + + private sealed record UnionDto(int Id, StringSet Union); + + /// The integration model's configuration, with the parameter-based processor factory replaced. + private sealed class ReplacedProcessorContext : DbContext + { + public DbSet Reservations => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql( + ContainerLifecycle.ConnectionString!, + npgsql => npgsql.UseValueRangesNodaTime() + .ConfigureDataSource(dataSource => dataSource.EnableUnmappedTypes())) + .ReplaceService(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().Property(r => r.Id).ValueGeneratedNever(); + } + + /// What a library replacing the service registers: the provider's own processor, no guard. + private sealed class PlainProcessorFactory(RelationalParameterBasedSqlProcessorDependencies dependencies) + : IRelationalParameterBasedSqlProcessorFactory + { + public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) + => new NpgsqlParameterBasedSqlProcessorFactory(dependencies).Create(parameters); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/WrapperArityIntegrationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/WrapperArityIntegrationTests.cs index 51b8c64..203ec45 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/WrapperArityIntegrationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/WrapperArityIntegrationTests.cs @@ -1,5 +1,6 @@ using CodoMetis.ValueRanges.Conventions.Tests; using Microsoft.EntityFrameworkCore; +using NodaTime; using Npgsql; namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; @@ -346,6 +347,46 @@ await context.Database.ExecuteSqlRawAsync( + $"driver's, got: {exception}"); } } + + /// + /// A wrapper over a YearMonth in a calendar other than ISO, as a query parameter. The + /// bridge formats it with the ISO pattern, which spells the Julian fields, so Julian January + /// 2024 — which begins on ISO 2024-01-14 — bound as ISO 2024-01-01 and matched a row + /// holding ISO January, silently. It is refused now, as the in-memory set refuses it. + /// + [TestMethod] + public async Task WrapperArity_NonIsoParameter_IsRefused() + { + ContainerLifecycle.RequireDatabase(); + + await Seed(new Reservation + { + Id = 8108, WrappedMonths = YearMonthSet.From(new BillingMonth(new YearMonth(2024, 1))) + }); + + var julian = new BillingMonth(new YearMonth(2024, 1, CalendarSystem.Julian)); + + await using var context = new IntegrationDbContext(); + + int? matched = null; + try + { + matched = await context.Reservations + .Where(r => r.Id == 8108 && r.WrappedMonths.Contains(julian)) + .CountAsync(); + } + catch (Exception exception) + { + var refused = false; + for (var current = exception; current is not null; current = current.InnerException) + refused |= current is ArgumentException && current.Message.Contains("ISO", StringComparison.Ordinal); + + Assert.IsTrue(refused, $"Expected the non-ISO parameter to be refused, got: {exception}"); + return; + } + + Assert.Fail($"A Julian parameter was bound as another month and matched {matched} row(s)."); + } } /// diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaWrapperSetTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaWrapperSetTranslationTests.cs index 3090819..d8f7cc4 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaWrapperSetTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaWrapperSetTranslationTests.cs @@ -211,4 +211,47 @@ public void WrapperSets_CapturedElement_BindsAsAParameter() StringAssert.Contains(clause, "r.\"WrappedHolidays\" @> ARRAY[@"); StringAssert.Contains(clause, "]::date[]"); } + + // ------------------------------------------------------------------------- + // A bare element backed by a calendar other than ISO + // ------------------------------------------------------------------------- + + /// + /// The bridge formats the element with the family's ISO pattern and parses the text as the + /// store primitive, and the pattern formats a value in its own calendar's fields: a wrapper + /// over a Julian 2024-01-01 — ISO 2024-01-14 — rendered ARRAY['2024-01-01']::date[] and + /// queried for a date thirteen days off, silently. The bridge now refuses an element whose + /// primitive does not read back as that element, as the in-memory set refuses it. + /// + [TestMethod] + public void WrapperSets_NonIsoElementOperand_IsRefused() + { + var julianDay = new CalendarDay(new LocalDate(2024, 1, 1, CalendarSystem.Julian)); + var julianMark = new WallClockStamp(new LocalDateTime(2024, 1, 1, 8, 0, CalendarSystem.Julian)); + var julianMonth = new BillingMonth(new YearMonth(2024, 1, CalendarSystem.Julian)); + + AssertRefused(db => db.Reservations.Where(r => r.WrappedHolidays.Contains(EF.Constant(julianDay)))); + AssertRefused(db => db.Reservations.Where(r => r.WrappedMarks.Contains(EF.Constant(julianMark)))); + AssertRefused(db => db.Reservations.Where(r => r.WrappedMonths.Contains(EF.Constant(julianMonth)))); + } + + private static void AssertRefused(Func> query) + { + string sql; + try + { + sql = Where(query); + } + catch (Exception ex) when (Causes(ex).OfType().Any(a => a.Message.Contains("ISO", StringComparison.Ordinal))) + { + return; + } + + Assert.Fail($"A non-ISO element reached SQL as another value: {sql}"); + } + + private static IEnumerable Causes(Exception? ex) + { + for (; ex is not null; ex = ex.InnerException) yield return ex; + } } diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ExecuteUpdateGuardTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ExecuteUpdateGuardTests.cs index 37edbac..1252f9c 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ExecuteUpdateGuardTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/ExecuteUpdateGuardTests.cs @@ -1,5 +1,3 @@ -using System.Reflection; -using System.Runtime.CompilerServices; using Microsoft.EntityFrameworkCore; namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests; @@ -41,8 +39,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } /// -/// 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 +/// ExecuteUpdate setting a value set inside a JSON column is refused while the command is built — +/// no database needed, the refusal comes before a connection. What stays allowed is exercised against a /// live server in ExecuteUpdateJsonTests (integration). /// /// @@ -75,23 +73,4 @@ public void AValueSetInANestedComplexTypeOfAJsonColumn_IsRefused() 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/SetQueryTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SetQueryTranslationTests.cs index ba074d4..1490c6f 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SetQueryTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SetQueryTranslationTests.cs @@ -234,17 +234,21 @@ public void Union_Equality_FailsTranslation() } [TestMethod] - public void Union_Equality_InAProjection_StillWorks() + public void Union_Equality_InAProjection_IsRefused_WhileCountFallsBackToTheClient() { - // The refusal covers the contexts EF must translate in full. A projection is not one: - // EF falls back to client evaluation there and computes the comparison against the - // materialized set, which is correct. Refusing it too would be stricter than the defect — - // and would put equality out of step with Count, which degrades to client evaluation in a - // projection for exactly the same reason. + // EF does not fall back to client evaluation for a comparison it can translate: in a + // projection it emits SELECT array_cat(…) = @p, which answered false against live + // PostgreSQL for {a,c} ∪ {a,b} = {a,b,c}. So equality is refused in a projection too + // (UnionCanonicalityTests runs both against a server). Count differs: its translator + // declines a union, which is what hands a projection back to the client. using var context = new TestDbContext(); - _ = context.Bookings.Select(b => b.Tags.Union(Wanted) == Wanted).ToQueryString(); - _ = context.Bookings.Select(b => b.Tags.Union(Wanted).Count).ToQueryString(); + Assert.ThrowsExactly( + () => context.Bookings.Select(b => b.Tags.Union(Wanted) == Wanted).ToQueryString()); + + var count = context.Bookings.Select(b => b.Tags.Union(Wanted).Count).ToQueryString(); + Assert.DoesNotContain("cardinality", count); + StringAssert.Contains(count, "array_cat"); } [TestMethod] diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlGuardRegistrationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlGuardRegistrationTests.cs new file mode 100644 index 0000000..a62d181 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlGuardRegistrationTests.cs @@ -0,0 +1,146 @@ +using CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Query; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.DependencyInjection; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; + +// NpgsqlParameterBasedSqlProcessorFactory is Npgsql-internal (EF1001): replacing the service with +// the provider's own factory is the realistic way a third-party registration drops the guard. +#pragma warning disable EF1001 + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests; + +/// +/// The final-SQL value set guard runs from a decorator around the provider's parameter-based SQL +/// processor factory. EF Core has no plugin seam at that stage, so a registration that silently +/// failed to decorate would switch every refusal off without any other test noticing the cause. +/// +[TestClass] +public sealed class SqlGuardRegistrationTests +{ + private static readonly StringSet Wanted = StringSet.From("a", "b"); + + [TestMethod] + public void UseValueRanges_DecoratesTheProvidersProcessorFactory() + { + using var context = new TestDbContext(); + + Assert.IsInstanceOfType( + context.GetService()); + } + + [TestMethod] + public void AnExternalProvider_IsDecoratedOnce_WhicheverOrderTheProviderIsAdded() + { + var services = new ServiceCollection() + .AddEntityFrameworkNpgsqlValueRanges() + .AddEntityFrameworkNpgsql() + .AddEntityFrameworkNpgsqlValueRanges(); + + Assert.AreEqual(1, services.Count(d => d.ServiceType == typeof(IRelationalParameterBasedSqlProcessorFactory))); + + using var provider = services.BuildServiceProvider(); + var options = new DbContextOptionsBuilder() + .UseInternalServiceProvider(provider) + .UseNpgsql("Host=localhost;Database=valueranges_tests;Username=postgres", npgsql => npgsql.UseValueRanges()) + .Options; + + using var context = new ExternalProviderContext(options); + Assert.IsInstanceOfType( + context.GetService()); + + // And the guard actually runs through it. + Assert.ThrowsExactly( + () => context.Bookings.Where(b => b.Tags.Union(Wanted) == Wanted).ToQueryString()); + } + + /// + /// A later ReplaceService (or another extension replacing the same service) drops the + /// decorator, and with it the guard. Up to the final 9.0.0 review that was silent: the + /// Union translated to array_cat and every refused query answered as in 8.x. The + /// translator now emits a placeholder that only the guard renames, so without the guard the SQL + /// names a function PostgreSQL does not have (executed in UnionCanonicalityTests). + /// + [TestMethod] + public void AReplacedProcessorFactory_SendsNoArrayCat() + { + using var context = new ReplacedFactoryContext(); + Assert.IsInstanceOfType(context.GetService()); + + var refused = context.Bookings.Where(b => b.Tags.Union(Wanted) == Wanted).Select(b => b.Id).ToQueryString(); + var harmless = context.Bookings.Where(b => b.Tags.Union(Wanted).Contains("a")).Select(b => b.Id).ToQueryString(); + + foreach (var sql in new[] { refused, harmless }) + { + Assert.IsFalse(sql.Contains("array_cat(", StringComparison.Ordinal), sql); + StringAssert.Contains(sql, UnguardedUnion + "("); + } + } + + /// + /// The placeholder is renamed by the guard itself, not by detecting the decorator, so another + /// library decorating around it — rather than replacing it — leaves the guard running and + /// the SQL unchanged. + /// + [TestMethod] + public void ADecoratorAroundTheGuard_KeepsItRunning() + { + var services = new ServiceCollection().AddEntityFrameworkNpgsql().AddEntityFrameworkNpgsqlValueRanges(); + var guarded = services.Single(d => d.ServiceType == typeof(IRelationalParameterBasedSqlProcessorFactory)); + services.Remove(guarded); + services.Add(new ServiceDescriptor( + typeof(IRelationalParameterBasedSqlProcessorFactory), + provider => new PassThroughProcessorFactory((IRelationalParameterBasedSqlProcessorFactory) guarded.ImplementationFactory!(provider)), + guarded.Lifetime)); + + using var provider = services.BuildServiceProvider(); + var options = new DbContextOptionsBuilder() + .UseInternalServiceProvider(provider) + .UseNpgsql("Host=localhost;Database=valueranges_tests;Username=postgres", npgsql => npgsql.UseValueRanges()) + .Options; + + using var context = new ExternalProviderContext(options); + Assert.IsInstanceOfType(context.GetService()); + + var sql = context.Bookings.Where(b => b.Tags.Union(Wanted).Contains("a")).Select(b => b.Id).ToQueryString(); + StringAssert.Contains(sql, "array_cat("); + Assert.IsFalse(sql.Contains(UnguardedUnion, StringComparison.Ordinal), sql); + + Assert.ThrowsExactly( + () => context.Bookings.Where(b => b.Tags.Union(Wanted) == Wanted).ToQueryString()); + } + + /// The name PostgreSQL reports when the guard did not run — part of the documented contract. + private const string UnguardedUnion = "valueranges_union_without_sql_guard"; + + private sealed class ExternalProviderContext(DbContextOptions options) : DbContext(options) + { + public DbSet Bookings => Set(); + } + + private sealed class ReplacedFactoryContext : DbContext + { + public DbSet Bookings => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql("Host=localhost;Database=valueranges_tests;Username=postgres", npgsql => npgsql.UseValueRanges()) + .ReplaceService(); + } + + /// What a library replacing the service registers: the provider's own processor, no guard. + private sealed class PlainProcessorFactory(RelationalParameterBasedSqlProcessorDependencies dependencies) + : IRelationalParameterBasedSqlProcessorFactory + { + public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) + => new NpgsqlParameterBasedSqlProcessorFactory(dependencies).Create(parameters); + } + + /// What a library decorating the service registers: the guarded factory, wrapped. + private sealed class PassThroughProcessorFactory(IRelationalParameterBasedSqlProcessorFactory inner) + : IRelationalParameterBasedSqlProcessorFactory + { + public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) + => inner.Create(parameters); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlLiteralTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlLiteralTests.cs index 42cae03..7ec3d1e 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlLiteralTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/SqlLiteralTests.cs @@ -76,6 +76,24 @@ public void WrapperTimestampSet_NormalizesKindToUnspecified() => LiteralOf(DateTimeSet.From( TestStamp.Parse("2024-06-15T10:30:00.1234567Z", CultureInfo.InvariantCulture)))); + /// + /// A local element keeps its wall clock, even one this machine's zone skips: the bridge drops + /// the offset its "O" text carries instead of converting it back through the zone, which + /// would move a skipped wall clock by the length of the gap. Inconclusive in a zone without one. + /// + [TestMethod] + public void WrapperTimestampSet_LocalElement_KeepsAWallClockTheZoneSkips() + { + var skipped = Enumerable.Range(0, 366 * 24) + .Select(hour => new DateTime(2026, 1, 1, 0, 30, 0, DateTimeKind.Local).AddHours(hour)) + .FirstOrDefault(TimeZoneInfo.Local.IsInvalidTime); + if (skipped == default) Assert.Inconclusive($"{TimeZoneInfo.Local.Id} skips no wall clock in 2026."); + + Assert.AreEqual( + $"ARRAY['{skipped:yyyy-MM-dd'T'HH:mm:ss}.0000000']::timestamp without time zone[]", + LiteralOf(DateTimeSet.From(TestStamp.From(skipped)))); + } + [TestMethod] public void WrapperDecimalSet_KeepsScale() => Assert.AreEqual( @@ -84,6 +102,29 @@ public void WrapperDecimalSet_KeepsScale() => TestRate.Parse("12.50", CultureInfo.InvariantCulture), TestRate.Parse("13", CultureInfo.InvariantCulture)))); + /// + /// The bridge reads an element's text with the family's grammar. An element formatting in the + /// current culture writes 1,5 under de-DE; NumberStyles.Number read that + /// as fifteen and bound it, silently. Refused instead, with the same message as any other + /// element that does not format as its primitive. + /// + [TestMethod] + public void WrapperDecimalSet_ElementLeakingTheCulture_IsRejected() + { + var leaky = DecimalSet.From(new TestCultureLeakingRate(1.5m)); + var original = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); + try + { + var error = Assert.ThrowsExactly(() => LiteralOf(leaky)); + StringAssert.Contains(error.Message, nameof(TestCultureLeakingRate)); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + /// /// The other half of the temporal contract: an element that swallows the format argument /// answers with a form the bridge refuses, rather than one it would quietly truncate. The diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/TestDbContext.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/TestDbContext.cs index 104adbf..dafa78b 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/TestDbContext.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/TestDbContext.cs @@ -137,6 +137,9 @@ public static bool TryParse(string? s, IFormatProvider? provider, out TestKey re private TestStamp(DateTime value) => _value = value; + /// Wraps a value as is, the way a generator's From does — any kind, any wall clock. + public static TestStamp From(DateTime value) => new(value); + public static TestStamp Parse(string s, IFormatProvider? provider) => new(DateTime.Parse(s, provider ?? CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); @@ -397,3 +400,33 @@ public string ToString(string? format, IFormatProvider? formatProvider) public override string ToString() => ToString(null, CultureInfo.InvariantCulture); } + +/// +/// A deliberately non-conforming decimal element: it ignores the provider and formats in the +/// current culture, so under de-DE 1.5 formats as 1,5. A boundary that +/// reads that text with NumberStyles.Number binds fifteen; the family grammar refuses it. +/// +public readonly record struct TestCultureLeakingRate : IFormattable, IParsable, IComparable +{ + private readonly decimal _value; + + public TestCultureLeakingRate(decimal value) => _value = value; + + public static TestCultureLeakingRate Parse(string s, IFormatProvider? provider) + => new(decimal.Parse(s, NumberStyles.Number, CultureInfo.InvariantCulture)); + + public static bool TryParse(string? s, IFormatProvider? provider, out TestCultureLeakingRate result) + { + var ok = decimal.TryParse(s, NumberStyles.Number, CultureInfo.InvariantCulture, out var value); + result = ok ? new TestCultureLeakingRate(value) : default; + return ok; + } + + public int CompareTo(TestCultureLeakingRate other) => _value.CompareTo(other._value); + + // The defect: the provider is ignored in favour of the current culture. + public string ToString(string? format, IFormatProvider? formatProvider) + => _value.ToString(format, CultureInfo.CurrentCulture); + + public override string ToString() => ToString(null, CultureInfo.InvariantCulture); +} diff --git a/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeConverterRegistrationTests.cs b/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeConverterRegistrationTests.cs index 638f5aa..6cb5639 100644 --- a/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeConverterRegistrationTests.cs +++ b/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeConverterRegistrationTests.cs @@ -116,6 +116,103 @@ public void ComposesWithConfigureForNodaTime_InEitherOrder() } } + /// + /// The registration-order claim, measured where it can fail: before 9.0.0 the element + /// converters claimed a type by being first in Converters, so with + /// AddNodaTimeRangeConverters() called first the library's converter shadowed + /// NodaTime's for all five types. Now the library's converter defers to any other converter + /// on the same options, wherever it sits. + /// + [TestMethod] + public void ConfigureForNodaTime_StaysAuthoritative_InEitherOrder() + { + JsonSerializerOptions[] variants = + [ + new JsonSerializerOptions().AddNodaTimeRangeConverters().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb), + new JsonSerializerOptions().ConfigureForNodaTime(DateTimeZoneProviders.Tzdb).AddNodaTimeRangeConverters() + ]; + + foreach (var options in variants) + { + Assert.AreSame(NodaConverters.LocalDateConverter, options.GetConverter(typeof(LocalDate))); + Assert.AreSame(NodaConverters.LocalDateTimeConverter, options.GetConverter(typeof(LocalDateTime))); + Assert.AreSame(NodaConverters.LocalTimeConverter, options.GetConverter(typeof(LocalTime))); + Assert.AreSame(NodaConverters.InstantConverter, options.GetConverter(typeof(Instant))); + Assert.AreSame(NodaConverters.YearMonthConverter, options.GetConverter(typeof(YearMonth))); + + // NodaTime's own refusal, so the outcome no longer depends on the call order. + Assert.ThrowsExactly( + () => JsonSerializer.Serialize(new LocalDate(2024, 1, 1, CalendarSystem.Julian), options)); + } + } + + /// + /// Not only NodaTime's converters: the element converters are a fallback, so any converter + /// registered for the element type wins over them, before or after. + /// + [TestMethod] + public void ElementConverterRegisteredAfterwards_Wins() + { + var options = new JsonSerializerOptions().AddNodaTimeRangeConverters(); + options.Converters.Add(new ShoutyLocalDateConverter()); + + Assert.AreEqual("\"DATE:2024-01-01\"", JsonSerializer.Serialize(new LocalDate(2024, 1, 1), options)); + Assert.AreEqual("[\"DATE:2024-01-01\"]", + JsonSerializer.Serialize(LocalDateSet.From(new LocalDate(2024, 1, 1)), options)); + Assert.AreEqual(new LocalDate(2024, 1, 1), JsonSerializer.Deserialize("\"DATE:2024-01-01\"", options)); + } + + // ----------------------------------------------------------------------- + // Bare values in a calendar other than ISO — refused, as NodaTime's converter refuses them + // ----------------------------------------------------------------------- + + /// + /// A Julian LocalDate(2024, 1, 1) is ISO 2024-01-14. Written in its own calendar's + /// fields it was "2024-01-01", which reads back as ISO 2024-01-01 — thirteen days off, + /// with no error on either leg. + /// + [TestMethod] + public void BareNonIsoValues_AreRefusedOnWrite() + { + var options = Standalone(); + + var date = Assert.ThrowsExactly( + () => JsonSerializer.Serialize(new LocalDate(2024, 1, 1, CalendarSystem.Julian), options)); + StringAssert.Contains(date.Message, "Julian"); + StringAssert.Contains(date.Message, "WithCalendar(CalendarSystem.Iso)"); + + Assert.ThrowsExactly( + () => JsonSerializer.Serialize(new LocalDateTime(2024, 1, 1, 8, 0, CalendarSystem.Julian), options)); + Assert.ThrowsExactly( + () => JsonSerializer.Serialize(new YearMonth(2024, 1, CalendarSystem.Julian), options)); + + // Coptic wrote "1740-13-05", which failed only on the way back in; now it fails on the way out. + Assert.ThrowsExactly( + () => JsonSerializer.Serialize(new LocalDate(1740, 13, 5, CalendarSystem.Coptic), options)); + + // Inside a DTO, as a list element and through object alike. + Assert.ThrowsExactly( + () => JsonSerializer.Serialize(new Dto(new LocalDate(2024, 1, 1, CalendarSystem.Julian), LocalDateSet.Empty), options)); + Assert.ThrowsExactly( + () => JsonSerializer.Serialize(new LocalDate(2024, 1, 1, CalendarSystem.Julian), options)); + } + + /// + /// The refusal is the element converter's, not the set's: a set normalizes a non-ISO date at + /// construction (and YearMonthSet rejects one), so what reaches the converter from a + /// set is always ISO — the same date, already in the calendar the wire form speaks. + /// + [TestMethod] + public void SetsOfNonIsoDates_StillSerialize_BecauseConstructionNormalized() + { + var options = Standalone(); + var julian = new LocalDate(2024, 1, 1, CalendarSystem.Julian); + + Assert.AreEqual("[\"2024-01-14\"]", JsonSerializer.Serialize(LocalDateSet.From(julian), options)); + Assert.AreEqual("[\"2024-01-14\"]", JsonSerializer.Serialize(LocalDateSet.From(julian))); + Assert.AreEqual("\"2024-01-14\"", JsonSerializer.Serialize(julian.WithCalendar(CalendarSystem.Iso), options)); + } + [TestMethod] public void ComposesWithAddRangeConverters_WithoutDuplicatingTheFactory() { diff --git a/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeSetElementBridgeTests.cs b/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeSetElementBridgeTests.cs index a9e340d..8282e94 100644 --- a/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeSetElementBridgeTests.cs +++ b/test/CodoMetis.ValueRanges.NodaTime.Tests/NodaTimeSetElementBridgeTests.cs @@ -191,6 +191,108 @@ public void WrapperSets_ElementValidation_RunsOnTheJsonPath() => Assert.ThrowsExactly( () => JsonSerializer.Deserialize>("[\"not-a-date\"]", Options)); + // ----------------------------------------------------------------------- + // Elements backed by a calendar other than ISO — refused, not stored as another value + // ----------------------------------------------------------------------- + + private static readonly CalendarDay JulianDay = + new(new LocalDate(2024, 1, 1, CalendarSystem.Julian)); // ISO 2024-01-14 + + private static readonly WallClockStamp JulianStamp = + new(new LocalDateTime(2024, 1, 1, 8, 0, CalendarSystem.Julian)); + + private static readonly BillingMonth JulianMonth = + new(new YearMonth(2024, 1, CalendarSystem.Julian)); + + /// + /// The pinned ISO pattern formats a value in its own calendar's fields, so a wrapper over a + /// Julian 2024-01-01 — ISO 2024-01-14 — wrote {2024-01-01}, and JSON + /// ["2024-01-01"], both reading back as ISO 2024-01-01: thirteen days off, silently. The + /// closed sets convert such a value to ISO; a wrapper arity cannot construct its element from + /// an ISO primitive except by parsing, so it refuses it instead. + /// + [TestMethod] + public void WrapperSets_RefuseANonIsoElement_AtConstruction() + { + var day = Assert.ThrowsExactly(() => LocalDateSet.From(JulianDay)); + StringAssert.Contains(day.Message, "ISO"); + StringAssert.Contains(day.Message, "2024-01-01"); + + Assert.ThrowsExactly(() => LocalDateSet.From(new List { JulianDay })); + Assert.ThrowsExactly(() => { LocalDateSet _ = [JulianDay]; }); + + Assert.ThrowsExactly(() => LocalDateTimeSet.From(JulianStamp)); + Assert.ThrowsExactly(() => LocalDateTimeSet.From(new List { JulianStamp })); + + Assert.ThrowsExactly(() => YearMonthSet.From(JulianMonth)); + Assert.ThrowsExactly(() => YearMonthSet.From(new List { JulianMonth })); + } + + /// + /// The bare-element probe: Contains, Add and Remove refuse what + /// From refuses. A Julian probe compared against ISO storage would otherwise answer + /// for a date the set holds. + /// + [TestMethod] + public void WrapperSets_RefuseANonIsoProbe() + { + var days = LocalDateSet.From(new CalendarDay(new LocalDate(2024, 1, 14))); + var stamps = LocalDateTimeSet.From(new WallClockStamp(new LocalDateTime(2024, 1, 14, 8, 0))); + var months = YearMonthSet.From(new BillingMonth(new YearMonth(2024, 2))); + + Assert.ThrowsExactly(() => days.Contains(JulianDay)); + Assert.ThrowsExactly(() => days.Add(JulianDay)); + Assert.ThrowsExactly(() => days.Remove(JulianDay)); + Assert.ThrowsExactly(() => stamps.Contains(JulianStamp)); + Assert.ThrowsExactly(() => months.Contains(JulianMonth)); + + // Against a non-empty set NodaTime's own CompareTo happens to throw across calendars; against + // the empty set nothing is compared, so Contains answered false and Add stored the Julian + // element that From refuses. + StringAssert.Contains(Assert.ThrowsExactly(() => days.Contains(JulianDay)).Message, "ISO 8601"); + Assert.ThrowsExactly(() => LocalDateSet.Empty.Contains(JulianDay)); + Assert.ThrowsExactly(() => LocalDateSet.Empty.Add(JulianDay)); + Assert.ThrowsExactly(() => LocalDateTimeSet.Empty.Add(JulianStamp)); + Assert.ThrowsExactly(() => YearMonthSet.Empty.Add(JulianMonth)); + } + + /// + /// A converter registered for the element is consulted on read, and can hand back a non-ISO + /// value; the set refuses it there too, as a . + /// + [TestMethod] + public void WrapperSets_RefuseANonIsoElement_FromARegisteredElementConverter() + { + var options = new JsonSerializerOptions { Converters = { new JulianDayConverter() } }; + + var ex = Assert.ThrowsExactly( + () => JsonSerializer.Deserialize>("[\"2024-01-01\"]", options)); + Assert.IsInstanceOfType(ex.InnerException); + } + + /// ISO elements are untouched, at the extremes too. + [TestMethod] + public void WrapperSets_AcceptIsoElements_AtTheExtremes() + { + var days = LocalDateSet.From( + new CalendarDay(LocalDate.MinIsoValue), new CalendarDay(LocalDate.MaxIsoValue)); + Assert.AreEqual(2, days.Count); + Assert.IsTrue(days.Contains(new CalendarDay(LocalDate.MaxIsoValue))); + + var stamps = LocalDateTimeSet.From( + new WallClockStamp(LocalDateTime.MinIsoValue), new WallClockStamp(LocalDateTime.MaxIsoValue)); + Assert.AreEqual(2, stamps.Count); + } + + private sealed class JulianDayConverter : JsonConverter + { + public override CalendarDay Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new(LocalDatePattern.Iso.Parse(reader.GetString()!).Value.WithCalendar(CalendarSystem.Julian)); + + public override void Write(Utf8JsonWriter writer, CalendarDay value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); + } + // ----------------------------------------------------------------------- // The counterexample // ----------------------------------------------------------------------- diff --git a/test/CodoMetis.ValueRanges.Tests/InterfaceDeclaredJsonTests.cs b/test/CodoMetis.ValueRanges.Tests/InterfaceDeclaredJsonTests.cs new file mode 100644 index 0000000..512e206 --- /dev/null +++ b/test/CodoMetis.ValueRanges.Tests/InterfaceDeclaredJsonTests.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CodoMetis.ValueRanges.Core; +using CodoMetis.ValueRanges.Serialization; + +namespace CodoMetis.ValueRanges.Tests; + +/// +/// A value declared as one of the range or value set interfaces. System.Text.Json +/// resolves the declared type, and an interface carried no converter, so it fell back to a +/// property dump of the interface's members: IRange<int> wrote {} and +/// IValueSet<string> wrote {"Values":["a"]}, silently, and neither read back. +/// Writes now go through the runtime type's own converter; reads are refused with the remedy, +/// since an interface does not say which type to construct. +/// +[TestClass] +public class InterfaceDeclaredJsonTests +{ + private static readonly Int32Range OneTwo = Int32Range.CreateFinite(1, 2); + + [TestMethod] + public void RangeInterfaceProperty_WritesTheLiteral() + { + Assert.AreEqual("""{"Range":"[1,2]"}""", JsonSerializer.Serialize(new RangeHolder(OneTwo))); + Assert.AreEqual("""{"Range":"empty"}""", JsonSerializer.Serialize(new RangeHolder(Int32Range.Empty))); + Assert.AreEqual("""["[1,2]","(,)"]""", + JsonSerializer.Serialize(new List> { OneTwo, Int32Range.Infinite })); + } + + /// Each shape interface is a declared type of its own; an attribute is not inherited. + [TestMethod] + public void ShapeInterfaces_WriteTheLiteral() + { + AssertWritesTheLiteral>((Int32Range.Finite)OneTwo); + AssertWritesTheLiteral>((Int32Range.UnboundedStart)Int32Range.CreateUnboundedStart(5)); + AssertWritesTheLiteral>((Int32Range.UnboundedEnd)Int32Range.CreateUnboundedEnd(5)); + AssertWritesTheLiteral>((Int32Range.EmptyRange)Int32Range.Empty); + AssertWritesTheLiteral>((Int32Range.Infinity)Int32Range.Infinite); + } + + private static void AssertWritesTheLiteral(TInterface value) + => Assert.AreEqual($"\"{value}\"", JsonSerializer.Serialize(value), typeof(TInterface).Name); + + /// The package's factory on the options serves the interfaces the same way. + [TestMethod] + public void UnderAddRangeConverters_TheSameWireForm() + { + var options = new JsonSerializerOptions().AddRangeConverters(); + + Assert.AreEqual("""{"Range":"[1,2]"}""", JsonSerializer.Serialize(new RangeHolder(OneTwo), options)); + Assert.AreEqual("""{"Tags":["a"]}""", JsonSerializer.Serialize(new SetHolder(StringSet.From("a")), options)); + Assert.ThrowsExactly(() => JsonSerializer.Deserialize("""{"Range":"[1,2]"}""", options)); + } + + [TestMethod] + public void SetInterfaceProperty_WritesTheArray() + { + Assert.AreEqual("""{"Tags":["a","b"]}""", JsonSerializer.Serialize(new SetHolder(StringSet.From("b", "a")))); + Assert.AreEqual("[1,2]", JsonSerializer.Serialize>(Int32Set.From(2, 1))); + } + + [TestMethod] + public void InterfaceDeclarations_RefuseToRead_NamingTheRemedy() + { + var range = Assert.ThrowsExactly( + () => JsonSerializer.Deserialize("""{"Range":"[1,2]"}""")); + StringAssert.Contains(range.Message, "IRange"); + StringAssert.Contains(range.Message, "Declare the property as the concrete type"); + + var set = Assert.ThrowsExactly( + () => JsonSerializer.Deserialize("""{"Tags":["a"]}""")); + StringAssert.Contains(set.Message, "IValueSet"); + + Assert.ThrowsExactly(() => JsonSerializer.Deserialize>("\"[1,2]\"")); + } + + /// Null is System.Text.Json's, as for any reference type: written and read as null. + [TestMethod] + public void Null_RoundTrips() + { + Assert.AreEqual("""{"Range":null}""", JsonSerializer.Serialize(new RangeHolder(null))); + Assert.IsNull(JsonSerializer.Deserialize("""{"Range":null}""")!.Range); + Assert.IsNull(JsonSerializer.Deserialize("""{"Tags":null}""")!.Tags); + } + + /// + /// The write follows the runtime type's resolution, so a converter on the options for the + /// range type applies here too, as it does to every other declaration of that type. + /// + [TestMethod] + public void Write_FollowsTheRuntimeTypesConverter() + { + var options = new JsonSerializerOptions { Converters = { new MarkerConverter() } }; + + Assert.AreEqual("""{"Range":"marker"}""", JsonSerializer.Serialize(new RangeHolder(OneTwo), options)); + } + + /// + /// The source-generated path honours the interface attribute too. The runtime type must be in + /// the context — the write asks the options for its contract, and fails loudly naming it if not. + /// + [TestMethod] + public void SourceGeneratedContext_WritesTheLiteral() + { + var ctx = InterfaceJsonContext.Default; + + Assert.AreEqual("""{"Range":"[1,2]"}""", JsonSerializer.Serialize(new RangeHolder(OneTwo), ctx.RangeHolder)); + Assert.AreEqual("""{"Tags":["a","b"]}""", JsonSerializer.Serialize(new SetHolder(StringSet.From("b", "a")), ctx.SetHolder)); + Assert.ThrowsExactly( + () => JsonSerializer.Serialize(new RangeHolder(Int32Range.Empty), ctx.RangeHolder)); + } + + internal sealed record RangeHolder(IRange? Range); + + internal sealed record SetHolder(IValueSet? Tags); + + private sealed class MarkerConverter : JsonConverter + { + public override Int32Range Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, Int32Range value, JsonSerializerOptions options) + => writer.WriteStringValue("marker"); + } +} + +[JsonSerializable(typeof(InterfaceDeclaredJsonTests.RangeHolder))] +[JsonSerializable(typeof(InterfaceDeclaredJsonTests.SetHolder))] +// The runtime types the holders are written with. Int32Range.EmptyRange is deliberately absent. +[JsonSerializable(typeof(Int32Range.Finite))] +[JsonSerializable(typeof(StringSet))] +[JsonSerializable(typeof(string))] +internal sealed partial class InterfaceJsonContext : JsonSerializerContext; diff --git a/test/CodoMetis.ValueRanges.Tests/JsonConverterPrecedenceTests.cs b/test/CodoMetis.ValueRanges.Tests/JsonConverterPrecedenceTests.cs index 40d601c..125acea 100644 --- a/test/CodoMetis.ValueRanges.Tests/JsonConverterPrecedenceTests.cs +++ b/test/CodoMetis.ValueRanges.Tests/JsonConverterPrecedenceTests.cs @@ -91,6 +91,131 @@ public void SourceGeneratedContext_UsesTheLiteral() Assert.AreEqual(set, JsonSerializer.Deserialize("\"{[2026-01-01,2026-06-30]}\"", ctx.RangeSetDateRangeDateOnly)); } + // ----------------------------------------------------------------------- + // A union converter on the options reaches the union's variants + // ----------------------------------------------------------------------- + + private static readonly Int32Range OneTwo = Int32Range.CreateFinite(1, 2); + + private static JsonSerializerOptions WithTaggedConverter() => new() { Converters = { new TaggedInt32RangeConverter() } }; + + /// + /// System.Text.Json resolves a converter by the exact type it is handed, which for a value + /// reached through , an object collection or a variant-typed + /// declaration is the variant — and the variant's own [JsonConverter] answered with the + /// literal. One payload carried two wire forms for the same type. + /// + [TestMethod] + public void UnionConverterOnTheOptions_ReachesEveryDeclaration() + { + var options = WithTaggedConverter(); + var finite = (Int32Range.Finite)OneTwo; + + Assert.AreEqual("\"tag:[1,2]\"", JsonSerializer.Serialize(OneTwo, options), "declared as the union"); + Assert.AreEqual("\"tag:[1,2]\"", JsonSerializer.Serialize(finite, options), "declared as the variant"); + Assert.AreEqual("\"tag:[1,2]\"", JsonSerializer.Serialize(OneTwo, options), "through object"); + Assert.AreEqual("""["tag:[1,2]"]""", JsonSerializer.Serialize(new List { OneTwo }, options), "List"); + Assert.AreEqual("""{"a":"tag:[1,2]"}""", + JsonSerializer.Serialize(new Dictionary { ["a"] = OneTwo }, options), "dictionary value"); + Assert.AreEqual("""{"Period":"tag:[1,2]"}""", JsonSerializer.Serialize(new VariantHolder(finite), options), + "variant-typed property"); + Assert.AreEqual("""{"Period":"tag:[1,2]"}""", JsonSerializer.Serialize(new ObjectHolder(OneTwo), options), + "object-typed property"); + } + + [TestMethod] + public void UnionConverterOnTheOptions_ReadsIntoAVariant_AndNarrows() + { + var options = WithTaggedConverter(); + + Assert.AreEqual(OneTwo, JsonSerializer.Deserialize("\"tag:[1,2]\"", options)); + Assert.AreEqual(new VariantHolder((Int32Range.Finite)OneTwo), + JsonSerializer.Deserialize("""{"Period":"tag:[1,2]"}""", options)); + + // The literal is not the wire form under this converter, so it is not accepted either. + Assert.ThrowsExactly(() => JsonSerializer.Deserialize("\"[1,2]\"", options)); + + // Narrowing stays the variant's, as without a registration: empty is not a Finite. + var narrowed = Assert.ThrowsExactly( + () => JsonSerializer.Deserialize("\"tag:empty\"", options)); + StringAssert.Contains(narrowed.Message, "EmptyRange"); + } + + [TestMethod] + public void UnionConverterOnTheOptions_ReachesVariantTypedDictionaryKeys() + { + var options = WithTaggedConverter(); + var keyed = new Dictionary { [(Int32Range.Finite)OneTwo] = 1 }; + + var json = JsonSerializer.Serialize(keyed, options); + + Assert.AreEqual("""{"tag:[1,2]":1}""", json); + Assert.AreEqual(OneTwo, JsonSerializer.Deserialize>(json, options)!.Keys.Single()); + } + + /// + /// The variant follows the union's resolution exactly, so first match still decides: with the + /// package's factory ahead of the custom converter, the union gets the literal — and so does + /// the variant. + /// + [TestMethod] + public void UnionConverterOnTheOptions_VariantFollowsFirstMatch() + { + var options = new JsonSerializerOptions { Converters = { new RangeJsonConverterFactory(), new TaggedInt32RangeConverter() } }; + + Assert.AreEqual("\"[1,2]\"", JsonSerializer.Serialize(OneTwo, options)); + Assert.AreEqual("\"[1,2]\"", JsonSerializer.Serialize(OneTwo, options)); + } + + /// + /// Already correct, pinned: RangeSet has no variants, so its runtime type is its declared + /// type and a converter on the options reaches it through too. + /// + [TestMethod] + public void RangeSetConverterOnTheOptions_ReachesObjectDeclarations() + { + var options = new JsonSerializerOptions { Converters = { new TaggedRangeSetConverter() } }; + var set = RangeSet.From([OneTwo]); + + Assert.AreEqual("\"set\"", JsonSerializer.Serialize(set, options)); + Assert.AreEqual("\"set\"", JsonSerializer.Serialize(set, options)); + Assert.AreEqual("""["set"]""", JsonSerializer.Serialize(new List { set }, options)); + } + + internal sealed record VariantHolder(Int32Range.Finite Period); + + internal sealed record ObjectHolder(object Period); + + /// A round-trippable custom wire form: the literal behind a prefix. + private sealed class TaggedInt32RangeConverter : JsonConverter + { + public override Int32Range Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Parse(reader.GetString()!); + + public override void Write(Utf8JsonWriter writer, Int32Range value, JsonSerializerOptions options) + => writer.WriteStringValue($"tag:{value}"); + + public override Int32Range ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Parse(reader.GetString()!); + + public override void WriteAsPropertyName(Utf8JsonWriter writer, Int32Range value, JsonSerializerOptions options) + => writer.WritePropertyName($"tag:{value}"); + + private static Int32Range Parse(string s) + => s.StartsWith("tag:", StringComparison.Ordinal) + ? Int32Range.Parse(s["tag:".Length..], null) + : throw new JsonException($"Expected a tagged literal, got '{s}'."); + } + + private sealed class TaggedRangeSetConverter : JsonConverter> + { + public override RangeSet Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, RangeSet value, JsonSerializerOptions options) + => writer.WriteStringValue("set"); + } + internal sealed record Booking(DateRange Period, StringSet Tags); internal sealed record Annotated([property: JsonConverter(typeof(MarkerConverter))] DateRange Period); diff --git a/test/CodoMetis.ValueRanges.Tests/NamedJsonConverterTests.cs b/test/CodoMetis.ValueRanges.Tests/NamedJsonConverterTests.cs index 0750d56..b08400e 100644 --- a/test/CodoMetis.ValueRanges.Tests/NamedJsonConverterTests.cs +++ b/test/CodoMetis.ValueRanges.Tests/NamedJsonConverterTests.cs @@ -15,11 +15,16 @@ namespace CodoMetis.ValueRanges.Tests; [TestClass] public class NamedJsonConverterTests { - /// Every exported converter whose name ends in JsonConverter. + /// + /// Every exported converter whose name ends in JsonConverter, except + /// : it is bound to no family — it serves the + /// interfaces every family shares, through the runtime type — and has its own tests. + /// private static IEnumerable DeclaredConverters => typeof(RangeJsonConverterFactory).Assembly .GetExportedTypes() .Where(t => t is { IsAbstract: false, IsGenericTypeDefinition: false } + && t != typeof(InterfaceDeclaredJsonConverter) && t.Name.EndsWith("JsonConverter", StringComparison.Ordinal)) .OrderBy(t => t.Name, StringComparer.Ordinal); diff --git a/test/CodoMetis.ValueRanges.Tests/SetJsonConverterTests.cs b/test/CodoMetis.ValueRanges.Tests/SetJsonConverterTests.cs index ba6b8fc..4f57d65 100644 --- a/test/CodoMetis.ValueRanges.Tests/SetJsonConverterTests.cs +++ b/test/CodoMetis.ValueRanges.Tests/SetJsonConverterTests.cs @@ -125,6 +125,41 @@ public void WrapperSet_DecimalBacked_SerializesAsNumbersKeepingScale() Assert.AreEqual(money, JsonSerializer.Deserialize>("[1.5,12.50]", Options)); } + /// + /// The element converter reads with the family grammar: a JSON number in exponent notation is + /// read as System.Text.Json reads it for , and a JSON string holding a + /// group separator is refused — it was fifteen through the element's own Parse. + /// + [TestMethod] + public void WrapperSet_DecimalBacked_ReadsTheFamilyGrammar() + { + Assert.AreEqual( + JsonSerializer.Deserialize("[1.5e1]", Options)!.ToString(), + JsonSerializer.Deserialize>("[1.5e1]", Options)!.ToString()); + Assert.ThrowsExactly(() => JsonSerializer.Deserialize>("[\"1,5\"]", Options)); + } + + /// + /// The write leg checks the element's text with the family grammar too. An element formatting + /// in the current culture writes 1,5 under de-DE; read with + /// NumberStyles.Number that was the number 15 in the payload. + /// + [TestMethod] + public void WrapperSet_DecimalBacked_ElementLeakingTheCulture_IsRefusedOnWrite() + { + var set = DecimalSet.From(new TestCultureLeakingMoney(1.5m)); + var original = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); + try + { + Assert.ThrowsExactly(() => JsonSerializer.Serialize(set, Options)); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + /// /// The temporal arities write the family's round-trip text form as a JSON string — the same /// token type System.Text.Json uses for the primitives they wrap, and the same text the diff --git a/test/CodoMetis.ValueRanges.Tests/SetParseFormatTests.cs b/test/CodoMetis.ValueRanges.Tests/SetParseFormatTests.cs index a0c027e..a499e07 100644 --- a/test/CodoMetis.ValueRanges.Tests/SetParseFormatTests.cs +++ b/test/CodoMetis.ValueRanges.Tests/SetParseFormatTests.cs @@ -182,4 +182,126 @@ public void RoundTrip_AllClosedTypes() [TestMethod] public void DecimalElement_WithAGroupSeparator_IsRejected() => Assert.ThrowsExactly(() => DecimalSet.Parse("{\"1,000\"}", null)); + + // The wrapper arity reads the same grammar, whatever its element's Parse accepts. TestMoney is + // the generated shape — decimal.Parse with NumberStyles.Number — which allows group separators + // and no exponent: the opposite of PostgreSQL's numeric grammar on both counts. + + /// + /// A group separator is refused by the wrapper arity too. Before 9.0.0 the raw text reached + /// TestMoney.Parse, and {"1,5"} was fifteen, silently. + /// + [TestMethod] + public void WrapperDecimalElement_WithAGroupSeparator_IsRejected() + { + Assert.ThrowsExactly(() => DecimalSet.Parse("{\"1,5\"}", null)); + Assert.ThrowsExactly(() => DecimalSet.Parse("{\"1,000.5\"}", null)); + Assert.IsFalse(DecimalSet.TryParse("{\"1,000\"}", null, out _)); + } + + /// Exponent notation is numeric grammar, and reads as it does for the primitive family. + [TestMethod] + public void WrapperDecimalElement_InExponentNotation_ReadsLikeThePrimitive() + { + Assert.AreEqual(DecimalSet.From(new TestMoney(1000m)), DecimalSet.Parse("{1e3}", null)); + Assert.AreEqual(DecimalSet.Parse("{1.5E-1}", null).ToString(), DecimalSet.Parse("{1.5E-1}", null).ToString()); + } + + /// Parsing with the family grammar first must not cost the element its scale. + [TestMethod] + public void WrapperDecimalElement_KeepsItsScale() + => Assert.AreEqual("{1.50,12.500}", DecimalSet.Parse("{12.500,1.50}", null).ToString()); + + // ----------------------------------------------------------------------- + // The array_in grammar — every expectation below is what PostgreSQL 17 answers for the same + // text cast to text[] (LiteralGrammarParityTests in the integration project asks a live + // server). Before 9.0.0 the unquoted element ran to the next comma or quote with no escapes + // and no structural characters, so {a\,b} was two elements and {{a,b}} a one-dimensional set + // of "{a" and "b}" — both silently. + // ----------------------------------------------------------------------- + + /// A backslash escapes the next character in an unquoted element too. + [TestMethod] + [DataRow(@"{a\,b}", "a,b")] + [DataRow(@"{a\\b}", @"a\b")] + [DataRow("{\\\"a}", "\"a")] + [DataRow(@"{a\ }", "a ")] + [DataRow(@"{\ a}", " a")] + [DataRow(@"{a b\ }", "a b ")] + [DataRow(@"{a\}b}", "a}b")] + [DataRow(@"{\{a}", "{a")] + public void Parse_UnquotedElement_HonoursBackslashEscapes(string literal, string expected) + => Assert.AreEqual(StringSet.From(expected), StringSet.Parse(literal, null)); + + /// + /// An escaped NULL is the four-character string, as a quoted one is: only a bare, + /// unescaped NULL is the null marker. + /// + [TestMethod] + public void Parse_EscapedNullWord_IsAValue() + => Assert.AreEqual(StringSet.From("NULL"), StringSet.Parse(@"{N\ULL}", null)); + + /// Whitespace around an unquoted element is dropped; whitespace inside it is kept. + [TestMethod] + public void Parse_UnquotedElement_KeepsInnerWhitespace() + { + Assert.AreEqual(StringSet.From("a b"), StringSet.Parse("{ a b }", null)); + Assert.AreEqual(StringSet.From("a", "b"), StringSet.Parse("{\"a\" , \"b\"}", null)); + + // Only ASCII whitespace surrounds an element; a no-break space is element text, as in PostgreSQL. + Assert.AreEqual(StringSet.From("\u00A0a"), StringSet.Parse("{\u00A0a}", null)); + } + + /// + /// Text PostgreSQL refuses, or reads as something a one-dimensional set cannot be. A nested + /// brace is a multi-dimensional array; an unescaped brace or quote inside an unquoted element, + /// text after a quoted one, a dangling escape and a missing element are malformed. + /// + [TestMethod] + [DataRow("{{a,b}}")] + [DataRow("{{a},{b}}")] + [DataRow("{a}b}")] + [DataRow("{a{b}")] + [DataRow("{a\"b}")] + [DataRow("{a\"}")] + [DataRow("{a\"b\"c}")] + [DataRow("{\"a\"b}")] + [DataRow("{\"x\" y}")] + [DataRow(@"{a\}")] + [DataRow("{a,}")] + [DataRow("{,a}")] + [DataRow("{a,,b}")] + [DataRow("{\"a\",}")] + public void Parse_MalformedArrayLiteral_IsRejected(string literal) + { + Assert.ThrowsExactly(() => StringSet.Parse(literal, null)); + Assert.IsFalse(StringSet.TryParse(literal, null, out _)); + } + + /// + /// Whatever the grammar reads, the formatter must still quote everything that needs it: + /// Parse(Format(x)) == x for strings made of every character the grammar treats + /// specially, alone and in combination. + /// + [TestMethod] + public void RoundTrip_EveryStructuralCharacter() + { + string[] atoms = ["\\", "{", "}", "\"", ",", " ", "\t", "\n", "\u00A0", "NULL", "null", "a", ""]; + + var strings = new List(); + foreach (var first in atoms) + foreach (var second in atoms) + foreach (var third in atoms) + strings.Add(first + second + third); + + Assert.IsGreaterThan(1000, strings.Count); + + foreach (var chunk in strings.Chunk(50)) + { + var original = StringSet.From(chunk); + var literal = original.ToString(); + + Assert.AreEqual(original, StringSet.Parse(literal, null), $"Round trip changed {literal}"); + } + } } diff --git a/test/CodoMetis.ValueRanges.Tests/TemporalLiteralGrammarTests.cs b/test/CodoMetis.ValueRanges.Tests/TemporalLiteralGrammarTests.cs index 875bde3..2238eee 100644 --- a/test/CodoMetis.ValueRanges.Tests/TemporalLiteralGrammarTests.cs +++ b/test/CodoMetis.ValueRanges.Tests/TemporalLiteralGrammarTests.cs @@ -60,15 +60,63 @@ public void DateTime_Accepts_Unspecified(string text, long extraTicks) } [TestMethod] - public void DateTime_Accepts_Utc_AndLocal_WithRoundTripKind() + public void DateTime_Accepts_Utc() { - var utc = DateTimeRange.ParseValue("2026-01-01T09:30:00.0000000Z", null); - var local = DateTimeRange.ParseValue("2026-01-01T09:30:00.0000000+01:00", null); + var utc = DateTimeRange.ParseValue("2026-01-01T09:30:00.0000000Z", 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()); + } + + /// + /// An offset is accepted and not applied: the wall clock is kept as written, which is what + /// PostgreSQL's timestamp input does ('2026-01-01 09:30+05:30'::timestamp is + /// 09:30). Before 9.0.0 the offset was converted into the parsing machine's zone, so one literal + /// held a different wall clock — the thing tsrange stores — on every server. Two offsets + /// no machine shares at once make the old behaviour fail wherever this runs. + /// + [TestMethod] + [DataRow("2026-01-01T09:30:00.0000000+14:00")] // this library ("O", Local) + [DataRow("2026-01-01T09:30:00.0000000-12:00")] + [DataRow("2026-01-01T09:30:00+05:30")] + [DataRow("2026-01-01 09:30:00+14")] // PostgreSQL's short form + [DataRow("2026-01-01 09:30:00-12")] + [DataRow("2026-01-01 09:30:00+05:53:28")] // tstzrange output, local mean time + [DataRow("2026-01-01T09:30+14:00")] + public void DateTime_Accepts_AnOffset_KeepingTheWallClock(string text) + { + var value = DateTimeRange.ParseValue(text, null); + + Assert.AreEqual(Wall.Ticks, value.Ticks); + Assert.AreEqual(DateTimeKind.Local, value.Kind); + } + + /// + /// A local value round-trips exactly, included: it formats with an + /// offset and the offset is not applied on the way back, so no zone rule is consulted — not even + /// for a wall clock the machine's zone skips or repeats. + /// + [TestMethod] + public void LocalValue_RoundTrips_WithItsKind() + { + foreach (var local in new[] + { + new DateTime(2026, 1, 1, 9, 30, 0, DateTimeKind.Local).AddTicks(1_234_567), + new DateTime(2026, 3, 29, 2, 30, 0, DateTimeKind.Local), + new DateTime(2026, 10, 25, 2, 30, 0, DateTimeKind.Local), + DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Local), + DateTime.SpecifyKind(DateTime.MaxValue.AddDays(-1), DateTimeKind.Local) + }) + { + var range = DateTimeRange.CreateFinite(local, local.AddHours(1)); + var back = DateTimeRange.Parse(range.ToString(), CultureInfo.InvariantCulture); + + Assert.AreEqual(range, back, range.ToString()); + Assert.AreEqual(DateTimeKind.Local, ((DateTimeRange.Finite) back).Start.Kind); + + var set = DateTimeSet.From(local); + Assert.AreEqual(local.Ticks, DateTimeSet.Parse(set.ToString(), CultureInfo.InvariantCulture)[0].Ticks, set.ToString()); + } } /// @@ -81,12 +129,6 @@ public void DateTime_Accepts_Utc_AndLocal_WithRoundTripKind() 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() { @@ -103,6 +145,10 @@ public void DateTime_Accepts_Infinity() [DataRow("2027-02-01T09:30:00,5")] // comma fraction [DataRow("2027-02-01T25:00:00")] [DataRow("2027-02-01T09:30:00 BC")] + [DataRow("2027-02-01T09:30:00+0530")] // offsets are ±HH, ±HH:mm or ±HH:mm:ss + [DataRow("2027-02-01T09:30:00+5:30")] + [DataRow("2027-02-01T09:30:00+15:00")] // no such offset + [DataRow("2027-02-01T09:30:00z")] public void DateTime_Rejects(string text) => Assert.ThrowsExactly(() => DateTimeRange.ParseValue(text, null)); diff --git a/test/CodoMetis.ValueRanges.Tests/TestKeys.cs b/test/CodoMetis.ValueRanges.Tests/TestKeys.cs index 1905a14..51ee7a2 100644 --- a/test/CodoMetis.ValueRanges.Tests/TestKeys.cs +++ b/test/CodoMetis.ValueRanges.Tests/TestKeys.cs @@ -329,3 +329,33 @@ public string ToString(string? format, IFormatProvider? formatProvider) public int CompareTo(TestLossyStamp other) => _value.CompareTo(other._value); } + +/// +/// A deliberately non-conforming decimal element: it ignores the provider and formats in the +/// current culture, so under de-DE 1.5 formats as 1,5. A boundary that +/// reads that text with NumberStyles.Number binds fifteen; the family grammar refuses it. +/// +internal readonly record struct TestCultureLeakingMoney : IFormattable, IParsable, IComparable +{ + private readonly decimal _value; + + public TestCultureLeakingMoney(decimal value) => _value = value; + + public static TestCultureLeakingMoney Parse(string s, IFormatProvider? provider) + => new(decimal.Parse(s, NumberStyles.Number, CultureInfo.InvariantCulture)); + + public static bool TryParse(string? s, IFormatProvider? provider, out TestCultureLeakingMoney result) + { + var ok = decimal.TryParse(s, NumberStyles.Number, CultureInfo.InvariantCulture, out var value); + result = ok ? new TestCultureLeakingMoney(value) : default; + return ok; + } + + public int CompareTo(TestCultureLeakingMoney other) => _value.CompareTo(other._value); + + // The defect: the provider is ignored in favour of the current culture. + public string ToString(string? format, IFormatProvider? formatProvider) + => _value.ToString(format, CultureInfo.CurrentCulture); + + public override string ToString() => ToString(null, CultureInfo.InvariantCulture); +} diff --git a/test/consumer-smoke-test.sh b/test/consumer-smoke-test.sh index a7e87de..cc3bbc3 100755 --- a/test/consumer-smoke-test.sh +++ b/test/consumer-smoke-test.sh @@ -232,10 +232,27 @@ Case("aot-object", () => JsonSerializer.Serialize(h1, ctx.Object)); 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()); +// Declared as an interface: written through the runtime type's contract, which the context lists. +Case("aot-interface", () => JsonSerializer.Serialize(new Declared(h1, StringSet.From("alpha")), ctx.Declared)); +// A union converter on the options reaches the variant through object, and reads into a variant. +var tagged = new SmokeJson(new JsonSerializerOptions { Converters = { new TaggedDateRange() } }); +Case("aot-union-converter", () => JsonSerializer.Serialize(h1, tagged.Object) + + " " + JsonSerializer.Deserialize("\"tag:[2026-01-01,2026-06-30]\"", tagged.Finite)); #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 +public sealed record Declared(CodoMetis.ValueRanges.Core.IRange Period, CodoMetis.ValueRanges.Core.IValueSet Tags); + +public sealed class TaggedDateRange : JsonConverter +{ + public override DateRange Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => DateRange.Parse(reader.GetString()!["tag:".Length..], null); + + public override void Write(Utf8JsonWriter writer, DateRange value, JsonSerializerOptions options) + => writer.WriteStringValue($"tag:{value}"); +} + public readonly record struct Key(string Value) : IFormattable, IParsable { public static Key Parse(string s, IFormatProvider? provider) => new(s); @@ -258,6 +275,7 @@ public readonly record struct Key(string Value) : IFormattable, IParsable [JsonSerializable(typeof(object))] [JsonSerializable(typeof(DateTimeOffsetSet))] [JsonSerializable(typeof(DateTimeOffset))] +[JsonSerializable(typeof(Declared))] internal partial class SmokeJson : JsonSerializerContext; public readonly record struct Code(int Value) : IFormattable, IParsable, IComparable @@ -309,6 +327,8 @@ assert_contains 'aot-object="[2026-01-01,2026-06-30]"' "$work/aot.native 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" +assert_contains 'aot-interface={"Period":"[2026-01-01,2026-06-30]","Tags":["alpha"]}' "$work/aot.native.out" "AOT: interface-declared properties write through the runtime type" +assert_contains 'aot-union-converter="tag:[2026-01-01,2026-06-30]" [2026-01-01,2026-06-30]' "$work/aot.native.out" "AOT: a union converter on the options reaches the variant" # ── EF Core + NodaTime satellite: the whole dependency chain ──────────────────────────────────────