Queries & parameters
Running a query means two things: binding parameters into the SQL, and reading the rows back out. jed gives each language an ergonomic layer for both — and deliberately not the same shape in every language. Each core adopts its ecosystem’s de facto embedded-SQL idiom, so the code feels native rather than translated:
- Rust — rusqlite’s traits:
run,query_row,query_map, withToValue/FromValuedoing the conversions androw.get::<T>(…)reading a typed column. - Go —
database/sql/ pgx:Exec,Query,QueryRowtaking...anyargs, withScan(&dest)and struct mapping. - TypeScript — better-sqlite3:
db.prepare(sql)returns aStatementwithrun/get/all/iterate, and rows come back as plain objects.
Use the language selector in the top bar to switch this example between the three.
use jed::Database;
fn main() -> jed::Result<()> {
let mut db = Database::open("app.jed")?;
// run() binds native params — a tuple here — to $1, $2, … and returns the affected-row count.
// No hand-built Value::Int / Value::Text: the rusqlite-style ToValue/Params traits do the
// conversion (and a raw &[Value] still works — it implements Params too).
let affected = db.run(
"INSERT INTO account (id, name, balance) VALUES ($1, $2, $3)",
(1, "Ada", 100_i64),
)?;
println!("inserted {affected} row");
// query_row maps the FIRST row through a closure, returning Option<T> (None when nothing
// matched). row.get::<T>(i) converts column i to a native type (FromValue); Option<T> is the
// only target that accepts SQL NULL — a bare scalar rejects it with 22004.
let balance: Option<i64> =
db.query_row("SELECT balance FROM account WHERE id = $1", (1,), |row| row.get(0))?;
println!("balance = {balance:?}");
// query_map maps every row; read columns by index or by name.
let names: Vec<String> = db.query_map("SELECT name FROM account ORDER BY id", (), |row| {
row.get_by_name("name")
})?;
println!("{} account(s): {names:?}", names.len());
Ok(())
}
package main
import (
"context"
"fmt"
"log"
jed "github.com/jackc/jed/impl/go"
)
// Account maps a result row by column name (the `db:"…"` tags), for RowToStructByName below.
type Account struct {
ID int64 `db:"id"`
Name string `db:"name"`
}
func main() {
db, err := jed.OpenDatabase("app.jed")
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
// Exec binds native Go args to $1, $2, … and returns a command tag. No hand-built Value — the
// pgx-style conversion handles int/string/[]byte/time.Time/…; the context.Context can cancel a
// long-running statement with 57014.
if _, err := db.Exec(ctx,
"INSERT INTO account (id, name, balance) VALUES ($1, $2, $3)",
1, "Ada", int64(100)); err != nil {
log.Fatal(err)
}
// QueryRow + Scan reads one row into typed destinations (database/sql's shape). It returns
// jed.ErrNoRows when empty; a *jed.Null[T] (or *any) destination accepts a NULL column.
var balance int64
if err := db.QueryRow(ctx, "SELECT balance FROM account WHERE id = $1", 1).Scan(&balance); err != nil {
log.Fatal(err)
}
fmt.Printf("balance = %d\n", balance)
// Query + the Collect iterator (Go 1.23+) maps each row to a struct by column name and ranges
// them; the cursor closes on loop exit, and a stream error surfaces as the loop's err.
rows, err := db.Query(ctx, "SELECT id, name FROM account ORDER BY id")
if err != nil {
log.Fatal(err)
}
for acct, err := range jed.Collect(rows, jed.RowToStructByName[Account]) {
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d: %s\n", acct.ID, acct.Name)
}
}
import { openDatabase } from 'jed-ts';
const db = openDatabase('app.jed');
// run() binds native JS params to $1, $2, … and returns a command tag ({ changes, cost }). Values
// convert automatically: an integer-valued number → int, a bigint → int, a string → text, a
// Uint8Array → bytea, null → NULL. No hand-built Value.
const { changes } = db.run(
'INSERT INTO account (id, name, balance) VALUES ($1, $2, $3)',
1,
'Ada',
100
);
console.log(`inserted ${changes} row`);
// get() returns the first row as a plain object keyed by output column name (or undefined). Result
// values map int → bigint (i64 is exact — jed's identity), the other scalars to their JS type.
const row = db.get('SELECT balance FROM account WHERE id = $1', 1);
console.log(`balance = ${row?.balance}`); // 100n — a bigint
// prepare() returns a reusable Statement; all() materializes every row, *iterate() yields lazily.
const stmt = db.prepare('SELECT id, name FROM account ORDER BY id');
for (const acct of stmt.iterate()) {
console.log(`${acct.id}: ${acct.name}`);
}
db.close();
Binding parameters
Parameters are positional $1, $2, … placeholders, bound left to right from the values you pass.
You pass native values, not engine Values — the ergonomic layer converts them: integers,
floats, booleans, strings, byte arrays, and NULL all map across. This keeps user data out of the
SQL string, so there is no string-interpolation injection surface.
A note on integers, because it is the one place the type systems differ. jed integers are 64-bit and exact. In Rust and Go that is the natural integer type. In TypeScript a number is a float, so
jed uses bigint for integer values — an integer-valued number like 1 still binds as an
integer (you write run(1), not run(1n)), but values read back come as bigint so a large i64 never loses precision.
Reading rows
How a row arrives depends on the idiom:
- Rust hands each row to a closure as a
Row; callrow.get::<T>(index)orrow.get_by_name::<T>(name)to pull a typed column.query_rowreturnsOption<T>(Nonewhen nothing matched);query_mapmaps every row. - Go scans columns into pointers with
Scan(&a, &b), or maps a whole row into a struct by column name withRowToStructByName.QueryRow(...).Scan(...)returnsjed.ErrNoRowson an empty result. - TypeScript returns each row as an object keyed by output column name —
getgives the first (orundefined),allgives an array,iterateyields them lazily.
NULL
SQL NULL needs an explicit home, so it can’t silently become a zero. Each layer has a nullable
target: Rust’s Option<T> (a bare T rejects NULL with 22004), Go’s *jed.Null[T] (or *any), and TypeScript’s null in the result object. A column you expect to be nullable should
be read into one of these.
The raw Value path is still there
These ergonomic methods are additive — a thin, idiomatic layer over the lower-level path that
speaks jed Values directly. Rust and TypeScript expose that path as dedicated methods: execute / query taking &[Value] in Rust and Value[] in TypeScript. Go needs no separate method — a raw jed.Value passes straight through the same variadic Query / Exec / QueryRow args (mixed freely
with native Go values), and Rows.Value(i) reads a column back as its engine Value. Either way the
raw path stays available for full fidelity: a rich type with no clean native counterpart in your
language (a range, a jsonb, a composite) round-trips losslessly as a Value, where the ergonomic
layer renders it to its canonical text. Reach for the raw Value when you need the engine value
itself; reach for the native ergonomic form — the recommended default — for everything else.
Prepared statements
For a hot statement — a point lookup run thousands of times — jed offers an explicit prepared
statement: the SQL is parsed once, and its resolved query plan is cached and reused across
executes (planning dominates the latency of a trivial lookup, so this is the single biggest win for
high-frequency queries). A prepared statement is a standalone value bound to no session: you run
it by handing it to whichever handle should execute it — a Database, a durable Session, or an
open Transaction — and that handle supplies the privileges, snapshot, and transaction state the
run observes. Prepare once at startup, run it from every request:
- Rust —
db.prepare(sql)→session.query_prepared(&stmt, ¶ms)/session.execute_prepared(&stmt, ¶ms)(also onDatabaseandTransaction). - Go —
db.Prepare(sql)→session.QueryPrepared(ctx, stmt, args...)/ExecPrepared/QueryRowPrepared(also onDatabaseandTransaction; the statement is safe to share across goroutines, each running it on its own session). - TypeScript —
db.prepareStatement(sql)→session.queryPrepared(stmt, params)/executePrepared(stmt, params)(also onDatabaseandTransaction). The better-sqlite3-styledb.prepare(sql)Statementabove uses the same machinery internally — its plan is cached too.
The cache is transparent and always correct: a schema change (CREATE/DROP/ALTER) invalidates
it and the next execute re-plans; reusing a cached plan is by construction result- and
cost-identical to planning fresh, so a prepared statement never changes what a query returns — only
how fast it gets there.