Skip to content

Commit cc39afe

Browse files
authored
db.pg: decode TIMESTAMPTZ / fractional-second timestamps into time.Time (fix #27556) (#27561)
1 parent cd213a1 commit cc39afe

2 files changed

Lines changed: 384 additions & 7 deletions

File tree

vlib/db/pg/orm.v

Lines changed: 163 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module pg
22

33
import orm
44
import time
5+
import strconv
56
import net.conv
67

78
// ---- 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
355356
formats << 0
356357
}
357358
time.Time {
358-
datetime := data.format_ss()
359+
// Use a microsecond-precision representation so fractional seconds survive
360+
// a write/read round-trip (relevant for TIMESTAMP/TIMESTAMPTZ columns).
361+
datetime := data.format_ss_micro()
359362
types << u32(0)
360363
vals << &char(datetime.str)
361364
lens << datetime.len
@@ -461,6 +464,158 @@ fn pg_type_from_v(typ int) !string {
461464
return str
462465
}
463466

467+
// pg_parse_timestamp parses a PostgreSQL `TIMESTAMP`/`TIMESTAMPTZ` text value into a
468+
// `time.Time`. It accepts the form `YYYY-MM-DD HH:mm:ss[.fraction][Z|±HH[:MM[:SS]]]`,
469+
// preserving up to nanosecond precision. When a timezone offset is present (as produced
470+
// by `TIMESTAMPTZ` columns, e.g. `+00`, `+02`, `+02:30`), the returned time is
471+
// normalized to UTC.
472+
fn pg_parse_timestamp(value string) !time.Time {
473+
str := value.trim_space()
474+
if str == 'infinity' || str == '-infinity' {
475+
return error('pg: cannot decode special timestamp value `${str}` into time.Time')
476+
}
477+
// PostgreSQL appends ` BC` for dates before year 1. `time.Time` cannot represent
478+
// those unambiguously, so reject them with a clear error instead of silently
479+
// constructing the corresponding AD instant.
480+
if str.ends_with(' BC') || str.ends_with(' bc') {
481+
return error('pg: cannot decode BC timestamp value `${str}` into time.Time')
482+
}
483+
space_pos := str.index(' ') or {
484+
// Fall back to the generic parser for values without a date/time separator.
485+
return time.parse(str)
486+
}
487+
date_part := str[..space_pos]
488+
mut time_part := str[space_pos + 1..]
489+
490+
// Detect and strip an optional timezone designator.
491+
mut offset_seconds := 0
492+
if time_part.ends_with('Z') || time_part.ends_with('z') {
493+
time_part = time_part[..time_part.len - 1]
494+
} else {
495+
// PostgreSQL appends the offset sign (`+`/`-`) directly after the time part.
496+
// Scan past the leading hour so a negative hour can never be mistaken for a sign.
497+
mut sign_pos := -1
498+
for i := 1; i < time_part.len; i++ {
499+
c := time_part[i]
500+
if c == `+` || c == `-` {
501+
sign_pos = i
502+
break
503+
}
504+
}
505+
if sign_pos != -1 {
506+
offset_seconds = pg_parse_offset(time_part[sign_pos..])!
507+
time_part = time_part[..sign_pos]
508+
}
509+
}
510+
511+
// Split the optional fractional seconds off the `HH:mm:ss` part.
512+
mut nanosecond := 0
513+
mut hms := time_part
514+
if dot_pos := time_part.index('.') {
515+
hms = time_part[..dot_pos]
516+
mut frac := time_part[dot_pos + 1..]
517+
if frac.len > 9 {
518+
frac = frac[..9]
519+
}
520+
// strconv.atoi is strict, so any non-digit (e.g. a stray suffix) errors out
521+
// instead of being silently truncated by `string.int()`.
522+
mut scaled := strconv.atoi(frac)!
523+
for _ in 0 .. 9 - frac.len {
524+
scaled *= 10
525+
}
526+
nanosecond = scaled
527+
}
528+
529+
ymd := date_part.split('-')
530+
if ymd.len != 3 {
531+
return error('pg: invalid timestamp date `${date_part}`')
532+
}
533+
hms_parts := hms.split(':')
534+
if hms_parts.len != 3 {
535+
return error('pg: invalid timestamp time `${hms}`')
536+
}
537+
538+
// Use strict numeric parsing so suffixes such as ` BC` or other malformed values
539+
// are rejected rather than silently coerced (`string.int()` keeps the digit prefix).
540+
year := strconv.atoi(ymd[0])!
541+
month := strconv.atoi(ymd[1])!
542+
day := strconv.atoi(ymd[2])!
543+
hour := strconv.atoi(hms_parts[0])!
544+
minute := strconv.atoi(hms_parts[1])!
545+
second := strconv.atoi(hms_parts[2])!
546+
547+
// Validate the ranges up front: `time.new` *panics* on out-of-range fields, but
548+
// PostgreSQL accepts values V cannot represent (e.g. years past 9999), so turn
549+
// those into a clear error instead of aborting the process. Supported AD years are
550+
// 1..9999; year 0 / negative years are BC (proleptic Gregorian) and unrepresentable,
551+
// matching the explicit ` BC` rejection above.
552+
if year > 9999 {
553+
return error('pg: year out of range in timestamp `${str}`')
554+
}
555+
if year < 1 {
556+
return error('pg: cannot decode BC/year-0 timestamp `${str}` into time.Time')
557+
}
558+
if month < 1 || month > 12 {
559+
return error('pg: month out of range in timestamp `${str}`')
560+
}
561+
if day < 1 || day > 31 {
562+
return error('pg: day out of range in timestamp `${str}`')
563+
}
564+
if hour < 0 || hour > 23 {
565+
return error('pg: hour out of range in timestamp `${str}`')
566+
}
567+
if minute < 0 || minute > 59 {
568+
return error('pg: minute out of range in timestamp `${str}`')
569+
}
570+
if second < 0 || second > 59 {
571+
return error('pg: second out of range in timestamp `${str}`')
572+
}
573+
574+
mut result := time.new(
575+
year: year
576+
month: month
577+
day: day
578+
hour: hour
579+
minute: minute
580+
second: second
581+
nanosecond: nanosecond
582+
is_local: false
583+
)
584+
if offset_seconds != 0 {
585+
// Normalize to UTC by subtracting the parsed offset.
586+
result = result.add_seconds(-offset_seconds)
587+
// The offset can push a boundary value past the representable range in either
588+
// direction, so re-check the normalized result; otherwise offset-bearing
589+
// timestamps would silently bypass the range guards above. Examples:
590+
// `9999-12-31 23:30:00-01` -> year 10000, `0001-01-01 00:30:00+01` -> year 0 (BC).
591+
if result.year > 9999 {
592+
return error('pg: year out of range in timestamp `${str}` after UTC normalization')
593+
}
594+
if result.year < 1 {
595+
return error('pg: timestamp `${str}` normalizes to a BC/year-0 date, which is unrepresentable')
596+
}
597+
}
598+
return result
599+
}
600+
601+
// pg_parse_offset parses a PostgreSQL timezone offset such as `+02`, `-05`, `+02:30`
602+
// or `+02:30:00` and returns the offset in seconds (signed).
603+
fn pg_parse_offset(offset string) !int {
604+
if offset.len < 3 {
605+
return error('pg: invalid timezone offset `${offset}`')
606+
}
607+
sign := if offset[0] == `-` { -1 } else { 1 }
608+
parts := offset[1..].split(':')
609+
mut seconds := strconv.atoi(parts[0])! * 3600
610+
if parts.len > 1 {
611+
seconds += strconv.atoi(parts[1])! * 60
612+
}
613+
if parts.len > 2 {
614+
seconds += strconv.atoi(parts[2])!
615+
}
616+
return sign * seconds
617+
}
618+
464619
fn val_to_primitive(val ?string, typ int) !orm.Primitive {
465620
if str := val {
466621
match typ {
@@ -516,13 +671,14 @@ fn val_to_primitive(val ?string, typ int) !orm.Primitive {
516671
return orm.Primitive(str)
517672
}
518673
orm.time_ {
519-
if str.contains_any(' /:-') {
520-
date_time_str := time.parse(str)!
521-
return orm.Primitive(date_time_str)
674+
// A bare (optionally signed) integer is a Unix timestamp; route every
675+
// other value through the PostgreSQL-aware parser so textual timestamps
676+
// and special values such as `infinity` are decoded (or rejected) there
677+
// instead of silently falling through to `time.unix(0)`.
678+
if timestamp := strconv.atoi64(str.trim_space()) {
679+
return orm.Primitive(time.unix(timestamp))
522680
}
523-
524-
timestamp := str.int()
525-
return orm.Primitive(time.unix(timestamp))
681+
return orm.Primitive(pg_parse_timestamp(str)!)
526682
}
527683
orm.enum_ {
528684
return orm.Primitive(str.i64())

0 commit comments

Comments
 (0)