Skip to content

Commit c484124

Browse files
committed
db.pg: route infinity through the timestamp parser and reject BC dates
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.
1 parent fcc0c26 commit c484124

2 files changed

Lines changed: 71 additions & 18 deletions

File tree

vlib/db/pg/orm.v

Lines changed: 31 additions & 18 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) ----
@@ -473,6 +474,12 @@ fn pg_parse_timestamp(value string) !time.Time {
473474
if str == 'infinity' || str == '-infinity' {
474475
return error('pg: cannot decode special timestamp value `${str}` into time.Time')
475476
}
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+
}
476483
space_pos := str.index(' ') or {
477484
// Fall back to the generic parser for values without a date/time separator.
478485
return time.parse(str)
@@ -510,10 +517,13 @@ fn pg_parse_timestamp(value string) !time.Time {
510517
if frac.len > 9 {
511518
frac = frac[..9]
512519
}
513-
for frac.len < 9 {
514-
frac += '0'
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
515525
}
516-
nanosecond = frac.int()
526+
nanosecond = scaled
517527
}
518528

519529
ymd := date_part.split('-')
@@ -525,13 +535,15 @@ fn pg_parse_timestamp(value string) !time.Time {
525535
return error('pg: invalid timestamp time `${hms}`')
526536
}
527537

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).
528540
mut result := time.new(
529-
year: ymd[0].int()
530-
month: ymd[1].int()
531-
day: ymd[2].int()
532-
hour: hms_parts[0].int()
533-
minute: hms_parts[1].int()
534-
second: hms_parts[2].int()
541+
year: strconv.atoi(ymd[0])!
542+
month: strconv.atoi(ymd[1])!
543+
day: strconv.atoi(ymd[2])!
544+
hour: strconv.atoi(hms_parts[0])!
545+
minute: strconv.atoi(hms_parts[1])!
546+
second: strconv.atoi(hms_parts[2])!
535547
nanosecond: nanosecond
536548
is_local: false
537549
)
@@ -550,12 +562,12 @@ fn pg_parse_offset(offset string) !int {
550562
}
551563
sign := if offset[0] == `-` { -1 } else { 1 }
552564
parts := offset[1..].split(':')
553-
mut seconds := parts[0].int() * 3600
565+
mut seconds := strconv.atoi(parts[0])! * 3600
554566
if parts.len > 1 {
555-
seconds += parts[1].int() * 60
567+
seconds += strconv.atoi(parts[1])! * 60
556568
}
557569
if parts.len > 2 {
558-
seconds += parts[2].int()
570+
seconds += strconv.atoi(parts[2])!
559571
}
560572
return sign * seconds
561573
}
@@ -615,13 +627,14 @@ fn val_to_primitive(val ?string, typ int) !orm.Primitive {
615627
return orm.Primitive(str)
616628
}
617629
orm.time_ {
618-
if str.contains_any(' /:-') {
619-
date_time_str := pg_parse_timestamp(str)!
620-
return orm.Primitive(date_time_str)
630+
// A bare (optionally signed) integer is a Unix timestamp; route every
631+
// other value through the PostgreSQL-aware parser so textual timestamps
632+
// and special values such as `infinity` are decoded (or rejected) there
633+
// instead of silently falling through to `time.unix(0)`.
634+
if timestamp := strconv.atoi64(str.trim_space()) {
635+
return orm.Primitive(time.unix(timestamp))
621636
}
622-
623-
timestamp := str.int()
624-
return orm.Primitive(time.unix(timestamp))
637+
return orm.Primitive(pg_parse_timestamp(str)!)
625638
}
626639
orm.enum_ {
627640
return orm.Primitive(str.i64())

vlib/db/pg/pg_timestamp_test.v

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module pg
22

3+
import orm
34
import time
45

56
// These tests exercise the PostgreSQL TIMESTAMP/TIMESTAMPTZ text decoder
@@ -95,6 +96,45 @@ fn test_negative_infinity_returns_clear_error() {
9596
assert false, 'expected an error for the special value `-infinity`'
9697
}
9798

99+
fn test_bc_suffix_returns_clear_error() {
100+
pg_parse_timestamp('0001-01-01 00:00:00 BC') or {
101+
assert err.msg().contains('BC')
102+
return
103+
}
104+
assert false, 'expected an error for a BC timestamp value'
105+
}
106+
107+
fn test_bc_suffix_with_offset_returns_clear_error() {
108+
pg_parse_timestamp('0044-03-15 12:00:00+00 BC') or {
109+
assert err.msg().contains('BC')
110+
return
111+
}
112+
assert false, 'expected an error for a BC TIMESTAMPTZ value'
113+
}
114+
115+
fn test_garbage_seconds_suffix_is_rejected() {
116+
// `string.int()` would silently keep the `00` prefix; strict parsing must reject it.
117+
pg_parse_timestamp('2024-01-15 14:00:00 XY') or { return }
118+
assert false, 'expected an error for a trailing non-numeric suffix'
119+
}
120+
121+
fn test_unix_timestamp_path_decodes_integer() {
122+
// Mirror the ORM fallback: a bare integer string is a Unix timestamp.
123+
p := val_to_primitive('1700000000', orm.time_)!
124+
t := p as time.Time
125+
assert t.unix() == 1700000000
126+
}
127+
128+
fn test_infinity_via_val_to_primitive_errors() {
129+
// `infinity` has no date/time punctuation; it must still reach the parser and
130+
// error instead of decoding to the Unix epoch.
131+
val_to_primitive('infinity', orm.time_) or {
132+
assert err.msg().contains('infinity')
133+
return
134+
}
135+
assert false, 'expected an error decoding `infinity` via val_to_primitive'
136+
}
137+
98138
fn test_issue_27556_example() {
99139
// The exact value from the issue: stored as '2024-01-15 14:00:00.123456+01:00'
100140
// with the session in UTC, PostgreSQL returns it as below.

0 commit comments

Comments
 (0)