Skip to content

nobleach/zenc-postgres

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ZenC PostgreSQL Driver

A high-level PostgreSQL client library for the ZenC programming language, wrapping libpq with an ergonomic, memory-safe API.

Write modern, safe database code like a high-level language, link and run on bare C.


Features

  • Simple, ergonomic APIPgConnection::new(), conn.query(), conn.exec()
  • Memory-safe by defaultDrop implementations automatically call PQfinish and PQclear
  • ZenC-native error handling — Returns Result<T> instead of raw error codes
  • No build system required — Uses ZenC's built-in //> link: directive
  • Lightweight wrapper — Thin abstraction over libpq with zero runtime overhead

Prerequisites

  1. ZenC compiler (zc) — Installation guide

  2. PostgreSQL client libraries (libpq):

    # Arch Linux
    sudo pacman -S postgresql-libs
    
    # Ubuntu / Debian
    sudo apt-get install libpq-dev
    
    # Fedora / RHEL
    sudo dnf install postgresql-devel
    
    # macOS
    brew install libpq

Project Structure

pg/
├── sys.zc   # Low-level C interop layer (raw libpq bindings)
└── pg.zc    # High-level ZenC wrapper (PgConnection, PgResult)

You only need to import pg/pg.zc in your application code.


Quick Start

Add the link directive and import the module:

//> link: -lpq

import "std/io.zc"
import "./pg/pg.zc"

fn main() {
    let conninfo = "host=localhost dbname=postgres user=postgres";
    let conn_res = PgConnection::new(conninfo);

    if (conn_res.is_err()) {
        println "Failed to connect: {conn_res.err}";
        return;
    }

    let conn = conn_res.unwrap();

    // Create a table
    let create = conn.exec(
        "CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)"
    );
    if (create.is_err()) {
        println "CREATE failed: {create.err}";
        return;
    }

    // Insert data
    let insert = conn.exec(
        "INSERT INTO users (name) VALUES ('Alice'), ('Bob')"
    );
    if (insert.is_err()) {
        println "INSERT failed: {insert.err}";
        return;
    }

    // Query data
    let query_res = conn.query("SELECT id, name FROM users");
    if (query_res.is_err()) {
        println "SELECT failed: {query_res.err}";
        return;
    }

    let result = query_res.unwrap();
    println "Rows: {result.row_count()}, Columns: {result.column_count()}";

    for (let i = 0; i < result.row_count(); i = i + 1) {
        let id   = result.get(i, 0).unwrap();
        let name = result.get(i, 1).unwrap();
        println "{id} | {name}";
        id.destroy();
        name.destroy();
    }

    // Or use the iterator interface:
    // let iter = PgResultIterator::new(&result);
    // while (iter.has_next()) {
    //     let row = iter.next().unwrap();
    //     let id   = row.get(0).unwrap();
    //     let name = row.get(1).unwrap();
    //     println "{id} | {name}";
    //     id.destroy();
    //     name.destroy();
    // }
}

Compile and run:

zc run main.zc

Or build an executable:

zc build main.zc -o myapp
./myapp

API Reference

PgConnection

Represents an open connection to a PostgreSQL server.

Method Signature Description
new fn new(conninfo: char*) -> Result<PgConnection> Opens a connection using a libpq connection string
query fn query(self, sql: char*) -> Result<PgResult> Executes a SQL query that returns rows (e.g. SELECT)
exec fn exec(self, sql: char*) -> Result<bool> Executes a SQL command with no result set (e.g. CREATE, INSERT)
query_params fn query_params(self, sql: char*, params: char**, nParams: c_int) -> Result<PgResult> Executes a parameterized query (e.g. SELECT ... WHERE x = $1)
exec_params fn exec_params(self, sql: char*, params: char**, nParams: c_int) -> Result<bool> Executes a parameterized command (e.g. INSERT ... VALUES ($1, $2))
last_error fn last_error(self) -> String Returns the last libpq error message

Connection string examples:

"host=localhost dbname=mydb user=myuser password=secret"
"host=/var/run/postgresql dbname=mydb user=myuser"
"postgresql://myuser:secret@localhost:5432/mydb"

PgResult

Represents the result of a query() call.

Method Signature Description
row_count fn row_count(self) -> c_int Number of rows in the result
column_count fn column_count(self) -> c_int Number of columns in the result
get fn get(self, row: c_int, col: c_int) -> Option<String> Returns the value at (row, col) as an Option<String>None for SQL NULL
column_name fn column_name(self, col: c_int) -> String Returns the name of the given column

Transaction

Represents an active database transaction. Created via PgConnection::begin().

Method Signature Description
commit fn commit(self) -> Result<bool> Commits the transaction
rollback fn rollback(self) -> Result<bool> Rolls back the transaction
exec fn exec(self, sql: char*) -> Result<bool> Executes a SQL command inside the transaction
query fn query(self, sql: char*) -> Result<PgResult> Executes a query inside the transaction
exec_params fn exec_params(self, sql: char*, params: char**, nParams: c_int) -> Result<bool> Parameterized command inside the transaction
query_params fn query_params(self, sql: char*, params: char**, nParams: c_int) -> Result<PgResult> Parameterized query inside the transaction
last_error fn last_error(self) -> String Returns the last libpq error message

Note: The PgConnection used to start the transaction must outlive the Transaction object. Dropping a Transaction without calling commit() or rollback() automatically issues ROLLBACK.

Async Functions

ZenC async/await is supported via wrapper functions. Under the hood ZenC v0.4 uses OS threads, so these wrappers run the blocking libpq call on a background thread.

Function Signature Description
pg_connect_async async fn pg_connect_async(conninfo: char*) -> Result<PgConnection> Opens a connection asynchronously
pg_query_async async fn pg_query_async(conn: PgConnection*, sql: char*) -> Result<PgResult> Executes a query asynchronously
pg_exec_async async fn pg_exec_async(conn: PgConnection*, sql: char*) -> Result<bool> Executes a command asynchronously
pg_query_params_async async fn pg_query_params_async(conn: PgConnection*, sql: char*, params: char**, nParams: int) -> Result<PgResult> Parameterized query asynchronously
pg_exec_params_async async fn pg_exec_params_async(conn: PgConnection*, sql: char*, params: char**, nParams: int) -> Result<bool> Parameterized command asynchronously
pg_begin_async async fn pg_begin_async(conn: PgConnection*) -> Result<Transaction> Begins a transaction asynchronously
pg_tx_commit_async async fn pg_tx_commit_async(tx: Transaction*) -> Result<bool> Commits a transaction asynchronously
pg_tx_rollback_async async fn pg_tx_rollback_async(tx: Transaction*) -> Result<bool> Rolls back a transaction asynchronously
pg_tx_exec_async async fn pg_tx_exec_async(tx: Transaction*, sql: char*) -> Result<bool> Async command inside a transaction
pg_tx_query_async async fn pg_tx_query_async(tx: Transaction*, sql: char*) -> Result<PgResult> Async query inside a transaction
pg_tx_query_params_async async fn pg_tx_query_params_async(tx: Transaction*, sql: char*, params: char**, nParams: int) -> Result<PgResult> Async parameterized query in a transaction
pg_tx_exec_params_async async fn pg_tx_exec_params_async(tx: Transaction*, sql: char*, params: char**, nParams: int) -> Result<bool> Async parameterized command in a transaction

Lifetime safety: The conn or tx pointer passed to an async function must remain valid until the future is awaited. Do not drop the connection or transaction before awaiting the result.

PgPool

A fixed-size, thread-safe connection pool. Pre-warms connections on creation and reuses them across concurrent workloads.

Method Signature Description
new fn new(conninfo: char*, max_size: int) -> Result<PgPool> Creates a pool of max_size connections
get fn get(self) -> Result<PooledConnection> Blocking checkout of a connection from the pool

PooledConnection

A borrowed connection from the pool. Automatically returns to the pool on drop.

Method Signature Description
exec fn exec(self, sql: char*) -> Result<bool> Executes a command
query fn query(self, sql: char*) -> Result<PgResult> Executes a query
exec_params fn exec_params(self, sql: char*, params: char**, nParams: int) -> Result<bool> Parameterized command
query_params fn query_params(self, sql: char*, params: char**, nParams: int) -> Result<PgResult> Parameterized query
begin fn begin(self) -> Result<Transaction> Begins a transaction
last_error fn last_error(self) -> String Returns the last libpq error message
release fn release(self) Manually returns the connection to the pool
Async Function Signature Description
pg_pool_get_async async fn pg_pool_get_async(pool: PgPool*) -> Result<PooledConnection> Async checkout from the pool

Note: PooledConnection holds a pointer into the pool's internal array. The PgPool must outlive all checked-out PooledConnection objects.

PgResultIterator

Provides row-by-row iteration over a PgResult. The PgResult must outlive the iterator.

Method Signature Description
new fn new(result: PgResult*) -> PgResultIterator Creates an iterator over the given result
next fn next(self) -> Option<Row> Returns the next row, or None if exhausted
has_next fn has_next(self) -> bool Returns true if more rows are available

Row

Represents a single row within a result set.

Method Signature Description
get fn get(self, col: int) -> Option<String> Returns the value at the given column index — None for SQL NULL
column_count fn column_count(self) -> int Number of columns in the row
let result = conn.query("SELECT id, name FROM users").unwrap();
let iter = PgResultIterator::new(&result);

while (iter.has_next()) {
    let row = iter.next().unwrap();
    let id   = row.get(0).unwrap();
    let name = row.get(1).unwrap();
    println "{id} | {name}";
    id.destroy();
    name.destroy();
}

Error Handling

All database operations return Result<T>:

let res = conn.exec("DELETE FROM users WHERE id = 99");
if (res.is_err()) {
    println "Delete failed: {res.err}";
} else {
    println "Delete succeeded";
}

To inspect the underlying libpq error after a failure, use last_error():

let conn_res = PgConnection::new("host=badhost dbname=test");
if (conn_res.is_err()) {
    println "Connection error: {conn_res.err}";
    // libpq-specific detail is not available here because
    // the connection handle is already cleaned up.
}

Memory Management

Both PgConnection and PgResult implement Drop, so resources are freed automatically when they go out of scope:

{
    let conn = PgConnection::new("...").unwrap();
    let res = conn.query("SELECT * FROM users").unwrap();
    // PQclear(res) and PQfinish(conn) are called automatically here
}

String lifetimes: PgResult::get() returns an Option<String> and PgResult::column_name() returns an owned String. You must call .destroy() on the unwrapped String values when done, or let them fall out of scope.

let maybe_name = result.get(0, 1);
if (maybe_name.is_some()) {
    let name = maybe_name.unwrap();
    println "Name: {name}";
    name.destroy();
} else {
    println "Name is NULL";
}

Build Directive

The //> link: -lpq directive at the top of your entry file tells the ZenC compiler to link against libpq automatically. No Makefile required.

If libpq-fe.h is in a non-standard location (e.g. macOS Homebrew), add:

//> include: /opt/homebrew/opt/libpq/include
//> lib: /opt/homebrew/opt/libpq/lib
//> link: -lpq

Examples

Checking for NULL values

let maybe_value = result.get(0, 2);
if (maybe_value.is_none()) {
    println "Value is NULL";
} else {
    let value = maybe_value.unwrap();
    println "Value: {value}";
    value.destroy();
}

Getting column names dynamically

for (let c = 0; c < result.column_count(); c = c + 1) {
    let col_name = result.column_name(c);
    println "Column {c}: {col_name}";
    col_name.destroy();
}

Conditional logic with multiple statements

let conn = PgConnection::new("host=localhost dbname=shop").unwrap();

let r1 = conn.exec("BEGIN");
let r2 = conn.exec("UPDATE inventory SET qty = qty - 1 WHERE id = 42");
let r3 = conn.exec("COMMIT");

if (r1.is_ok() && r2.is_ok() && r3.is_ok()) {
    println "Transaction committed";
} else {
    conn.exec("ROLLBACK");
    println "Transaction rolled back";
}

Transactions

Use the Transaction struct for safer transaction handling with automatic rollback on drop:

let conn = PgConnection::new("host=localhost dbname=shop").unwrap();

let tx_res = conn.begin();
if (tx_res.is_err()) {
    println "BEGIN failed: {tx_res.err}";
    return;
}
let tx = tx_res.unwrap();

let r1 = tx.exec("UPDATE inventory SET qty = qty - 1 WHERE id = 42");
let r2 = tx.exec("INSERT INTO orders (item_id) VALUES (42)");

if (r1.is_ok() && r2.is_ok()) {
    tx.commit();
    println "Transaction committed";
} else {
    tx.rollback();
    println "Transaction rolled back";
}

Async queries

Run blocking operations on background threads using ZenC's async/await:

// Establish two connections in parallel
let f1 = pg_connect_async("host=db1 dbname=shop user=postgres");
let f2 = pg_connect_async("host=db2 dbname=shop user=postgres");

let conn1 = (await f1).unwrap();
let conn2 = (await f2).unwrap();

// Run queries in parallel on different connections
let q1 = pg_query_async(&conn1, "SELECT * FROM inventory");
let q2 = pg_query_async(&conn2, "SELECT * FROM orders");

let res1 = (await q1).unwrap();
let res2 = (await q2).unwrap();

println "Inventory rows: {res1.row_count()}, Order rows: {res2.row_count()}";

Note: ZenC's current async/await implementation uses OS threads. The pointer passed to an async wrapper (&conn, &tx) must remain valid until the future is awaited.

Connection pool

Reuse connections across concurrent workloads with a fixed-size pool:

let pool = PgPool::new("host=localhost dbname=shop user=postgres", 4).unwrap();

// Two workers share the pool (only 4 connections ever created)
{
    let pc = pool.get().unwrap();
    let res = pc.query("SELECT * FROM inventory");
    // PooledConnection auto-releases on drop
}

{
    let pc = pool.get().unwrap();
    let tx = pc.begin().unwrap();
    tx.exec("UPDATE inventory SET qty = qty - 1 WHERE id = 42");
    tx.commit();
}

Parameterized queries

Use exec_params and query_params to pass values safely without manual escaping:

let conn = PgConnection::new("host=localhost dbname=shop").unwrap();

let params = ["Alice", "30"];
let insert = conn.exec_params(
    "INSERT INTO users (name, age) VALUES ($1, $2)",
    params, 2
);

let query_params = ["Alice"];
let res = conn.query_params(
    "SELECT id, name, age FROM users WHERE name = $1",
    query_params, 1
).unwrap();

println "Rows: {res.row_count()}";

Testing

Running the Test Suite

This project includes an integration test suite in the tests/ directory.

  1. Ensure you have a PostgreSQL server running on localhost.

  2. (Optional) Set the ZENC_PG_TEST_CONNINFO environment variable if your server uses non-default credentials:

    export ZENC_PG_TEST_CONNINFO="host=localhost dbname=postgres user=postgres password=secret"
  3. Run all tests:

    ./run_tests.sh

Or run individual test files directly:

zc run tests/test_connection.zc
zc run tests/test_query.zc
zc run tests/test_null.zc
zc run tests/test_transaction.zc
zc run tests/test_async.zc
zc run tests/test_pool.zc
zc run tests/test_iterator.zc

Running the Demo

zc run main.zc

If no server is available, the tests and demo will report connection errors.


Future Work

  • Parameterized queriesPQexecParams wrapper for safe value binding
  • Transactions — Dedicated Transaction struct with commit() / rollback()
  • Async support — Integration with ZenC's async / await
  • Connection pooling — Simple pool for concurrent workloads
  • Iterator interface — Row-by-row iteration over PgResult
  • Better NULL handling — Return Option<String> instead of empty strings

License

This wrapper is provided as-is for use in ZenC projects. It links against PostgreSQL's libpq, which is licensed under the PostgreSQL License.

About

A PostgreSQL Driver for ZenC (libpq wrapper)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors