Skip to content

fix: strict temporal parsing, temporal store text as Npgsql binds it, ExecuteUpdate guard (9.0.0) - #32

Merged
CaffeinatedCoder merged 15 commits into
mainfrom
fix/temporal-store-and-strict-parsing
Oct 11, 2026
Merged

CaffeinatedCoder merged 15 commits into
mainfrom
fix/temporal-store-and-strict-parsing

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

The pre-tag defects from the release-readiness probes against a live PostgreSQL 17, the ones a value-semantics review of this branch found, and the three items brought forward from 9.0.1. Each returned a wrong value or threw on a valid query.

1. Strict ISO 8601 temporal parsing (breaking, so it's 9.0.0 or 10.0)

The BCL temporal types parsed element text with the lenient BCL Parse, which reads month first:

  • DateRange.Parse("[01.02.2027,05.03.2027)") was January 2 to May 2.
  • A DateTimeOffset without an offset took the parsing machine's offset.

All of it was silent, through Parse, JSON, the TypeConverter and configuration binding.

IsoTemporal now accepts exactly what this library writes and what PostgreSQL prints in its ISO DateStyle:

  • dates as yyyy-MM-dd
  • times as HH:mm or HH:mm:ss, with any number of fraction digits, truncated to 100 ns
  • T or a space between date and time
  • Z, ±HH, ±HH:mm or ±HH:mm:ss offsets (required for a DateTimeOffset)
  • infinity / -infinity, read as the extreme values, which is what Npgsql materializes for them

The wrapper arities, BCL and NodaTime, parse the text as their primitive first. NodaTime was already strict and now also reads infinity.

Closed temporal sets read their JSON elements with the same grammar. System.Text.Json's own converter read an offset-less DateTimeOffset, or a bare date, in the machine's offset. A converter you register for the element type still wins, and the written form is unchanged.

2. Temporal values written as text are the value Npgsql binds

The JSON writer (new in 9.0.0) and the SQL literals sent 7 or 9 fraction digits, which PostgreSQL rounds half-to-even. Npgsql instead truncates parameters and native columns toward 2000-01-01, so a value could miss its own row, and MinValue/MaxValue stayed finite in text while a parameter carried ±infinity.

What changed:

  • Truncation: StoreTemporal and NodaStoreTemporal truncate as Npgsql does and spell the extremes as infinity. They're applied as a separate storeValue.
  • Ranges are rebuilt through their factory afterwards; sets are deduplicated.
  • The JSON writer resolves its text through the registries, which also works for compiled models.
  • YearMonth types: a range ending in 9999-12 ends on infinity, and YearMonthSet<T> in JSON is written as dates.

3. Element operands get the range's or set's own mapping

  • Sets: InstantSet/LocalTimeSet.Contains(EF.Constant(x)) used Npgsql's nine-digit literal and missed the element it was stored from. This one was found by the review.

  • Ranges:

    • DateTimeOffsetRange.Contains(dto) threw for any non-UTC offset, and DateTimeRange.Contains for a UTC-kinded DateTime.
    • A range's element constant was Npgsql's literal.

    Every temporal range now has RangeElementTypeMapping: parameters are normalized and truncated, and constants are the stored value in PostgreSQL's typed-literal form.

4. A range built inline in a query translates

r.Period == DateRange.CreateFinite(new DateOnly(2026, 1, 1), …), Int32Range.Parse("[1,5]", null) and similar threw "does not have a type mapping assigned" under ==, !=, Equals and inside conditionals. EF Core evaluates such a call on the client and inlines a constant typed as the sealed variant. A translator can't help, because the call is gone before translation. The query interceptor now retypes the constant to its range type.

5. ExecuteUpdate of a value set inside a JSON column is refused

EF Core writes such a setter with to_jsonb, which stores a JSON array where the document holds the set's literal; the row then no longer loads. The guard matches EF's private lowering by shape, and a test pins that shape. Ranges, multiranges and the whole JSON property can still be set.

Docs and policy

  • Changelogs (root and four packages); strict parsing is in "what can break you".
  • docs/efcore.md: the document holds the store's text, not the API JSON; it's a persisted format; and a support matrix measured against the server.
  • Serialization grammar and migration call-site edits.
  • Extensibility: the range interfaces are declared not an extension point (docs only).
  • CLAUDE.md rules.

Review follow-ups (tests and CI only)

  • Element operands are held to an expected answer. The parity test compared a parameter with a constant, so a miss on both paths passed. A stored set element must now match its own row both ways, and a range's Contains on its original sub-microsecond bounds must answer as the materialized range does for the probe as stored. The matrix now covers every temporal wrapper family, because each one is a separate registration with its own truncation.
  • Tracked JSON updates and null transitions. Covered on a complex document, which EF rewrites whole, and on an owned one, which EF updates with jsonb_set for a lone change. Values are changed one per save; untouched properties are checked after each phase.
  • Npgsql.DisableDateTimeInfinityConversions gets its own test project, because the switch is read once per process. It shows the extremes stay finite and truncated in the document, the literal and the element operands. CI's integration job now runs every test/*IntegrationTests/.

Verification

Every fix was checked by removing it and confirming its test fails for the expected reason:

Fix removed What failed
Lenient ParseValue restored the strictness sweep, by type
BCL truncation / NodaTime truncation / infinity text / set deduplication TemporalStoreParityTests: 165 / 127 / 46 / 16 findings
Set element mapping constant-vs-parameter check
Range element mapping 21 parity findings, including the offset throw
Registry lookup in the JSON writer compiled-model stand-in
YearMonth family store text stored document plus a cast error
Variant constant retyper all three inline-constant test classes, with the original error
Strict set JSON read unit tests and sweep
Fraction truncation nine-digit cases
ExecuteUpdate guard unit and integration refusals
The parser's PostgreSQL space form ExecuteUpdate read-back
(seeded) DateTimeOffset element operand shifted 1 µs on both paths 42 "both wrong" findings; the previous parameter-vs-constant check passed
(seeded) value comparer blind to replacements both tracked-update tests
(seeded) plugin ignoring the infinity switch / switch missing from runtimeconfig every path / all three tests, the switch check by name

Test plan

Release gate run on a fresh clone of e1a7671, in release.yml order:

  • dotnet build -c Release: 0 warnings
  • CI=true dotnet test -c Release --no-build: 1,481 passing, including 95 + 3 against a live PostgreSQL (both integration projects)
  • dotnet pack -c Release --no-build -o dist: package validation against 8.0.0 clean
  • ./test/consumer-smoke-test.sh dist: passes, including the NativeAOT consumer with no trim or AOT warnings
  • CI

🤖 Generated with Claude Code

CaffeinatedCoder and others added 6 commits October 11, 2026 16:59
The BCL temporal ranges and closed sets parsed element text with the
lenient BCL Parse, which reads the invariant culture month first:
DateRange.Parse("[01.02.2027,05.03.2027)") was January 2 to May 2, and
a DateTimeOffset without an offset took the parsing machine's. The
wrapper arities handed the text to their element's own, possibly
lenient, Parse. All of it was silent.

Element text now goes through IsoTemporal, which accepts exactly the
forms this library writes and PostgreSQL's ISO-DateStyle output (space
separator, +05 offsets, up to seven fraction digits), HH:mm without
seconds, and infinity / -infinity as MaxValue / MinValue - what Npgsql
materializes for them. Everything else is a FormatException through the
existing bounded-excerpt path. A DateTimeOffset requires its offset.

The wrapper arities, BCL and NodaTime, parse as the primitive first and
hand the element its own round-trip form, so a lenient element parser
never sees non-ISO text. NodaTime reads infinity for Instant, LocalDate
and LocalDateTime, the types Npgsql maps it for (measured).

TemporalLiteralGrammarTests pins the grammar; the conventions sweep
TemporalLiteralStrictnessContractTests refuses a non-ISO element through
Parse, TryParse, the span overload, JSON and the TypeConverter for all
26 temporal range and set types. Restoring a lenient ParseValue on
DateRange, DateTimeOffsetRange or DateSet<T> fails the sweep by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PostgreSQL writes a historical local-mean-time offset with seconds
(Asia/Kolkata before 1854 is +05:53:28), for instance into a JSON
document through to_jsonb. DateTimeOffset cannot hold such an offset,
so the strict parser refused it and a row holding one would not load.
It now applies the offset to the wall clock and keeps the instant at
offset zero; offsets beyond +-14:00 or with 60 seconds stay refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A temporal value the EF provider writes as text - inside a JSON column
(new in 9.0.0), or as a SQL literal - carried seven fraction digits
(nine for NodaTime) and let PostgreSQL round them half-to-even, while
Npgsql truncates a parameter and a native column to the microsecond
toward 2000-01-01. For a remainder above 500 ns after 2000 (below it
before), the document and the constant held a different value from the
column: Doc.X == v missed its own row, Contains disagreed at the bounds,
and a range narrower than a microsecond was empty only on the server.
MinValue / MaxValue stayed finite in text while a parameter carried
-infinity / infinity. EF 10 inlines evaluatable expressions as
constants, so the literal path was reachable without EF.Constant.

The store text is now the value Npgsql would send:
- StoreTemporal / NodaStoreTemporal truncate as Npgsql does and spell
  the extremes it maps (measured: DateTime, DateTimeOffset, DateOnly,
  Instant, LocalDate, LocalDateTime) as infinity, honouring
  Npgsql.DisableDateTimeInfinityConversions. Applied as a separate
  storeValue after normalizeValue, which stays the core element
  normalization.
- Ranges and multiranges are rebuilt through their factory after
  truncation, so they collapse and merge as the server would.
- Value sets deduplicate the store values on every path; a native array
  no longer holds two equal timestamps whose cardinality disagrees with
  Count. Temporal sets write a JSON document as their store primitives.
- The JSON writer resolves the store text through the registries on
  first use, so a compiled model that never built a wrapper family's
  definition still writes the store form; an unregistered type throws.
- YearMonth types route their date form through the LocalDate store
  text (9999-12 ends on infinity). YearMonthSet<T> in a JSON column is
  written as dates - its own {2024-06} form could not be cast to date[],
  so every query into it failed.

TemporalStoreParityTests (integration) builds every temporal type from
seven probes - remainders above, below and at 500 ns, before 2000, the
extremes, sub-microsecond ranges - and asserts, per property, that the
server reads the document as the column, that parameters and constants
match both copies, that the copies materialize equal, and that Contains
agrees with memory around every bound. Removing the BCL truncation, the
NodaTime truncation, the infinity text or the set dedupe fails it with
165, 127, 46 and 16 findings. TemporalStoreTextTests pins the text, and
a never-resolved wrapper set stands in for a compiled model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EF Core translates such a setter to jsonb_set(doc, path, to_jsonb(@p)),
binding the set's PostgreSQL array: the document then holds a JSON array
where it holds the set's literal string everywhere else. The update
reported success; afterwards the entity no longer loaded ("Cannot get
the value of a token type 'StartArray' as a string") and every query on
the property failed with a malformed array literal.

The query interceptor now refuses it at compilation, before a
connection opens, naming the property and pointing at SaveChanges or a
whole-document SetProperty. It matches EF Core's private ExecuteUpdate
lowering by name, declaring type and parameter shape rather than the
internal ExecuteUpdateMethodInfo (EF1001), walks the setter's member
chain through the model's complex properties and refuses only a set
whose declaring type IsMappedToJson. Ranges and multiranges stay
allowed: to_jsonb writes PostgreSQL's range output, which the strict
parser reads.

ExecuteUpdateGuardTests pins the refusal (top-level and nested) and the
shape of EF's lowering, so an EF upgrade that changes it fails loudly.
ExecuteUpdateJsonTests (integration) sets temporal and plain ranges and
a multirange inside a document and reads them back, sets the whole
document and a native set column, and checks the refused row still
loads. Removing the guard fails both; removing the space-separated form
from the strict parser fails the read-back with a FormatException.

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

- Changelogs (root and the four packages): strict ISO 8601 temporal
  parsing joins "what can break you"; Fixed entries for temporal SQL
  constants at the database's resolution and set deduplication; the
  JSON-column entry states the store representation, the temporal rule
  and the ExecuteUpdate refusal.
- docs/efcore.md: the document holds the store's text, not the API JSON
  (a table of where they differ), and is a persisted format; what works
  inside a document, measured against PostgreSQL 17.
- docs/serialization.md: the temporal grammar beside the numeric one;
  a custom-format ToString is display text; DateTimeOffsetSet JSON keeps
  System.Text.Json's local-offset rule.
- docs/migration.md v8.x: strict temporal literals with the call-site
  edits, and temporal values reaching the database at its resolution.
- Extensibility: the range interfaces are the shipped types' shared
  vocabulary, not an extension point, and may gain members in a minor
  version (IRangeFactory remarks, docs/ranges.md, docs/architecture.md).
- SetJsonConverterTests pins null versus empty for sets and multiranges.
- CLAUDE.md: rules for strict temporal parsing, temporal store text and
  the ExecuteUpdate refusal; the wrapper-arity rule now names the core.

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

Found by the value-semantics review of this branch. InstantSet and
LocalTimeSet (and the BCL TimeSet) have no element normalization, so a
bare element probe kept the provider's element mapping. A parameter is
fine there - Npgsql truncates on the wire - but a constant is not:
Npgsql renders a NodaTime Instant or LocalTime constant with nine
fraction digits, which the server rounds, so
InstantSet.Contains(EF.Constant(x)) answered false for the element x was
stored as, while in memory it answered true.

The bridged element mapping is now built whenever the family has a
store resolution, not only a normalization, so the constant carries the
truncated value. TemporalStoreParityTests gains the check that an
element probed as a constant matches exactly as it does as a parameter,
on the column and in the document; keying the mapping on normalizeValue
alone again fails it for every NodaTime instant and time set element.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from chore/release-readiness to main October 11, 2026 16:04
CaffeinatedCoder and others added 9 commits October 11, 2026 18:09
r.Period == DateRange.CreateFinite(new DateOnly(2026, 1, 1), ...)
failed with "Expression '[2026-01-01,...]' in the SQL tree does not have
a type mapping assigned". EF Core evaluates a call that depends on
nothing in the query on the client and inlines the result as a constant
typed by its value - the sealed variant, DateRange.Finite, under a
conversion back to DateRange. No type mapping exists for a variant, and
EF does not carry the column's mapping across to an operand of another
CLR type, so ==, !=, Equals and a conditional failed for every range
type, including Parse(...) and the unbounded factories. Contains and
Overlaps were unaffected (their translators type the operand), and so
were captured variables and EF.Constant.

No translator can help: the call is gone before translation. The query
interceptor now retypes such a constant to its range union, which is
what EF.Constant ends up doing; the literal then goes through the store
text like any other range constant. A factory call that depends on a
column still reaches the existing range-constructor translation.

InlineRangeConstantTests pins the SQL for the operators, every shape,
Parse and a temporal range at the store's resolution;
NodaInlineRangeConstantTests covers InstantRange and YearMonthRange's
date form; InlineRangeConstantExecutionTests (integration) matches an
inline constant against its own row for all 11 range types. Removing the
retyper fails all three with the original error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A closed DateTimeOffsetSet read its JSON elements through
System.Text.Json's own converter, which reads an ISO timestamp without
an offset - or a bare date - in the parsing machine's offset: the same
payload held different instants on different servers, while the set's
literal refused the text. DateTimeSet took a bare date as midnight, and
TimeSet an unpadded "9:30".

When the element would be read by System.Text.Json's built-in converter
for a BCL temporal type (compared by converter type, so a converter
registered on the options or substituted by a source-generated context
still reads), the set converter now reads the string through the
family's strict ParseValue, with the usual bounded-excerpt JsonException.
The write side is unchanged and reads back.

The grammar widens where it was needlessly strict, so JSON clients keep
working: any number of fraction digits, truncated to 100 ns as
System.Text.Json truncates them (Go and Java write nine), and a +-HH
offset for a DateTime.

TemporalSetJsonTests pins the refusals, nine-digit input, the unchanged
written form, a registered converter winning and a source-generated
context; the strictness sweep now probes offset-less and date-only
forms through every entry point; the NativeAOT consumer round-trips a
DateTimeOffsetSet and refuses an offset-less element. Disabling the
strict read fails the unit tests and the sweep; removing the fraction
truncation fails the nine-digit cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A range's element operand - range.Contains(x), a constructor argument -
fell back to the provider's element mapping, which knows neither the
range's normalization nor the store's resolution:
- DateTimeOffsetRange.Contains(dto) threw for any non-UTC offset
  ("Cannot write DateTimeOffset with Offset=05:30:00 ... only offset 0
  (UTC) is supported"), DateTimeRange.Contains likewise for a
  UTC-kinded DateTime;
- an element constant was Npgsql's literal: nine fraction digits for
  NodaTime, which the server rounds, and before 2000 a cut toward 0001
  rather than toward the PostgreSQL epoch - so
  range.Contains(EF.Constant(x)) could disagree with the same probe as
  a parameter.

Every temporal RangeTypeDefinition now supplies RangeElementTypeMapping:
parameters go through the definition's normalization and store
resolution, and constants are the store text in PostgreSQL's typed-
literal form (TIMESTAMP '...', TIMESTAMPTZ '...', DATE '...',
TIME '...'), still self-describing as the polymorphic range operators
require. Ranges without a normalization keep the provider's mapping.

TemporalStoreParityTests probes each range with its original sub-
microsecond bounds as a parameter and as a constant; its +05:30
DateTimeOffset probes threw before. TemporalStoreTextTests pins the
literals. Removing the mapping fails both: 21 parity findings, the
offset throw among them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The element checks compared a parameter with a constant, so both missing
passed. A set element the set was built from must now match its own row
both ways; a range's Contains on its original sub-microsecond bounds must
answer as the materialized range does for the probe as stored, computed
in the test rather than by the plugin. Document copies are probed too.

The matrix now holds every temporal wrapper family: each is a separate
registration that has to pass its own truncation, and only DateTimeSet<T>
was exercised below a microsecond.

Seeded: a DateTimeOffset element operand shifted one microsecond early in
both paths passes the old checks and fails these with 42 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExecuteUpdate was covered, the path an application takes was not. On the
complex document (rewritten whole on every save) and on an owned one
(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; untouched properties are
checked after each phase, read back and queried on the server.

Seeded: a value comparer that misses replacements fails both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
StoreTemporal honours Npgsql.DisableDateTimeInfinityConversions, but the
switch is read once per process, so it cannot be tested inside the other
integration run. A project of its own sets it from its runtimeconfig and
checks MinValue / MaxValue stay finite and truncated in the document, the
literal and the element operands, as Npgsql then sends them. Its first
test checks the switch reached the process.

Seeded: the plugin ignoring the switch fails every path; dropping the
runtimeconfig option fails all three tests, the switch check by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration job named one project. It now runs each
test/*IntegrationTests/, which the unit job already skips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parity matrix covers each registered store conversion, not every
closed type; the JSON writer writes the store's text, which is not
ToString() for temporal values or the YearMonth types; the tracked-update
tests check untouched properties per phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder
CaffeinatedCoder merged commit fae6a9d into main Oct 11, 2026
8 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the fix/temporal-store-and-strict-parsing branch October 11, 2026 17:02
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