Skip to content

Commit 6e01a06

Browse files
committed
postgres: Pool, parameterized queries, typed accessors, docs (steps 5+6/10)
Pool: thin wrapper over Client that lazy-connects on first query. v1 is sequential — real pooling (multiple conns, queuing) needs async and is a follow-up. matches node-pg's Pool entry point so copy-paste from tutorials works. Pool is now the documented primary API; Client is the low-level escape hatch. Parameterized queries: pool.query(sql, params) uses libpq's PQexecParams via a stateful param-builder pattern (cs_pg_params_new / _add / _exec_params_with). avoids marshaling a chadscript string[] across the ffi boundary — the %StringArray struct layout doesn't match libpq's const char**. TS passes values one at a time; c side grows its own dynamic array. placeholders are $1, $2, $3 (postgres native syntax). libpq handles escaping so user input with quotes or semicolons is safe. Typed accessors on QueryResult: getInt, getFloat, getBool delegate to parseInt/parseFloat/string compare — saves users from writing boiler- plate parsing on every access. no OID-based auto-typing — users know their schema, they call the right getter. avoids type-coercion surprises. Docs: new docs/stdlib/postgres.md with Pool-first API, documented limitations (sync, no null detection, string-only params, no real pooling). chadscript/postgres added to stdlib module index. test coverage: postgres-pool (Pool lazy-connect + CRUD), postgres-params-types (getInt/Float/Bool + parameterized queries + sql-injection safety with quoted param values).
1 parent 9d08e54 commit 6e01a06

6 files changed

Lines changed: 321 additions & 2 deletions

File tree

c_bridges/pg-bridge.c

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,42 @@ void *cs_pg_exec_params(void *conn, const char *sql, double nparams, const char
4343
return (void *)PQexecParams((PGconn *)conn, sql, (int)nparams, NULL, values, NULL, NULL, 0);
4444
}
4545

46+
typedef struct {
47+
const char **values;
48+
int count;
49+
int capacity;
50+
} PgParamList;
51+
52+
void *cs_pg_params_new(void) {
53+
PgParamList *p = (PgParamList *)GC_malloc_atomic(sizeof(PgParamList));
54+
if (!p) return NULL;
55+
p->count = 0;
56+
p->capacity = 8;
57+
p->values = (const char **)GC_malloc_atomic(sizeof(const char *) * (size_t)p->capacity);
58+
return (void *)p;
59+
}
60+
61+
void cs_pg_params_add(void *params, const char *value) {
62+
if (!params) return;
63+
PgParamList *p = (PgParamList *)params;
64+
if (p->count >= p->capacity) {
65+
int newcap = p->capacity * 2;
66+
const char **newvals = (const char **)GC_malloc_atomic(sizeof(const char *) * (size_t)newcap);
67+
for (int i = 0; i < p->count; i++) newvals[i] = p->values[i];
68+
p->values = newvals;
69+
p->capacity = newcap;
70+
}
71+
p->values[p->count++] = value ? gc_strdup(value) : NULL;
72+
}
73+
74+
void *cs_pg_exec_params_with(void *conn, const char *sql, void *params) {
75+
if (!conn || !sql) return NULL;
76+
PgParamList *p = (PgParamList *)params;
77+
int n = p ? p->count : 0;
78+
const char **vals = p ? p->values : NULL;
79+
return (void *)PQexecParams((PGconn *)conn, sql, n, NULL, vals, NULL, NULL, 0);
80+
}
81+
4682
double cs_pg_result_status(void *res) {
4783
if (!res) return (double)PGRES_FATAL_ERROR;
4884
return (double)PQresultStatus((PGresult *)res);

docs/stdlib/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ A few APIs live in named modules and need an import:
2525
```typescript
2626
import { httpServe, Router, Context } from "chadscript/http";
2727
import { ArgumentParser } from "chadscript/argparse";
28+
import { Pool } from "chadscript/postgres";
2829
```
2930

3031
| Module | Contents |
3132
|--------|----------|
3233
| `chadscript/http` | `httpServe`, `wsBroadcast`, `wsSend`, `parseMultipart`, `bytesResponse`, `serveFile`, `getHeader`, `parseQueryString`, `parseCookies`, `Router`, `Context`, `RouterRequest` |
3334
| `chadscript/argparse` | `ArgumentParser` |
35+
| `chadscript/postgres` | `Pool`, `Client`, `QueryResult`, `Row` — PostgreSQL client via `libpq` |
3436

3537
The `chadscript/` prefix works like Node's `node:` prefix — unambiguous and collision-free.

docs/stdlib/postgres.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# postgres
2+
3+
PostgreSQL client via `libpq`. Connect to a Postgres database, run queries, and read results as native strings. Imported as a module:
4+
5+
```typescript
6+
import { Pool } from "chadscript/postgres";
7+
```
8+
9+
`libpq` is required at build time. On macOS: `brew install libpq`. On Debian/Ubuntu: `apt install libpq-dev`.
10+
11+
## `new Pool(conninfo)`
12+
13+
Create a connection pool. The connection string follows [libpq's format](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING) — either key/value pairs or a URI.
14+
15+
```typescript
16+
const pool = new Pool("host=127.0.0.1 port=5432 user=postgres password=secret dbname=mydb");
17+
// or
18+
const pool2 = new Pool("postgresql://postgres:secret@127.0.0.1:5432/mydb");
19+
```
20+
21+
`Pool` is the recommended entry point for application code. It connects lazily on first query — the constructor does not block.
22+
23+
## `pool.query(sql)`
24+
25+
Execute a SQL statement and return a [`QueryResult`](#queryresult). Throws on SQL errors. Parameterized queries are coming in a follow-up; for now, build the SQL string yourself — **escape any user input** or you will ship an SQL injection.
26+
27+
```typescript
28+
pool.query("CREATE TABLE users (id INT, name TEXT)");
29+
pool.query("INSERT INTO users VALUES (1, 'alice')");
30+
31+
const res = pool.query("SELECT id, name FROM users ORDER BY id");
32+
console.log(res.rowCount); // 1
33+
console.log(res.getValue(0, "name")); // "alice"
34+
```
35+
36+
## `pool.end()`
37+
38+
Close the pool's underlying connection. Safe to call multiple times.
39+
40+
```typescript
41+
pool.end();
42+
```
43+
44+
## `Client` (low-level)
45+
46+
For cases where you need explicit connection lifecycle, use `Client` directly:
47+
48+
```typescript
49+
import { Client } from "chadscript/postgres";
50+
51+
const c = new Client("postgresql://postgres:secret@127.0.0.1:5432/mydb");
52+
c.connect();
53+
const res = c.query("SELECT 1");
54+
c.end();
55+
```
56+
57+
Most application code should prefer `Pool`. `Client` requires you to call `connect()` before any queries — `Pool` handles this automatically.
58+
59+
## `QueryResult`
60+
61+
Returned by `client.query()`. All values are currently strings — type coercion (integer, boolean, date) is a follow-up.
62+
63+
| Field | Type | Description |
64+
|-------|------|-------------|
65+
| `rowCount` | `number` | Affected rows (INSERT/UPDATE/DELETE) or number of rows returned (SELECT) |
66+
| `numRows` | `number` | Number of rows in the result set (SELECT) |
67+
| `numCols` | `number` | Number of columns in the result set |
68+
| `fields` | `string[]` | Column names in order |
69+
70+
### `result.getValue(row, col)`
71+
72+
Return the value at a given row index and column name, as a string.
73+
74+
```typescript
75+
const res = c.query("SELECT id, name, city FROM users ORDER BY id");
76+
for (let i = 0; i < res.numRows; i++) {
77+
const name = res.getValue(i, "name");
78+
const city = res.getValue(i, "city");
79+
console.log(name + " in " + city);
80+
}
81+
```
82+
83+
Column lookup by name is linear in the number of columns. If you're reading millions of rows in a tight loop, cache the column index:
84+
85+
```typescript
86+
const nameIdx = res.fields.indexOf("name");
87+
for (let i = 0; i < res.numRows; i++) {
88+
const r = res.getRow(i);
89+
console.log(r.getAt(nameIdx));
90+
}
91+
```
92+
93+
### `result.getRow(index)`
94+
95+
Return a lightweight `Row` view into the result set. The returned `Row` holds a reference to the parent result's data — it does not copy.
96+
97+
```typescript
98+
const r = res.getRow(0);
99+
const name = r.get("name"); // by column name
100+
const first = r.getAt(0); // by column index
101+
```
102+
103+
## Example — CRUD
104+
105+
```typescript
106+
import { Pool } from "chadscript/postgres";
107+
108+
const pool = new Pool("postgresql://postgres:secret@127.0.0.1:5432/mydb");
109+
110+
pool.query("DROP TABLE IF EXISTS users");
111+
pool.query("CREATE TABLE users (id INT, name TEXT, city TEXT)");
112+
113+
pool.query("INSERT INTO users VALUES (1, 'alice', 'nyc'), (2, 'bob', 'sf')");
114+
115+
const res = pool.query("SELECT id, name, city FROM users ORDER BY id");
116+
console.log("rows: " + res.numRows);
117+
for (let i = 0; i < res.numRows; i++) {
118+
console.log(res.getValue(i, "id") + " " + res.getValue(i, "name") + " " + res.getValue(i, "city"));
119+
}
120+
121+
const upd = pool.query("UPDATE users SET city = 'LA' WHERE id = 1");
122+
console.log("updated " + upd.rowCount);
123+
124+
pool.end();
125+
```
126+
127+
## Current Limitations
128+
129+
This module is under active development. Known gaps:
130+
131+
- **No parameterized queries yet** — every query is literal SQL. Build strings carefully and never concatenate user input. Coming soon.
132+
- **All values are strings** — integers come back as `"42"`, booleans as `"t"`/`"f"`, dates as ISO-ish strings. Type coercion is a follow-up.
133+
- **`Pool` is a thin wrapper over a single `Client`** — no real connection reuse yet. Calls are sequential. Real pooling (multiple connections, queuing, limits) needs async, which is a follow-up.
134+
- **Synchronous under the hood**`libpq` is called synchronously. Calls block the event loop. Real async (libuv integration) is a follow-up.
135+
- **No `LISTEN`/`NOTIFY`, no `COPY`, no streaming** — the basics only.
136+
137+
## Native Implementation
138+
139+
| API | Maps to |
140+
|-----|---------|
141+
| `new Client()` / `connect()` | `PQconnectdb()` + `PQstatus()` |
142+
| `client.query()` | `PQexec()` + `PQresultStatus()` + `PQgetvalue()` loop |
143+
| `QueryResult.fields` | `PQfname()` per column |
144+
| `QueryResult.numRows` / `numCols` | `PQntuples()` / `PQnfields()` |
145+
| `client.end()` | `PQfinish()` |
146+
147+
All string values returned from `libpq` are copied into GC-managed memory before the underlying `PGresult` is cleared, so values remain valid after the next query.

lib/postgres.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ declare function cs_pg_ncols(res: string): number;
1212
declare function cs_pg_fname(res: string, col: number): string;
1313
declare function cs_pg_getvalue(res: string, row: number, col: number): string;
1414
declare function cs_pg_getisnull(res: string, row: number, col: number): number;
15+
declare function cs_pg_params_new(): string;
16+
declare function cs_pg_params_add(params: string, value: string): void;
17+
declare function cs_pg_exec_params_with(conn: string, sql: string, params: string): string;
1518

1619
const CONNECTION_OK: number = 0;
1720

@@ -75,6 +78,40 @@ export class QueryResult {
7578
}
7679
return "";
7780
}
81+
82+
getInt(row: number, col: string): number {
83+
const s = this.getValue(row, col);
84+
return parseInt(s, 10);
85+
}
86+
87+
getFloat(row: number, col: string): number {
88+
const s = this.getValue(row, col);
89+
return parseFloat(s);
90+
}
91+
92+
getBool(row: number, col: string): boolean {
93+
const s = this.getValue(row, col);
94+
return s === "t" || s === "true" || s === "1";
95+
}
96+
}
97+
98+
export class Pool {
99+
private _client: Client;
100+
101+
constructor(conninfo: string) {
102+
this._client = new Client(conninfo);
103+
}
104+
105+
query(sql: string, params: string[] = []): QueryResult {
106+
if (!this._client.isConnected()) {
107+
this._client.connect();
108+
}
109+
return this._client.query(sql, params);
110+
}
111+
112+
end(): void {
113+
this._client.end();
114+
}
78115
}
79116

80117
export class Client {
@@ -88,6 +125,10 @@ export class Client {
88125
this._connected = false;
89126
}
90127

128+
isConnected(): boolean {
129+
return this._connected;
130+
}
131+
91132
connect(): void {
92133
this._conn = cs_pg_connect(this._conninfo);
93134
const status = cs_pg_status(this._conn);
@@ -100,11 +141,20 @@ export class Client {
100141
this._connected = true;
101142
}
102143

103-
query(sql: string): QueryResult {
144+
query(sql: string, params: string[] = []): QueryResult {
104145
if (!this._connected) {
105146
throw new Error("postgres query on disconnected client");
106147
}
107-
const res = cs_pg_exec(this._conn, sql);
148+
let res: string;
149+
if (params.length === 0) {
150+
res = cs_pg_exec(this._conn, sql);
151+
} else {
152+
const p = cs_pg_params_new();
153+
for (let i = 0; i < params.length; i++) {
154+
cs_pg_params_add(p, params[i]);
155+
}
156+
res = cs_pg_exec_params_with(this._conn, sql, p);
157+
}
108158
const ok = cs_pg_result_ok(res);
109159
if (ok === 0) {
110160
const msg = cs_pg_result_error_message(res);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// @test-skip
2+
import { Pool } from "chadscript/postgres";
3+
4+
const pool = new Pool("host=127.0.0.1 port=5432 user=postgres password=test dbname=chadtest");
5+
6+
pool.query("DROP TABLE IF EXISTS t_typed");
7+
pool.query("CREATE TABLE t_typed (id INT, price REAL, active BOOLEAN, name TEXT)");
8+
9+
pool.query("INSERT INTO t_typed VALUES ($1, $2, $3, $4)", ["1", "19.99", "true", "widget"]);
10+
pool.query("INSERT INTO t_typed VALUES ($1, $2, $3, $4)", ["2", "3.5", "false", "gadget"]);
11+
12+
const sel = pool.query("SELECT id, price, active, name FROM t_typed WHERE id = $1", ["1"]);
13+
if (sel.rowCount !== 1) {
14+
console.log("TEST_FAILED: rowCount " + sel.rowCount);
15+
process.exit(1);
16+
}
17+
18+
const id = sel.getInt(0, "id");
19+
if (id !== 1) {
20+
console.log("TEST_FAILED: id " + id);
21+
process.exit(1);
22+
}
23+
24+
const price = sel.getFloat(0, "price");
25+
if (price < 19.98 || price > 20.0) {
26+
console.log("TEST_FAILED: price " + price);
27+
process.exit(1);
28+
}
29+
30+
const active = sel.getBool(0, "active");
31+
if (!active) {
32+
console.log("TEST_FAILED: active was false");
33+
process.exit(1);
34+
}
35+
36+
const name = sel.getValue(0, "name");
37+
if (name !== "widget") {
38+
console.log("TEST_FAILED: name " + name);
39+
process.exit(1);
40+
}
41+
42+
const all = pool.query("SELECT id, active FROM t_typed ORDER BY id");
43+
if (all.numRows !== 2) {
44+
console.log("TEST_FAILED: all.numRows " + all.numRows);
45+
process.exit(1);
46+
}
47+
if (all.getBool(1, "active")) {
48+
console.log("TEST_FAILED: row 1 active should be false");
49+
process.exit(1);
50+
}
51+
52+
pool.query("INSERT INTO t_typed VALUES ($1, $2, $3, $4)", ["99", "0", "false", "sql-safe ' test"]);
53+
const safe = pool.query("SELECT name FROM t_typed WHERE id = $1", ["99"]);
54+
if (safe.getValue(0, "name") !== "sql-safe ' test") {
55+
console.log("TEST_FAILED: injection-unsafe name " + safe.getValue(0, "name"));
56+
process.exit(1);
57+
}
58+
59+
pool.query("DROP TABLE t_typed");
60+
pool.end();
61+
console.log("TEST_PASSED");
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// @test-skip
2+
import { Pool } from "chadscript/postgres";
3+
4+
const pool = new Pool("host=127.0.0.1 port=5432 user=postgres password=test dbname=chadtest");
5+
6+
pool.query("DROP TABLE IF EXISTS t_pool");
7+
pool.query("CREATE TABLE t_pool (id INT, name TEXT)");
8+
pool.query("INSERT INTO t_pool VALUES (1, 'alice'), (2, 'bob')");
9+
10+
const res = pool.query("SELECT id, name FROM t_pool ORDER BY id");
11+
if (res.rowCount !== 2) {
12+
console.log("TEST_FAILED: rowCount was " + res.rowCount);
13+
process.exit(1);
14+
}
15+
const name0 = res.getValue(0, "name");
16+
if (name0 !== "alice") {
17+
console.log("TEST_FAILED: name0 was " + name0);
18+
process.exit(1);
19+
}
20+
21+
pool.query("DROP TABLE t_pool");
22+
pool.end();
23+
console.log("TEST_PASSED");

0 commit comments

Comments
 (0)