diff --git a/async_postgres/pg_protocol.nim b/async_postgres/pg_protocol.nim index 2aeb1da..cc09e3f 100644 --- a/async_postgres/pg_protocol.nim +++ b/async_postgres/pg_protocol.nim @@ -174,11 +174,17 @@ const 718, # circle 1043, # varchar 3904, # int4range + 3905, # int4range[] 3906, # numrange + 3907, # numrange[] 3908, # tsrange + 3909, # tsrange[] 3910, # tstzrange + 3911, # tstzrange[] 3912, # daterange + 3913, # daterange[] 3926, # int8range + 3927, # int8range[] 4451, # int4multirange 4532, # nummultirange 4533, # tsmultirange diff --git a/async_postgres/pg_types.nim b/async_postgres/pg_types.nim index d8ae668..c64d79b 100644 --- a/async_postgres/pg_types.nim +++ b/async_postgres/pg_types.nim @@ -189,6 +189,14 @@ const OidDateRange* = 3912'i32 OidInt8Range* = 3926'i32 + # Range array types + OidInt4RangeArray* = 3905'i32 + OidNumRangeArray* = 3907'i32 + OidTsRangeArray* = 3909'i32 + OidTsTzRangeArray* = 3911'i32 + OidDateRangeArray* = 3913'i32 + OidInt8RangeArray* = 3927'i32 + # Multirange types (PostgreSQL 14+) OidInt4Multirange* = 4451'i32 OidNumMultirange* = 4532'i32 @@ -3378,6 +3386,100 @@ proc toPgBinaryTsTzRangeParam*(v: PgRange[DateTime]): PgParam = proc toPgBinaryDateRangeParam*(v: PgRange[DateTime]): PgParam = encodeRangeBinary(v, OidDateRange, encodeBinaryDate) +# toPgBinaryParam for range array types + +proc toPgBinaryParam*(v: seq[PgRange[int32]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidInt4RangeArray, + format: 1, + value: some(encodeBinaryArrayEmpty(OidInt4Range)), + ) + var elements = newSeq[seq[byte]](v.len) + for i, r in v: + elements[i] = toPgBinaryParam(r).value.get + PgParam( + oid: OidInt4RangeArray, + format: 1, + value: some(encodeBinaryArray(OidInt4Range, elements)), + ) + +proc toPgBinaryParam*(v: seq[PgRange[int64]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidInt8RangeArray, + format: 1, + value: some(encodeBinaryArrayEmpty(OidInt8Range)), + ) + var elements = newSeq[seq[byte]](v.len) + for i, r in v: + elements[i] = toPgBinaryParam(r).value.get + PgParam( + oid: OidInt8RangeArray, + format: 1, + value: some(encodeBinaryArray(OidInt8Range, elements)), + ) + +proc toPgBinaryParam*(v: seq[PgRange[PgNumeric]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidNumRangeArray, format: 1, value: some(encodeBinaryArrayEmpty(OidNumRange)) + ) + var elements = newSeq[seq[byte]](v.len) + for i, r in v: + elements[i] = toPgBinaryParam(r).value.get + PgParam( + oid: OidNumRangeArray, + format: 1, + value: some(encodeBinaryArray(OidNumRange, elements)), + ) + +proc toPgBinaryParam*(v: seq[PgRange[DateTime]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidTsRangeArray, format: 1, value: some(encodeBinaryArrayEmpty(OidTsRange)) + ) + var elements = newSeq[seq[byte]](v.len) + for i, r in v: + elements[i] = toPgBinaryParam(r).value.get + PgParam( + oid: OidTsRangeArray, + format: 1, + value: some(encodeBinaryArray(OidTsRange, elements)), + ) + +proc toPgBinaryTsTzRangeArrayParam*(v: seq[PgRange[DateTime]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidTsTzRangeArray, + format: 1, + value: some(encodeBinaryArrayEmpty(OidTsTzRange)), + ) + var elements = newSeq[seq[byte]](v.len) + for i, r in v: + elements[i] = toPgBinaryTsTzRangeParam(r).value.get + PgParam( + oid: OidTsTzRangeArray, + format: 1, + value: some(encodeBinaryArray(OidTsTzRange, elements)), + ) + +proc toPgBinaryDateRangeArrayParam*(v: seq[PgRange[DateTime]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidDateRangeArray, + format: 1, + value: some(encodeBinaryArrayEmpty(OidDateRange)), + ) + var elements = newSeq[seq[byte]](v.len) + for i, r in v: + elements[i] = toPgBinaryDateRangeParam(r).value.get + PgParam( + oid: OidDateRangeArray, + format: 1, + value: some(encodeBinaryArray(OidDateRange, elements)), + ) + # Range text format getters proc getInt4Range*(row: Row, col: int): PgRange[int32] = @@ -3817,6 +3919,208 @@ optAccessor(getTsMultirange, getTsMultirangeOpt, PgMultirange[DateTime]) optAccessor(getTsTzMultirange, getTsTzMultirangeOpt, PgMultirange[DateTime]) optAccessor(getDateMultirange, getDateMultirangeOpt, PgMultirange[DateTime]) +# Range array type support +# +# PostgreSQL range array types store arrays of range values. +# Text format: {"[1,10)","[20,30)"} +# Binary format: standard array container with range elements. + +proc getInt4RangeArray*(row: Row, col: int): seq[PgRange[int32]] = + ## Get a column value as an int4range[]. Handles binary format. + if row.isBinaryCol(col): + let (off, clen) = cellInfo(row, col) + if clen == -1: + raise newException(PgTypeError, "Column " & $col & " is NULL") + let decoded = decodeBinaryArray(row.data.buf.toOpenArray(off, off + clen - 1)) + result = newSeq[PgRange[int32]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + raise newException(PgTypeError, "NULL element in range array") + result[i] = decodeInt4RangeBinary( + row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1) + ) + return + let s = row.getStr(col) + let elems = parseTextArray(s) + for e in elems: + if e.isNone: + raise newException(PgTypeError, "NULL element in range array") + result.add( + parseRangeText[int32]( + e.get, + proc(x: string): int32 = + int32(parseInt(x)), + ) + ) + +proc getInt8RangeArray*(row: Row, col: int): seq[PgRange[int64]] = + ## Get a column value as an int8range[]. Handles binary format. + if row.isBinaryCol(col): + let (off, clen) = cellInfo(row, col) + if clen == -1: + raise newException(PgTypeError, "Column " & $col & " is NULL") + let decoded = decodeBinaryArray(row.data.buf.toOpenArray(off, off + clen - 1)) + result = newSeq[PgRange[int64]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + raise newException(PgTypeError, "NULL element in range array") + result[i] = decodeInt8RangeBinary( + row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1) + ) + return + let s = row.getStr(col) + let elems = parseTextArray(s) + for e in elems: + if e.isNone: + raise newException(PgTypeError, "NULL element in range array") + result.add( + parseRangeText[int64]( + e.get, + proc(x: string): int64 = + parseBiggestInt(x), + ) + ) + +proc getNumRangeArray*(row: Row, col: int): seq[PgRange[PgNumeric]] = + ## Get a column value as a numrange[]. Handles binary format. + if row.isBinaryCol(col): + let (off, clen) = cellInfo(row, col) + if clen == -1: + raise newException(PgTypeError, "Column " & $col & " is NULL") + let decoded = decodeBinaryArray(row.data.buf.toOpenArray(off, off + clen - 1)) + result = newSeq[PgRange[PgNumeric]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + raise newException(PgTypeError, "NULL element in range array") + result[i] = decodeNumRangeBinary( + row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1) + ) + return + let s = row.getStr(col) + let elems = parseTextArray(s) + for e in elems: + if e.isNone: + raise newException(PgTypeError, "NULL element in range array") + result.add( + parseRangeText[PgNumeric]( + e.get, + proc(x: string): PgNumeric = + parsePgNumeric(x), + ) + ) + +proc getTsRangeArray*(row: Row, col: int): seq[PgRange[DateTime]] = + ## Get a column value as a tsrange[]. Handles binary format. + if row.isBinaryCol(col): + let (off, clen) = cellInfo(row, col) + if clen == -1: + raise newException(PgTypeError, "Column " & $col & " is NULL") + let decoded = decodeBinaryArray(row.data.buf.toOpenArray(off, off + clen - 1)) + result = newSeq[PgRange[DateTime]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + raise newException(PgTypeError, "NULL element in range array") + result[i] = decodeTsRangeBinary( + row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1) + ) + return + let s = row.getStr(col) + let elems = parseTextArray(s) + for e in elems: + if e.isNone: + raise newException(PgTypeError, "NULL element in range array") + result.add( + parseRangeText[DateTime]( + e.get, + proc(x: string): DateTime = + const formats = ["yyyy-MM-dd HH:mm:ss'.'ffffff", "yyyy-MM-dd HH:mm:ss"] + for fmt in formats: + try: + return parse(x, fmt) + except TimeParseError: + discard + raise newException(PgTypeError, "Invalid timestamp in range array: " & x), + ) + ) + +proc getTsTzRangeArray*(row: Row, col: int): seq[PgRange[DateTime]] = + ## Get a column value as a tstzrange[]. Handles binary format. + if row.isBinaryCol(col): + let (off, clen) = cellInfo(row, col) + if clen == -1: + raise newException(PgTypeError, "Column " & $col & " is NULL") + let decoded = decodeBinaryArray(row.data.buf.toOpenArray(off, off + clen - 1)) + result = newSeq[PgRange[DateTime]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + raise newException(PgTypeError, "NULL element in range array") + result[i] = decodeTsRangeBinary( + row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1) + ) + return + let s = row.getStr(col) + let elems = parseTextArray(s) + for e in elems: + if e.isNone: + raise newException(PgTypeError, "NULL element in range array") + result.add( + parseRangeText[DateTime]( + e.get, + proc(x: string): DateTime = + 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(x, fmt) + except TimeParseError: + discard + raise newException(PgTypeError, "Invalid timestamptz in range array: " & x), + ) + ) + +proc getDateRangeArray*(row: Row, col: int): seq[PgRange[DateTime]] = + ## Get a column value as a daterange[]. Handles binary format. + if row.isBinaryCol(col): + let (off, clen) = cellInfo(row, col) + if clen == -1: + raise newException(PgTypeError, "Column " & $col & " is NULL") + let decoded = decodeBinaryArray(row.data.buf.toOpenArray(off, off + clen - 1)) + result = newSeq[PgRange[DateTime]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + raise newException(PgTypeError, "NULL element in range array") + result[i] = decodeDateRangeBinary( + row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1) + ) + return + let s = row.getStr(col) + let elems = parseTextArray(s) + for e in elems: + if e.isNone: + raise newException(PgTypeError, "NULL element in range array") + result.add( + parseRangeText[DateTime]( + e.get, + proc(x: string): DateTime = + try: + return parse(x, "yyyy-MM-dd") + except TimeParseError: + raise newException(PgTypeError, "Invalid date in range array: " & x), + ) + ) + +# Range array Opt accessors + +optAccessor(getInt4RangeArray, getInt4RangeArrayOpt, seq[PgRange[int32]]) +optAccessor(getInt8RangeArray, getInt8RangeArrayOpt, seq[PgRange[int64]]) +optAccessor(getNumRangeArray, getNumRangeArrayOpt, seq[PgRange[PgNumeric]]) +optAccessor(getTsRangeArray, getTsRangeArrayOpt, seq[PgRange[DateTime]]) +optAccessor(getTsTzRangeArray, getTsTzRangeArrayOpt, seq[PgRange[DateTime]]) +optAccessor(getDateRangeArray, getDateRangeArrayOpt, seq[PgRange[DateTime]]) + # Generic accessors — static dispatch by type, no OID branching. # DateTime-based types (DateTime, PgRange[DateTime], PgMultirange[DateTime]) # are excluded because DateTime maps to multiple PG types (timestamp, diff --git a/tests/test_types.nim b/tests/test_types.nim index 2b2e3f2..4ed664d 100644 --- a/tests/test_types.nim +++ b/tests/test_types.nim @@ -3430,6 +3430,136 @@ suite "Multirange binary roundtrip": let row = mkRow(@[none(seq[byte])], fields) check row.getInt4MultirangeOpt(0).isNone +suite "Range array binary roundtrip": + test "int4range[] roundtrip": + let orig = @[rangeOf(1'i32, 10'i32), rangeOf(20'i32, 30'i32)] + let p = toPgBinaryParam(orig) + check p.oid == OidInt4RangeArray + check p.format == 1'i16 + let fields = @[mkField(OidInt4RangeArray, 1'i16)] + let row = mkRow(@[p.value], fields) + let decoded = row.getInt4RangeArray(0) + check decoded == orig + + test "int8range[] roundtrip": + let orig = @[rangeOf(100'i64, 200'i64), rangeOf(300'i64, 400'i64)] + let p = toPgBinaryParam(orig) + check p.oid == OidInt8RangeArray + let fields = @[mkField(OidInt8RangeArray, 1'i16)] + let row = mkRow(@[p.value], fields) + let decoded = row.getInt8RangeArray(0) + check decoded == orig + + test "numrange[] roundtrip": + let orig = @[ + rangeOf(parsePgNumeric("1.5"), parsePgNumeric("9.5")), + rangeOf(parsePgNumeric("10.0"), parsePgNumeric("20.0")), + ] + let p = toPgBinaryParam(orig) + check p.oid == OidNumRangeArray + let fields = @[mkField(OidNumRangeArray, 1'i16)] + let row = mkRow(@[p.value], fields) + let decoded = row.getNumRangeArray(0) + check decoded == orig + + test "tsrange[] roundtrip": + let dt1 = dateTime(2023, mJan, 1, zone = utc()) + let dt2 = dateTime(2023, mJun, 1, zone = utc()) + let dt3 = dateTime(2023, mJul, 1, zone = utc()) + let dt4 = dateTime(2023, mDec, 31, zone = utc()) + let orig = @[rangeOf(dt1, dt2), rangeOf(dt3, dt4)] + let p = toPgBinaryParam(orig) + check p.oid == OidTsRangeArray + let fields = @[mkField(OidTsRangeArray, 1'i16)] + let row = mkRow(@[p.value], fields) + let decoded = row.getTsRangeArray(0) + check decoded.len == 2 + check decoded[0].lower.value.year == 2023 + check decoded[0].lower.value.month == mJan + + test "tstzrange[] roundtrip": + let dt1 = dateTime(2023, mJan, 1, zone = utc()) + let dt2 = dateTime(2023, mJun, 1, zone = utc()) + let orig = @[rangeOf(dt1, dt2)] + let p = toPgBinaryTsTzRangeArrayParam(orig) + check p.oid == OidTsTzRangeArray + let fields = @[mkField(OidTsTzRangeArray, 1'i16)] + let row = mkRow(@[p.value], fields) + let decoded = row.getTsTzRangeArray(0) + check decoded.len == 1 + check decoded[0].lower.value.year == 2023 + + test "daterange[] roundtrip": + let dt1 = dateTime(2023, mJan, 1, zone = utc()) + let dt2 = dateTime(2023, mDec, 31, zone = utc()) + let orig = @[rangeOf(dt1, dt2)] + let p = toPgBinaryDateRangeArrayParam(orig) + check p.oid == OidDateRangeArray + let fields = @[mkField(OidDateRangeArray, 1'i16)] + let row = mkRow(@[p.value], fields) + let decoded = row.getDateRangeArray(0) + check decoded.len == 1 + check decoded[0].lower.value.year == 2023 + check decoded[0].lower.value.month == mJan + + test "empty int4range[] roundtrip": + let orig: seq[PgRange[int32]] = @[] + let p = toPgBinaryParam(orig) + check p.oid == OidInt4RangeArray + let fields = @[mkField(OidInt4RangeArray, 1'i16)] + let row = mkRow(@[p.value], fields) + let decoded = row.getInt4RangeArray(0) + check decoded.len == 0 + + test "int4range[] with empty range element": + let orig = @[rangeOf(1'i32, 10'i32), emptyRange[int32]()] + let p = toPgBinaryParam(orig) + let fields = @[mkField(OidInt4RangeArray, 1'i16)] + let row = mkRow(@[p.value], fields) + let decoded = row.getInt4RangeArray(0) + check decoded.len == 2 + check decoded[0] == rangeOf(1'i32, 10'i32) + check decoded[1].isEmpty == true + +suite "Range array row getters": + test "getInt4RangeArray text": + let row: Row = @[some(toBytes("{\"[1,10)\",\"[20,30)\"}"))] + let arr = row.getInt4RangeArray(0) + check arr.len == 2 + check arr[0] == rangeOf(1'i32, 10'i32) + check arr[1] == rangeOf(20'i32, 30'i32) + + test "getInt8RangeArray text": + let row: Row = @[some(toBytes("{\"[100,200)\"}"))] + let arr = row.getInt8RangeArray(0) + check arr.len == 1 + check arr[0] == rangeOf(100'i64, 200'i64) + + test "getNumRangeArray text": + let row: Row = @[some(toBytes("{\"[1.5,9.5)\"}"))] + let arr = row.getNumRangeArray(0) + check arr.len == 1 + check arr[0].lower.value == parsePgNumeric("1.5") + + test "getDateRangeArray text": + let row: Row = @[some(toBytes("{\"[2023-01-01,2023-12-31)\"}"))] + let arr = row.getDateRangeArray(0) + check arr.len == 1 + check arr[0].lower.value.year == 2023 + + test "getInt4RangeArrayOpt some": + let p = toPgBinaryParam(@[rangeOf(1'i32, 10'i32)]) + let fields = @[mkField(OidInt4RangeArray, 1'i16)] + let row = mkRow(@[p.value], fields) + let r = row.getInt4RangeArrayOpt(0) + check r.isSome + check r.get.len == 1 + + test "getInt4RangeArrayOpt none": + let fields = @[mkField(OidInt4RangeArray, 1'i16)] + let row = mkRow(@[none(seq[byte])], fields) + check row.getInt4RangeArrayOpt(0).isNone + suite "Geometry types": test "OID constants": check OidPoint == 600'i32