From 9db5dc938e7f8ceed9e9bffe5c3cfeedd99e732a Mon Sep 17 00:00:00 2001 From: fox0430 Date: Fri, 17 Apr 2026 18:32:02 +0900 Subject: [PATCH] Add NULL-safe/enum arrays, rename queryOne to queryRowOpt --- async_postgres/pg_client.nim | 4 +- async_postgres/pg_pool.nim | 4 +- async_postgres/pg_pool_cluster.nim | 4 +- async_postgres/pg_sql.nim | 8 +- async_postgres/pg_types.nim | 521 ++++++++++++++++++++++++++++- tests/test_e2e.nim | 165 ++++++++- tests/test_pool_cluster.nim | 8 +- tests/test_types.nim | 197 +++++++++++ 8 files changed, 878 insertions(+), 33 deletions(-) diff --git a/async_postgres/pg_client.nim b/async_postgres/pg_client.nim index 75687d8..c06b960 100644 --- a/async_postgres/pg_client.nim +++ b/async_postgres/pg_client.nim @@ -1127,7 +1127,7 @@ proc query*( qr = await queryInlineImpl(conn, sql, data, ranges, oids, formats, resultFormats) return qr -proc queryOne*( +proc queryRowOpt*( conn: PgConnection, sql: string, params: seq[PgParam] = @[], @@ -1153,7 +1153,7 @@ proc queryRow*( ## Execute a query and return the first row. ## Raises `PgError` if no rows are returned. let row = - await conn.queryOne(sql, params, resultFormat = resultFormat, timeout = timeout) + await conn.queryRowOpt(sql, params, resultFormat = resultFormat, timeout = timeout) if row.isNone: raise newException(PgError, "Query returned no rows") return row.get diff --git a/async_postgres/pg_pool.nim b/async_postgres/pg_pool.nim index 0ef9107..28f3770 100644 --- a/async_postgres/pg_pool.nim +++ b/async_postgres/pg_pool.nim @@ -673,7 +673,7 @@ proc queryEach*( await pool.resetSession(conn) pool.release(conn) -proc queryOne*( +proc queryRowOpt*( pool: PgPool, sql: string, params: seq[PgParam] = @[], @@ -698,7 +698,7 @@ proc queryRow*( ## Execute a query and return the first row. ## Raises `PgError` if no rows are returned. let row = - await pool.queryOne(sql, params, resultFormat = resultFormat, timeout = timeout) + await pool.queryRowOpt(sql, params, resultFormat = resultFormat, timeout = timeout) if row.isNone: raise newException(PgError, "Query returned no rows") return row.get diff --git a/async_postgres/pg_pool_cluster.nim b/async_postgres/pg_pool_cluster.nim index 0f4c83b..5533646 100644 --- a/async_postgres/pg_pool_cluster.nim +++ b/async_postgres/pg_pool_cluster.nim @@ -229,7 +229,7 @@ clusterForwards("read"): timeout: Duration = ZeroDuration, ): Future[QueryResult] - proc readQueryOne*( + proc readQueryRowOpt*( cluster: PgPoolCluster, sql: string, params: seq[PgParam] = @[], @@ -339,7 +339,7 @@ clusterForwards("write"): timeout: Duration = ZeroDuration, ): Future[QueryResult] - proc writeQueryOne*( + proc writeQueryRowOpt*( cluster: PgPoolCluster, sql: string, params: seq[PgParam] = @[], diff --git a/async_postgres/pg_sql.nim b/async_postgres/pg_sql.nim index f3c132a..6438fec 100644 --- a/async_postgres/pg_sql.nim +++ b/async_postgres/pg_sql.nim @@ -300,7 +300,7 @@ sqlQueryForwards: timeout: Duration = ZeroDuration, ): untyped - proc queryOne*( + proc queryRowOpt*( conn: PgConnection, sq: SqlQuery, resultFormat: ResultFormat = rfAuto, @@ -403,7 +403,7 @@ sqlQueryForwards: timeout: Duration = ZeroDuration, ): untyped - proc queryOne*( + proc queryRowOpt*( pool: PgPool, sq: SqlQuery, resultFormat: ResultFormat = rfAuto, @@ -482,7 +482,7 @@ sqlQueryForwards: timeout: Duration = ZeroDuration, ): untyped - proc readQueryOne*( + proc readQueryRowOpt*( cluster: PgPoolCluster, sq: SqlQuery, resultFormat: ResultFormat = rfAuto, @@ -563,7 +563,7 @@ sqlQueryForwards: timeout: Duration = ZeroDuration, ): untyped - proc writeQueryOne*( + proc writeQueryRowOpt*( cluster: PgPoolCluster, sq: SqlQuery, resultFormat: ResultFormat = rfAuto, diff --git a/async_postgres/pg_types.nim b/async_postgres/pg_types.nim index 4848b42..c1146b2 100644 --- a/async_postgres/pg_types.nim +++ b/async_postgres/pg_types.nim @@ -1294,6 +1294,52 @@ proc encodeBinaryArray*(elemOid: int32, elements: seq[seq[byte]]): seq[byte] = copyMem(addr result[pos], unsafeAddr e[0], e.len) pos += e.len +proc encodeBinaryArray*(elemOid: int32, elements: seq[Option[seq[byte]]]): seq[byte] = + ## Encode a 1-dimensional binary array that may contain NULL elements. + ## NULL elements are written with length ``-1`` and no payload. + ## ``has_null`` is set to 1 iff any element is ``none``. + if elements.len > int32.high.int: + raise + newException(PgError, "Array has too many elements for PostgreSQL binary format") + let headerSize = 20 + var dataSize = 0 + var anyNull = false + for e in elements: + if e.isNone: + anyNull = true + dataSize += 4 + else: + let ev = e.get + if ev.len > int32.high.int: + raise + newException(PgError, "Array element too large for PostgreSQL binary format") + dataSize += 4 + ev.len + result = newSeq[byte](headerSize + dataSize) + let ndim = toBE32(1'i32) + copyMem(addr result[0], unsafeAddr ndim[0], 4) + let hasNull = toBE32(if anyNull: 1'i32 else: 0'i32) + copyMem(addr result[4], unsafeAddr hasNull[0], 4) + let oid = toBE32(elemOid) + copyMem(addr result[8], unsafeAddr oid[0], 4) + let dimLen = toBE32(int32(elements.len)) + copyMem(addr result[12], unsafeAddr dimLen[0], 4) + let lb = toBE32(1'i32) + copyMem(addr result[16], unsafeAddr lb[0], 4) + var pos = headerSize + for e in elements: + if e.isNone: + let eLen = toBE32(-1'i32) + copyMem(addr result[pos], unsafeAddr eLen[0], 4) + pos += 4 + else: + let ev = e.get + let eLen = toBE32(int32(ev.len)) + copyMem(addr result[pos], unsafeAddr eLen[0], 4) + pos += 4 + if ev.len > 0: + copyMem(addr result[pos], unsafeAddr ev[0], ev.len) + pos += ev.len + proc encodeBinaryArrayEmpty*(elemOid: int32): seq[byte] = ## Encode an empty 1-dimensional PostgreSQL binary array. ## ndim=0, has_null=0, elem_oid @@ -1392,6 +1438,134 @@ proc toPgParam*(v: seq[string]): PgParam = oid: OidTextArray, format: 1, value: some(encodeBinaryArray(OidText, elements)) ) +proc toPgParam*(v: seq[Option[int16]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidInt2Array, format: 1, value: some(encodeBinaryArrayEmpty(OidInt2)) + ) + var elements = newSeq[Option[seq[byte]]](v.len) + for i, x in v: + elements[i] = + if x.isSome: + some(@(toBE16(x.get))) + else: + none(seq[byte]) + PgParam( + oid: OidInt2Array, format: 1, value: some(encodeBinaryArray(OidInt2, elements)) + ) + +proc toPgParam*(v: seq[Option[int32]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidInt4Array, format: 1, value: some(encodeBinaryArrayEmpty(OidInt4)) + ) + var elements = newSeq[Option[seq[byte]]](v.len) + for i, x in v: + elements[i] = + if x.isSome: + some(@(toBE32(x.get))) + else: + none(seq[byte]) + PgParam( + oid: OidInt4Array, format: 1, value: some(encodeBinaryArray(OidInt4, elements)) + ) + +proc toPgParam*(v: seq[Option[int64]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidInt8Array, format: 1, value: some(encodeBinaryArrayEmpty(OidInt8)) + ) + var elements = newSeq[Option[seq[byte]]](v.len) + for i, x in v: + elements[i] = + if x.isSome: + some(@(toBE64(x.get))) + else: + none(seq[byte]) + PgParam( + oid: OidInt8Array, format: 1, value: some(encodeBinaryArray(OidInt8, elements)) + ) + +proc toPgParam*(v: seq[Option[int]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidInt8Array, format: 1, value: some(encodeBinaryArrayEmpty(OidInt8)) + ) + var elements = newSeq[Option[seq[byte]]](v.len) + for i, x in v: + elements[i] = + if x.isSome: + some(@(toBE64(int64(x.get)))) + else: + none(seq[byte]) + PgParam( + oid: OidInt8Array, format: 1, value: some(encodeBinaryArray(OidInt8, elements)) + ) + +proc toPgParam*(v: seq[Option[float32]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidFloat4Array, format: 1, value: some(encodeBinaryArrayEmpty(OidFloat4)) + ) + var elements = newSeq[Option[seq[byte]]](v.len) + for i, x in v: + elements[i] = + if x.isSome: + some(@(toBE32(cast[int32](x.get)))) + else: + none(seq[byte]) + PgParam( + oid: OidFloat4Array, format: 1, value: some(encodeBinaryArray(OidFloat4, elements)) + ) + +proc toPgParam*(v: seq[Option[float64]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidFloat8Array, format: 1, value: some(encodeBinaryArrayEmpty(OidFloat8)) + ) + var elements = newSeq[Option[seq[byte]]](v.len) + for i, x in v: + elements[i] = + if x.isSome: + some(@(toBE64(cast[int64](x.get)))) + else: + none(seq[byte]) + PgParam( + oid: OidFloat8Array, format: 1, value: some(encodeBinaryArray(OidFloat8, elements)) + ) + +proc toPgParam*(v: seq[Option[bool]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidBoolArray, format: 1, value: some(encodeBinaryArrayEmpty(OidBool)) + ) + var elements = newSeq[Option[seq[byte]]](v.len) + for i, x in v: + elements[i] = + if x.isSome: + some(@[if x.get: 1'u8 else: 0'u8]) + else: + none(seq[byte]) + PgParam( + oid: OidBoolArray, format: 1, value: some(encodeBinaryArray(OidBool, elements)) + ) + +proc toPgParam*(v: seq[Option[string]]): PgParam = + if v.len == 0: + return PgParam( + oid: OidTextArray, format: 1, value: some(encodeBinaryArrayEmpty(OidText)) + ) + var elements = newSeq[Option[seq[byte]]](v.len) + for i, x in v: + elements[i] = + if x.isSome: + some(toBytes(x.get)) + else: + none(seq[byte]) + PgParam( + oid: OidTextArray, format: 1, value: some(encodeBinaryArray(OidText, elements)) + ) + proc toPgParam*(v: Option[JsonNode]): PgParam = if v.isSome: toPgParam(v.get) @@ -4286,6 +4460,175 @@ proc getHstoreArray*(row: Row, col: int): seq[PgHstore] = raise newException(PgTypeError, "NULL element in hstore array") result.add(parseHstoreText(e.get)) +# Element-level NULL-safe array getters: each element becomes ``Option[T]``, +# with ``none`` for NULL elements. Column-level NULL still raises (like the +# base getters); the column-and-element NULL-safe variants are generated just +# below via ``optAccessor``. + +proc getIntArrayElemOpt*(row: Row, col: int): seq[Option[int32]] = + 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[Option[int32]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + result[i] = none(int32) + else: + result[i] = + some(fromBE32(row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1))) + return + let s = row.getStr(col) + for e in parseTextArray(s): + if e.isNone: + result.add(none(int32)) + else: + result.add(some(int32(parseInt(e.get)))) + +proc getInt16ArrayElemOpt*(row: Row, col: int): seq[Option[int16]] = + 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[Option[int16]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + result[i] = none(int16) + else: + result[i] = + some(fromBE16(row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1))) + return + let s = row.getStr(col) + for e in parseTextArray(s): + if e.isNone: + result.add(none(int16)) + else: + result.add(some(int16(parseInt(e.get)))) + +proc getInt64ArrayElemOpt*(row: Row, col: int): seq[Option[int64]] = + 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[Option[int64]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + result[i] = none(int64) + else: + result[i] = + some(fromBE64(row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1))) + return + let s = row.getStr(col) + for e in parseTextArray(s): + if e.isNone: + result.add(none(int64)) + else: + result.add(some(parseBiggestInt(e.get))) + +proc getFloatArrayElemOpt*(row: Row, col: int): seq[Option[float64]] = + 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[Option[float64]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + result[i] = none(float64) + elif e.len == 4: + result[i] = some( + float64( + cast[float32](cast[uint32](fromBE32( + row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1) + ))) + ) + ) + else: + result[i] = some( + cast[float64](cast[uint64](fromBE64( + row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1) + ))) + ) + return + let s = row.getStr(col) + for e in parseTextArray(s): + if e.isNone: + result.add(none(float64)) + else: + result.add(some(parseFloat(e.get))) + +proc getFloat32ArrayElemOpt*(row: Row, col: int): seq[Option[float32]] = + 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[Option[float32]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + result[i] = none(float32) + else: + result[i] = some( + cast[float32](cast[uint32](fromBE32( + row.data.buf.toOpenArray(off + e.off, off + e.off + e.len - 1) + ))) + ) + return + let s = row.getStr(col) + for e in parseTextArray(s): + if e.isNone: + result.add(none(float32)) + else: + result.add(some(float32(parseFloat(e.get)))) + +proc getBoolArrayElemOpt*(row: Row, col: int): seq[Option[bool]] = + 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[Option[bool]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + result[i] = none(bool) + else: + result[i] = some(row.data.buf[off + e.off] == 1'u8) + return + let s = row.getStr(col) + for e in parseTextArray(s): + if e.isNone: + result.add(none(bool)) + else: + case e.get + of "t", "true", "1": + result.add(some(true)) + of "f", "false", "0": + result.add(some(false)) + else: + raise newException(PgTypeError, "Invalid boolean: " & e.get) + +proc getStrArrayElemOpt*(row: Row, col: int): seq[Option[string]] = + 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[Option[string]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + result[i] = none(string) + else: + var s = newString(e.len) + if e.len > 0: + copyMem(addr s[0], unsafeAddr row.data.buf[off + e.off], e.len) + result[i] = some(s) + return + let s = row.getStr(col) + result = parseTextArray(s) + # Array Opt accessors (text format) optAccessor(getIntArray, getIntArrayOpt, seq[int32]) @@ -4323,6 +4666,17 @@ optAccessor(getTsVectorArray, getTsVectorArrayOpt, seq[PgTsVector]) optAccessor(getTsQueryArray, getTsQueryArrayOpt, seq[PgTsQuery]) optAccessor(getHstoreArray, getHstoreArrayOpt, seq[PgHstore]) +# Column-level + element-level NULL-safe: returns ``none`` when the whole +# column is NULL; otherwise each element is ``Option[T]`` with ``none`` for +# NULL elements. +optAccessor(getIntArrayElemOpt, getIntArrayElemOptOpt, seq[Option[int32]]) +optAccessor(getInt16ArrayElemOpt, getInt16ArrayElemOptOpt, seq[Option[int16]]) +optAccessor(getInt64ArrayElemOpt, getInt64ArrayElemOptOpt, seq[Option[int64]]) +optAccessor(getFloatArrayElemOpt, getFloatArrayElemOptOpt, seq[Option[float64]]) +optAccessor(getFloat32ArrayElemOpt, getFloat32ArrayElemOptOpt, seq[Option[float32]]) +optAccessor(getBoolArrayElemOpt, getBoolArrayElemOptOpt, seq[Option[bool]]) +optAccessor(getStrArrayElemOpt, getStrArrayElemOptOpt, seq[Option[string]]) + # Coerce a binary PgParam to match the server-inferred type from a prepared # statement. This handles the common case where e.g. int32.toPgParam is # passed but the server inferred int8 (LIMIT/OFFSET). Only safe widening @@ -4623,22 +4977,113 @@ macro addBindDirect*( # let m = row.getEnum[Mood](0) # let m = row.getEnumOpt[Mood](0) +proc encodeEnumTextArray*(labels: seq[Option[string]]): string = + ## Encode enum labels as a PostgreSQL text-format array literal. + ## ``none`` labels become unquoted ``NULL``. + result = "{" + for i, lbl in labels: + if i > 0: + result.add(',') + if lbl.isSome: + result.add('"') + for c in lbl.get: + if c == '"' or c == '\\': + result.add('\\') + result.add(c) + result.add('"') + else: + result.add("NULL") + result.add('}') + macro pgEnum*(T: untyped): untyped = - ## Generate ``toPgParam`` for a Nim enum type. - ## The parameter is sent as text with OID 0 (unspecified) so that - ## PostgreSQL infers the enum type from context. + ## Generate ``toPgParam`` overloads for a Nim enum type and its array forms. + ## OIDs are 0 (unspecified) so PostgreSQL infers the type from context + ## (use ``$1::mytype`` / ``$1::mytype[]`` in the SQL). result = newStmtList() result.add quote do: proc toPgParam*(v: `T`): PgParam = PgParam(oid: 0'i32, format: 0'i16, value: some(toBytes($v))) + proc toPgParam*(v: seq[`T`]): PgParam = + var labels = newSeq[Option[string]](v.len) + for i, x in v: + labels[i] = some($x) + PgParam( + oid: 0'i32, format: 0'i16, value: some(toBytes(encodeEnumTextArray(labels))) + ) + + proc toPgParam*(v: seq[Option[`T`]]): PgParam = + var labels = newSeq[Option[string]](v.len) + for i, x in v: + labels[i] = + if x.isSome: + some($x.get) + else: + none(string) + PgParam( + oid: 0'i32, format: 0'i16, value: some(toBytes(encodeEnumTextArray(labels))) + ) + macro pgEnum*(T: untyped, oid: untyped): untyped = - ## Generate ``toPgParam`` for a Nim enum type with an explicit OID. + ## Generate ``toPgParam`` overloads for a Nim enum type with an explicit + ## scalar OID. The array OID is unspecified (0); add a ``$1::mytype[]`` + ## cast in the SQL, or use the 3-argument form to set the array OID too. result = newStmtList() result.add quote do: proc toPgParam*(v: `T`): PgParam = PgParam(oid: int32(`oid`), format: 0'i16, value: some(toBytes($v))) + proc toPgParam*(v: seq[`T`]): PgParam = + var labels = newSeq[Option[string]](v.len) + for i, x in v: + labels[i] = some($x) + PgParam( + oid: 0'i32, format: 0'i16, value: some(toBytes(encodeEnumTextArray(labels))) + ) + + proc toPgParam*(v: seq[Option[`T`]]): PgParam = + var labels = newSeq[Option[string]](v.len) + for i, x in v: + labels[i] = + if x.isSome: + some($x.get) + else: + none(string) + PgParam( + oid: 0'i32, format: 0'i16, value: some(toBytes(encodeEnumTextArray(labels))) + ) + +macro pgEnum*(T: untyped, oid: untyped, arrayOid: untyped): untyped = + ## Generate ``toPgParam`` overloads with explicit scalar and array OIDs. + result = newStmtList() + result.add quote do: + proc toPgParam*(v: `T`): PgParam = + PgParam(oid: int32(`oid`), format: 0'i16, value: some(toBytes($v))) + + proc toPgParam*(v: seq[`T`]): PgParam = + var labels = newSeq[Option[string]](v.len) + for i, x in v: + labels[i] = some($x) + PgParam( + oid: int32(`arrayOid`), + format: 0'i16, + value: some(toBytes(encodeEnumTextArray(labels))), + ) + + proc toPgParam*(v: seq[Option[`T`]]): PgParam = + var labels = newSeq[Option[string]](v.len) + for i, x in v: + labels[i] = + if x.isSome: + some($x.get) + else: + none(string) + PgParam( + oid: int32(`arrayOid`), + format: 0'i16, + value: some(toBytes(encodeEnumTextArray(labels))), + ) + proc getEnum*[T: enum](row: Row, col: int): T = ## Read a PostgreSQL enum column (text format) as a Nim enum. ## The column value must exactly match one of ``T``'s string representations. @@ -4652,6 +5097,60 @@ proc getEnumOpt*[T: enum](row: Row, col: int): Option[T] = else: some(getEnum[T](row, col)) +proc getEnumArray*[T: enum](row: Row, col: int): seq[T] = + ## Read a PostgreSQL enum[] column as ``seq[T]``. + ## Raises ``PgTypeError`` on NULL column or NULL element. + 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[T](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + raise newException(PgTypeError, "NULL element in enum array") + var s = newString(e.len) + if e.len > 0: + copyMem(addr s[0], unsafeAddr row.data.buf[off + e.off], e.len) + result[i] = parseEnum[T](s) + return + let s = row.getStr(col) + for e in parseTextArray(s): + if e.isNone: + raise newException(PgTypeError, "NULL element in enum array") + result.add(parseEnum[T](e.get)) + +proc getEnumArrayOpt*[T: enum](row: Row, col: int): Option[seq[T]] = + ## NULL-safe column-level variant. Element NULL still raises. + if row.isNull(col): + none(seq[T]) + else: + some(getEnumArray[T](row, col)) + +proc getEnumArrayElemOpt*[T: enum](row: Row, col: int): seq[Option[T]] = + ## Element-level NULL-safe: each element is ``Option[T]``. + 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[Option[T]](decoded.elements.len) + for i, e in decoded.elements: + if e.len == -1: + result[i] = none(T) + else: + var s = newString(e.len) + if e.len > 0: + copyMem(addr s[0], unsafeAddr row.data.buf[off + e.off], e.len) + result[i] = some(parseEnum[T](s)) + return + let s = row.getStr(col) + for e in parseTextArray(s): + if e.isNone: + result.add(none(T)) + else: + result.add(some(parseEnum[T](e.get))) + # User-defined composite type support # # PostgreSQL composite types (row types / record types) have dynamic OIDs. @@ -6682,6 +7181,20 @@ nameAccessor(getXmlArrayOpt, Option[seq[PgXml]]) nameAccessor(getTsVectorArrayOpt, Option[seq[PgTsVector]]) nameAccessor(getTsQueryArrayOpt, Option[seq[PgTsQuery]]) nameAccessor(getHstoreArrayOpt, Option[seq[PgHstore]]) +nameAccessor(getIntArrayElemOpt, seq[Option[int32]]) +nameAccessor(getInt16ArrayElemOpt, seq[Option[int16]]) +nameAccessor(getInt64ArrayElemOpt, seq[Option[int64]]) +nameAccessor(getFloatArrayElemOpt, seq[Option[float64]]) +nameAccessor(getFloat32ArrayElemOpt, seq[Option[float32]]) +nameAccessor(getBoolArrayElemOpt, seq[Option[bool]]) +nameAccessor(getStrArrayElemOpt, seq[Option[string]]) +nameAccessor(getIntArrayElemOptOpt, Option[seq[Option[int32]]]) +nameAccessor(getInt16ArrayElemOptOpt, Option[seq[Option[int16]]]) +nameAccessor(getInt64ArrayElemOptOpt, Option[seq[Option[int64]]]) +nameAccessor(getFloatArrayElemOptOpt, Option[seq[Option[float64]]]) +nameAccessor(getFloat32ArrayElemOptOpt, Option[seq[Option[float32]]]) +nameAccessor(getBoolArrayElemOptOpt, Option[seq[Option[bool]]]) +nameAccessor(getStrArrayElemOptOpt, Option[seq[Option[string]]]) nameAccessor(getInt4Range, PgRange[int32]) nameAccessor(getInt8Range, PgRange[int64]) nameAccessor(getNumRange, PgRange[PgNumeric]) diff --git a/tests/test_e2e.nim b/tests/test_e2e.nim index 1cf79a7..7b534bf 100644 --- a/tests/test_e2e.nim +++ b/tests/test_e2e.nim @@ -4609,10 +4609,11 @@ suite "E2E: Column Name Access": waitFor t() - test "name-based queryOne accessors": + test "name-based queryRowOpt accessors": proc t() {.async.} = let conn = await connect(plainConfig()) - let rowOpt = await conn.queryOne("SELECT 99::int8 AS big, 'hello'::text AS msg") + let rowOpt = + await conn.queryRowOpt("SELECT 99::int8 AS big, 'hello'::text AS msg") doAssert rowOpt.isSome let row = rowOpt.get doAssert row.getInt64("big") == 99'i64 @@ -4650,10 +4651,10 @@ suite "E2E: Column Name Access": waitFor t() suite "E2E: Convenience Query Methods": - test "queryOne returns first row": + test "queryRowOpt returns first row": proc t() {.async.} = let conn = await connect(plainConfig()) - let row = await conn.queryOne("SELECT 1 AS a, 'hello' AS b") + let row = await conn.queryRowOpt("SELECT 1 AS a, 'hello' AS b") doAssert row.isSome doAssert row.get.getStr(0) == "1" doAssert row.get.getStr(1) == "hello" @@ -4661,10 +4662,10 @@ suite "E2E: Convenience Query Methods": waitFor t() - test "queryOne returns none for empty result": + test "queryRowOpt returns none for empty result": proc t() {.async.} = let conn = await connect(plainConfig()) - let row = await conn.queryOne("SELECT 1 WHERE false") + let row = await conn.queryRowOpt("SELECT 1 WHERE false") doAssert row.isNone await conn.close() @@ -4914,10 +4915,10 @@ suite "E2E: Convenience Query Methods": waitFor t() - test "queryOne returns only first row from multiple": + test "queryRowOpt returns only first row from multiple": proc t() {.async.} = let conn = await connect(plainConfig()) - let row = await conn.queryOne("SELECT generate_series(10,12)::text AS v") + let row = await conn.queryRowOpt("SELECT generate_series(10,12)::text AS v") doAssert row.isSome doAssert row.get.getStr(0) == "10" await conn.close() @@ -4943,11 +4944,11 @@ suite "E2E: Convenience Query Methods": waitFor t() - test "queryOne with params": + test "queryRowOpt with params": proc t() {.async.} = let conn = await connect(plainConfig()) let row = - await conn.queryOne("SELECT $1::int + $2::int", @[3.toPgParam, 4.toPgParam]) + await conn.queryRowOpt("SELECT $1::int + $2::int", @[3.toPgParam, 4.toPgParam]) doAssert row.isSome doAssert row.get.getStr(0) == "7" await conn.close() @@ -4969,10 +4970,10 @@ suite "E2E: Convenience Query Methods": waitFor t() - test "pool queryOne": + test "pool queryRowOpt": proc t() {.async.} = let pool = await newPool(initPoolConfig(plainConfig(), minSize = 1, maxSize = 2)) - let row = await pool.queryOne("SELECT 'pooled'") + let row = await pool.queryRowOpt("SELECT 'pooled'") doAssert row.isSome doAssert row.get.getStr(0) == "pooled" await pool.close() @@ -6326,16 +6327,16 @@ suite "E2E: Pipelined Pool": waitFor t() - test "pipelined pool: queryOne works": + test "pipelined pool: queryRowOpt works": proc t() {.async.} = let pool = await newPool( PoolConfig(connConfig: plainConfig(), minSize: 1, maxSize: 3, pipelined: true) ) - let row = await pool.queryOne("SELECT 42::int4 AS answer") + let row = await pool.queryRowOpt("SELECT 42::int4 AS answer") doAssert row.isSome doAssert row.get.getStr(0) == "42" - let empty = await pool.queryOne("SELECT 1 WHERE false") + let empty = await pool.queryRowOpt("SELECT 1 WHERE false") doAssert empty.isNone await pool.close() @@ -8539,3 +8540,137 @@ suite "E2E: Multirange array types": await conn.close() waitFor t() + +suite "E2E: Option/NULL array input and element-level output": + test "seq[Option[int32]] roundtrip with NULL element": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let arr = @[some(1'i32), none(int32), some(3'i32)] + let res = await conn.query("SELECT $1::int4[] AS a", pgParams(arr)) + doAssert res.rows.len == 1 + doAssert res.rows[0].getIntArrayElemOpt(0) == arr + await conn.close() + + waitFor t() + + test "seq[Option[string]] roundtrip with NULL element": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let arr = @[some("a"), none(string), some("c")] + let res = await conn.query("SELECT $1::text[] AS a", pgParams(arr)) + doAssert res.rows.len == 1 + doAssert res.rows[0].getStrArrayElemOpt(0) == arr + await conn.close() + + waitFor t() + + test "seq[Option[bool]] roundtrip with NULL element": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let arr = @[some(true), none(bool), some(false)] + let res = await conn.query("SELECT $1::bool[] AS a", pgParams(arr)) + doAssert res.rows.len == 1 + doAssert res.rows[0].getBoolArrayElemOpt(0) == arr + await conn.close() + + waitFor t() + + test "seq[Option[float64]] roundtrip with NULL element": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let arr = @[some(1.5), none(float64), some(3.25)] + let res = await conn.query("SELECT $1::float8[] AS a", pgParams(arr)) + doAssert res.rows.len == 1 + let got = res.rows[0].getFloatArrayElemOpt(0) + doAssert got.len == 3 + doAssert got[0].isSome and abs(got[0].get - 1.5) < 1e-10 + doAssert got[1].isNone + doAssert got[2].isSome and abs(got[2].get - 3.25) < 1e-10 + await conn.close() + + waitFor t() + + test "all-NULL array roundtrips": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let arr = @[none(int32), none(int32)] + let res = await conn.query("SELECT $1::int4[] AS a", pgParams(arr)) + doAssert res.rows.len == 1 + doAssert res.rows[0].getIntArrayElemOpt(0) == arr + await conn.close() + + waitFor t() + + test "empty seq[Option[int32]] works": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let arr: seq[Option[int32]] = @[] + let res = await conn.query("SELECT $1::int4[] AS a", pgParams(arr)) + doAssert res.rows.len == 1 + doAssert res.rows[0].getIntArrayElemOpt(0).len == 0 + await conn.close() + + waitFor t() + + test "literal ARRAY[1, NULL, 3] via getIntArrayElemOpt": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let res = await conn.query("SELECT ARRAY[1, NULL, 3]::int4[] AS a") + doAssert res.rows.len == 1 + doAssert res.rows[0].getIntArrayElemOpt(0) == + @[some(1'i32), none(int32), some(3'i32)] + await conn.close() + + waitFor t() + + test "column NULL via getIntArrayElemOptOpt returns none": + proc t() {.async.} = + let conn = await connect(plainConfig()) + let res = await conn.query("SELECT NULL::int4[] AS a") + doAssert res.rows.len == 1 + doAssert res.rows[0].getIntArrayElemOptOpt(0) == none(seq[Option[int32]]) + await conn.close() + + waitFor t() + +type PgE2eMood = enum + happy2 = "happy2" + sad2 = "sad2" + ok2 = "ok2" + +pgEnum(PgE2eMood) + +suite "E2E: enum arrays": + test "enum array roundtrip with and without NULL": + proc t() {.async.} = + let conn = await connect(plainConfig()) + discard await conn.exec("DROP TYPE IF EXISTS e2e_mood CASCADE") + discard await conn.exec("CREATE TYPE e2e_mood AS ENUM ('happy2', 'sad2', 'ok2')") + block: + let arr = @[happy2, sad2, ok2] + let res = await conn.query("SELECT $1::e2e_mood[] AS a", @[toPgParam(arr)]) + doAssert res.rows.len == 1 + doAssert getEnumArray[PgE2eMood](res.rows[0], 0) == arr + block: + let arr = @[some(happy2), none(PgE2eMood), some(ok2)] + let res = await conn.query("SELECT $1::e2e_mood[] AS a", @[toPgParam(arr)]) + doAssert res.rows.len == 1 + doAssert getEnumArrayElemOpt[PgE2eMood](res.rows[0], 0) == arr + discard await conn.exec("DROP TYPE IF EXISTS e2e_mood CASCADE") + await conn.close() + + waitFor t() + +suite "E2E: queryRowOpt via pool": + test "pool queryRowOpt returns first row and none on empty": + proc t() {.async.} = + let pool = + await newPool(PoolConfig(connConfig: plainConfig(), minSize: 1, maxSize: 2)) + let row = await pool.queryRowOpt("SELECT 7 AS v") + doAssert row.isSome + doAssert row.get.getStr(0) == "7" + let empty = await pool.queryRowOpt("SELECT 1 WHERE false") + doAssert empty.isNone + await pool.close() + + waitFor t() diff --git a/tests/test_pool_cluster.nim b/tests/test_pool_cluster.nim index d465eea..9a2b261 100644 --- a/tests/test_pool_cluster.nim +++ b/tests/test_pool_cluster.nim @@ -353,12 +353,12 @@ suite "Write routing": expect(PgError): discard waitFor cluster.writeQuery("SELECT 1") - test "writeQueryOne routes to primary": + test "writeQueryRowOpt routes to primary": let cluster = makeCluster() cluster.primary.closed = true expect(PgError): - discard waitFor cluster.writeQueryOne("SELECT 1") + discard waitFor cluster.writeQueryRowOpt("SELECT 1") test "writeQueryRow routes to primary": let cluster = makeCluster() @@ -455,13 +455,13 @@ suite "Read routing targets replica": expect(PgError): discard waitFor cluster.readQuery("SELECT 1") - test "readQueryOne routes to replica": + test "readQueryRowOpt routes to replica": let cluster = makeCluster() cluster.replica.closed = true cluster.fallback = fallbackNone expect(PgError): - discard waitFor cluster.readQueryOne("SELECT 1") + discard waitFor cluster.readQueryRowOpt("SELECT 1") test "readQueryRow routes to replica": let cluster = makeCluster() diff --git a/tests/test_types.nim b/tests/test_types.nim index 60b103f..233bbbe 100644 --- a/tests/test_types.nim +++ b/tests/test_types.nim @@ -6341,3 +6341,200 @@ suite "Pipeline appendInline SoA layout": check p.ops[1].inlineStart == 2 # resumes after the two params of op A check p.ops[1].inlineCount == 0 check p.inlineRanges.len == 2 # unchanged by op B + +suite "encodeBinaryArray with Option elements": + test "mixed null and non-null int32": + let elements = @[some(@(toBE32(1'i32))), none(seq[byte]), some(@(toBE32(3'i32)))] + let encoded = encodeBinaryArray(OidInt4, elements) + # header(20) + 3 × len(4) + 2 non-null × payload(4) = 40 + check encoded.len == 40 + check fromBE32(encoded.toOpenArray(0, 3)) == 1'i32 # ndim + check fromBE32(encoded.toOpenArray(4, 7)) == 1'i32 # has_null = 1 + check fromBE32(encoded.toOpenArray(8, 11)) == OidInt4 + check fromBE32(encoded.toOpenArray(12, 15)) == 3'i32 # dim_len + # element 0 len + check fromBE32(encoded.toOpenArray(20, 23)) == 4'i32 + # element 1 len = -1 (NULL) + check fromBE32(encoded.toOpenArray(28, 31)) == -1'i32 + # element 2 len + check fromBE32(encoded.toOpenArray(32, 35)) == 4'i32 + + test "all-some matches non-optional encoder byte-for-byte": + let same = encodeBinaryArray(OidInt4, @[@(toBE32(1'i32)), @(toBE32(2'i32))]) + let withOpt = + encodeBinaryArray(OidInt4, @[some(@(toBE32(1'i32))), some(@(toBE32(2'i32)))]) + check same == withOpt + + test "all-none sets has_null": + let encoded = encodeBinaryArray(OidInt4, @[none(seq[byte]), none(seq[byte])]) + check fromBE32(encoded.toOpenArray(4, 7)) == 1'i32 + +suite "toPgParam seq[Option[T]]": + test "int32 with null": + let p = toPgParam(@[some(1'i32), none(int32), some(3'i32)]) + check p.oid == OidInt4Array + check p.format == 1'i16 + check p.value.isSome + # has_null should be 1 + check fromBE32(p.value.get.toOpenArray(4, 7)) == 1'i32 + + test "int16 with null": + let p = toPgParam(@[some(10'i16), none(int16)]) + check p.oid == OidInt2Array + check fromBE32(p.value.get.toOpenArray(4, 7)) == 1'i32 + + test "int64 with null": + let p = toPgParam(@[none(int64), some(99'i64)]) + check p.oid == OidInt8Array + check fromBE32(p.value.get.toOpenArray(4, 7)) == 1'i32 + + test "int with null": + let p = toPgParam(@[some(1), none(int)]) + check p.oid == OidInt8Array + + test "float32 with null": + let p = toPgParam(@[some(1.5'f32), none(float32)]) + check p.oid == OidFloat4Array + + test "float64 with null": + let p = toPgParam(@[some(1.5), none(float64)]) + check p.oid == OidFloat8Array + + test "bool with null": + let p = toPgParam(@[some(true), none(bool), some(false)]) + check p.oid == OidBoolArray + + test "string with null": + let p = toPgParam(@[some("a"), none(string), some("c")]) + check p.oid == OidTextArray + check fromBE32(p.value.get.toOpenArray(4, 7)) == 1'i32 + + test "empty seq[Option[int32]]": + let v: seq[Option[int32]] = @[] + let p = toPgParam(v) + check p.oid == OidInt4Array + check p.value.isSome + # empty array: ndim=0, has_null=0, elem_oid + check fromBE32(p.value.get.toOpenArray(0, 3)) == 0'i32 + + test "all-some int32 matches non-optional": + let a = toPgParam(@[1'i32, 2, 3]) + let b = toPgParam(@[some(1'i32), some(2'i32), some(3'i32)]) + check a.value.get == b.value.get + +suite "getXxxArrayElemOpt": + test "getIntArrayElemOpt text with NULL": + let row: Row = @[some(toBytes("{1,NULL,3}"))] + check row.getIntArrayElemOpt(0) == @[some(1'i32), none(int32), some(3'i32)] + + test "getIntArrayElemOpt text all-some": + let row: Row = @[some(toBytes("{1,2,3}"))] + check row.getIntArrayElemOpt(0) == @[some(1'i32), some(2'i32), some(3'i32)] + + test "getIntArrayElemOpt NULL column raises": + let row: Row = @[none(seq[byte])] + var raised = false + try: + discard row.getIntArrayElemOpt(0) + except PgTypeError: + raised = true + check raised + + test "getIntArrayElemOptOpt NULL column is none": + let row: Row = @[none(seq[byte])] + check row.getIntArrayElemOptOpt(0) == none(seq[Option[int32]]) + + test "getIntArrayElemOptOpt some with NULL element": + let row: Row = @[some(toBytes("{1,NULL}"))] + let v = row.getIntArrayElemOptOpt(0) + check v.isSome + check v.get == @[some(1'i32), none(int32)] + + test "getStrArrayElemOpt text with NULL": + let row: Row = @[some(toBytes("{\"a\",NULL,\"c\"}"))] + check row.getStrArrayElemOpt(0) == @[some("a"), none(string), some("c")] + + test "getBoolArrayElemOpt text with NULL": + let row: Row = @[some(toBytes("{t,NULL,f}"))] + check row.getBoolArrayElemOpt(0) == @[some(true), none(bool), some(false)] + + test "getInt16ArrayElemOpt text": + let row: Row = @[some(toBytes("{10,NULL}"))] + check row.getInt16ArrayElemOpt(0) == @[some(10'i16), none(int16)] + + test "getInt64ArrayElemOpt text": + let row: Row = @[some(toBytes("{99,NULL}"))] + check row.getInt64ArrayElemOpt(0) == @[some(99'i64), none(int64)] + + test "getFloatArrayElemOpt text": + let row: Row = @[some(toBytes("{1.5,NULL}"))] + let v = row.getFloatArrayElemOpt(0) + check v.len == 2 + check v[0].isSome + check abs(v[0].get - 1.5) < 1e-10 + check v[1].isNone + + test "getFloat32ArrayElemOpt text": + let row: Row = @[some(toBytes("{2.5,NULL}"))] + let v = row.getFloat32ArrayElemOpt(0) + check v.len == 2 + check v[0].isSome + check v[1].isNone + +suite "enum arrays": + test "toPgParam seq[Mood] emits text array literal": + let p = toPgParam(@[happy, sad, ok]) + check p.format == 0'i16 + check toString(p.value.get) == "{\"happy\",\"sad\",\"ok\"}" + + test "toPgParam seq[Option[Mood]] emits NULL tokens": + let p = toPgParam(@[some(happy), none(Mood), some(ok)]) + check toString(p.value.get) == "{\"happy\",NULL,\"ok\"}" + + test "toPgParam empty seq[Mood]": + let v: seq[Mood] = @[] + let p = toPgParam(v) + check toString(p.value.get) == "{}" + + test "pgEnum OID=0: array uses OID 0": + let p = toPgParam(@[happy]) + check p.oid == 0'i32 + + test "pgEnum with explicit OID: scalar OID propagates to array with OID 0": + let p = toPgParam(@[red, green]) + # 2-arg pgEnum keeps arrayOid = 0 + check p.oid == 0'i32 + + test "getEnumArray text": + let row: Row = @[some(toBytes("{happy,sad,ok}"))] + check getEnumArray[Mood](row, 0) == @[happy, sad, ok] + + test "getEnumArray raises on NULL element": + let row: Row = @[some(toBytes("{happy,NULL,ok}"))] + var raised = false + try: + discard getEnumArray[Mood](row, 0) + except PgTypeError: + raised = true + check raised + + test "getEnumArrayElemOpt": + let row: Row = @[some(toBytes("{happy,NULL,ok}"))] + check getEnumArrayElemOpt[Mood](row, 0) == @[some(happy), none(Mood), some(ok)] + + test "getEnumArrayOpt NULL column": + let row: Row = @[none(seq[byte])] + check getEnumArrayOpt[Mood](row, 0) == none(seq[Mood]) + + test "getEnumArrayOpt some": + let row: Row = @[some(toBytes("{happy}"))] + check getEnumArrayOpt[Mood](row, 0) == some(@[happy]) + + test "getEnumArray invalid label raises": + let row: Row = @[some(toBytes("{unknown}"))] + var raised = false + try: + discard getEnumArray[Mood](row, 0) + except ValueError: + raised = true + check raised