Skip to content

Commit c4a03a6

Browse files
committed
convert offset date times
1 parent d19c00d commit c4a03a6

3 files changed

Lines changed: 119 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,9 @@ returns the shifted calendar date plus an offset-preserving `TimeTz`.
731731
`OffsetDateTime.formatIso` renders that local date, time, and numeric offset as
732732
one string in `types.OffsetDateTime.iso_buffer_len` bytes. Exact day-long
733733
(`+/-24:00`) offsets are accepted consistently and wider offsets remain invalid.
734+
`OffsetDateTime.toOffset(offset)` converts the same instant to another explicit
735+
offset while retaining nanosecond precision and shifting the wall-clock date as
736+
needed.
734737

735738
### Offline checks
736739

‎docs/FEATURE_MATRIX.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ coverage; PostgreSQL rows marked live also run against PostgreSQL 16 in CI.
2020
| Native PostgreSQL driver | Complete | full-open `connect_timeout`; ordered grammar-validated SCRAM attributes/nonces, allocator-bounded salt handling, constant-time raw verifier comparison, and secure secret erasure; endpoint-owning `CancelHandle`; owned notifications; clean pooled listeners; direct and short-lease pooled COPY with owned output; lifecycle-aware command recovery and failure-safe teardown after transport loss | SCRAM ordering/grammar/extension/ambiguity/error/base64/password-erasure and exhaustive allocation-failure tests; cancellation/notification/COPY ownership and OOM tests; loopback drain-loss teardown regression; live post-teardown ownership, clean pool reuse, cancellation/COPY/notification recovery in CI |
2121
| Prepared statements | Complete | driver-selected `Statement(D)`; reusable ownership-safe SQLite query borrows with busy/deferred-close state and leak-free cache-copy bind scratch; explicit PostgreSQL direct/pool owners; owned prepared-query rows survive statement/pool teardown; consuming pooled-statement recovery when explicit Close fails; reusable named-bind scratch; session-monotonic PostgreSQL names; allocation-free cache teardown; unsigned OID metadata; narrow invalidation recovery | external façade contract; SQLite reuse/OOM/teardown/text-bind-cache/schema-change tests, deterministic PostgreSQL pooled-close-failure lease cleanup, and PostgreSQL protocol/direct/pool/cache/OOM/lifetime tests |
2222
| Safe parameter binding | Complete | positional/named APIs; two-allocation SQLite named-index resolution with one unbounded shared marker probe and exact OOM errors; single-allocation PostgreSQL Bind packets; typed canonical UUID and explicit ISO date/time/UTC-timestamp binding with optional-null support across direct and batched builder paths; full unsigned 16-bit parameter count; strict protocol C-strings | long-name/allocation-boundary/failure-atomicity, typed-wrapper batch/OOM, temporal-format/state/OOM, byte-parity, NUL, driver, and PostgreSQL live tests |
23-
| Typed row decoding | Complete | `Row.as`, `Row.asName`, `Row.to`, `zsql.decode`; strict allocation-free opt-in ISO temporal parser/formatters with signed/expanded years, BC-era input, second-precision offsets, exact day-long offsets, offset-aware TimeTz plus UTC decomposition, shifted timestamp decomposition, first-class combined OffsetDateTime parsing and formatting, exact named buffer lengths, and wrapper-based row mapping (including UTC-offset normalization or explicit offset policy); allocation-free struct mapping without arbitrary result-width caps; owned PostgreSQL bytea hex/escape decode; single-schema query contract; failure-atomic PostgreSQL result finalization | temporal valid/invalid/precision/full-range/BC/TimeTz/day-boundary/day-offset/offset-decomposition/offset-formatting/offset-date-time-parsing/public-alias/deterministic-round-trip/wrapper-mapping/buffer-boundary tests; QueryBuilder joined-binding exhaustive-OOM tests; malformed/OOM unit tests; forced column-name append/final-transfer failures; wide-result mapping, multi-result recovery, and core/live driver tests |
23+
| Typed row decoding | Complete | `Row.as`, `Row.asName`, `Row.to`, `zsql.decode`; strict allocation-free opt-in ISO temporal parser/formatters with signed/expanded years, BC-era input, second-precision offsets, exact day-long offsets, offset-aware TimeTz plus UTC decomposition, shifted timestamp decomposition, first-class combined OffsetDateTime parsing/formatting/offset conversion, exact named buffer lengths, and wrapper-based row mapping (including UTC-offset normalization or explicit offset policy); allocation-free struct mapping without arbitrary result-width caps; owned PostgreSQL bytea hex/escape decode; single-schema query contract; failure-atomic PostgreSQL result finalization | temporal valid/invalid/precision/full-range/BC/TimeTz/day-boundary/day-offset/offset-decomposition/offset-conversion/offset-formatting/offset-date-time-parsing/public-alias/deterministic-round-trip/wrapper-mapping/buffer-boundary tests; QueryBuilder joined-binding exhaustive-OOM tests; malformed/OOM unit tests; forced column-name append/final-transfer failures; wide-result mapping, multi-result recovery, and core/live driver tests |
2424
| Explicit owned rows | Complete | `OwnedRow`, `Row.getOwned`, `zsql.freeOwnedRows`; direct PostgreSQL deep copies without staging arrays; failure-atomic cross-driver `queryAll` append; documented invalidation boundary for borrowed values | exhaustive partial-OOM core/driver and append-growth tests plus external SQLite survival after rows, connection, and database teardown |
2525
| Connection pooling | Complete | `Pool(D)`, `Lease(D)`, allocator-owned connection configuration, health-aware consuming release, PostgreSQL protocol and SQLite setup/handle teardown outside the accounting mutex, opt-in PostgreSQL `DISCARD ALL` borrower isolation with pipelined restoration and a single asynchronous-event boundary, OOM-safe idle return and owned-result unwind, failed-open capacity wakeup, shutdown draining/wakeup, stats and timeouts | Cross-driver config-clone OOM/deferred-shutdown and deterministic failed-open infinite-waiter tests; SQLite release-OOM/result-unwind/responsive-setup-and-teardown/recovery tests; deterministic PostgreSQL stalled-peer mutex-liveness and stale-reset-notification regressions; PostgreSQL live session-state reset, reset-OOM consumption, release-OOM/result-unwind/shutdown tests |
2626
| Transactions and savepoints | Complete | authoritative SQLite/raw-SQL and explicit PostgreSQL `transactionOpen` boundaries; nested/idle/aborted states; typed PostgreSQL isolation/access/deferrable options; PostgreSQL `25P02` mapping; prepared-statement transition safety; failed-state savepoint recovery; failed SQLite best-effort rollback retains retryable transaction ownership; cross-driver bounded savepoint IDs; direct and pooled failure-scoped `withSavepoint`; `Tx(D)`, `Savepoint(D)`, `withTx` | SQLite state/rollback-retry/savepoint-exhaustion plus cross-driver savepoint-exhaustion/transaction-view tests plus raw-SQL SQLite transaction-state/pool-reuse tests and deterministic direct/pool scoped-savepoint tests and PostgreSQL direct/pool/live scoped-savepoint and typed-transaction-option tests |

‎src/core/types.zig‎

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,7 @@ pub const TimeTz = struct {
9999
return error.InvalidArguments;
100100
}
101101

102-
const day_us = std.math.mul(i64, date.days_since_unix_epoch, 86_400_000_000) catch
103-
return error.Overflow;
102+
const day_us: i128 = @as(i128, date.days_since_unix_epoch) * 86_400_000_000;
104103
const local_us: i128 = @intCast(self.nanos_since_midnight / 1_000);
105104
const offset_us: i128 = @as(i128, self.offset_seconds) * 1_000_000;
106105
const total_us: i128 = @as(i128, day_us) + local_us - offset_us;
@@ -173,6 +172,43 @@ pub const Timestamp = struct {
173172
return self.time.utcTimestamp(self.date);
174173
}
175174

175+
/// Render the same instant using another explicit UTC offset. Unlike a
176+
/// round trip through microsecond-precision `Timestamp`, this retains
177+
/// all nine source nanoseconds.
178+
pub fn toOffset(
179+
self: Timestamp.OffsetDateTime,
180+
offset_seconds: i32,
181+
) !Timestamp.OffsetDateTime {
182+
if (self.time.nanos_since_midnight >= 86_400_000_000_000 or
183+
self.time.offset_seconds < -86_400 or
184+
self.time.offset_seconds > 86_400)
185+
{
186+
return error.InvalidArguments;
187+
}
188+
if (offset_seconds < -86_400 or offset_seconds > 86_400) {
189+
return error.InvalidArguments;
190+
}
191+
192+
const delta_ns: i128 = @as(i128, offset_seconds) * 1_000_000_000 -
193+
@as(i128, self.time.offset_seconds) * 1_000_000_000;
194+
const local_ns: i128 = @as(i128, self.date.days_since_unix_epoch) *
195+
86_400_000_000_000 +
196+
@as(i128, self.time.nanos_since_midnight) + delta_ns;
197+
const days_i128 = @divFloor(local_ns, 86_400_000_000_000);
198+
const nanos_since_midnight: u64 = @intCast(
199+
@mod(local_ns, 86_400_000_000_000),
200+
);
201+
const days = std.math.cast(i32, days_i128) orelse return error.Overflow;
202+
203+
return .{
204+
.date = .{ .days_since_unix_epoch = days },
205+
.time = .{
206+
.nanos_since_midnight = nanos_since_midnight,
207+
.offset_seconds = offset_seconds,
208+
},
209+
};
210+
}
211+
176212
/// Format the shifted local date and timezone-aware time as one ISO
177213
/// string without allocation.
178214
pub fn formatIso(self: Timestamp.OffsetDateTime, buffer: []u8) ![]const u8 {
@@ -1190,6 +1226,83 @@ test "OffsetDateTime has a first-class public spelling" {
11901226
try std.testing.expectEqual(Timestamp.OffsetDateTime, OffsetDateTime);
11911227
}
11921228

1229+
test "convert offset date/times while retaining nanoseconds" {
1230+
var buffer: [OffsetDateTime.iso_buffer_len]u8 = undefined;
1231+
const source = try parseIsoOffsetDateTime(
1232+
"2024-03-10T01:30:00.123456789-02:30",
1233+
);
1234+
1235+
const ahead = try source.toOffset(3_600);
1236+
try std.testing.expectEqual(@as(i32, 19792), ahead.date.days_since_unix_epoch);
1237+
try std.testing.expectEqual(
1238+
@as(u64, 5 * 3_600_000_000_000 + 123_456_789),
1239+
ahead.time.nanos_since_midnight,
1240+
);
1241+
try std.testing.expectEqual(@as(i32, 3_600), ahead.time.offset_seconds);
1242+
try std.testing.expectEqualStrings(
1243+
"2024-03-10T05:00:00.123456789+01:00",
1244+
try ahead.formatIso(&buffer),
1245+
);
1246+
try std.testing.expectEqual(
1247+
try source.utcTimestamp(),
1248+
try ahead.utcTimestamp(),
1249+
);
1250+
1251+
const behind = try (try parseIsoOffsetDateTime(
1252+
"2024-03-10T01:30:00.123456789+05:30",
1253+
)).toOffset(-9_000);
1254+
try std.testing.expectEqual(@as(i32, 19791), behind.date.days_since_unix_epoch);
1255+
try std.testing.expectEqual(
1256+
@as(u64, (17 * 3_600 + 30 * 60) * 1_000_000_000 + 123_456_789),
1257+
behind.time.nanos_since_midnight,
1258+
);
1259+
try std.testing.expectEqual(@as(i32, -9_000), behind.time.offset_seconds);
1260+
1261+
try std.testing.expectEqual(source, try source.toOffset(source.time.offset_seconds));
1262+
try std.testing.expectError(error.InvalidArguments, source.toOffset(86_401));
1263+
}
1264+
1265+
test "offset conversion covers deterministic temporal extrema" {
1266+
const offsets = [_]i32{ -86_400, 0, 86_400 };
1267+
1268+
for ([_]i64{ std.math.minInt(i64), 0, std.math.maxInt(i64) }) |unix_us| {
1269+
const timestamp = Timestamp{ .unix_us = unix_us };
1270+
for (offsets) |old_offset| {
1271+
const source = try timestamp.toOffsetDateTime(old_offset);
1272+
for (offsets) |new_offset| {
1273+
const converted = try source.toOffset(new_offset);
1274+
try std.testing.expectEqual(
1275+
try timestamp.toOffsetDateTime(new_offset),
1276+
converted,
1277+
);
1278+
try std.testing.expectEqual(timestamp, try converted.utcTimestamp());
1279+
}
1280+
}
1281+
}
1282+
}
1283+
1284+
test "reject invalid offset date/time conversion state" {
1285+
const date = try parseIsoDate("2024-03-10");
1286+
const valid_time = TimeTz{ .nanos_since_midnight = 0, .offset_seconds = 0 };
1287+
const valid = OffsetDateTime{ .date = date, .time = valid_time };
1288+
try std.testing.expectError(error.InvalidArguments, valid.toOffset(86_401));
1289+
1290+
const invalid_nanos = OffsetDateTime{
1291+
.date = date,
1292+
.time = .{
1293+
.nanos_since_midnight = 86_400_000_000_000,
1294+
.offset_seconds = 0,
1295+
},
1296+
};
1297+
try std.testing.expectError(error.InvalidArguments, invalid_nanos.toOffset(0));
1298+
1299+
const invalid_source_offset = OffsetDateTime{
1300+
.date = date,
1301+
.time = .{ .nanos_since_midnight = 0, .offset_seconds = 86_401 },
1302+
};
1303+
try std.testing.expectError(error.InvalidArguments, invalid_source_offset.toOffset(0));
1304+
}
1305+
11931306
test "parse exact day-long timezone offsets" {
11941307
try std.testing.expectEqual(@as(i32, -86_400), (try parseIsoTimeTz("00:00:00-24:00")).offset_seconds);
11951308
try std.testing.expectEqual(@as(i32, 86_400), (try parseIsoTimeTz("00:00:00+24:00")).offset_seconds);

0 commit comments

Comments
 (0)