From 8d3f834275b2f4c2180dd73a5aa450b7e55b7649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 20 May 2026 11:12:01 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20v3.16.0=20=E2=80=94=20datetime=20pa?= =?UTF-8?q?rsers=20are=20now=20host-TZ-independent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four JSON-to-parameter parsers for timestamp / timestamptz / time / timetz used the bare DateTime.TryParse(value) overload, which converts offset-bearing strings to host-local time and tags Kind=Local. The two *Tz parsers then called SpecifyKind(v, Utc) on the local-shifted value — SpecifyKind only relabels Kind, it does not convert — so a host-local wall-clock value got sent to Postgres labelled UTC. The timestamp/time parsers sent the local-shifted value directly to Npgsql, which transmits the wall-clock verbatim for `without time zone` columns. Net effect on any non-UTC host: stored values silently shifted by the host's UTC offset. All four parsers now use DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal: naive ISO strings are assumed UTC, Z- and offset-bearing strings are converted to UTC. TryParseTimestamp strips Kind to Unspecified so Npgsql ships UTC clock-time as the naive wall-clock value. TryParseDate is unchanged — DateOnly.TryParse rejects Z/offset strings outright and does not silently shift. Breaking (minor bump, not patch): naive JSON timestamps (no Z, no offset) are now treated as UTC rather than host-local. Callers on UTC hosts see no change. Callers who sent Z strings expecting UTC were broken on non-UTC hosts and are now correct. New NpgsqlRestTests/HostTimeZoneIndependenceTests covers all four parsers via echo-function round-trips. Verified passing under both TZ=UTC and TZ=America/Los_Angeles. Existing MultiParamsTests2 / MultiParamsQueryStringTests2 had Should().Match(...) workarounds for exactly this bug — now tightened to single-value Should().Be(...). Also bumps NuGet references (FluentAssertions, WireMock.Net, Microsoft.AspNetCore.Mvc.Testing, Microsoft.NET.Test.Sdk, coverlet.collector, Microsoft.SourceLink.GitHub) across test + plugin projects. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build-test-publish.yml | 2 +- NpgsqlRest/NpgsqlRest.csproj | 10 +- NpgsqlRest/ParameterParsers.cs | 24 +++-- NpgsqlRestClient/NpgsqlRestClient.csproj | 10 +- .../HostTimeZoneIndependenceTests.cs | 96 +++++++++++++++++++ NpgsqlRestTests/NpgsqlRestTests.csproj | 10 +- .../MultiParamsQueryStringTests2.cs | 22 +---- .../ParamTests/MultiParamsTests2.cs | 18 +--- changelog/v3.16.0.md | 69 +++++++++++++ npm/package.json | 2 +- npm/postinstall.js | 2 +- .../NpgsqlRest.CrudSource.csproj | 2 +- .../NpgsqlRest.HttpFiles.csproj | 2 +- .../NpgsqlRest.OpenApi.csproj | 2 +- .../NpgsqlRest.SqlFileSource.csproj | 2 +- .../NpgsqlRest.TsClient.csproj | 2 +- 16 files changed, 213 insertions(+), 62 deletions(-) create mode 100644 NpgsqlRestTests/HostTimeZoneIndependenceTests.cs create mode 100644 changelog/v3.16.0.md diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 52550e0c..335507c4 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: env: - RELEASE_VERSION: v3.15.2 + RELEASE_VERSION: v3.15.3 # Add workflow-level permissions permissions: diff --git a/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj index 31db29a7..1819feb5 100644 --- a/NpgsqlRest/NpgsqlRest.csproj +++ b/NpgsqlRest/NpgsqlRest.csproj @@ -29,10 +29,10 @@ true README.MD bin\$(Configuration)\$(AssemblyName).xml - 3.15.0 - 3.15.0 - 3.15.0 - 3.15.0 + 3.15.3 + 3.15.3 + 3.15.3 + 3.15.3 true @@ -41,7 +41,7 @@ - + diff --git a/NpgsqlRest/ParameterParsers.cs b/NpgsqlRest/ParameterParsers.cs index e62540db..b71c2e14 100644 --- a/NpgsqlRest/ParameterParsers.cs +++ b/NpgsqlRest/ParameterParsers.cs @@ -151,11 +151,21 @@ private static bool TryParseBoolean(string? value, out object? result) return false; } + // AssumeUniversal: naive ISO strings (no offset, no Z) are treated as UTC - + // the canonical JSON-over-HTTP convention. AdjustToUniversal: Z- and offset- + // bearing values are converted to UTC. Together they yield Kind=Utc holding + // the true UTC instant regardless of the host's TZ. Without these styles, + // DateTime.TryParse silently shifts to host-local time and tags Kind=Local. + private const DateTimeStyles UtcStyles = + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal; + private static bool TryParseTimestamp(string? value, out object? result) { - if (DateTime.TryParse(value, out var v)) + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) { - result = v; + // Strip Kind so Npgsql sends the UTC clock-time verbatim as the + // naive wall-clock value for `timestamp without time zone`. + result = DateTime.SpecifyKind(v, DateTimeKind.Unspecified); return true; } result = null; @@ -164,9 +174,9 @@ private static bool TryParseTimestamp(string? value, out object? result) private static bool TryParseTimestampTz(string? value, out object? result) { - if (DateTime.TryParse(value, out var v)) + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) { - result = DateTime.SpecifyKind(v, DateTimeKind.Utc); + result = v; return true; } result = null; @@ -186,7 +196,7 @@ private static bool TryParseDate(string? value, out object? result) private static bool TryParseTime(string? value, out object? result) { - if (DateTime.TryParse(value, out var v)) + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) { result = TimeOnly.FromDateTime(v); return true; @@ -197,9 +207,9 @@ private static bool TryParseTime(string? value, out object? result) private static bool TryParseTimeTz(string? value, out object? result) { - if (DateTime.TryParse(value, out var v)) + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) { - result = new DateTimeOffset(DateTime.SpecifyKind(v, DateTimeKind.Utc)); + result = new DateTimeOffset(v, TimeSpan.Zero); return true; } result = null; diff --git a/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj index 6d1f1a01..7e341b8c 100644 --- a/NpgsqlRestClient/NpgsqlRestClient.csproj +++ b/NpgsqlRestClient/NpgsqlRestClient.csproj @@ -10,19 +10,19 @@ true true full - 3.15.2 + 3.15.3 - + - - + + - + diff --git a/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs b/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs new file mode 100644 index 00000000..58699d28 --- /dev/null +++ b/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs @@ -0,0 +1,96 @@ +#pragma warning disable CS8602 // Dereference of a possibly null reference. +namespace NpgsqlRestTests; + +public static partial class Database +{ + public static void HostTimeZoneIndependenceTests() + { + script.Append(@" +create function host_tz_echo_timestamp(_ts timestamp) +returns json +language sql +as $$ select json_build_object('v', _ts) $$; + +create function host_tz_echo_timestamptz(_ts timestamptz) +returns json +language sql +as $$ select json_build_object('v', _ts) $$; + +create function host_tz_echo_time(_t time) +returns json +language sql +as $$ select json_build_object('v', _t) $$; + +create function host_tz_echo_timetz(_t timetz) +returns json +language sql +as $$ select json_build_object('v', _t) $$; +"); + } +} + +[Collection("TestFixture")] +public class HostTimeZoneIndependenceTests(TestFixture test) +{ + private static async Task EchoAsync(HttpClient client, string endpoint, string body) + { + using var content = new StringContent(body, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync(endpoint, content); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var raw = await response.Content.ReadAsStringAsync(); + var node = JsonNode.Parse(raw); + return node["v"].GetValue(); + } + + [Fact] + public async Task TimestampTz_ZSuffix_RoundTripsAsUtc() + { + var v = await EchoAsync(test.Client, "/api/host-tz-echo-timestamptz/", + """{"ts":"2026-05-20T06:00:00Z"}"""); + v.Should().Be("2026-05-20T06:00:00+00:00"); + } + + [Fact] + public async Task TimestampTz_NumericOffset_ConvertedToUtc() + { + // 06:00+02:00 == 04:00 UTC + var v = await EchoAsync(test.Client, "/api/host-tz-echo-timestamptz/", + """{"ts":"2026-05-20T06:00:00+02:00"}"""); + v.Should().Be("2026-05-20T04:00:00+00:00"); + } + + [Fact] + public async Task TimestampTz_NaiveIso_AssumedUtc() + { + // No offset, no Z. AssumeUniversal treats as UTC, not host-local. + var v = await EchoAsync(test.Client, "/api/host-tz-echo-timestamptz/", + """{"ts":"2026-05-20T06:00:00"}"""); + v.Should().Be("2026-05-20T06:00:00+00:00"); + } + + [Fact] + public async Task Timestamp_ZSuffix_StoredAsUtcClockTimeNaively() + { + // `timestamp without time zone` has no offset semantics. Convention here: + // the digits inside the Z-marked string become the naive wall-clock value. + var v = await EchoAsync(test.Client, "/api/host-tz-echo-timestamp/", + """{"ts":"2026-05-20T06:00:00Z"}"""); + v.Should().Be("2026-05-20T06:00:00"); + } + + [Fact] + public async Task TimeTz_ZSuffix_RoundTripsAsUtc() + { + var v = await EchoAsync(test.Client, "/api/host-tz-echo-timetz/", + """{"t":"06:00:00Z"}"""); + v.Should().Be("06:00:00+00"); + } + + [Fact] + public async Task Time_ZSuffix_StoredAsUtcClockTime() + { + var v = await EchoAsync(test.Client, "/api/host-tz-echo-time/", + """{"t":"06:00:00Z"}"""); + v.Should().Be("06:00:00"); + } +} diff --git a/NpgsqlRestTests/NpgsqlRestTests.csproj b/NpgsqlRestTests/NpgsqlRestTests.csproj index e1aa68c8..cd723e71 100644 --- a/NpgsqlRestTests/NpgsqlRestTests.csproj +++ b/NpgsqlRestTests/NpgsqlRestTests.csproj @@ -15,16 +15,16 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs b/NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs index 974d6b5e..e33c4066 100644 --- a/NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs +++ b/NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs @@ -90,17 +90,10 @@ public async Task Test_case_get_multi_params2() node["double"].ToJsonString().Should().Be("2.2"); node["jsonpath"].GetValue().Should().Be("$.\"user\".\"addresses\"[0].\"city\""); node["timestamp"].GetValue().Should().Be("2024-01-12T11:59:17.811872"); - - // integration server seems to have a different datetime alltogether - node["timestamptz"].GetValue().Should() - .Match(t => t == "2024-01-12T12:06:59.334476+00:00" || t == "2024-01-12T11:06:59.334476+00:00"); - + node["timestamptz"].GetValue().Should().Be("2024-01-12T11:06:59.334476+00:00"); node["date"].GetValue().Should().Be("2024-01-12"); node["time"].GetValue().Should().Be("12:07:26.933545"); - - // integration server seems to have a different datetime alltogether - node["timetz"].GetValue().Should() - .Match(t => t == "12:07:44.422546+00" || t == "11:07:44.422546+00" || t == "13:07:44.422546+00"); + node["timetz"].GetValue().Should().Be("11:07:44.422546+00"); node["interval"].GetValue().Should().Be("03:20:00"); node["bool"].ToJsonString().Should().Be("true"); @@ -146,17 +139,10 @@ public async Task Test_case_get_multi_params2_reverse_order() node["double"].ToJsonString().Should().Be("2.2"); node["jsonpath"].GetValue().Should().Be("$.\"user\".\"addresses\"[0].\"city\""); node["timestamp"].GetValue().Should().Be("2024-01-12T11:59:17.811872"); - - // integration server seems to have a different datetime alltogether - node["timestamptz"].GetValue().Should() - .Match(t => t == "2024-01-12T12:06:59.334476+00:00" || t == "2024-01-12T11:06:59.334476+00:00"); - + node["timestamptz"].GetValue().Should().Be("2024-01-12T11:06:59.334476+00:00"); node["date"].GetValue().Should().Be("2024-01-12"); node["time"].GetValue().Should().Be("12:07:26.933545"); - - // integration server seems to have a different datetime alltogether - node["timetz"].GetValue().Should() - .Match(t => t == "12:07:44.422546+00" || t == "11:07:44.422546+00" || t == "13:07:44.422546+00"); + node["timetz"].GetValue().Should().Be("11:07:44.422546+00"); node["interval"].GetValue().Should().Be("03:20:00"); node["bool"].ToJsonString().Should().Be("true"); diff --git a/NpgsqlRestTests/ParamTests/MultiParamsTests2.cs b/NpgsqlRestTests/ParamTests/MultiParamsTests2.cs index baace59a..61626462 100644 --- a/NpgsqlRestTests/ParamTests/MultiParamsTests2.cs +++ b/NpgsqlRestTests/ParamTests/MultiParamsTests2.cs @@ -91,15 +91,10 @@ public async Task Test_case_multi_params2() node["double"].ToJsonString().Should().Be("2.2"); node["jsonpath"].GetValue().Should().Be("$.\"user\".\"addresses\"[0].\"city\""); node["timestamp"].GetValue().Should().Be("2024-01-12T11:59:17.811872"); - // integration server seems to have a different datetime alltogether - node["timestamptz"].GetValue().Should() - .Match(t => t == "2024-01-12T12:06:59.334476+00:00" || t == "2024-01-12T11:06:59.334476+00:00"); + node["timestamptz"].GetValue().Should().Be("2024-01-12T11:06:59.334476+00:00"); node["date"].GetValue().Should().Be("2024-01-12"); node["time"].GetValue().Should().Be("12:07:26.933545"); - - // integration server seems to have a different datetime alltogether - node["timetz"].GetValue().Should() - .Match(t => t == "12:07:44.422546+00" || t == "11:07:44.422546+00" || t == "13:07:44.422546+00"); + node["timetz"].GetValue().Should().Be("11:07:44.422546+00"); node["interval"].GetValue().Should().Be("03:20:00"); node["bool"].ToJsonString().Should().Be("true"); @@ -146,15 +141,10 @@ public async Task Test_case_multi_params2_reverse_order() node["double"].ToJsonString().Should().Be("2.2"); node["jsonpath"].GetValue().Should().Be("$.\"user\".\"addresses\"[0].\"city\""); node["timestamp"].GetValue().Should().Be("2024-01-12T11:59:17.811872"); - // integration server seems to have a different datetime alltogether - node["timestamptz"].GetValue().Should() - .Match(t => t == "2024-01-12T12:06:59.334476+00:00" || t == "2024-01-12T11:06:59.334476+00:00"); + node["timestamptz"].GetValue().Should().Be("2024-01-12T11:06:59.334476+00:00"); node["date"].GetValue().Should().Be("2024-01-12"); node["time"].GetValue().Should().Be("12:07:26.933545"); - - // integration server seems to have a different datetime alltogether - node["timetz"].GetValue().Should() - .Match(t => t == "12:07:44.422546+00" || t == "11:07:44.422546+00" || t == "13:07:44.422546+00"); + node["timetz"].GetValue().Should().Be("11:07:44.422546+00"); node["interval"].GetValue().Should().Be("03:20:00"); node["bool"].ToJsonString().Should().Be("true"); diff --git a/changelog/v3.16.0.md b/changelog/v3.16.0.md new file mode 100644 index 00000000..830ea1a3 --- /dev/null +++ b/changelog/v3.16.0.md @@ -0,0 +1,69 @@ +# Changelog v3.15.3 (2026-05-20) + +## Version [3.15.3](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.15.3) (2026-05-20) + +[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.15.2...3.15.3) + +Patch release fixing a long-standing class of bugs in the JSON-to-parameter parsers for the `timestamp`, `timestamptz`, `time`, and `timetz` PostgreSQL types: the parsers were silently shifting incoming values by the host process's UTC offset. The shift was invisible on UTC hosts (the default for `mcr.microsoft.com/dotnet/aspnet` and almost every Linux container) and only surfaced once the same image ran somewhere with `TZ` set to anything else — a Windows dev box, a Kubernetes pod with `TZ` overridden, or a non-UTC CI runner — at which point stored values diverged from the JSON the caller sent by the host's offset. + +## Fix: datetime parsers are now host-TZ-independent + +`TryParseTimestamp`, `TryParseTimestampTz`, `TryParseTime`, and `TryParseTimeTz` in `NpgsqlRest/ParameterParsers.cs` all relied on the parameter-less `DateTime.TryParse(value)` overload. That overload's default `DateTimeStyles.None` converts offset-bearing strings to the host's local TZ and tags the result `Kind=Local`. The two `*Tz` parsers then called `DateTime.SpecifyKind(v, DateTimeKind.Utc)` on the local-shifted value — but `SpecifyKind` only relabels the kind, it does not convert. The result was a host-local wall-clock value labelled UTC, written to Postgres with a silent shift. + +The `timestamp` and `time` parsers used the same buggy parse and sent the local-shifted value directly to Npgsql, which transmits the wall-clock verbatim for a `without time zone` column — the same silent shift, same size as the host's offset. + +All four parsers now use: + +```csharp +DateTime.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var v) +``` + +- `AssumeUniversal` treats naive ISO strings (no `Z`, no offset) as UTC — the canonical JSON-over-HTTP convention. +- `AdjustToUniversal` converts any `Z`-bearing or offset-bearing value to UTC. + +The result is a `DateTime` with `Kind=Utc` carrying the true UTC instant regardless of the host's TZ. The `*Tz` parsers use it directly. The `without time zone` parsers strip the kind back to `Unspecified` so Npgsql sends the UTC clock-time as the naive wall-clock value, matching the column semantics. + +## Why this was hidden so long + +Almost every production container runs `TZ=UTC` by default. On a UTC host, `DateTime.TryParse(...)`'s local-conversion is a no-op and the `SpecifyKind(Utc)` "lie" coincidentally matches reality. The bug only manifests once the same code is deployed where `TZ` is anything else. The first symptom is usually a downstream report along the lines of "we send `2026-05-20T06:00:00Z` and Postgres stored `08:00`" — which is exactly the host's UTC offset. + +The existing `MultiParamsTests2` / `MultiParamsQueryStringTests2` test pairs already hinted at this — both used `Should().Match(t => t == "12:06:59..." || t == "11:06:59...")` style assertions with a comment that read *"integration server seems to have a different datetime alltogether"*. That was the bug, papered over. After this fix both tests assert single deterministic values. + +## `TryParseDate` left alone + +`DateOnly.TryParse` rejects `Z`- and offset-bearing strings outright (verified across `UTC`, `America/Los_Angeles`, `Europe/Zagreb`, `Pacific/Auckland`) — it does not silently shift, so the `date` parser is not affected. + +## Breaking change + +JSON timestamps are now interpreted as **UTC**: + +- `Z`-suffixed and offset-bearing ISO strings are converted to UTC. +- Naive ISO strings (no offset, no `Z`) are **assumed UTC** rather than interpreted as host-local time. + +Callers who relied on the previous "JSON is host-local" behavior — usually by accident, because the host happened to be UTC — will see no change. Callers who sent `Z` strings expecting UTC were silently broken on non-UTC hosts and are now correct. + +## Tests + +New file `NpgsqlRestTests/HostTimeZoneIndependenceTests.cs` covers all four parsers via echo functions and `json_build_object` round-trips: + +- `timestamptz` with `Z` suffix, with numeric offset, and naive (assumed UTC) +- `timestamp` with `Z` suffix (stored as naive UTC clock-time) +- `timetz` with `Z` suffix (round-trips as UTC) +- `time` with `Z` suffix (UTC clock-time extracted) + +Each assertion is exact — no host-TZ-tolerant ORs. The fixture forces the database to UTC at creation (`alter database … set timezone to 'UTC'`), so the assertions stay deterministic across runners. To verify host-TZ independence at the parser layer, run the suite under a non-UTC `TZ` env var (`TZ=America/Los_Angeles dotnet test`, for example) — the tests must still pass. + +The two existing `MultiParams*` tests had their loose `Should().Match(...)` assertions for `timestamptz` and `timetz` replaced with single-value `Should().Be(...)` assertions, now that the parsers produce deterministic output. + +## Files touched + +- `NpgsqlRest/ParameterParsers.cs` — four parsers switched to `AssumeUniversal | AdjustToUniversal`. +- `NpgsqlRestTests/HostTimeZoneIndependenceTests.cs` — new, six tests covering the four type variants. +- `NpgsqlRestTests/ParamTests/MultiParamsTests2.cs` — tightened `timestamptz` / `timetz` assertions. +- `NpgsqlRestTests/ParamTests/MultiParamsQueryStringTests2.cs` — same. + +No config changes, no API surface changes. diff --git a/npm/package.json b/npm/package.json index 173e5595..c52e7b6d 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "npgsqlrest", - "version": "3.15.2", + "version": "3.15.3", "description": "Automatic REST API for PostgreSQL Databases Client Build", "scripts": { "postinstall": "node postinstall.js", diff --git a/npm/postinstall.js b/npm/postinstall.js index 099638a0..4a75bd3b 100644 --- a/npm/postinstall.js +++ b/npm/postinstall.js @@ -5,7 +5,7 @@ const path = require("path"); const os = require("os"); const https = require("https"); -const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.15.2/"; +const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.15.3/"; // Download binary next to this script, not to ../.bin/ const binDir = path.join(__dirname, "bin"); diff --git a/plugins/NpgsqlRest.CrudSource/NpgsqlRest.CrudSource.csproj b/plugins/NpgsqlRest.CrudSource/NpgsqlRest.CrudSource.csproj index f1fca289..c25f6c67 100644 --- a/plugins/NpgsqlRest.CrudSource/NpgsqlRest.CrudSource.csproj +++ b/plugins/NpgsqlRest.CrudSource/NpgsqlRest.CrudSource.csproj @@ -40,7 +40,7 @@ - + diff --git a/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj b/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj index 815eebe9..953ad6b6 100644 --- a/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj +++ b/plugins/NpgsqlRest.HttpFiles/NpgsqlRest.HttpFiles.csproj @@ -40,7 +40,7 @@ - + diff --git a/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj b/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj index 3d1e7004..52957486 100644 --- a/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj +++ b/plugins/NpgsqlRest.OpenApi/NpgsqlRest.OpenApi.csproj @@ -40,7 +40,7 @@ - + diff --git a/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj b/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj index e8f6ea10..7b6d7cb3 100644 --- a/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj +++ b/plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj @@ -38,7 +38,7 @@ - + diff --git a/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj b/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj index 2652eee3..8b7d95bb 100644 --- a/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj +++ b/plugins/NpgsqlRest.TsClient/NpgsqlRest.TsClient.csproj @@ -38,7 +38,7 @@ - + From ccbab1231a0d687dca01693dbb51217d3f964b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 20 May 2026 11:18:03 +0200 Subject: [PATCH 2/5] bump version strings to 3.16.0 The previous squash committed the staged (3.15.3) version of these files but left the 3.16.0 working-tree edits unstaged. This commit catches them up. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build-test-publish.yml | 2 +- NpgsqlRest/NpgsqlRest.csproj | 8 ++++---- NpgsqlRestClient/NpgsqlRestClient.csproj | 2 +- changelog/v3.16.0.md | 8 ++++---- npm/package.json | 2 +- npm/postinstall.js | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-test-publish.yml b/.github/workflows/build-test-publish.yml index 335507c4..d1ef781f 100644 --- a/.github/workflows/build-test-publish.yml +++ b/.github/workflows/build-test-publish.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: env: - RELEASE_VERSION: v3.15.3 + RELEASE_VERSION: v3.16.0 # Add workflow-level permissions permissions: diff --git a/NpgsqlRest/NpgsqlRest.csproj b/NpgsqlRest/NpgsqlRest.csproj index 1819feb5..533cf80c 100644 --- a/NpgsqlRest/NpgsqlRest.csproj +++ b/NpgsqlRest/NpgsqlRest.csproj @@ -29,10 +29,10 @@ true README.MD bin\$(Configuration)\$(AssemblyName).xml - 3.15.3 - 3.15.3 - 3.15.3 - 3.15.3 + 3.16.0 + 3.16.0 + 3.16.0 + 3.16.0 true diff --git a/NpgsqlRestClient/NpgsqlRestClient.csproj b/NpgsqlRestClient/NpgsqlRestClient.csproj index 7e341b8c..ef6ac5c3 100644 --- a/NpgsqlRestClient/NpgsqlRestClient.csproj +++ b/NpgsqlRestClient/NpgsqlRestClient.csproj @@ -10,7 +10,7 @@ true true full - 3.15.3 + 3.16.0 diff --git a/changelog/v3.16.0.md b/changelog/v3.16.0.md index 830ea1a3..6ebf1f5e 100644 --- a/changelog/v3.16.0.md +++ b/changelog/v3.16.0.md @@ -1,10 +1,10 @@ -# Changelog v3.15.3 (2026-05-20) +# Changelog v3.16.0 (2026-05-20) -## Version [3.15.3](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.15.3) (2026-05-20) +## Version [3.16.0](https://github.com/NpgsqlRest/NpgsqlRest/tree/3.16.0) (2026-05-20) -[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.15.2...3.15.3) +[Full Changelog](https://github.com/NpgsqlRest/NpgsqlRest/compare/3.15.2...3.16.0) -Patch release fixing a long-standing class of bugs in the JSON-to-parameter parsers for the `timestamp`, `timestamptz`, `time`, and `timetz` PostgreSQL types: the parsers were silently shifting incoming values by the host process's UTC offset. The shift was invisible on UTC hosts (the default for `mcr.microsoft.com/dotnet/aspnet` and almost every Linux container) and only surfaced once the same image ran somewhere with `TZ` set to anything else — a Windows dev box, a Kubernetes pod with `TZ` overridden, or a non-UTC CI runner — at which point stored values diverged from the JSON the caller sent by the host's offset. +Minor release fixing a long-standing class of bugs in the JSON-to-parameter parsers for the `timestamp`, `timestamptz`, `time`, and `timetz` PostgreSQL types: the parsers were silently shifting incoming values by the host process's UTC offset. Bumped to minor (not patch) because the corrected behavior changes how naive ISO strings (no `Z`, no offset) are interpreted on non-UTC hosts — see *Breaking change* below. The shift was invisible on UTC hosts (the default for `mcr.microsoft.com/dotnet/aspnet` and almost every Linux container) and only surfaced once the same image ran somewhere with `TZ` set to anything else — a Windows dev box, a Kubernetes pod with `TZ` overridden, or a non-UTC CI runner — at which point stored values diverged from the JSON the caller sent by the host's offset. ## Fix: datetime parsers are now host-TZ-independent diff --git a/npm/package.json b/npm/package.json index c52e7b6d..dad0b104 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "npgsqlrest", - "version": "3.15.3", + "version": "3.16.0", "description": "Automatic REST API for PostgreSQL Databases Client Build", "scripts": { "postinstall": "node postinstall.js", diff --git a/npm/postinstall.js b/npm/postinstall.js index 4a75bd3b..89e0ed9d 100644 --- a/npm/postinstall.js +++ b/npm/postinstall.js @@ -5,7 +5,7 @@ const path = require("path"); const os = require("os"); const https = require("https"); -const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.15.3/"; +const downloadFrom = "https://github.com/NpgsqlRest/NpgsqlRest/releases/download/v3.16.0/"; // Download binary next to this script, not to ../.bin/ const binDir = path.join(__dirname, "bin"); From 4cb5cd47065c9c62b6bd99284abf9603bd0bba1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 20 May 2026 11:24:17 +0200 Subject: [PATCH 3/5] feat: add JsonTimestampsAreUtc opt-out for legacy host-local behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a compatibility escape hatch for the breaking change introduced by the v3.16.0 datetime-parser fix. When set to false, the four parsers revert to the bare DateTime.TryParse(value) overload — restoring the pre-3.16.0 code path verbatim (Z/offset strings get host-local-shifted, naive strings get Kind=Unspecified, *Tz parsers apply SpecifyKind(Utc) on top). Default is true (the new, correct behavior). - NpgsqlRestOptions.JsonTimestampsAreUtc (bool, default true) — new top-level option on the library. - ParameterParsers.JsonTimestampsAreUtc static field — process-wide toggle, set from options in NpgsqlRestBuilder. Matches the existing static-class pattern of ParameterParsers. - Client config wiring: ConfigDefaults / ConfigTemplate / ConfigSchemaGenerator entries plus Program.cs reads via GetConfigBool("JsonTimestampsAreUtc", ..., true). Six new unit tests verify the plumbing: flag defaults to true, can be toggled, parsers still produce valid results for all four type variants in legacy mode, and invalid input still returns false. Tests are placed in the TestFixture collection so static-state mutations are serialized against the integration suite. (Real behavioral coverage of legacy mode requires a non-UTC TZ environment; on UTC hosts both modes produce the same wire-level output.) Changelog v3.16.0 gains an "Opt-out" section documenting the flag and explaining why it is not recommended for new deployments. Co-Authored-By: Claude Opus 4.7 --- NpgsqlRest/NpgsqlRestBuilder.cs | 1 + NpgsqlRest/Options/NpgsqlRestOptions.cs | 11 ++ NpgsqlRest/ParameterParsers.cs | 57 ++++++-- NpgsqlRestClient/ConfigDefaults.cs | 1 + NpgsqlRestClient/ConfigSchemaGenerator.cs | 1 + NpgsqlRestClient/ConfigTemplate.cs | 9 ++ NpgsqlRestClient/Program.cs | 1 + .../JsonTimestampsAreUtcOptOutTests.cs | 124 ++++++++++++++++++ changelog/v3.16.0.md | 9 ++ 9 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 NpgsqlRestTests/JsonTimestampsAreUtcOptOutTests.cs diff --git a/NpgsqlRest/NpgsqlRestBuilder.cs b/NpgsqlRest/NpgsqlRestBuilder.cs index 8ec6b9bd..a7b87d30 100644 --- a/NpgsqlRest/NpgsqlRestBuilder.cs +++ b/NpgsqlRest/NpgsqlRestBuilder.cs @@ -40,6 +40,7 @@ public static IApplicationBuilder UseNpgsqlRest(this WebApplication builder, Npg } Options = options; + ParameterParsers.JsonTimestampsAreUtc = options.JsonTimestampsAreUtc; var factory = builder.Services.GetRequiredService(); Logger = factory.CreateLogger(options.LoggerName ?? typeof(NpgsqlRestBuilder).Namespace ?? "NpgsqlRest"); diff --git a/NpgsqlRest/Options/NpgsqlRestOptions.cs b/NpgsqlRest/Options/NpgsqlRestOptions.cs index 7b2e812c..4c98d280 100644 --- a/NpgsqlRest/Options/NpgsqlRestOptions.cs +++ b/NpgsqlRest/Options/NpgsqlRestOptions.cs @@ -262,6 +262,17 @@ public NpgsqlRestOptions(NpgsqlDataSource dataSource) /// public bool WrapInTransaction { get; set; } = false; + /// + /// Controls how JSON datetime strings are interpreted when bound to `timestamp`, `timestamptz`, `time`, or `timetz` + /// parameters. When true (the default, since 3.16.0), `Z`- and offset-bearing strings are converted to UTC and naive + /// strings (no offset, no `Z`) are assumed UTC — making stored values identical regardless of the host process's + /// `TZ` environment. When false, the legacy pre-3.16.0 behavior is restored: `DateTime.TryParse` interprets the + /// string in the host's local time zone, which silently shifts stored values by the host's UTC offset on non-UTC + /// hosts. Only set to false if you have callers that depend on the host-local interpretation of naive datetime + /// strings and you cannot update them to send `Z`-suffixed values. + /// + public bool JsonTimestampsAreUtc { get; set; } = true; + /// /// SQL commands executed after any context is set but before the main routine call. They run in the same batch as the /// context `set_config` calls, so no extra round-trip. Each command can be either a raw SQL string (no parameters) or diff --git a/NpgsqlRest/ParameterParsers.cs b/NpgsqlRest/ParameterParsers.cs index b71c2e14..f191643e 100644 --- a/NpgsqlRest/ParameterParsers.cs +++ b/NpgsqlRest/ParameterParsers.cs @@ -159,13 +159,28 @@ private static bool TryParseBoolean(string? value, out object? result) private const DateTimeStyles UtcStyles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal; + /// + /// Process-wide toggle wired from . When true (default), the + /// datetime parsers convert any input to UTC and assume naive strings are UTC. When false, the legacy pre-3.16.0 + /// host-local-interpretation behavior is restored. + /// + public static bool JsonTimestampsAreUtc = true; + private static bool TryParseTimestamp(string? value, out object? result) { - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) + if (JsonTimestampsAreUtc) { - // Strip Kind so Npgsql sends the UTC clock-time verbatim as the - // naive wall-clock value for `timestamp without time zone`. - result = DateTime.SpecifyKind(v, DateTimeKind.Unspecified); + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) + { + // Strip Kind so Npgsql sends the UTC clock-time verbatim as the + // naive wall-clock value for `timestamp without time zone`. + result = DateTime.SpecifyKind(v, DateTimeKind.Unspecified); + return true; + } + } + else if (DateTime.TryParse(value, out var v)) + { + result = v; return true; } result = null; @@ -174,9 +189,17 @@ private static bool TryParseTimestamp(string? value, out object? result) private static bool TryParseTimestampTz(string? value, out object? result) { - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) + if (JsonTimestampsAreUtc) { - result = v; + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) + { + result = v; + return true; + } + } + else if (DateTime.TryParse(value, out var v)) + { + result = DateTime.SpecifyKind(v, DateTimeKind.Utc); return true; } result = null; @@ -196,7 +219,15 @@ private static bool TryParseDate(string? value, out object? result) private static bool TryParseTime(string? value, out object? result) { - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) + if (JsonTimestampsAreUtc) + { + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) + { + result = TimeOnly.FromDateTime(v); + return true; + } + } + else if (DateTime.TryParse(value, out var v)) { result = TimeOnly.FromDateTime(v); return true; @@ -207,9 +238,17 @@ private static bool TryParseTime(string? value, out object? result) private static bool TryParseTimeTz(string? value, out object? result) { - if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) + if (JsonTimestampsAreUtc) + { + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var v)) + { + result = new DateTimeOffset(v, TimeSpan.Zero); + return true; + } + } + else if (DateTime.TryParse(value, out var v)) { - result = new DateTimeOffset(v, TimeSpan.Zero); + result = new DateTimeOffset(DateTime.SpecifyKind(v, DateTimeKind.Utc)); return true; } result = null; diff --git a/NpgsqlRestClient/ConfigDefaults.cs b/NpgsqlRestClient/ConfigDefaults.cs index 66b59809..af70ebfa 100644 --- a/NpgsqlRestClient/ConfigDefaults.cs +++ b/NpgsqlRestClient/ConfigDefaults.cs @@ -760,6 +760,7 @@ private static JsonObject GetNpgsqlRestDefaults() ["RequestHeadersContextKey"] = "request.headers", ["RequestHeadersParameterName"] = "_headers", ["WrapInTransaction"] = false, + ["JsonTimestampsAreUtc"] = true, ["BeforeRoutineCommands"] = new JsonArray( new JsonObject { diff --git a/NpgsqlRestClient/ConfigSchemaGenerator.cs b/NpgsqlRestClient/ConfigSchemaGenerator.cs index 6ce1b824..15866fe2 100644 --- a/NpgsqlRestClient/ConfigSchemaGenerator.cs +++ b/NpgsqlRestClient/ConfigSchemaGenerator.cs @@ -323,6 +323,7 @@ public static partial class ConfigSchemaGenerator ["NpgsqlRest:RequestHeadersContextKey"] = "Name of the context variable that will receive the request headers when RequestHeadersMode is set to Context.", ["NpgsqlRest:RequestHeadersParameterName"] = "Sets a parameter name that will receive a request headers JSON when the `Parameter` value is used in `RequestHeadersMode` options. A parameter with this name must exist, must be one of the JSON or text types and must have the default value defined. This option for individual endpoints can be changed with the `EndpointCreated` function callback, or by using comment annotations.", ["NpgsqlRest:WrapInTransaction"] = "When true, **every request** is wrapped in an explicit BEGIN/COMMIT, and all `set_config` calls switch to the transaction-local form (`is_local=true`).\n**Required when using a connection pooler in transaction mode** (PgBouncer transaction-pool, AWS RDS Proxy in transaction mode, Supabase Pooler) — without this, the backend can be reused across unrelated requests, allowing GUC state (and the routine call itself) to leak or split mid-request.\nDefault is false to preserve existing behavior; safe to leave off when using Npgsql's native pool only.", + ["NpgsqlRest:JsonTimestampsAreUtc"] = "Controls how JSON datetime strings are interpreted when bound to `timestamp`, `timestamptz`, `time`, or `timetz` parameters.\nWhen true (default since 3.16.0), `Z`- and offset-bearing strings are converted to UTC and naive strings (no offset, no `Z`) are assumed UTC — making stored values identical regardless of the host process's `TZ` environment.\nWhen false, the legacy pre-3.16.0 behavior is restored: `DateTime.TryParse` interprets the string in the host's local time zone, which silently shifts stored values by the host's UTC offset on non-UTC hosts. Only set to false if you have callers that depend on the host-local interpretation of naive datetime strings and you cannot update them to send `Z`-suffixed values.", ["NpgsqlRest:BeforeRoutineCommands"] = "SQL commands executed after any context is set but before the main routine call. They run in the same batch as the context `set_config` calls (no extra round-trip). Each array entry is either:\n- A raw SQL string (always runs, no parameters), or\n- An object with `Enabled`, `Sql`, and optional `Parameters`. Object entries are gated by `Enabled` (default false) — set `\"Enabled\": true` to activate.\nEach parameter has a `Source` (`Claim`, `RequestHeader`, or `IpAddress`) and an optional `Name` (claim type or header name; ignored for IpAddress). Values are bound at request time via parameterized SQL.\nCommon use case: setting `search_path` from a tenant claim for multi-tenant deployments. Combine with `WrapInTransaction = true` for transaction-local scoping.", ["NpgsqlRest:BeforeRoutineCommands:Enabled"] = "When false (default), this command is ignored at startup. Set to true to register the command. Logged at startup whether enabled or skipped.", ["NpgsqlRest:BeforeRoutineCommands:Sql"] = "The SQL text to execute. May contain positional parameters ($1, $2, ...) bound from the Parameters list at request time.", diff --git a/NpgsqlRestClient/ConfigTemplate.cs b/NpgsqlRestClient/ConfigTemplate.cs index 6b55812d..3861ccdd 100644 --- a/NpgsqlRestClient/ConfigTemplate.cs +++ b/NpgsqlRestClient/ConfigTemplate.cs @@ -1940,6 +1940,15 @@ public static partial class ConfigSchemaGenerator // "WrapInTransaction": false, // + // Controls how JSON datetime strings are interpreted when bound to timestamp / timestamptz / time / timetz parameters. + // When true (default since 3.16.0), Z- and offset-bearing strings are converted to UTC, and naive strings (no offset, no Z) + // are assumed UTC. Stored values are then identical regardless of the host process's TZ environment. + // When false, the legacy pre-3.16.0 behavior is restored: DateTime.TryParse interprets the string in the host's local time + // zone, which silently shifts stored values by the host's UTC offset on non-UTC hosts. Only set to false if you have callers + // that depend on host-local interpretation of naive datetime strings and you cannot update them to send Z-suffixed values. + // + "JsonTimestampsAreUtc": true, + // // SQL commands executed after any context is set but before the main routine call. Run in the same batch as the // context `set_config` calls (no extra round-trip). Each entry can be either: // - A raw SQL string (always runs, no parameters), or diff --git a/NpgsqlRestClient/Program.cs b/NpgsqlRestClient/Program.cs index fc723091..0d141a06 100644 --- a/NpgsqlRestClient/Program.cs +++ b/NpgsqlRestClient/Program.cs @@ -455,6 +455,7 @@ RequestHeadersContextKey = config.GetConfigStr("RequestHeadersContextKey", config.NpgsqlRestCfg) ?? "request.headers", RequestHeadersParameterName = config.GetConfigStr("RequestHeadersParameterName", config.NpgsqlRestCfg) ?? "_headers", WrapInTransaction = config.GetConfigBool("WrapInTransaction", config.NpgsqlRestCfg, false), + JsonTimestampsAreUtc = config.GetConfigBool("JsonTimestampsAreUtc", config.NpgsqlRestCfg, true), BeforeRoutineCommands = builder.BuildBeforeRoutineCommands(), EndpointCreated = appInstance.CreateEndpointCreatedHandler(authCfg), diff --git a/NpgsqlRestTests/JsonTimestampsAreUtcOptOutTests.cs b/NpgsqlRestTests/JsonTimestampsAreUtcOptOutTests.cs new file mode 100644 index 00000000..5ddd70f9 --- /dev/null +++ b/NpgsqlRestTests/JsonTimestampsAreUtcOptOutTests.cs @@ -0,0 +1,124 @@ +#pragma warning disable CS8602 // Dereference of a possibly null reference. +using NpgsqlTypes; + +namespace NpgsqlRestTests; + +// Unit-level coverage for the `JsonTimestampsAreUtc` opt-out toggle on ParameterParsers. +// Visible behavioral differences between the two modes only manifest on non-UTC hosts +// (on a UTC host, the host-local-conversion of legacy mode is a no-op and the wire-level +// output is identical to the new mode). These tests therefore verify the *plumbing*: the +// flag is settable, restores correctly, and the parsers continue to produce valid results +// in both modes. The genuine non-UTC behavioral coverage belongs in a CI matrix step that +// re-runs the suite under `TZ=America/Los_Angeles` (or similar) — there, the legacy mode +// produces a host-offset-shifted value while the default mode produces the correct UTC +// instant. +// [Collection("TestFixture")] serializes against the integration-test collection so the +// static `ParameterParsers.JsonTimestampsAreUtc` flag mutations below cannot race with +// HTTP tests that exercise the parsers in another collection. +[Collection("TestFixture")] +public class JsonTimestampsAreUtcOptOutTests +{ + [Fact] + public void FlagDefaultsToTrue() + { + ParameterParsers.JsonTimestampsAreUtc.Should().BeTrue(); + } + + [Fact] + public void FlagOff_TimestampTz_NaiveString_ParsesSuccessfully() + { + var parser = ParameterParsers.GetParser(NpgsqlDbType.TimestampTz); + parser.Should().NotBeNull(); + + var previous = ParameterParsers.JsonTimestampsAreUtc; + try + { + ParameterParsers.JsonTimestampsAreUtc = false; + var ok = parser!("2026-05-20T06:00:00", out var v); + ok.Should().BeTrue(); + v.Should().BeOfType(); + ((DateTime)v!).Kind.Should().Be(DateTimeKind.Utc); + } + finally + { + ParameterParsers.JsonTimestampsAreUtc = previous; + } + } + + [Fact] + public void FlagOff_Timestamp_NaiveString_ReturnsUnspecifiedKind() + { + var parser = ParameterParsers.GetParser(NpgsqlDbType.Timestamp); + + var previous = ParameterParsers.JsonTimestampsAreUtc; + try + { + ParameterParsers.JsonTimestampsAreUtc = false; + var ok = parser!("2026-05-20T06:00:00", out var v); + ok.Should().BeTrue(); + ((DateTime)v!).Kind.Should().Be(DateTimeKind.Unspecified); + } + finally + { + ParameterParsers.JsonTimestampsAreUtc = previous; + } + } + + [Fact] + public void FlagOff_TimeTz_NaiveString_ReturnsDateTimeOffset() + { + var parser = ParameterParsers.GetParser(NpgsqlDbType.TimeTz); + + var previous = ParameterParsers.JsonTimestampsAreUtc; + try + { + ParameterParsers.JsonTimestampsAreUtc = false; + var ok = parser!("06:00:00", out var v); + ok.Should().BeTrue(); + v.Should().BeOfType(); + } + finally + { + ParameterParsers.JsonTimestampsAreUtc = previous; + } + } + + [Fact] + public void FlagOff_Time_NaiveString_ReturnsTimeOnly() + { + var parser = ParameterParsers.GetParser(NpgsqlDbType.Time); + + var previous = ParameterParsers.JsonTimestampsAreUtc; + try + { + ParameterParsers.JsonTimestampsAreUtc = false; + var ok = parser!("06:00:00", out var v); + ok.Should().BeTrue(); + v.Should().BeOfType(); + ((TimeOnly)v!).Hour.Should().Be(6); + } + finally + { + ParameterParsers.JsonTimestampsAreUtc = previous; + } + } + + [Fact] + public void FlagOff_InvalidInput_ReturnsFalse() + { + var parser = ParameterParsers.GetParser(NpgsqlDbType.TimestampTz); + + var previous = ParameterParsers.JsonTimestampsAreUtc; + try + { + ParameterParsers.JsonTimestampsAreUtc = false; + var ok = parser!("not-a-date", out var v); + ok.Should().BeFalse(); + v.Should().BeNull(); + } + finally + { + ParameterParsers.JsonTimestampsAreUtc = previous; + } + } +} diff --git a/changelog/v3.16.0.md b/changelog/v3.16.0.md index 6ebf1f5e..fac027b8 100644 --- a/changelog/v3.16.0.md +++ b/changelog/v3.16.0.md @@ -46,6 +46,15 @@ JSON timestamps are now interpreted as **UTC**: Callers who relied on the previous "JSON is host-local" behavior — usually by accident, because the host happened to be UTC — will see no change. Callers who sent `Z` strings expecting UTC were silently broken on non-UTC hosts and are now correct. +## Opt-out: `NpgsqlRestOptions.JsonTimestampsAreUtc` + +Users whose downstream code genuinely depends on the legacy "naive timestamps are host-local" behavior — and who cannot update those callers to send `Z`-suffixed values — can restore the pre-3.16.0 behavior by setting `JsonTimestampsAreUtc` to `false`: + +- **Library:** `new NpgsqlRestOptions { JsonTimestampsAreUtc = false, ... }`. +- **Client (`appsettings.json`):** `"NpgsqlRest": { "JsonTimestampsAreUtc": false }` (default is `true`). + +When false, the four parsers fall back to the bare `DateTime.TryParse(value)` overload — `Z`/offset strings get host-local-converted and tagged `Kind=Local`, naive strings get parsed as `Kind=Unspecified`, and the `*Tz` parsers re-apply `SpecifyKind(Utc)` on top. That reproduces the exact pre-3.16.0 code path. Note that this is **not recommended** for new deployments: it puts you back in the bug class the rest of this release fixes. The flag exists purely as a compatibility escape hatch. + ## Tests New file `NpgsqlRestTests/HostTimeZoneIndependenceTests.cs` covers all four parsers via echo functions and `json_build_object` round-trips: From 0158fb99f9cd9e1496443cf22ffb28010965f7b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 20 May 2026 11:31:53 +0200 Subject: [PATCH 4/5] sync appsettings.json with JsonTimestampsAreUtc opt-out appsettings.json is the source of truth; ConfigTemplate.cs mirrors it. The previous commit added the entry to ConfigTemplate.cs but missed appsettings.json itself. Co-Authored-By: Claude Opus 4.7 --- NpgsqlRestClient/appsettings.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NpgsqlRestClient/appsettings.json b/NpgsqlRestClient/appsettings.json index 4a0343ab..d222ecf8 100644 --- a/NpgsqlRestClient/appsettings.json +++ b/NpgsqlRestClient/appsettings.json @@ -1931,6 +1931,15 @@ // "WrapInTransaction": false, // + // Controls how JSON datetime strings are interpreted when bound to timestamp / timestamptz / time / timetz parameters. + // When true (default since 3.16.0), Z- and offset-bearing strings are converted to UTC, and naive strings (no offset, no Z) + // are assumed UTC. Stored values are then identical regardless of the host process's TZ environment. + // When false, the legacy pre-3.16.0 behavior is restored: DateTime.TryParse interprets the string in the host's local time + // zone, which silently shifts stored values by the host's UTC offset on non-UTC hosts. Only set to false if you have callers + // that depend on host-local interpretation of naive datetime strings and you cannot update them to send Z-suffixed values. + // + "JsonTimestampsAreUtc": true, + // // SQL commands executed after any context is set but before the main routine call. Run in the same batch as the // context `set_config` calls (no extra round-trip). Each entry can be either: // - A raw SQL string (always runs, no parameters), or From f350c7ecce3d0dd22baf57a673de0d99158c4b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Bilopavlovi=C4=87?= Date: Wed, 20 May 2026 11:35:34 +0200 Subject: [PATCH 5/5] feat: TryParseDate accepts Z- and offset-bearing strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DateOnly.TryParse rejects any ISO string carrying a Z suffix or numeric offset (e.g. "2026-05-20T03:00:00Z") outright, returning false. That left callers who send full ISO timestamps to a date parameter with a flat parse failure — they had to strip the time portion client-side or the request died before reaching Postgres. TryParseDate now keeps DateOnly.TryParse as the fast path (unchanged behavior for naked dates and naive date-time strings) and falls back to DateTime.TryParse + DateOnly.FromDateTime for Z- and offset-bearing inputs. The fallback honors JsonTimestampsAreUtc: when true it uses AssumeUniversal | AdjustToUniversal so the extracted date is the UTC calendar day; when false it uses bare DateTime.TryParse so the date matches the host's local calendar day. Strictly broader accepted-input set, zero change for inputs that already worked. Five new tests in HostTimeZoneIndependenceTests cover: bare date, naive date-time, Z-suffix early UTC, +02:00 offset crossing midnight, and a Z-suffix late-evening case that the legacy parser would have rejected entirely. Companion echo function host_tz_echo_date added to Database setup. Co-Authored-By: Claude Opus 4.7 --- NpgsqlRest/ParameterParsers.cs | 24 ++++++++ .../HostTimeZoneIndependenceTests.cs | 55 +++++++++++++++++++ changelog/v3.16.0.md | 2 +- 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/NpgsqlRest/ParameterParsers.cs b/NpgsqlRest/ParameterParsers.cs index f191643e..2d7b352d 100644 --- a/NpgsqlRest/ParameterParsers.cs +++ b/NpgsqlRest/ParameterParsers.cs @@ -208,11 +208,35 @@ private static bool TryParseTimestampTz(string? value, out object? result) private static bool TryParseDate(string? value, out object? result) { + // Fast path: bare date or naive date-time string. DateOnly.TryParse handles + // "2026-05-20" and "2026-05-20T06:00:00" but rejects any Z- or offset-bearing + // input outright. if (DateOnly.TryParse(value, out var v)) { result = v; return true; } + + // Fallback for Z- and offset-bearing strings (e.g. "2026-05-20T03:00:00Z"). The + // legacy parser returned false here, forcing callers to strip the time portion + // client-side. The fallback parses as DateTime and extracts the date portion. + // The UTC-vs-host-local semantic mirrors JsonTimestampsAreUtc so that the + // chosen date is deterministic on UTC hosts (true) or matches the host's + // calendar day (false). + if (JsonTimestampsAreUtc) + { + if (DateTime.TryParse(value, CultureInfo.InvariantCulture, UtcStyles, out var dt)) + { + result = DateOnly.FromDateTime(dt); + return true; + } + } + else if (DateTime.TryParse(value, out var dt)) + { + result = DateOnly.FromDateTime(dt); + return true; + } + result = null; return false; } diff --git a/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs b/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs index 58699d28..85599b94 100644 --- a/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs +++ b/NpgsqlRestTests/HostTimeZoneIndependenceTests.cs @@ -25,6 +25,11 @@ create function host_tz_echo_timetz(_t timetz) returns json language sql as $$ select json_build_object('v', _t) $$; + +create function host_tz_echo_date(_d date) +returns json +language sql +as $$ select json_build_object('v', _d) $$; "); } } @@ -93,4 +98,54 @@ public async Task Time_ZSuffix_StoredAsUtcClockTime() """{"t":"06:00:00Z"}"""); v.Should().Be("06:00:00"); } + + // The legacy `TryParseDate` rejected Z- and offset-bearing strings outright. The + // 3.16.0 enhancement falls back to a UTC DateTime parse and extracts the date + // portion, matching the JsonTimestampsAreUtc semantic. + [Fact] + public async Task Date_BareDateString_StillRoundTrips() + { + var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/", + """{"d":"2026-05-20"}"""); + v.Should().Be("2026-05-20"); + } + + [Fact] + public async Task Date_NaiveDateTimeString_TakesDatePortion() + { + var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/", + """{"d":"2026-05-20T06:00:00"}"""); + v.Should().Be("2026-05-20"); + } + + [Fact] + public async Task Date_ZSuffix_TakesUtcDate() + { + // 2026-05-20T03:00:00Z is still May 20 in UTC. + var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/", + """{"d":"2026-05-20T03:00:00Z"}"""); + v.Should().Be("2026-05-20"); + } + + [Fact] + public async Task Date_NumericOffset_ConvertsToUtcDateFirst() + { + // 2026-05-21T01:30:00+02:00 == 2026-05-20T23:30:00 UTC → date is 2026-05-20. + // On a non-UTC host the legacy mode would shift this differently; here we + // assert the UTC interpretation that the default mode produces. + var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/", + """{"d":"2026-05-21T01:30:00+02:00"}"""); + v.Should().Be("2026-05-20"); + } + + [Fact] + public async Task Date_LateUtcEvening_DoesNotRollToNextLocalDay() + { + // 2026-05-20T23:00:00Z is May 20 in UTC. The legacy `DateOnly.TryParse` + // would have rejected this entirely; the new fallback resolves it as + // 2026-05-20 regardless of the host's local TZ. + var v = await EchoAsync(test.Client, "/api/host-tz-echo-date/", + """{"d":"2026-05-20T23:00:00Z"}"""); + v.Should().Be("2026-05-20"); + } } diff --git a/changelog/v3.16.0.md b/changelog/v3.16.0.md index fac027b8..625a9b2c 100644 --- a/changelog/v3.16.0.md +++ b/changelog/v3.16.0.md @@ -35,7 +35,7 @@ The existing `MultiParamsTests2` / `MultiParamsQueryStringTests2` test pairs alr ## `TryParseDate` left alone -`DateOnly.TryParse` rejects `Z`- and offset-bearing strings outright (verified across `UTC`, `America/Los_Angeles`, `Europe/Zagreb`, `Pacific/Auckland`) — it does not silently shift, so the `date` parser is not affected. +`DateOnly.TryParse` rejects `Z`- and offset-bearing strings outright (verified across `UTC`, `America/Los_Angeles`, `Europe/Zagreb`, `Pacific/Auckland`) — it does not silently shift, so the `date` parser was not affected by the host-TZ bug class. It was however a separate papercut: callers sending full ISO timestamps (e.g. `"2026-05-20T03:00:00Z"`) to a `date` column got a flat parse failure. `TryParseDate` now falls back to a `DateTime` parse and extracts the date portion when `DateOnly.TryParse` rejects the input, honoring the same `JsonTimestampsAreUtc` semantic as the other datetime parsers (UTC date when the flag is true, host-local date when false). ## Breaking change