db.pg: decode TIMESTAMPTZ / fractional-second timestamps into time.Time (fix #27556) - #27561
Conversation
…e.Time (fix #27556) The PostgreSQL ORM requested text results and passed the returned string straight to time.parse(), which only accepts 'YYYY-MM-DD HH:mm:ss'. Any TIMESTAMPTZ value (or a TIMESTAMP with fractional seconds) failed to decode, e.g. 'invalid second format: 00.123456+00'. Add pg_parse_timestamp(), a PostgreSQL-aware decoder accepting 'YYYY-MM-DD HH:mm:ss[.fraction][Z|+-HH[:MM[:SS]]]'. It preserves up to nanosecond precision and normalizes any timezone offset to UTC. The serializer now uses format_ss_micro() so fractional seconds survive a write/read round-trip. Adds unit tests (no DB required) covering UTC, positive/negative offsets, fractional seconds, Z suffix and the infinity/-infinity error cases.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fcc0c26ead
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| orm.time_ { | ||
| if str.contains_any(' /:-') { | ||
| date_time_str := time.parse(str)! | ||
| date_time_str := pg_parse_timestamp(str)! |
There was a problem hiding this comment.
Route
infinity through the timestamp parser
This new parser call is still guarded by str.contains_any(' /:-'), so a PostgreSQL TIMESTAMP 'infinity' value has no matching punctuation and skips the new pg_parse_timestamp() special-value check; the ORM path then falls through to str.int() below and returns time.unix(0). In real selects this still corrupts infinity to the Unix epoch instead of producing the clear error covered by the new helper tests, so please check special timestamp values before the numeric fallback or send all non-numeric time strings through the parser.
Useful? React with 👍 / 👎.
| day: ymd[2].int() | ||
| hour: hms_parts[0].int() | ||
| minute: hms_parts[1].int() | ||
| second: hms_parts[2].int() |
There was a problem hiding this comment.
Reject BC suffixes instead of treating them as AD
When PostgreSQL returns a valid ISO BC timestamp such as 0001-01-01 00:00:00 BC, hms_parts[2] is 00 BC; V's string.int() accepts the numeric prefix, so this new parser silently constructs year 1 AD rather than rejecting or representing the BC date. The previous time.parse() path used strict conversion and failed on that suffix, so selects of BC timestamp values can now return the wrong instant without an error; please detect the BC suffix or use strict numeric parsing before constructing time.Time.
Useful? React with 👍 / 👎.
Address PR review feedback on the TIMESTAMPTZ decoder: - val_to_primitive: a time value is now decoded via time.unix() only when the string is a bare integer (Unix timestamp). Every other value, including `infinity` (which has no date/time punctuation), is routed through pg_parse_timestamp() so it produces the clear special-value error instead of silently falling through to time.unix(0). - pg_parse_timestamp: reject PostgreSQL BC timestamps (` BC` suffix) with a clear error, and parse the date/time fields and timezone offset with strict strconv.atoi so stray suffixes are rejected rather than silently truncated by string.int(). Adds tests for the BC suffix (with and without offset), a trailing non-numeric suffix, the Unix-timestamp path, and `infinity` decoded via val_to_primitive.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c484124e2e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| // Use strict numeric parsing so suffixes such as ` BC` or other malformed values | ||
| // are rejected rather than silently coerced (`string.int()` keeps the digit prefix). | ||
| mut result := time.new( |
There was a problem hiding this comment.
Guard out-of-range timestamps before time.new
Because this helper returns !time.Time, callers expect unrepresentable database values to become errors, but time.new panics when fields are outside V's supported range. PostgreSQL accepts timestamps beyond year 9999, such as 10000-01-01 00:00:00, so selecting one through the ORM now aborts the process; the previous time.parse path returned an error instead. Please validate the parsed fields before constructing the time.Time.
Useful? React with 👍 / 👎.
time.new() panics when a field is outside V's supported range, but PostgreSQL accepts timestamps V cannot represent (e.g. years past 9999, up to 294276). Since pg_parse_timestamp returns !time.Time, callers expect such values to become errors, not abort the process — matching the previous time.parse path. Validate year/month/day/hour/minute/second ranges and return a clear error before constructing the time.Time. Adds tests for an out-of-range year, both directly and via val_to_primitive.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ef128ddc53
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ) | ||
| if offset_seconds != 0 { | ||
| // Normalize to UTC by subtracting the parsed offset. | ||
| result = result.add_seconds(-offset_seconds) |
There was a problem hiding this comment.
Reject timestamps that leave range after offset normalization
For TIMESTAMPTZ values near the supported boundary, the parsed offset can move an otherwise validated local timestamp outside the range this decoder says it supports; for example, a PostgreSQL session at -01 can return 9999-12-31 23:30:00-01 for an instant in year 10000, this line converts it to a time.Time with year == 10000 instead of returning the clear out-of-range error added above. Please re-check the normalized result after add_seconds so offset-bearing timestamps cannot bypass the range guard.
Useful? React with 👍 / 👎.
A TIMESTAMPTZ value can pass the pre-construction range guard as a local time yet land outside V's representable range once the offset is applied, e.g. a session at -01 returning '9999-12-31 23:30:00-01' normalizes to year 10000. add_seconds() does not validate, so it would silently produce year 10000 and bypass the guard. Re-check result.year after add_seconds and return the clear out-of-range error. Adds tests for an offset that pushes the year out of range and for a near-boundary value that stays in range.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d1296fb148
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // The offset can push a boundary value (e.g. `9999-12-31 23:30:00-01`) past the | ||
| // representable range, so re-check the normalized result; otherwise offset-bearing | ||
| // timestamps would silently bypass the range guard above. | ||
| if result.year < -9999 || result.year > 9999 { |
There was a problem hiding this comment.
Reject offsets that normalize into BC
When a TIMESTAMPTZ near the lower boundary is returned in an east-of-UTC session, for example 0001-01-01 00:30:00+01, this normalization produces result.year == 0 and accepts it because the post-offset guard only checks -9999..9999. PostgreSQL has no year 0 and this parser already rejects explicit BC values as unrepresentable, so the same instant can be silently decoded as an unsupported/ambiguous year-0 time.Time instead of returning the clear BC error; please reject normalized years before 1 as well.
Useful? React with 👍 / 👎.
A TIMESTAMPTZ near the lower boundary returned in an east-of-UTC session, e.g. `0001-01-01 00:30:00+01`, normalizes to `0000-12-31 23:30:00` (year 0 == 1 BC in proleptic Gregorian). The post-offset guard only checked the upper bound, so this was silently decoded as an ambiguous year-0 time.Time instead of the clear BC error the parser already returns for explicit ` BC` values. Treat the supported AD range as 1..9999 at both guard sites: reject year < 1 before construction (covers a literal year-0 value) and after UTC normalization (covers offsets that shift a valid local year-1 instant into BC). Adds tests for the offset-into-BC case, a literal year-0 value, and near-lower-boundary values that stay in range.
Summary
Fixes #27556 —
db.pgORM could not decodeTIMESTAMPTZcolumns (or anyTIMESTAMPwith fractional seconds) intotime.Time.The PG ORM requests text results and previously passed the returned string straight to
time.parse(), which only acceptsYYYY-MM-DD HH:mm:ss. As a result, a value like2024-01-15 13:00:00.123456+00failed with:Changes
Add
pg_parse_timestamp()(and a smallpg_parse_offset()helper) invlib/db/pg/orm.v, a PostgreSQL-aware decoder that accepts:YYYY-MM-DD HH:mm:ss[.fraction][Z|±HH[:MM[:SS]]]It:
+00,+02,-05,+02:30;infinity/-infinity.This replaces the
time.parse(str)call inval_to_primitive. The same path is used for?time.Timefields (aNULLcolumn still decodes toorm.Null{}; a present value flows through the new decoder).Serialize
time.Timewithformat_ss_micro()instead offormat_ss(), so fractional seconds survive a write/read round-trip.Example
For the issue's reproduction (
SET TIME ZONE 'UTC', value stored as2024-01-15 14:00:00.123456+01:00), PG returns2024-01-15 13:00:00.123456+00, which now decodes to2024-01-15 13:00:00.123456 UTCas expected.Tests
Adds
vlib/db/pg/pg_timestamp_test.v, an internal unit test that exercises the decoder directly and does not require a running PostgreSQL server. It covers UTC, positive and negative offsets, short (+02) and minute (+02:30) offsets, day rollover, fractional seconds, theZsuffix, and theinfinity/-infinityerror cases.🤖 Generated with Claude Code