@@ -2,6 +2,7 @@ module pg
22
33import orm
44import time
5+ import strconv
56import 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+
464619fn 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