Skip to content

Commit bed5988

Browse files
authored
Add bytea type conversion (#119)
PostgreSQL bytea columns (OID 17) are now decoded from hex format into `Array[U8] val` instead of being returned as raw hex strings. Only hex format is supported (the default since PostgreSQL 9.0). The `FieldDataTypes` union gains `Array[U8] val` as a ninth variant. Existing code is unaffected — Pony's match is non-exhaustive, so code without a bytea arm continues to work. `Field.eq()` implements element-by-element comparison for byte arrays since Pony's `Array` uses identity comparison by default. Design: #118
1 parent a6a31ba commit bed5988

18 files changed

+519
-10
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
## Add bytea type conversion
2+
3+
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.
4+
5+
```pony
6+
be pg_query_result(session: Session, result: Result) =>
7+
match result
8+
| let rs: ResultSet =>
9+
for row in rs.rows().values() do
10+
for field in row.fields.values() do
11+
match field.value
12+
| let bytes: Array[U8] val =>
13+
// Decoded bytes — e.g., [72; 101; 108; 108; 111] for "Hello"
14+
for b in bytes.values() do
15+
_env.out.print("byte: " + b.string())
16+
end
17+
end
18+
end
19+
end
20+
end
21+
```
22+
23+
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).

CLAUDE.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer
9898
- `NamedPreparedQuery` — val class wrapping a statement name + `Array[(String | None)] val` params (executes a previously prepared named statement)
9999
- `Result` trait — `ResultSet` (rows), `SimpleResult` (no rows), `RowModifying` (INSERT/UPDATE/DELETE with count)
100100
- `Rows` / `Row` / `Field` — result data. `Field.value` is `FieldDataTypes` union
101-
- `FieldDataTypes` = `(Bool | F32 | F64 | I16 | I32 | I64 | None | String)`
101+
- `FieldDataTypes` = `(Array[U8] val | Bool | F32 | F64 | I16 | I32 | I64 | None | String)`
102102
- `TransactionStatus` — union type `(TransactionIdle | TransactionInBlock | TransactionFailed)`. Reported via `pg_transaction_status` callback on every `ReadyForQuery`.
103103
- `Notification` — val class wrapping channel name, payload string, and notifying backend's process ID. Delivered via `pg_notification` callback.
104104
- `NoticeResponseMessage` — non-fatal PostgreSQL notice with all standard fields (same structure as `ErrorResponseMessage`). Delivered via `pg_notice` callback.
@@ -117,6 +117,7 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer
117117

118118
In `_RowsBuilder._field_to_type()`:
119119
- 16 (bool) → `Bool` (checks for "t")
120+
- 17 (bytea) → `Array[U8] val` (hex-format decode: strips `\x` prefix, parses hex pairs)
120121
- 20 (int8) → `I64`
121122
- 21 (int2) → `I16`
122123
- 23 (int4) → `I32`
@@ -145,6 +146,8 @@ Tests live in the main `postgres/` package (private test classes), organized acr
145146
- `_TestPrepareShutdownDrainsPrepareQueue` — uses a local TCP listener that auto-auths but never becomes ready; verifies pending prepare operations get `SessionClosed` failures on shutdown
146147
- `_TestTerminateSentOnClose` — mock server fully authenticates and becomes ready; verifies that closing the session sends a Terminate message ('X') to the server
147148
- `_TestZeroRowSelectReturnsResultSet` — mock server sends RowDescription + CommandComplete("SELECT 0") with no DataRows; verifies ResultSet (not RowModifying) with zero rows
149+
- `_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
150+
- `_TestEmptyByteaResultDecoding` — mock server sends DataRow with empty bytea (`\x`); verifies empty `Array[U8] val`
148151

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

169172
**`_test_query.pony`** — Query integration tests:
170-
- Simple query: Query/Results, Query/AfterAuthenticationFailure, Query/AfterConnectionFailure, Query/AfterSessionHasBeenClosed, Query/OfNonExistentTable, Query/CreateAndDropTable, Query/InsertAndDelete, Query/EmptyQuery, ZeroRowSelect, MultiStatementMixedResults
173+
- Simple query: Query/Results, Query/ByteaResults, Query/AfterAuthenticationFailure, Query/AfterConnectionFailure, Query/AfterSessionHasBeenClosed, Query/OfNonExistentTable, Query/CreateAndDropTable, Query/InsertAndDelete, Query/EmptyQuery, ZeroRowSelect, MultiStatementMixedResults
171174
- Prepared query: PreparedQuery/Results, PreparedQuery/NullParam, PreparedQuery/OfNonExistentTable, PreparedQuery/InsertAndDelete, PreparedQuery/MixedWithSimple
172175
- Named prepared statements: PreparedStatement/Prepare, PreparedStatement/PrepareAndExecute, PreparedStatement/PrepareAndExecuteMultiple, PreparedStatement/PrepareAndClose, PreparedStatement/PrepareFails, PreparedStatement/PrepareAfterClose, PreparedStatement/CloseNonexistent, PreparedStatement/PrepareDuplicateName, PreparedStatement/MixedWithSimpleAndPrepared
173176
- COPY IN: CopyIn/Insert, CopyIn/AbortRollback
@@ -410,6 +413,7 @@ postgres/ # Main package (49 files)
410413
assets/test-cert.pem # Self-signed test certificate for SSL unit tests
411414
assets/test-key.pem # Private key for SSL unit tests
412415
examples/README.md # Examples overview
416+
examples/bytea/bytea-example.pony # Binary data with bytea columns
413417
examples/query/query-example.pony # Simple query with result inspection
414418
examples/ssl-query/ssl-query-example.pony # SSL-encrypted query with SSLRequired
415419
examples/prepared-query/prepared-query-example.pony # PreparedQuery with params and NULL

examples/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

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

5+
## bytea
6+
7+
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.
8+
59
## query
610

711
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.

examples/bytea/bytea-example.pony

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""
2+
Querying binary data using `bytea` columns. Executes a SELECT that returns a
3+
bytea value, matches on `Array[U8] val` in the result, and prints the decoded
4+
bytes. Shows how the driver automatically decodes PostgreSQL's hex-format
5+
bytea representation into raw byte arrays.
6+
"""
7+
use "cli"
8+
use "collections"
9+
use lori = "lori"
10+
// in your code this `use` statement would be:
11+
// use "postgres"
12+
use "../../postgres"
13+
14+
actor Main
15+
new create(env: Env) =>
16+
let server_info = ServerInfo(env.vars)
17+
let auth = lori.TCPConnectAuth(env.root)
18+
19+
let client = Client(auth, server_info, env.out)
20+
21+
actor Client is (SessionStatusNotify & ResultReceiver)
22+
let _session: Session
23+
let _out: OutStream
24+
25+
new create(auth: lori.TCPConnectAuth, info: ServerInfo, out: OutStream) =>
26+
_out = out
27+
_session = Session(
28+
ServerConnectInfo(auth, info.host, info.port),
29+
DatabaseConnectInfo(info.username, info.password, info.database),
30+
this)
31+
32+
be close() =>
33+
_session.close()
34+
35+
be pg_session_authenticated(session: Session) =>
36+
_out.print("Authenticated.")
37+
_out.print("Sending bytea query....")
38+
// The hex string \x48656c6c6f represents the ASCII bytes for "Hello".
39+
let q = SimpleQuery("SELECT '\\x48656c6c6f'::bytea AS data")
40+
session.execute(q, this)
41+
42+
be pg_session_authentication_failed(
43+
s: Session,
44+
reason: AuthenticationFailureReason)
45+
=>
46+
_out.print("Failed to authenticate.")
47+
48+
be pg_query_result(session: Session, result: Result) =>
49+
match result
50+
| let r: ResultSet =>
51+
_out.print("ResultSet (" + r.rows().size().string() + " rows):")
52+
for row in r.rows().values() do
53+
for field in row.fields.values() do
54+
_out.write(field.name + "=")
55+
match field.value
56+
| let v: Array[U8] val =>
57+
_out.print(v.size().string() + " bytes")
58+
// Print each byte's decimal value
59+
for b in v.values() do
60+
_out.print(" byte: " + b.string())
61+
end
62+
| let v: String => _out.print(v)
63+
| let v: I16 => _out.print(v.string())
64+
| let v: I32 => _out.print(v.string())
65+
| let v: I64 => _out.print(v.string())
66+
| let v: F32 => _out.print(v.string())
67+
| let v: F64 => _out.print(v.string())
68+
| let v: Bool => _out.print(v.string())
69+
| None => _out.print("NULL")
70+
end
71+
end
72+
end
73+
| let r: RowModifying =>
74+
_out.print(r.command() + " " + r.impacted().string() + " rows")
75+
| let r: SimpleResult =>
76+
_out.print("Query executed.")
77+
end
78+
close()
79+
80+
be pg_query_failed(session: Session, query: Query,
81+
failure: (ErrorResponseMessage | ClientQueryError))
82+
=>
83+
_out.print("Query failed.")
84+
close()
85+
86+
class val ServerInfo
87+
let host: String
88+
let port: String
89+
let username: String
90+
let password: String
91+
let database: String
92+
93+
new val create(vars: (Array[String] val | None)) =>
94+
let e = EnvVars(vars)
95+
host = try e("POSTGRES_HOST")? else "127.0.0.1" end
96+
port = try e("POSTGRES_PORT")? else "5432" end
97+
username = try e("POSTGRES_USERNAME")? else "postgres" end
98+
password = try e("POSTGRES_PASSWORD")? else "postgres" end
99+
database = try e("POSTGRES_DATABASE")? else "postgres" end

examples/crud/crud-example.pony

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
104104
| let v: F32 => _out.write(v.string())
105105
| let v: F64 => _out.write(v.string())
106106
| let v: Bool => _out.write(v.string())
107+
| let v: Array[U8] val =>
108+
_out.write(v.size().string() + " bytes")
107109
| None => _out.write("NULL")
108110
end
109111
end

examples/named-prepared-query/named-prepared-query-example.pony

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ actor Client is (SessionStatusNotify & ResultReceiver & PrepareReceiver)
7373
| let v: F32 => _out.print(v.string())
7474
| let v: F64 => _out.print(v.string())
7575
| let v: Bool => _out.print(v.string())
76+
| let v: Array[U8] val =>
77+
_out.print(v.size().string() + " bytes")
7678
| None => _out.print("NULL")
7779
end
7880
end

examples/prepared-query/prepared-query-example.pony

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
5858
| let v: F32 => _out.print(v.string())
5959
| let v: F64 => _out.print(v.string())
6060
| let v: Bool => _out.print(v.string())
61+
| let v: Array[U8] val =>
62+
_out.print(v.size().string() + " bytes")
6163
| None => _out.print("NULL")
6264
end
6365
end

examples/query/query-example.pony

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
5353
| let v: F32 => _out.print(v.string())
5454
| let v: F64 => _out.print(v.string())
5555
| let v: Bool => _out.print(v.string())
56+
| let v: Array[U8] val =>
57+
_out.print(v.size().string() + " bytes")
5658
| None => _out.print("NULL")
5759
end
5860
end

examples/ssl-query/ssl-query-example.pony

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ actor Client is (SessionStatusNotify & ResultReceiver)
7777
| let v: F32 => _out.print(v.string())
7878
| let v: F64 => _out.print(v.string())
7979
| let v: Bool => _out.print(v.string())
80+
| let v: Array[U8] val =>
81+
_out.print(v.size().string() + " bytes")
8082
| None => _out.print("NULL")
8183
end
8284
end

postgres/_test.pony

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ actor \nodoc\ Main is TestList
2626
test(_TestQueryAfterConnectionFailure)
2727
test(_TestQueryAfterSessionHasBeenClosed)
2828
test(_TestQueryResults)
29+
test(_TestQueryByteaResults)
2930
test(_TestQueryOfNonExistentTable)
3031
test(_TestResponseParserAuthenticationMD5PasswordMessage)
3132
test(_TestResponseParserAuthenticationOkMessage)
@@ -73,6 +74,8 @@ actor \nodoc\ Main is TestList
7374
test(_TestUnansweredQueriesFailOnShutdown)
7475
test(_TestPrepareShutdownDrainsPrepareQueue)
7576
test(_TestZeroRowSelectReturnsResultSet)
77+
test(_TestByteaResultDecoding)
78+
test(_TestEmptyByteaResultDecoding)
7679
test(_TestZeroRowSelect)
7780
test(_TestMultiStatementMixedResults)
7881
test(_TestPreparedQueryResults)

0 commit comments

Comments
 (0)