Skip to content

fix: final v9.0.0 review — silent defects, final-SQL guard, strict literal grammar - #33

Merged
CaffeinatedCoder merged 28 commits into
mainfrom
fix/v9-final-review
Oct 11, 2026
Merged

CaffeinatedCoder merged 28 commits into
mainfrom
fix/v9-final-review

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Owner

The last review round before tagging v9.0.0. Four parallel reviews (literals, JSON/AOT, live EF, release wiring) found more silent defects. They are all fixed here, following the release rule: a silent wrong value, or a fix that tightens or changes output, goes in the major. Only items that are loud and fixable later without a major are deferred to 9.1.

Fixes

EF Core

  • The value set Union guard runs on the final SQL tree (fcd28d7), through a decorator around the provider's IRelationalParameterBasedSqlProcessorFactory. The 8.0 LINQ pattern missed projections, ?:/??, GroupBy, Distinct, OrderBy, set operations and ExecuteUpdate; all of these translated and answered wrongly. The JSON ExecuteUpdate guard reads the translated UPDATE, which closes the join/EF.Property bypass.
  • The guard fails closed (2f15a8d). The translator emits Union as valueranges_union_without_sql_guard, and only the guard renames it to array_cat. A later ReplaceService that dropped the decorator used to switch every refusal off silently: the refused equality query returned no rows where memory finds both. Now that query fails with PostgreSQL 42883. A decorator around ours keeps working.
  • A NodaTime range element operand in a non-ISO calendar is compared as its ISO date.

Literals

  • A DateTime literal keeps its wall clock, as Local, instead of applying its offset. This is PostgreSQL's timestamp input rule.
  • Range bounds and array elements use PostgreSQL's range_in/array_in grammar: escapes, a refused quoted-empty bound, structural braces, ASCII-only whitespace.
  • Wrapper arities read element text with the family's numeric grammar.
  • A "" format behaves like no format.
  • NodaTime types accept HH:mm.

JSON

  • A union converter registered on the options reaches the union's variants.
  • Interface-declared ranges and value sets write through their runtime type.
  • AddNodaTimeRangeConverters() no longer shadows ConfigureForNodaTime.
  • Bare non-ISO NodaTime values, and non-ISO elements of the calendar-bearing wrapper sets, are refused instead of written days off.

Build

  • NodaTime dependency floor restored to 3.3.3. A Dependabot test group had edited src; dependabot.yml now ignores NodaTime.

Docs

  • All four changelogs updated.
  • The v8.x migration section now opens with a checklist of every observable change, linked to its section (96f62ec).
  • Package READMEs corrected; CLAUDE.md rules and architecture.md/efcore.md updated.

Verification

  • Full suite with CI=true: 1,579 of 1,579 passed, including both live PostgreSQL integration projects.
  • Revert checks: each fix has a test that fails without it, proven by reverting the fix (or by seeding the defect).
  • Release gate (build, test, pack against the 8.0.0 package-validation baseline, consumer smoke test including Native AOT) passed on a fresh clone at 35613f9. The two commits after it are the guard fix and docs; CI re-runs the gate on this head.

Deferred to 9.1 (loud, and fixable without a major)

  • --nativeaot compiled models.
  • Truncated parse error messages.
  • A quoted bound with a leading space is refused where PostgreSQL trims it.
  • Npgsql cannot write List<StringSet> for list.Contains(r.Tags).
  • IComparable for ranges.
  • The internal Shape enum.

After merge: tag the merged commit v9.0.0. The release workflow re-runs the gate and waits for approval before the NuGet push.

🤖 Generated with Claude Code

CaffeinatedCoder and others added 28 commits October 11, 2026 19:30
A grouped test-dependency update edited the shipping satellite's reference, raising every
consumer's minimum without a release decision. Dependabot now ignores NodaTime, which the test
projects only reach through the satellite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LocalDateRange and LocalDateTimeRange passed no normalizeValue, so an element operand in another
calendar bound its own year/month/day as if ISO: a constant silently missed, a LocalDate
parameter threw inside Npgsql. The sets already normalized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The core value-set table listed six existing wrapper arities as missing; the NodaTime README
still promised JSON setup and carried a sample that does not compile (CS1023). architecture.md
described MakeGenericType where GenericConverters is used, CONTRIBUTING's skip command ran one
of seven projects, and the release-notes migration link was relative, which does not resolve
in a GitHub release body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.NET reads "" as the general format, exactly like null, but the literal
entry points checked only `format is null`. ToString("", de-DE) on
DecimalRange [1.5,2) wrote [1,5,2) and RangeSet {[1,5,2)}; DateRange
with ("", th-TH) wrote Buddhist-era short dates, since `format ??
"yyyy-MM-dd"` does not trigger on ""; Int32Range ("", sv-SE) took U+2212
as its minus; DecimalSet ("", de-DE) wrote {"1,5"}.

IRangeFactory's IFormattable.ToString and SetFormat.Format now normalize
an empty format to null before choosing the provider, and every
FormatValue that defaults a null format (the temporal ranges and sets,
their wrapper arities, and the NodaTime satellite) reads "" as that
default too, since FormatValue is public and reachable directly.

LiteralProviderContractTests and LiteralCultureContractTests sweep ""
exactly like null for every discovered type, and a new sweep asserts
FormatValue(value, "") == FormatValue(value, null) for every range and
set type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DecimalSet<TElement> and the integer arities handed the raw element
text to TElement.Parse, so the element decided the grammar. A generated
wrapper (`new(decimal.Parse(s, provider))`, NumberStyles.Number) accepts
group separators: DecimalSet.Parse("{\"1,5\"}") threw while
DecimalSet<Price>.Parse("{\"1,5\"}") was {15}, and {"1,000.5"} was
1000.5 - the same through TryParse, the TypeConverter and a JSON string.
In the other direction {1e3} and a JSON 1.5e1, which DecimalSet reads,
were refused by the wrapper.

ParseValue now parses with the primitive family's own ParseValue
(NumberStyles.Float / Integer, invariant) and hands the element the
parsed value's invariant text, so its validation still runs and decimal
scale is kept - the pattern the temporal arities already used. The
decimal JSON writer checks the element's text with the same grammar
(NumberStyles.Number read a culture-leaking "1,5" as the number 15), and
the EF bridge entries for the numeric arities parse through the family's
ParseValue for the same reason, so such an element is refused rather
than bound as fifteen.

WrapperGrammarContractTests sweeps every discovered wrapper family,
closed over a deliberately lenient element and over its representative
one, and asserts each probe text is accepted or refused exactly as the
primitive sibling does, with the same value, through Parse, TryParse,
the span overload, the TypeConverter and JSON (string and number).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onfigureForNodaTime

AddNodaTimeRangeConverters registered plain element converters, which
System.Text.Json resolves first-match: called before ConfigureForNodaTime
they shadowed NodaTime's converters for all five types, so the XML doc's
"either registration order" claim was false. They also wrote a Julian
LocalDate(2024, 1, 1) - ISO 2024-01-14 - as "2024-01-01", which read back
thirteen days off with no error; LocalDateTime and YearMonth likewise.

The element converters now sit behind a factory that is resolved only once
the options are in use, looks past itself and hands over any other
converter registered for the type. The calendar-bearing converters refuse
a non-ISO value on write with ArgumentException, as NodaTime's own
converter does; sets and ranges normalize or reject at construction, so
they never reach it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A DateTime element with an offset was converted into the parsing machine's zone, so one literal
stored a different wall clock — what tsrange holds — on every server, and a local value inside a
DST gap came back an hour off. The offset is now accepted and dropped, as PostgreSQL's timestamp
input does, with the kind Local; the EF wrapper bridge reads its "O" text the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IsoTemporal accepts a time of day without seconds, and CLAUDE.md lists
HH:mm as part of the strict grammar, but NodaPatterns did not:
TimeRange.Parse("[09:00,17:00)") parsed while LocalTimeSet.Parse("{09:00}")
and LocalDateTimeRange.Parse("[2024-01-01T09:00,2024-01-01T17:00)")
threw.

NodaPatterns now reads HH:mm wherever IsoTemporal does: a LocalTime, the
time part of a LocalDateTime (T or space), and an Instant written with
its offset or Z. Formatting is unchanged, the hour is still two digits,
and an Instant without an offset is still refused.

TemporalLiteralStrictnessContractTests asserts HH:mm is accepted
uniformly, and reads as the full form, for every time-of-day range and
set type through Parse, TryParse, the span overload, JSON, the
TypeConverter and RangeSet; its refusal table gains a one-digit hour for
LocalTime and LocalDateTime and an offset-less HH:mm Instant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
System.Text.Json resolves a converter by the exact type it is handed. A
range reached through object, an object collection or dictionary, or a
variant-typed declaration is handed over as its variant, whose own
[JsonConverter] wrote the literal - so with a custom Int32Range converter
on the options, Serialize(range) gave the custom form and
Serialize<object>(range) gave "[1,2]", and one payload mixed both.

RangeVariantJsonConverter now looks up what System.Text.Json would choose
for the union from Converters (first match, factories expanded through
their own CreateConverter) and, unless that is this package's converter,
delegates values and dictionary keys to it on write and read, narrowing
reads as before. Cached per read-only options instance; nothing is built
by reflection, so AOT and source generation are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RangeFormat unquoted each bound and then tested it for emptiness, so a
quoted empty bound read as an absent one: Int32Range.Parse("[\"\",5)")
was (,4] and ("","") was (,), silently, where PostgreSQL answers
`invalid input syntax for type integer: ""`. A bound of only whitespace
was trimmed to nothing the same way ([ ,5), [1, )).

Bounds are now scanned as range_in scans them: only an absent bound (the
next character is the comma or the end) is infinite; a present bound,
quoted-empty or whitespace-only, goes to ParseValue, which refuses it for
every element type. A double quote opens or closes a quoted section
anywhere in a bound, a doubled quote inside one is a literal quote, and
a backslash escapes the next character inside quotes or out ([\1,5) is
[1,5), as in PostgreSQL). A second comma or an unquoted closing bracket
inside a bound is refused. Whitespace around the unquoted part of a bound
is still dropped - every element input function behind these types
ignores it. The multirange splitter skips quoted and escaped characters.

RangeLiteralGrammarContractTests sweeps every discovered range type,
core and NodaTime, through Parse, TryParse, the span overload, JSON, the
TypeConverter and the multirange literal. The expectations mirror probes
run against PostgreSQL 17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…time type

System.Text.Json resolves the declared type, and the range and set
interfaces carried no converter: a property declared as IRange<int> wrote
{} and one declared as IValueSet<string> wrote {"Values":["a"]}, silently,
and neither read back.

IRange<T>, its five shape interfaces and IValueSet<T> now carry
[JsonConverter(typeof(InterfaceDeclaredJsonConverter))], a non-generic
JsonConverter<object> that writes through options.GetTypeInfo of the
runtime type - the same literal or array, honouring any converter
registered for that type, reflection-based or source-generated - and
refuses a read with a NotSupportedException naming the remedy: declare
the concrete type. No generic converter is closed at runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two refusals recognized their subject by how the LINQ was written and
were bypassed by every shape the pattern did not list.

Union translates to array_cat, a concatenation. The 8.0.0 guard refused
==/Equals only inside a fixed list of Queryable operators, and Count only
over a bare union. A projected comparison, a comparison on a projected
union, a ternary or ?? around a union, GroupBy, Distinct, Count through
COALESCE, and ExecuteUpdate to a union all translated and answered (or
stored) something different from memory. Its comment claimed EF
client-evaluates a comparison in a projection; it translates it.

ExecuteUpdate of a value set inside a JSON column (new in 9.0.0) was
refused only for an entity-rooted member chain; a join or EF.Property
reached the same jsonb_set(..., to_jsonb(@p)) and corrupted the row.

Both are now checked by ValueSetSqlGuard on the tree the provider's
parameter-based SQL processor returns, through a decorator around
IRelationalParameterBasedSqlProcessorFactory: the last stage before SQL
generation, and the one queries, ExecuteUpdate and ExecuteDelete all pass
through. It follows values derived from array_cat (through array_remove,
CASE, COALESCE and subquery/set-operation columns), allows them only
under @>, <@, &&, cardinality = 0, a null test or the outermost
projection, and refuses any to_jsonb/to_json of a value set in an UPDATE
setter, naming the column and JSON path. The LINQ-level guards are
removed; the Count translator keeps declining a visible union, now also
through CASE and COALESCE, so a projection still falls back to the
client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UnionCanonicalityTests runs each shape that used to translate and answer
wrongly (projection, projected union, ternary, coalesce, removed union,
membership, GroupBy, Distinct, OrderBy, set operation, Count through
COALESCE and through a derived table, ExecuteUpdate) and asserts the
refusal by executing the query. The legal compositions are executed and
compared with memory, and a projected union is shown to materialize
canonical bare, in an anonymous type, a DTO, a conditional, a derived
table, a nested collection and a UNION ALL. Shapes over a UNION ALL are
what prove the guard follows a column back to its array_cat.

ExecuteUpdateJsonTests adds the join and EF.Property bypasses, the nested
and computed setters (refused, row still loads), and the allowed cases:
a whole complex property holding a set, and the same complex CLR type
mapped outside the JSON column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ve AOT

Two cases in the NativeAOT consumer: a record with IRange<DateOnly> and
IValueSet<string> properties written through a source-generated context,
and a custom DateRange converter on a context's options reaching a value
serialized through object and read into a variant. Seeding both defects
fails both assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The EF guide, architecture notes, CLAUDE.md and the package READMEs
described the 8.0.0 LINQ-level Union refusal, including the false claim
that a comparison in a projection is client-evaluated and correct. They
now describe what is refused on the translated SQL and why, the
migration guide lists the shapes that start throwing in 9.0.0 (and
corrects the 8.0 section), and the testing guide says what the new live
tests prove.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unquoted element ran to the next comma or quote with no escapes and
no structural characters, so the array grammar departed from array_in
with silently wrong values: StringSet.Parse(@"{a\,b}") was two elements,
a\ and b, where PostgreSQL reads one, a,b; {{a,b}} was the
one-dimensional set {"{a","b}"} where PostgreSQL reads a 2-D array; and
{a}b} was {"a}b"} where PostgreSQL raises an error.

Unquoted elements now follow array_in: a backslash escapes the next
character; an unescaped {, } or " inside one is refused, and a leading {
is refused as a multi-dimensional array; whitespace around the element
is dropped and whitespace inside it kept; only a bare, unescaped NULL is
the null marker (N\ULL is the string). The literal is no longer trimmed
as a whole, so a trailing escaped space belongs to the last element.

Both literal parsers skip only the whitespace PostgreSQL skips (ASCII
space, tab, newline, CR, VT, FF - LiteralWhiteSpace): a no-break space
is element text in an array, as on the server, and makes a range bound
invalid rather than being trimmed away.

SetParseFormatTests pins each case and sweeps Parse(Format(x)) == x over
strings built from backslash, braces, quotes, commas, whitespace, NBSP
and NULL. LiteralGrammarParityTests (integration) hands the same text to
PostgreSQL's array_in and range_in and requires the same answer, for
text[], int4range, daterange and int4multirange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/architecture.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/serialization.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…igration guide

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…E.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sets

NodaTime's ISO patterns format a value in its own calendar's fields. A
LocalDateSet<T> over a Julian LocalDate(2024, 1, 1) - ISO 2024-01-14 -
wrote {2024-01-01} and ["2024-01-01"], and the EF bridge bound, inlined
and stored ISO 2024-01-01: thirteen days off, silently, with an unequal
round trip. LocalDateTimeSet<T> and YearMonthSet<T> likewise; measured
live, a Julian January parameter matched a row holding ISO January.

A wrapper arity cannot convert its element to ISO - it can only build one
by parsing - so it refuses it: From, collection expressions and reads
check that the element's ISO text reads back as that element, and
NormalizeElement applies the same check, so Contains/Add/Remove refuse a
non-ISO probe (against the empty set nothing was compared, so Contains
answered false and Add stored it). The EF bridge gains requireRoundTrip,
set for the three families, which refuses such an element as a bare
operand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The intro named only the JSON and culture changes. It now lists, in order of
likelihood, every change a consumer can observe: payload shape, input that now
throws, queries that now throw, the DateTime offset and NodaTime calendar
rules, and the microsecond store text, each linking to its section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The final-SQL guard runs from a decorator around the provider's
IRelationalParameterBasedSqlProcessorFactory. A later ReplaceService, or
another extension replacing rather than decorating that service, dropped it
without a trace, and every refused Union shape answered as in 8.x again:
array_cat equality returned no rows where memory finds both.

The translator now emits Union as valueranges_union_without_sql_guard, a
function PostgreSQL does not have, and only the guard renames it to
array_cat. Without the guard, any query with a server-side Union fails on
the server, naming the placeholder. A decorator around the guard still
reaches it, so wrapping keeps working; checking for the decorator's type
instead would have refused that legitimate setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder
CaffeinatedCoder merged commit c4f0272 into main Oct 11, 2026
8 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the fix/v9-final-review branch October 11, 2026 18:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant