From 691819cfa708e5d5acba5968ac5eb8903f072ba1 Mon Sep 17 00:00:00 2001 From: fox0430 Date: Tue, 14 Apr 2026 19:04:08 +0900 Subject: [PATCH 1/2] Add time, timetz, timestamptz type support --- async_postgres/pg_protocol.nim | 5 + async_postgres/pg_types.nim | 215 ++++++++++++++++++++++++++ tests/test_e2e.nim | 122 +++++++++++++++ tests/test_types.nim | 267 +++++++++++++++++++++++++++++++++ 4 files changed, 609 insertions(+) diff --git a/async_postgres/pg_protocol.nim b/async_postgres/pg_protocol.nim index 8ee0026..b426784 100644 --- a/async_postgres/pg_protocol.nim +++ b/async_postgres/pg_protocol.nim @@ -184,6 +184,11 @@ const 701, # float8 718, # circle 1043, # varchar + 1082, # date + 1083, # time + 1114, # timestamp + 1184, # timestamptz + 1266, # timetz 1560, # bit 1561, # bit[] 1562, # varbit diff --git a/async_postgres/pg_types.nim b/async_postgres/pg_types.nim index 0422371..b2df6b6 100644 --- a/async_postgres/pg_types.nim +++ b/async_postgres/pg_types.nim @@ -36,6 +36,19 @@ type days*: int32 microseconds*: int64 + PgTime* = object ## PostgreSQL time without time zone. + hour*: int32 ## 0..23 + minute*: int32 ## 0..59 + second*: int32 ## 0..59 + microsecond*: int32 ## 0..999999 + + PgTimeTz* = object ## PostgreSQL time with time zone. + hour*: int32 ## 0..23 + minute*: int32 ## 0..59 + second*: int32 ## 0..59 + microsecond*: int32 ## 0..999999 + utcOffset*: int32 ## UTC offset in seconds (positive = east of UTC) + PgInet* = object ## PostgreSQL inet type: an IP address with a subnet mask. address*: IpAddress mask*: uint8 @@ -158,6 +171,7 @@ const OidDate* = 1082'i32 OidTime* = 1083'i32 OidTimestampTz* = 1184'i32 + OidTimeTz* = 1266'i32 OidNumeric* = 1700'i32 OidJson* = 114'i32 OidInterval* = 1186'i32 @@ -602,6 +616,32 @@ proc `$`*(v: PgInterval): string = proc `==`*(a, b: PgInterval): bool = a.months == b.months and a.days == b.days and a.microseconds == b.microseconds +proc `$`*(v: PgTime): string = + result = + align($v.hour, 2, '0') & ":" & align($v.minute, 2, '0') & ":" & + align($v.second, 2, '0') + if v.microsecond != 0: + result.add("." & align($v.microsecond, 6, '0')) + +proc `$`*(v: PgTimeTz): string = + result = + align($v.hour, 2, '0') & ":" & align($v.minute, 2, '0') & ":" & + align($v.second, 2, '0') + if v.microsecond != 0: + result.add("." & align($v.microsecond, 6, '0')) + let off = v.utcOffset + if off >= 0: + result.add("+") + else: + result.add("-") + let absOff = abs(off) + let offH = absOff div 3600 + let offM = (absOff mod 3600) div 60 + let offS = absOff mod 60 + result.add(align($offH, 2, '0') & ":" & align($offM, 2, '0')) + if offS != 0: + result.add(":" & align($offS, 2, '0')) + proc toBytes*(s: string): seq[byte] = ## Converts a string to a sequence of bytes. result = newSeq[byte](s.len) @@ -685,6 +725,22 @@ proc toPgParam*(v: DateTime): PgParam = let s = v.format("yyyy-MM-dd HH:mm:ss'.'ffffff") PgParam(oid: OidTimestamp, format: 0, value: some(toBytes(s))) +proc toPgDateParam*(v: DateTime): PgParam = + ## Encode a DateTime as a date parameter (OID 1082). + let s = v.format("yyyy-MM-dd") + PgParam(oid: OidDate, format: 0, value: some(toBytes(s))) + +proc toPgTimestampTzParam*(v: DateTime): PgParam = + ## Encode a DateTime as a timestamptz parameter (OID 1184). + let s = v.format("yyyy-MM-dd HH:mm:ss'.'ffffffzzz") + PgParam(oid: OidTimestampTz, format: 0, value: some(toBytes(s))) + +proc toPgParam*(v: PgTime): PgParam = + PgParam(oid: OidTime, format: 0, value: some(toBytes($v))) + +proc toPgParam*(v: PgTimeTz): PgParam = + PgParam(oid: OidTimeTz, format: 0, value: some(toBytes($v))) + proc toPgParam*(v: PgUuid): PgParam = PgParam(oid: OidUuid, format: 0, value: some(toBytes(string(v)))) @@ -970,6 +1026,34 @@ proc toPgBinaryParam*(v: DateTime): PgParam = let pgUs = unixUs - pgEpochUnix * 1_000_000 PgParam(oid: OidTimestamp, format: 1, value: some(@(toBE64(pgUs)))) +proc toPgBinaryDateParam*(v: DateTime): PgParam = + ## Encode a DateTime as a binary date parameter (OID 1082). + let t = v.toTime() + let pgDays = int32(t.toUnix() div 86400 - int64(pgEpochDaysOffset)) + PgParam(oid: OidDate, format: 1, value: some(@(toBE32(pgDays)))) + +proc toPgBinaryTimestampTzParam*(v: DateTime): PgParam = + ## Encode a DateTime as a binary timestamptz parameter (OID 1184). + let t = v.toTime() + let unixUs = t.toUnix() * 1_000_000 + int64(t.nanosecond div 1000) + let pgUs = unixUs - pgEpochUnix * 1_000_000 + PgParam(oid: OidTimestampTz, format: 1, value: some(@(toBE64(pgUs)))) + +proc toPgBinaryParam*(v: PgTime): PgParam = + let us = + int64(v.hour) * 3_600_000_000'i64 + int64(v.minute) * 60_000_000'i64 + + int64(v.second) * 1_000_000'i64 + int64(v.microsecond) + PgParam(oid: OidTime, format: 1, value: some(@(toBE64(us)))) + +proc toPgBinaryParam*(v: PgTimeTz): PgParam = + let us = + int64(v.hour) * 3_600_000_000'i64 + int64(v.minute) * 60_000_000'i64 + + int64(v.second) * 1_000_000'i64 + int64(v.microsecond) + let pgOffset = int32(-v.utcOffset) # PostgreSQL stores offset negated + var data: seq[byte] = @(toBE64(us)) + data.add(@(toBE32(pgOffset))) + PgParam(oid: OidTimeTz, format: 1, value: some(data)) + proc putBE16(buf: var seq[byte], off: int, v: int16) = let b = toBE16(v) buf[off] = b[0] @@ -1323,6 +1407,33 @@ proc decodeBinaryDate(data: openArray[byte]): DateTime = let unixSec = (int64(pgDays) + int64(pgEpochDaysOffset)) * 86400 initTime(unixSec, 0).utc() +proc decodeBinaryTime(data: openArray[byte]): PgTime = + let us = fromBE64(data) + let hours = int32(us div 3_600_000_000) + let rem1 = us mod 3_600_000_000 + let minutes = int32(rem1 div 60_000_000) + let rem2 = rem1 mod 60_000_000 + let seconds = int32(rem2 div 1_000_000) + let microseconds = int32(rem2 mod 1_000_000) + PgTime(hour: hours, minute: minutes, second: seconds, microsecond: microseconds) + +proc decodeBinaryTimeTz(data: openArray[byte]): PgTimeTz = + let us = fromBE64(data) + let pgOffset = fromBE32(data.toOpenArray(8, 11)) + let hours = int32(us div 3_600_000_000) + let rem1 = us mod 3_600_000_000 + let minutes = int32(rem1 div 60_000_000) + let rem2 = rem1 mod 60_000_000 + let seconds = int32(rem2 div 1_000_000) + let microseconds = int32(rem2 mod 1_000_000) + PgTimeTz( + hour: hours, + minute: minutes, + second: seconds, + microsecond: microseconds, + utcOffset: -pgOffset, # un-negate PostgreSQL wire format + ) + proc decodeInetBinary(data: openArray[byte]): tuple[address: IpAddress, mask: uint8] = ## Decode PostgreSQL binary inet/cidr format: ## 1 byte: family (2=IPv4, 3=IPv6) @@ -1948,6 +2059,107 @@ proc getDate*(row: Row, col: int): DateTime = except TimeParseError: raise newException(PgTypeError, "Invalid date: " & s) +proc getTimestampTz*(row: Row, col: int): DateTime = + ## Get a column value as DateTime from a timestamptz column. + if row.isBinaryCol(col): + let (off, clen) = cellInfo(row, col) + if clen == -1: + raise newException(PgTypeError, "Column " & $col & " is NULL") + return decodeBinaryTimestamp(row.data.buf.toOpenArray(off, off + 7)) + let s = row.getStr(col) + const formats = [ + "yyyy-MM-dd HH:mm:ss'.'ffffffzzz", "yyyy-MM-dd HH:mm:ss'.'ffffffzz", + "yyyy-MM-dd HH:mm:ss'.'ffffff", "yyyy-MM-dd HH:mm:sszzz", "yyyy-MM-dd HH:mm:sszz", + "yyyy-MM-dd HH:mm:ss", + ] + for fmt in formats: + try: + return parse(s, fmt) + except TimeParseError: + discard + raise newException(PgTypeError, "Invalid timestamptz: " & s) + +proc parseTimeText(s: string): PgTime = + ## Parse PostgreSQL time text format: "HH:mm:ss" or "HH:mm:ss.ffffff". + if s.len < 8 or s[2] != ':' or s[5] != ':': + raise newException(PgTypeError, "Invalid time: " & s) + var h, m, sec, us: int + try: + h = parseInt(s[0 .. 1]) + m = parseInt(s[3 .. 4]) + sec = parseInt(s[6 .. 7]) + except ValueError: + raise newException(PgTypeError, "Invalid time: " & s) + if h notin 0 .. 23 or m notin 0 .. 59 or sec notin 0 .. 59: + raise newException(PgTypeError, "Invalid time: " & s) + if s.len > 8 and s[8] == '.': + let frac = s[9 .. ^1] + if frac.len == 0 or frac.len > 6: + raise newException(PgTypeError, "Invalid time: " & s) + try: + us = parseInt(frac) + except ValueError: + raise newException(PgTypeError, "Invalid time: " & s) + # Pad to 6 digits + for _ in 0 ..< (6 - frac.len): + us *= 10 + PgTime(hour: int32(h), minute: int32(m), second: int32(sec), microsecond: int32(us)) + +proc getTime*(row: Row, col: int): PgTime = + ## Get a column value as PgTime. Handles binary time format. + if row.isBinaryCol(col): + let (off, clen) = cellInfo(row, col) + if clen == -1: + raise newException(PgTypeError, "Column " & $col & " is NULL") + return decodeBinaryTime(row.data.buf.toOpenArray(off, off + 7)) + let s = row.getStr(col) + return parseTimeText(s) + +proc getTimeTz*(row: Row, col: int): PgTimeTz = + ## Get a column value as PgTimeTz. Handles binary timetz format. + if row.isBinaryCol(col): + let (off, clen) = cellInfo(row, col) + if clen == -1: + raise newException(PgTypeError, "Column " & $col & " is NULL") + return decodeBinaryTimeTz(row.data.buf.toOpenArray(off, off + 11)) + let s = row.getStr(col) + # Find the timezone offset separator (+ or - after the time part) + var tzPos = -1 + for i in 8 ..< s.len: + if s[i] == '+' or s[i] == '-': + tzPos = i + break + if tzPos < 0: + raise newException(PgTypeError, "Invalid timetz (no offset): " & s) + let timePart = s[0 ..< tzPos] + let t = parseTimeText(timePart) + # Parse offset: "+HH", "+HH:MM", "+HH:MM:SS" + let sign = if s[tzPos] == '+': 1 else: -1 + let offStr = s[tzPos + 1 .. ^1] + var offH, offM, offS: int + try: + if offStr.len == 2: + offH = parseInt(offStr) + elif offStr.len == 5 and offStr[2] == ':': + offH = parseInt(offStr[0 .. 1]) + offM = parseInt(offStr[3 .. 4]) + elif offStr.len == 8 and offStr[2] == ':' and offStr[5] == ':': + offH = parseInt(offStr[0 .. 1]) + offM = parseInt(offStr[3 .. 4]) + offS = parseInt(offStr[6 .. 7]) + else: + raise newException(PgTypeError, "Invalid timetz offset: " & s) + except ValueError: + raise newException(PgTypeError, "Invalid timetz offset: " & s) + let utcOff = sign * (offH * 3600 + offM * 60 + offS) + PgTimeTz( + hour: t.hour, + minute: t.minute, + second: t.second, + microsecond: t.microsecond, + utcOffset: int32(utcOff), + ) + proc parseHstoreText*(s: string): PgHstore = ## Parse PostgreSQL hstore text format: ``"key1"=>"val1", "key2"=>NULL``. result = initTable[string, Option[string]]() @@ -2630,6 +2842,9 @@ optAccessor(getBytes, getBytesOpt, seq[byte]) optAccessor(getJson, getJsonOpt, JsonNode) optAccessor(getTimestamp, getTimestampOpt, DateTime) optAccessor(getDate, getDateOpt, DateTime) +optAccessor(getTime, getTimeOpt, PgTime) +optAccessor(getTimeTz, getTimeTzOpt, PgTimeTz) +optAccessor(getTimestampTz, getTimestampTzOpt, DateTime) optAccessor(getInterval, getIntervalOpt, PgInterval) optAccessor(getInet, getInetOpt, PgInet) optAccessor(getCidr, getCidrOpt, PgCidr) diff --git a/tests/test_e2e.nim b/tests/test_e2e.nim index 28ed437..282f993 100644 --- a/tests/test_e2e.nim +++ b/tests/test_e2e.nim @@ -2812,6 +2812,68 @@ suite "E2E: Extended Type Roundtrip": waitFor t() + test "time roundtrip": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let tm = PgTime(hour: 14, minute: 30, second: 45, microsecond: 123456) + let res = await conn.query("SELECT $1::time", @[toPgParam(tm)]) + doAssert res.rows.len == 1 + let got = res.rows[0].getTime(0) + doAssert got.hour == 14 + doAssert got.minute == 30 + doAssert got.second == 45 + doAssert got.microsecond == 123456 + await conn.close() + + waitFor t() + + test "timetz roundtrip": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let tm = + PgTimeTz(hour: 14, minute: 30, second: 45, microsecond: 0, utcOffset: 18000) + let res = await conn.query("SELECT $1::timetz", @[toPgParam(tm)]) + doAssert res.rows.len == 1 + let got = res.rows[0].getTimeTz(0) + doAssert got.hour == 14 + doAssert got.minute == 30 + doAssert got.second == 45 + doAssert got.utcOffset == 18000 + await conn.close() + + waitFor t() + + test "date param roundtrip": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let dt = dateTime(2025, mJun, 15, 0, 0, 0, zone = utc()) + let res = await conn.query("SELECT $1::date", @[toPgDateParam(dt)]) + doAssert res.rows.len == 1 + let got = res.rows[0].getDate(0) + doAssert got.year == 2025 + doAssert got.month == mJun + doAssert got.monthday == 15 + await conn.close() + + waitFor t() + + test "timestamptz roundtrip": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let dt = dateTime(2025, mMar, 15, 10, 30, 45, zone = utc()) + let res = await conn.query("SELECT $1::timestamptz", @[toPgTimestampTzParam(dt)]) + doAssert res.rows.len == 1 + let got = res.rows[0].getTimestampTz(0) + doAssert got.utc().year == 2025 + doAssert got.utc().month == mMar + doAssert got.utc().monthday == 15 + doAssert got.utc().hour == 10 + doAssert got.utc().minute == 30 + doAssert got.utc().second == 45 + await conn.close() + + waitFor t() + test "UUID roundtrip": proc t() {.async.} = let conn = await connect(plainConfig()) @@ -3393,6 +3455,66 @@ suite "E2E: Binary Format": waitFor t() + test "binary time param roundtrip": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let tm = PgTime(hour: 14, minute: 30, second: 45, microsecond: 123456) + let params = @[toPgBinaryParam(tm)] + let qr = await conn.query("SELECT $1::time", params, resultFormat = rfBinary) + doAssert qr.rows.len == 1 + let got = qr.rows[0].getTime(0) + doAssert got == tm + await conn.close() + + waitFor t() + + test "binary timetz param roundtrip": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let tm = + PgTimeTz(hour: 14, minute: 30, second: 45, microsecond: 0, utcOffset: 18000) + let params = @[toPgBinaryParam(tm)] + let qr = await conn.query("SELECT $1::timetz", params, resultFormat = rfBinary) + doAssert qr.rows.len == 1 + let got = qr.rows[0].getTimeTz(0) + doAssert got == tm + await conn.close() + + waitFor t() + + test "binary date param roundtrip": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let dt = dateTime(2024, mJan, 15, 0, 0, 0, 0, utc()) + let params = @[toPgBinaryDateParam(dt)] + let qr = await conn.query("SELECT $1::date", params, resultFormat = rfBinary) + doAssert qr.rows.len == 1 + let got = qr.rows[0].getDate(0) + doAssert got.year == 2024 + doAssert got.month == mJan + doAssert got.monthday == 15 + await conn.close() + + waitFor t() + + test "binary timestamptz param roundtrip": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let dt = dateTime(2024, mJan, 15, 10, 30, 0, 0, utc()) + let params = @[toPgBinaryTimestampTzParam(dt)] + let qr = + await conn.query("SELECT $1::timestamptz", params, resultFormat = rfBinary) + doAssert qr.rows.len == 1 + let got = qr.rows[0].getTimestampTz(0) + doAssert got.year == 2024 + doAssert got.month == mJan + doAssert got.monthday == 15 + doAssert got.hour == 10 + doAssert got.minute == 30 + await conn.close() + + waitFor t() + test "binary float roundtrip": proc t() {.async.} = let conn = await connect(plainConfig()) diff --git a/tests/test_types.nim b/tests/test_types.nim index 892c5ec..0afe2b2 100644 --- a/tests/test_types.nim +++ b/tests/test_types.nim @@ -467,6 +467,273 @@ suite "getDate accessor": raised = true check raised +suite "PgTime": + test "$PgTime without microseconds": + let t = PgTime(hour: 14, minute: 30, second: 0, microsecond: 0) + check $t == "14:30:00" + + test "$PgTime with microseconds": + let t = PgTime(hour: 9, minute: 5, second: 3, microsecond: 123456) + check $t == "09:05:03.123456" + + test "$PgTime with leading zero microseconds": + let t = PgTime(hour: 0, minute: 0, second: 0, microsecond: 100) + check $t == "00:00:00.000100" + + test "toPgParam OID and format": + let p = toPgParam(PgTime(hour: 10, minute: 20, second: 30)) + check p.oid == OidTime + check p.format == 0 + + test "getTime text without microseconds": + let row = @[some(toBytes("14:30:00"))] + let t = row.getTime(0) + check t.hour == 14 + check t.minute == 30 + check t.second == 0 + check t.microsecond == 0 + + test "getTime text with microseconds": + let row = @[some(toBytes("09:05:03.123456"))] + let t = row.getTime(0) + check t.hour == 9 + check t.minute == 5 + check t.second == 3 + check t.microsecond == 123456 + + test "getTime text with partial microseconds": + let row = @[some(toBytes("10:00:00.5"))] + let t = row.getTime(0) + check t.microsecond == 500000 + + test "getTime invalid raises": + let row = @[some(toBytes("not-a-time"))] + var raised = false + try: + discard row.getTime(0) + except PgTypeError: + raised = true + check raised + + test "getTime out-of-range hour raises": + let row = @[some(toBytes("25:00:00"))] + var raised = false + try: + discard row.getTime(0) + except PgTypeError: + raised = true + check raised + + test "getTime out-of-range minute raises": + let row = @[some(toBytes("12:60:00"))] + var raised = false + try: + discard row.getTime(0) + except PgTypeError: + raised = true + check raised + + test "getTime out-of-range second raises": + let row = @[some(toBytes("12:00:60"))] + var raised = false + try: + discard row.getTime(0) + except PgTypeError: + raised = true + check raised + + test "getTime NULL raises": + let row = @[none(seq[byte])] + var raised = false + try: + discard row.getTime(0) + except PgTypeError: + raised = true + check raised + + test "getTime binary roundtrip": + let t = PgTime(hour: 14, minute: 30, second: 45, microsecond: 123456) + let p = toPgBinaryParam(t) + check p.oid == OidTime + check p.format == 1 + let fields = @[mkField(OidTime, 1)] + let row = mkRow(@[p.value], fields) + let result = row.getTime(0) + check result == t + + test "getTime binary midnight": + let t = PgTime(hour: 0, minute: 0, second: 0, microsecond: 0) + let p = toPgBinaryParam(t) + let fields = @[mkField(OidTime, 1)] + let row = mkRow(@[p.value], fields) + let result = row.getTime(0) + check result == t + + test "getTimeOpt NULL returns none": + let row = @[none(seq[byte])] + check row.getTimeOpt(0).isNone + + test "getTimeOpt returns some": + let row = @[some(toBytes("10:30:00"))] + let opt = row.getTimeOpt(0) + check opt.isSome + check opt.get().hour == 10 + +suite "PgTimeTz": + test "$PgTimeTz positive offset": + let t = PgTimeTz(hour: 14, minute: 30, second: 0, microsecond: 0, utcOffset: 18000) + check $t == "14:30:00+05:00" + + test "$PgTimeTz negative offset": + let t = PgTimeTz(hour: 14, minute: 30, second: 0, microsecond: 0, utcOffset: -12600) + check $t == "14:30:00-03:30" + + test "$PgTimeTz UTC": + let t = PgTimeTz(hour: 10, minute: 0, second: 0, microsecond: 0, utcOffset: 0) + check $t == "10:00:00+00:00" + + test "$PgTimeTz with microseconds": + let t = + PgTimeTz(hour: 9, minute: 5, second: 3, microsecond: 123456, utcOffset: 3600) + check $t == "09:05:03.123456+01:00" + + test "toPgParam OID": + let p = toPgParam(PgTimeTz(hour: 10, minute: 0, second: 0, utcOffset: 0)) + check p.oid == OidTimeTz + check p.format == 0 + + test "getTimeTz text +HH": + let row = @[some(toBytes("14:30:00+05"))] + let t = row.getTimeTz(0) + check t.hour == 14 + check t.minute == 30 + check t.utcOffset == 18000 + + test "getTimeTz text -HH:MM": + let row = @[some(toBytes("10:00:00.123456-03:30"))] + let t = row.getTimeTz(0) + check t.hour == 10 + check t.minute == 0 + check t.microsecond == 123456 + check t.utcOffset == -12600 + + test "getTimeTz text +HH:MM:SS": + let row = @[some(toBytes("12:00:00+05:30:15"))] + let t = row.getTimeTz(0) + check t.utcOffset == 19815 + + test "getTimeTz invalid raises": + let row = @[some(toBytes("not-a-time"))] + var raised = false + try: + discard row.getTimeTz(0) + except PgTypeError: + raised = true + check raised + + test "getTimeTz missing offset raises": + let row = @[some(toBytes("14:30:00"))] + var raised = false + try: + discard row.getTimeTz(0) + except PgTypeError: + raised = true + check raised + + test "getTimeTz binary roundtrip": + let t = + PgTimeTz(hour: 14, minute: 30, second: 45, microsecond: 123456, utcOffset: 18000) + let p = toPgBinaryParam(t) + check p.oid == OidTimeTz + check p.format == 1 + let fields = @[mkField(OidTimeTz, 1)] + let row = mkRow(@[p.value], fields) + let result = row.getTimeTz(0) + check result == t + + test "getTimeTz binary negative offset": + let t = PgTimeTz(hour: 10, minute: 0, second: 0, microsecond: 0, utcOffset: -12600) + let p = toPgBinaryParam(t) + let fields = @[mkField(OidTimeTz, 1)] + let row = mkRow(@[p.value], fields) + let result = row.getTimeTz(0) + check result == t + + test "getTimeTzOpt NULL returns none": + let row = @[none(seq[byte])] + check row.getTimeTzOpt(0).isNone + +suite "date parameter encoding": + test "toPgDateParam OID and format": + let dt = dateTime(2024, mJan, 15, 0, 0, 0, 0, utc()) + let p = toPgDateParam(dt) + check p.oid == OidDate + check p.format == 0 + check p.value.isSome + let s = cast[string](p.value.get()) + check s == "2024-01-15" + + test "toPgBinaryDateParam roundtrip": + let dt = dateTime(2024, mJan, 15, 0, 0, 0, 0, utc()) + let p = toPgBinaryDateParam(dt) + check p.oid == OidDate + check p.format == 1 + let fields = @[mkField(OidDate, 1)] + let row = mkRow(@[p.value], fields) + let result = row.getDate(0) + check result.year == 2024 + check result.month == mJan + check result.monthday == 15 + +suite "getTimestampTz accessor": + test "timestamptz with tz": + let row = @[some(toBytes("2024-01-15 10:30:00.000000+05:00"))] + let dt = row.getTimestampTz(0) + check dt.year == 2024 + check dt.month == mJan + check dt.monthday == 15 + + test "timestamptz without fractional seconds": + let row = @[some(toBytes("2024-06-20 14:05:30+00:00"))] + let dt = row.getTimestampTz(0) + check dt.year == 2024 + # The parsed DateTime is converted to local timezone by Nim's parse(), + # so we compare using UTC. + check dt.utc().hour == 14 + + test "invalid timestamptz raises": + let row = @[some(toBytes("not-a-timestamp"))] + var raised = false + try: + discard row.getTimestampTz(0) + except PgTypeError: + raised = true + check raised + + test "timestamptz binary roundtrip": + let dt = dateTime(2024, mJan, 15, 10, 30, 0, 0, utc()) + let p = toPgBinaryTimestampTzParam(dt) + check p.oid == OidTimestampTz + check p.format == 1 + let fields = @[mkField(OidTimestampTz, 1)] + let row = mkRow(@[p.value], fields) + let result = row.getTimestampTz(0) + check result.year == 2024 + check result.month == mJan + check result.monthday == 15 + check result.hour == 10 + check result.minute == 30 + + test "toPgTimestampTzParam OID": + let dt = dateTime(2024, mJan, 15, 10, 30, 0, 0, utc()) + let p = toPgTimestampTzParam(dt) + check p.oid == OidTimestampTz + check p.format == 0 + + test "getTimestampTzOpt NULL returns none": + let row = @[none(seq[byte])] + check row.getTimestampTzOpt(0).isNone + suite "Binary encode/decode helpers": test "int16 roundtrip": let p = toPgBinaryParam(42'i16) From 99ac629e4343aaa5e9bcc73420660534e76ed416 Mon Sep 17 00:00:00 2001 From: fox0430 Date: Tue, 14 Apr 2026 19:12:02 +0900 Subject: [PATCH 2/2] fix --- async_postgres/pg_types.nim | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/async_postgres/pg_types.nim b/async_postgres/pg_types.nim index b2df6b6..3bf19fe 100644 --- a/async_postgres/pg_types.nim +++ b/async_postgres/pg_types.nim @@ -2042,7 +2042,7 @@ proc getTimestamp*(row: Row, col: int): DateTime = for fmt in formats: try: return parse(s, fmt) - except TimeParseError: + except TimeParseError, IndexDefect: discard raise newException(PgTypeError, "Invalid timestamp: " & s) @@ -2075,7 +2075,7 @@ proc getTimestampTz*(row: Row, col: int): DateTime = for fmt in formats: try: return parse(s, fmt) - except TimeParseError: + except TimeParseError, IndexDefect: discard raise newException(PgTypeError, "Invalid timestamptz: " & s)