From cc39afe7d730cd7b4edc16a2802f3430495fa4a3 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Fri, 26 Jun 2026 16:43:21 +0300 Subject: [PATCH] db.pg: decode TIMESTAMPTZ / fractional-second timestamps into time.Time (fix #27556) (#27561) --- vlib/db/pg/orm.v | 170 +++++++++++++++++++++++-- vlib/db/pg/pg_timestamp_test.v | 221 +++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 vlib/db/pg/pg_timestamp_test.v diff --git a/vlib/db/pg/orm.v b/vlib/db/pg/orm.v index a93ecfa12..5459b9314 100644 --- a/vlib/db/pg/orm.v +++ b/vlib/db/pg/orm.v @@ -2,6 +2,7 @@ module pg import orm import time +import strconv import net.conv // ---- ORM on Conn (single pinned connection) ---- @@ -355,7 +356,9 @@ fn pg_stmt_match(mut types []u32, mut vals []&char, mut lens []int, mut formats formats << 0 } time.Time { - datetime := data.format_ss() + // Use a microsecond-precision representation so fractional seconds survive + // a write/read round-trip (relevant for TIMESTAMP/TIMESTAMPTZ columns). + datetime := data.format_ss_micro() types << u32(0) vals << &char(datetime.str) lens << datetime.len @@ -461,6 +464,158 @@ fn pg_type_from_v(typ int) !string { return str } +// pg_parse_timestamp parses a PostgreSQL `TIMESTAMP`/`TIMESTAMPTZ` text value into a +// `time.Time`. It accepts the form `YYYY-MM-DD HH:mm:ss[.fraction][Z|±HH[:MM[:SS]]]`, +// preserving up to nanosecond precision. When a timezone offset is present (as produced +// by `TIMESTAMPTZ` columns, e.g. `+00`, `+02`, `+02:30`), the returned time is +// normalized to UTC. +fn pg_parse_timestamp(value string) !time.Time { + str := value.trim_space() + if str == 'infinity' || str == '-infinity' { + return error('pg: cannot decode special timestamp value `${str}` into time.Time') + } + // PostgreSQL appends ` BC` for dates before year 1. `time.Time` cannot represent + // those unambiguously, so reject them with a clear error instead of silently + // constructing the corresponding AD instant. + if str.ends_with(' BC') || str.ends_with(' bc') { + return error('pg: cannot decode BC timestamp value `${str}` into time.Time') + } + space_pos := str.index(' ') or { + // Fall back to the generic parser for values without a date/time separator. + return time.parse(str) + } + date_part := str[..space_pos] + mut time_part := str[space_pos + 1..] + + // Detect and strip an optional timezone designator. + mut offset_seconds := 0 + if time_part.ends_with('Z') || time_part.ends_with('z') { + time_part = time_part[..time_part.len - 1] + } else { + // PostgreSQL appends the offset sign (`+`/`-`) directly after the time part. + // Scan past the leading hour so a negative hour can never be mistaken for a sign. + mut sign_pos := -1 + for i := 1; i < time_part.len; i++ { + c := time_part[i] + if c == `+` || c == `-` { + sign_pos = i + break + } + } + if sign_pos != -1 { + offset_seconds = pg_parse_offset(time_part[sign_pos..])! + time_part = time_part[..sign_pos] + } + } + + // Split the optional fractional seconds off the `HH:mm:ss` part. + mut nanosecond := 0 + mut hms := time_part + if dot_pos := time_part.index('.') { + hms = time_part[..dot_pos] + mut frac := time_part[dot_pos + 1..] + if frac.len > 9 { + frac = frac[..9] + } + // strconv.atoi is strict, so any non-digit (e.g. a stray suffix) errors out + // instead of being silently truncated by `string.int()`. + mut scaled := strconv.atoi(frac)! + for _ in 0 .. 9 - frac.len { + scaled *= 10 + } + nanosecond = scaled + } + + ymd := date_part.split('-') + if ymd.len != 3 { + return error('pg: invalid timestamp date `${date_part}`') + } + hms_parts := hms.split(':') + if hms_parts.len != 3 { + return error('pg: invalid timestamp time `${hms}`') + } + + // 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). + year := strconv.atoi(ymd[0])! + month := strconv.atoi(ymd[1])! + day := strconv.atoi(ymd[2])! + hour := strconv.atoi(hms_parts[0])! + minute := strconv.atoi(hms_parts[1])! + second := strconv.atoi(hms_parts[2])! + + // Validate the ranges up front: `time.new` *panics* on out-of-range fields, but + // PostgreSQL accepts values V cannot represent (e.g. years past 9999), so turn + // those into a clear error instead of aborting the process. Supported AD years are + // 1..9999; year 0 / negative years are BC (proleptic Gregorian) and unrepresentable, + // matching the explicit ` BC` rejection above. + if year > 9999 { + return error('pg: year out of range in timestamp `${str}`') + } + if year < 1 { + return error('pg: cannot decode BC/year-0 timestamp `${str}` into time.Time') + } + if month < 1 || month > 12 { + return error('pg: month out of range in timestamp `${str}`') + } + if day < 1 || day > 31 { + return error('pg: day out of range in timestamp `${str}`') + } + if hour < 0 || hour > 23 { + return error('pg: hour out of range in timestamp `${str}`') + } + if minute < 0 || minute > 59 { + return error('pg: minute out of range in timestamp `${str}`') + } + if second < 0 || second > 59 { + return error('pg: second out of range in timestamp `${str}`') + } + + mut result := time.new( + year: year + month: month + day: day + hour: hour + minute: minute + second: second + nanosecond: nanosecond + is_local: false + ) + if offset_seconds != 0 { + // Normalize to UTC by subtracting the parsed offset. + result = result.add_seconds(-offset_seconds) + // The offset can push a boundary value past the representable range in either + // direction, so re-check the normalized result; otherwise offset-bearing + // timestamps would silently bypass the range guards above. Examples: + // `9999-12-31 23:30:00-01` -> year 10000, `0001-01-01 00:30:00+01` -> year 0 (BC). + if result.year > 9999 { + return error('pg: year out of range in timestamp `${str}` after UTC normalization') + } + if result.year < 1 { + return error('pg: timestamp `${str}` normalizes to a BC/year-0 date, which is unrepresentable') + } + } + return result +} + +// pg_parse_offset parses a PostgreSQL timezone offset such as `+02`, `-05`, `+02:30` +// or `+02:30:00` and returns the offset in seconds (signed). +fn pg_parse_offset(offset string) !int { + if offset.len < 3 { + return error('pg: invalid timezone offset `${offset}`') + } + sign := if offset[0] == `-` { -1 } else { 1 } + parts := offset[1..].split(':') + mut seconds := strconv.atoi(parts[0])! * 3600 + if parts.len > 1 { + seconds += strconv.atoi(parts[1])! * 60 + } + if parts.len > 2 { + seconds += strconv.atoi(parts[2])! + } + return sign * seconds +} + fn val_to_primitive(val ?string, typ int) !orm.Primitive { if str := val { match typ { @@ -516,13 +671,14 @@ fn val_to_primitive(val ?string, typ int) !orm.Primitive { return orm.Primitive(str) } orm.time_ { - if str.contains_any(' /:-') { - date_time_str := time.parse(str)! - return orm.Primitive(date_time_str) + // A bare (optionally signed) integer is a Unix timestamp; route every + // other value through the PostgreSQL-aware parser so textual timestamps + // and special values such as `infinity` are decoded (or rejected) there + // instead of silently falling through to `time.unix(0)`. + if timestamp := strconv.atoi64(str.trim_space()) { + return orm.Primitive(time.unix(timestamp)) } - - timestamp := str.int() - return orm.Primitive(time.unix(timestamp)) + return orm.Primitive(pg_parse_timestamp(str)!) } orm.enum_ { return orm.Primitive(str.i64()) diff --git a/vlib/db/pg/pg_timestamp_test.v b/vlib/db/pg/pg_timestamp_test.v new file mode 100644 index 000000000..d695714c0 --- /dev/null +++ b/vlib/db/pg/pg_timestamp_test.v @@ -0,0 +1,221 @@ +module pg + +import orm +import time + +// These tests exercise the PostgreSQL TIMESTAMP/TIMESTAMPTZ text decoder +// directly, so they do not require a running PostgreSQL server. + +fn test_plain_timestamp() { + t := pg_parse_timestamp('2024-01-15 14:00:00')! + assert t.year == 2024 + assert t.month == 1 + assert t.day == 15 + assert t.hour == 14 + assert t.minute == 0 + assert t.second == 0 + assert t.nanosecond == 0 +} + +fn test_fractional_seconds_microsecond_precision() { + t := pg_parse_timestamp('2024-01-15 14:00:00.123456')! + assert t.hour == 14 + assert t.second == 0 + // 123456 microseconds preserved as nanoseconds + assert t.nanosecond == 123456000 +} + +fn test_timestamptz_utc_offset() { + // `+00` offset -> already UTC, value unchanged. + t := pg_parse_timestamp('2024-01-15 13:00:00.123456+00')! + assert t.year == 2024 + assert t.month == 1 + assert t.day == 15 + assert t.hour == 13 + assert t.minute == 0 + assert t.second == 0 + assert t.nanosecond == 123456000 +} + +fn test_timestamptz_positive_offset_normalized_to_utc() { + // 14:00 at +01:00 -> 13:00 UTC + t := pg_parse_timestamp('2024-01-15 14:00:00.123456+01:00')! + assert t.hour == 13 + assert t.minute == 0 + assert t.nanosecond == 123456000 +} + +fn test_timestamptz_positive_short_offset() { + // 14:00 at +02 -> 12:00 UTC + t := pg_parse_timestamp('2024-01-15 14:00:00+02')! + assert t.hour == 12 + assert t.minute == 0 +} + +fn test_timestamptz_offset_with_minutes() { + // 14:00 at +02:30 -> 11:30 UTC + t := pg_parse_timestamp('2024-01-15 14:00:00+02:30')! + assert t.hour == 11 + assert t.minute == 30 +} + +fn test_timestamptz_negative_offset_normalized_to_utc() { + // 14:00 at -05:00 -> 19:00 UTC + t := pg_parse_timestamp('2024-01-15 14:00:00-05')! + assert t.hour == 19 + assert t.minute == 0 +} + +fn test_timestamptz_negative_offset_with_minutes_day_rollover() { + // 23:00 at -05:30 -> 04:30 UTC next day + t := pg_parse_timestamp('2024-01-15 23:00:00-05:30')! + assert t.day == 16 + assert t.hour == 4 + assert t.minute == 30 +} + +fn test_timestamptz_z_suffix() { + t := pg_parse_timestamp('2024-01-15 14:00:00.5Z')! + assert t.hour == 14 + assert t.nanosecond == 500000000 +} + +fn test_infinity_returns_clear_error() { + pg_parse_timestamp('infinity') or { + assert err.msg().contains('infinity') + return + } + assert false, 'expected an error for the special value `infinity`' +} + +fn test_negative_infinity_returns_clear_error() { + pg_parse_timestamp('-infinity') or { + assert err.msg().contains('infinity') + return + } + assert false, 'expected an error for the special value `-infinity`' +} + +fn test_bc_suffix_returns_clear_error() { + pg_parse_timestamp('0001-01-01 00:00:00 BC') or { + assert err.msg().contains('BC') + return + } + assert false, 'expected an error for a BC timestamp value' +} + +fn test_bc_suffix_with_offset_returns_clear_error() { + pg_parse_timestamp('0044-03-15 12:00:00+00 BC') or { + assert err.msg().contains('BC') + return + } + assert false, 'expected an error for a BC TIMESTAMPTZ value' +} + +fn test_garbage_seconds_suffix_is_rejected() { + // `string.int()` would silently keep the `00` prefix; strict parsing must reject it. + pg_parse_timestamp('2024-01-15 14:00:00 XY') or { return } + assert false, 'expected an error for a trailing non-numeric suffix' +} + +fn test_unix_timestamp_path_decodes_integer() { + // Mirror the ORM fallback: a bare integer string is a Unix timestamp. + p := val_to_primitive('1700000000', orm.time_)! + t := p as time.Time + assert t.unix() == 1700000000 +} + +fn test_infinity_via_val_to_primitive_errors() { + // `infinity` has no date/time punctuation; it must still reach the parser and + // error instead of decoding to the Unix epoch. + val_to_primitive('infinity', orm.time_) or { + assert err.msg().contains('infinity') + return + } + assert false, 'expected an error decoding `infinity` via val_to_primitive' +} + +fn test_year_out_of_range_returns_error_not_panic() { + // PostgreSQL accepts years past 9999, which `time.new` cannot represent. The + // decoder must return an error rather than panicking. + pg_parse_timestamp('10000-01-01 00:00:00') or { + assert err.msg().contains('out of range') + return + } + assert false, "expected an error for a year beyond V's supported range" +} + +fn test_out_of_range_via_val_to_primitive_errors() { + val_to_primitive('10000-01-01 00:00:00', orm.time_) or { + assert err.msg().contains('out of range') + return + } + assert false, 'expected an error decoding an out-of-range timestamp' +} + +fn test_offset_pushes_year_out_of_range_returns_error() { + // A valid local timestamp that normalizes to year 10000 in UTC must still error, + // not silently bypass the range guard via the offset shift. + pg_parse_timestamp('9999-12-31 23:30:00-01') or { + assert err.msg().contains('out of range') + return + } + assert false, 'expected an error when the UTC offset pushes the year out of range' +} + +fn test_offset_within_range_still_decodes() { + // A near-boundary value that stays in range after normalization must succeed. + t := pg_parse_timestamp('9999-12-31 22:30:00-01')! + assert t.year == 9999 + assert t.month == 12 + assert t.day == 31 + assert t.hour == 23 + assert t.minute == 30 +} + +fn test_offset_normalizes_into_bc_returns_error() { + // `0001-01-01 00:30:00+01` normalizes to `0000-12-31 23:30:00` (year 0 == 1 BC), + // which is unrepresentable and must be rejected rather than silently accepted. + pg_parse_timestamp('0001-01-01 00:30:00+01') or { + assert err.msg().contains('BC') + return + } + assert false, 'expected an error when the UTC offset normalizes into a BC/year-0 date' +} + +fn test_lower_boundary_offset_within_range_still_decodes() { + // `0001-01-01 01:30:00+01` normalizes to `0001-01-01 00:30:00` (year 1), still valid. + t := pg_parse_timestamp('0001-01-01 01:30:00+01')! + assert t.year == 1 + assert t.month == 1 + assert t.day == 1 + assert t.hour == 0 + assert t.minute == 30 +} + +fn test_year_zero_literal_returns_bc_error() { + // A direct year-0 value (no ` BC` suffix) is still unrepresentable. + pg_parse_timestamp('0000-01-01 00:00:00') or { + assert err.msg().contains('BC') + return + } + assert false, 'expected a BC/year-0 error for a literal year-0 timestamp' +} + +fn test_issue_27556_example() { + // The exact value from the issue: stored as '2024-01-15 14:00:00.123456+01:00' + // with the session in UTC, PostgreSQL returns it as below. + t := pg_parse_timestamp('2024-01-15 13:00:00.123456+00')! + expected := time.new( + year: 2024 + month: 1 + day: 15 + hour: 13 + minute: 0 + second: 0 + nanosecond: 123456000 + is_local: false + ) + assert t.unix() == expected.unix() + assert t.nanosecond == expected.nanosecond +} -- 2.39.5