Skip to content

db.pg: decode TIMESTAMPTZ / fractional-second timestamps into time.Time (fix #27556) - #27561

Merged
medvednikov merged 5 commits into
masterfrom
fix-pg-timestamptz-decode
Jun 26, 2026
Merged

db.pg: decode TIMESTAMPTZ / fractional-second timestamps into time.Time (fix #27556)#27561
medvednikov merged 5 commits into
masterfrom
fix-pg-timestamptz-decode

Conversation

@medvednikov

Copy link
Copy Markdown
Member

Summary

Fixes #27556db.pg ORM could not decode TIMESTAMPTZ columns (or any TIMESTAMP with fractional seconds) into time.Time.

The PG ORM requests text results and previously passed the returned string straight to time.parse(), which only accepts YYYY-MM-DD HH:mm:ss. As a result, a value like 2024-01-15 13:00:00.123456+00 failed with:

Invalid time format code: 0, error: invalid second format: 00.123456+00

Changes

  • Add pg_parse_timestamp() (and a small pg_parse_offset() helper) in vlib/db/pg/orm.v, a PostgreSQL-aware decoder that accepts:

    YYYY-MM-DD HH:mm:ss[.fraction][Z|±HH[:MM[:SS]]]

    It:

    • parses PostgreSQL offsets such as +00, +02, -05, +02:30;
    • preserves up to nanosecond precision (so microseconds survive);
    • normalizes the instant to UTC when an offset is present;
    • returns a clear error for special values such as infinity / -infinity.

    This replaces the time.parse(str) call in val_to_primitive. The same path is used for ?time.Time fields (a NULL column still decodes to orm.Null{}; a present value flows through the new decoder).

  • Serialize time.Time with format_ss_micro() instead of format_ss(), so fractional seconds survive a write/read round-trip.

Example

For the issue's reproduction (SET TIME ZONE 'UTC', value stored as 2024-01-15 14:00:00.123456+01:00), PG returns 2024-01-15 13:00:00.123456+00, which now decodes to 2024-01-15 13:00:00.123456 UTC as 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, the Z suffix, and the infinity/-infinity error cases.

🤖 Generated with Claude Code

…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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread vlib/db/pg/orm.v Outdated
orm.time_ {
if str.contains_any(' /:-') {
date_time_str := time.parse(str)!
date_time_str := pg_parse_timestamp(str)!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread vlib/db/pg/orm.v Outdated
day: ymd[2].int()
hour: hms_parts[0].int()
minute: hms_parts[1].int()
second: hms_parts[2].int()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread vlib/db/pg/orm.v

// 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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread vlib/db/pg/orm.v
)
if offset_seconds != 0 {
// Normalize to UTC by subtracting the parsed offset.
result = result.add_seconds(-offset_seconds)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread vlib/db/pg/orm.v Outdated
// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
@medvednikov
medvednikov merged commit cc39afe into master Jun 26, 2026
76 of 83 checks passed
@JalonSolov
JalonSolov deleted the fix-pg-timestamptz-decode branch July 28, 2026 01:20
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.

db.pg: ORM cannot parse TIMESTAMPTZ columns into time.Time

1 participant