Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .release-notes/bytea-type-conversion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## Add bytea type conversion

PostgreSQL `bytea` columns are now automatically decoded from hex format into `Array[U8] val`. Previously, bytea values were returned as raw hex strings (e.g., `\x48656c6c6f`). They are now decoded into byte arrays that you can work with directly.

```pony
be pg_query_result(session: Session, result: Result) =>
match result
| let rs: ResultSet =>
for row in rs.rows().values() do
for field in row.fields.values() do
match field.value
| let bytes: Array[U8] val =>
// Decoded bytes — e.g., [72; 101; 108; 108; 111] for "Hello"
for b in bytes.values() do
_env.out.print("byte: " + b.string())
end
end
end
end
end
```

Existing code is unaffected — if your `match` on `field.value` doesn't include an `Array[U8] val` arm, bytea values simply won't match any branch (Pony's match is non-exhaustive).
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer
- `NamedPreparedQuery` — val class wrapping a statement name + `Array[(String | None)] val` params (executes a previously prepared named statement)
- `Result` trait — `ResultSet` (rows), `SimpleResult` (no rows), `RowModifying` (INSERT/UPDATE/DELETE with count)
- `Rows` / `Row` / `Field` — result data. `Field.value` is `FieldDataTypes` union
- `FieldDataTypes` = `(Bool | F32 | F64 | I16 | I32 | I64 | None | String)`
- `FieldDataTypes` = `(Array[U8] val | Bool | F32 | F64 | I16 | I32 | I64 | None | String)`
- `TransactionStatus` — union type `(TransactionIdle | TransactionInBlock | TransactionFailed)`. Reported via `pg_transaction_status` callback on every `ReadyForQuery`.
- `Notification` — val class wrapping channel name, payload string, and notifying backend's process ID. Delivered via `pg_notification` callback.
- `NoticeResponseMessage` — non-fatal PostgreSQL notice with all standard fields (same structure as `ErrorResponseMessage`). Delivered via `pg_notice` callback.
Expand All @@ -117,6 +117,7 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer

In `_RowsBuilder._field_to_type()`:
- 16 (bool) → `Bool` (checks for "t")
- 17 (bytea) → `Array[U8] val` (hex-format decode: strips `\x` prefix, parses hex pairs)
- 20 (int8) → `I64`
- 21 (int2) → `I16`
- 23 (int4) → `I32`
Expand Down Expand Up @@ -145,6 +146,8 @@ Tests live in the main `postgres/` package (private test classes), organized acr
- `_TestPrepareShutdownDrainsPrepareQueue` — uses a local TCP listener that auto-auths but never becomes ready; verifies pending prepare operations get `SessionClosed` failures on shutdown
- `_TestTerminateSentOnClose` — mock server fully authenticates and becomes ready; verifies that closing the session sends a Terminate message ('X') to the server
- `_TestZeroRowSelectReturnsResultSet` — mock server sends RowDescription + CommandComplete("SELECT 0") with no DataRows; verifies ResultSet (not RowModifying) with zero rows
- `_TestByteaResultDecoding` — mock server sends RowDescription (bytea column, OID 17) + DataRow with hex-encoded value + CommandComplete; verifies field value is `Array[U8] val` with correct decoded bytes
- `_TestEmptyByteaResultDecoding` — mock server sends DataRow with empty bytea (`\x`); verifies empty `Array[U8] val`

**`_test_ssl.pony`** — SSL negotiation unit tests (mock servers) and SSL integration tests:
- `_TestSSLNegotiationRefused` — mock server responds 'N' to SSLRequest; verifies `pg_session_connection_failed` fires
Expand All @@ -167,7 +170,7 @@ Tests live in the main `postgres/` package (private test classes), organized acr
**`_test_md5.pony`** — MD5 integration tests: MD5/Authenticate, MD5/AuthenticateFailure, MD5/QueryResults

**`_test_query.pony`** — Query integration tests:
- Simple query: Query/Results, Query/AfterAuthenticationFailure, Query/AfterConnectionFailure, Query/AfterSessionHasBeenClosed, Query/OfNonExistentTable, Query/CreateAndDropTable, Query/InsertAndDelete, Query/EmptyQuery, ZeroRowSelect, MultiStatementMixedResults
- Simple query: Query/Results, Query/ByteaResults, Query/AfterAuthenticationFailure, Query/AfterConnectionFailure, Query/AfterSessionHasBeenClosed, Query/OfNonExistentTable, Query/CreateAndDropTable, Query/InsertAndDelete, Query/EmptyQuery, ZeroRowSelect, MultiStatementMixedResults
- Prepared query: PreparedQuery/Results, PreparedQuery/NullParam, PreparedQuery/OfNonExistentTable, PreparedQuery/InsertAndDelete, PreparedQuery/MixedWithSimple
- Named prepared statements: PreparedStatement/Prepare, PreparedStatement/PrepareAndExecute, PreparedStatement/PrepareAndExecuteMultiple, PreparedStatement/PrepareAndClose, PreparedStatement/PrepareFails, PreparedStatement/PrepareAfterClose, PreparedStatement/CloseNonexistent, PreparedStatement/PrepareDuplicateName, PreparedStatement/MixedWithSimpleAndPrepared
- COPY IN: CopyIn/Insert, CopyIn/AbortRollback
Expand Down Expand Up @@ -410,6 +413,7 @@ postgres/ # Main package (49 files)
assets/test-cert.pem # Self-signed test certificate for SSL unit tests
assets/test-key.pem # Private key for SSL unit tests
examples/README.md # Examples overview
examples/bytea/bytea-example.pony # Binary data with bytea columns
examples/query/query-example.pony # Simple query with result inspection
examples/ssl-query/ssl-query-example.pony # SSL-encrypted query with SSLRequired
examples/prepared-query/prepared-query-example.pony # PreparedQuery with params and NULL
Expand Down
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Each subdirectory is a self-contained Pony program demonstrating a different part of the postgres library.

## bytea

Binary data using `bytea` columns. Executes a SELECT that returns a bytea value, matches on `Array[U8] val` in the result, and prints the decoded bytes. Shows how the driver automatically decodes PostgreSQL's hex-format bytea representation into raw byte arrays.

## query

Minimal example using `SimpleQuery`. Connects, authenticates, executes `SELECT 525600::text`, and prints the result by iterating rows and matching on `FieldDataTypes`. Start here if you're new to the library.
Expand Down
99 changes: 99 additions & 0 deletions examples/bytea/bytea-example.pony
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
Querying binary data using `bytea` columns. Executes a SELECT that returns a
bytea value, matches on `Array[U8] val` in the result, and prints the decoded
bytes. Shows how the driver automatically decodes PostgreSQL's hex-format
bytea representation into raw byte arrays.
"""
use "cli"
use "collections"
use lori = "lori"
// in your code this `use` statement would be:
// use "postgres"
use "../../postgres"

actor Main
new create(env: Env) =>
let server_info = ServerInfo(env.vars)
let auth = lori.TCPConnectAuth(env.root)

let client = Client(auth, server_info, env.out)

actor Client is (SessionStatusNotify & ResultReceiver)
let _session: Session
let _out: OutStream

new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
_out = out
_session = Session(
ServerConnectInfo(auth, info.host, info.port),
DatabaseConnectInfo(info.username, info.password, info.database),
this)

be close() =>
_session.close()

be pg_session_authenticated(session: Session) =>
_out.print("Authenticated.")
_out.print("Sending bytea query....")
// The hex string \x48656c6c6f represents the ASCII bytes for "Hello".
let q = SimpleQuery("SELECT '\\x48656c6c6f'::bytea AS data")
session.execute(q, this)

be pg_session_authentication_failed(
s: Session,
reason: AuthenticationFailureReason)
=>
_out.print("Failed to authenticate.")

be pg_query_result(session: Session, result: Result) =>
match result
| let r: ResultSet =>
_out.print("ResultSet (" + r.rows().size().string() + " rows):")
for row in r.rows().values() do
for field in row.fields.values() do
_out.write(field.name + "=")
match field.value
| let v: Array[U8] val =>
_out.print(v.size().string() + " bytes")
// Print each byte's decimal value
for b in v.values() do
_out.print(" byte: " + b.string())
end
| let v: String => _out.print(v)
| let v: I16 => _out.print(v.string())
| let v: I32 => _out.print(v.string())
| let v: I64 => _out.print(v.string())
| let v: F32 => _out.print(v.string())
| let v: F64 => _out.print(v.string())
| let v: Bool => _out.print(v.string())
| None => _out.print("NULL")
end
end
end
| let r: RowModifying =>
_out.print(r.command() + " " + r.impacted().string() + " rows")
| let r: SimpleResult =>
_out.print("Query executed.")
end
close()

be pg_query_failed(session: Session, query: Query,
failure: (ErrorResponseMessage | ClientQueryError))
=>
_out.print("Query failed.")
close()

class val ServerInfo
let host: String
let port: String
let username: String
let password: String
let database: String

new val create(vars: (Array[String] val | None)) =>
let e = EnvVars(vars)
host = try e("POSTGRES_HOST")? else "127.0.0.1" end
port = try e("POSTGRES_PORT")? else "5432" end
username = try e("POSTGRES_USERNAME")? else "postgres" end
password = try e("POSTGRES_PASSWORD")? else "postgres" end
database = try e("POSTGRES_DATABASE")? else "postgres" end
2 changes: 2 additions & 0 deletions examples/crud/crud-example.pony
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
| let v: F32 => _out.write(v.string())
| let v: F64 => _out.write(v.string())
| let v: Bool => _out.write(v.string())
| let v: Array[U8] val =>
_out.write(v.size().string() + " bytes")
| None => _out.write("NULL")
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ actor Client is (SessionStatusNotify & ResultReceiver & PrepareReceiver)
| let v: F32 => _out.print(v.string())
| let v: F64 => _out.print(v.string())
| let v: Bool => _out.print(v.string())
| let v: Array[U8] val =>
_out.print(v.size().string() + " bytes")
| None => _out.print("NULL")
end
end
Expand Down
2 changes: 2 additions & 0 deletions examples/prepared-query/prepared-query-example.pony
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
| let v: F32 => _out.print(v.string())
| let v: F64 => _out.print(v.string())
| let v: Bool => _out.print(v.string())
| let v: Array[U8] val =>
_out.print(v.size().string() + " bytes")
| None => _out.print("NULL")
end
end
Expand Down
2 changes: 2 additions & 0 deletions examples/query/query-example.pony
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
| let v: F32 => _out.print(v.string())
| let v: F64 => _out.print(v.string())
| let v: Bool => _out.print(v.string())
| let v: Array[U8] val =>
_out.print(v.size().string() + " bytes")
| None => _out.print("NULL")
end
end
Expand Down
2 changes: 2 additions & 0 deletions examples/ssl-query/ssl-query-example.pony
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
| let v: F32 => _out.print(v.string())
| let v: F64 => _out.print(v.string())
| let v: Bool => _out.print(v.string())
| let v: Array[U8] val =>
_out.print(v.size().string() + " bytes")
| None => _out.print("NULL")
end
end
Expand Down
3 changes: 3 additions & 0 deletions postgres/_test.pony
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ actor \nodoc\ Main is TestList
test(_TestQueryAfterConnectionFailure)
test(_TestQueryAfterSessionHasBeenClosed)
test(_TestQueryResults)
test(_TestQueryByteaResults)
test(_TestQueryOfNonExistentTable)
test(_TestResponseParserAuthenticationMD5PasswordMessage)
test(_TestResponseParserAuthenticationOkMessage)
Expand Down Expand Up @@ -73,6 +74,8 @@ actor \nodoc\ Main is TestList
test(_TestUnansweredQueriesFailOnShutdown)
test(_TestPrepareShutdownDrainsPrepareQueue)
test(_TestZeroRowSelectReturnsResultSet)
test(_TestByteaResultDecoding)
test(_TestEmptyByteaResultDecoding)
test(_TestZeroRowSelect)
test(_TestMultiStatementMixedResults)
test(_TestPreparedQueryResults)
Expand Down
57 changes: 53 additions & 4 deletions postgres/_test_equality.pony
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ use "pony_test"
class \nodoc\ iso _TestFieldEqualityReflexive is UnitTest
"""
Every FieldDataTypes variant produces a Field that is equal to itself.
Covers all 8 variants of the FieldDataTypes union to verify each match
Covers all 9 variants of the FieldDataTypes union to verify each match
branch in Field.eq.
"""
fun name(): String => "Field/Equality/Reflexive"

fun apply(h: TestHelper) =>
let fields: Array[Field] val = [
Field("bytes", recover val [as U8: 1; 2; 3] end)
Field("b", true)
Field("f32", F32(1.5))
Field("f64", F64(2.5))
Expand All @@ -28,11 +29,14 @@ class \nodoc\ iso _TestFieldEqualityReflexive is UnitTest
class \nodoc\ iso _TestFieldEqualityStructural is UnitTest
"""
Two independently constructed Fields with the same name and value are equal.
Covers all 8 variants of the FieldDataTypes union.
Covers all 9 variants of the FieldDataTypes union.
"""
fun name(): String => "Field/Equality/Structural"

fun apply(h: TestHelper) =>
h.assert_true(
Field("a", recover val [as U8: 1; 2] end)
== Field("a", recover val [as U8: 1; 2] end))
h.assert_true(Field("a", true) == Field("a", true))
h.assert_true(Field("a", F32(1.5)) == Field("a", F32(1.5)))
h.assert_true(Field("a", F64(2.5)) == Field("a", F64(2.5)))
Expand Down Expand Up @@ -70,6 +74,11 @@ class \nodoc\ iso _TestFieldEqualitySymmetric is UnitTest
let f8 = Field("x", I32(0))
h.assert_true(f7.eq(f8) == f8.eq(f7))

// Array[U8] vs String
let f9 = Field("x", recover val [as U8: 1; 2] end)
let f10 = Field("x", "hello")
h.assert_true(f9.eq(f10) == f10.eq(f9))

class \nodoc\ iso _TestFieldInequality is UnitTest
fun name(): String => "Field/Inequality"

Expand All @@ -92,6 +101,19 @@ class \nodoc\ iso _TestFieldInequality is UnitTest
h.assert_false(Field("a", None) == Field("a", I32(0)))
h.assert_false(Field("a", I32(0)) == Field("a", None))

// Array[U8] vs different Array[U8]
h.assert_false(
Field("a", recover val [as U8: 1; 2] end)
== Field("a", recover val [as U8: 1; 3] end))
h.assert_false(
Field("a", recover val [as U8: 1; 2] end)
== Field("a", recover val [as U8: 1; 2; 3] end))

// Array[U8] vs String
h.assert_false(
Field("a", recover val [as U8: 1; 2] end)
== Field("a", "hello"))


class \nodoc\ iso _TestRowEquality is UnitTest
fun name(): String => "Row/Equality"
Expand Down Expand Up @@ -182,6 +204,17 @@ class \nodoc\ iso _TestRowsInequality is UnitTest
primitive \nodoc\ _FieldDataTypesGen
fun apply(): Generator[FieldDataTypes] =>
Generators.frequency[FieldDataTypes]([
(1, Generator[FieldDataTypes](object is GenObj[FieldDataTypes]
fun generate(rnd: Randomness): FieldDataTypes =>
let size = rnd.usize(0, 10)
recover val
let arr = Array[U8](size)
for _ in Range(0, size) do
arr.push(rnd.u8())
end
arr
end
end))
(1, Generators.bool().map[FieldDataTypes]({(v) => v }))
(1, Generators.i32().map[FieldDataTypes]({(v) => F32.from[I32](v) }))
(1, Generators.i64().map[FieldDataTypes]({(v) => F64.from[I64](v) }))
Expand Down Expand Up @@ -213,14 +246,22 @@ primitive \nodoc\ _RowGen
Row(consume fields)

fun _random_field_value(rnd: Randomness): FieldDataTypes =>
match rnd.usize(0, 7)
match rnd.usize(0, 8)
| 0 => rnd.bool()
| 1 => F32.from[I32](rnd.i32())
| 2 => F64.from[I64](rnd.i64())
| 3 => rnd.i16()
| 4 => rnd.i32()
| 5 => rnd.i64()
| 6 => None
| 7 =>
recover val
let arr = Array[U8](rnd.usize(0, 10))
for _ in Range(0, arr.space()) do
arr.push(rnd.u8())
end
arr
end
else
"str" + rnd.u32().string()
end
Expand All @@ -244,14 +285,22 @@ primitive \nodoc\ _RowsGen
Rows(consume rows)

fun _random_field_value(rnd: Randomness): FieldDataTypes =>
match rnd.usize(0, 7)
match rnd.usize(0, 8)
| 0 => rnd.bool()
| 1 => F32.from[I32](rnd.i32())
| 2 => F64.from[I64](rnd.i64())
| 3 => rnd.i16()
| 4 => rnd.i32()
| 5 => rnd.i64()
| 6 => None
| 7 =>
recover val
let arr = Array[U8](rnd.usize(0, 10))
for _ in Range(0, arr.space()) do
arr.push(rnd.u8())
end
arr
end
else
"str" + rnd.u32().string()
end
Expand Down
Loading