From 25b284c9d85d53ba0f6a64f61e50328421bc4b24 Mon Sep 17 00:00:00 2001
From: "carpentry-heartbeat[bot]"
Date: Sat, 4 Jul 2026 14:47:31 +0200
Subject: [PATCH 1/2] Add with-prepared and params macros for prepared
statements
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
with-prepared prepares a statement, binds it, runs the body, and
finalizes it on every exit path — mirroring with-transaction: the body
yields a Result and the macro returns it flattened, or the prepare error.
params wraps each argument in to-sqlite3 (pre-built Type values like Null
and Blob pass through via a new Type identity impl), collapsing the
&[(to-sqlite3 x) ...] boilerplate.
Addresses #8.
---
README.md | 23 ++++++++++++++
sqlite3.carp | 52 +++++++++++++++++++++++++++++++-
test/memory.carp | 17 +++++++++++
test/sqlite3.carp | 76 ++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 166 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 8d457bb..eb01686 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,29 @@ databases.
Because `open` and `query` return `Result` types, we could also use
combinators!
+### Prepared statements
+
+For a statement you run many times, prepare it once and reuse it. The
+`with-prepared` macro handles the whole lifecycle: it prepares the statement,
+binds it for the body, and finalizes it on every exit path — even when the body
+short-circuits. The `params` macro builds the parameter array so you don't have
+to wrap each value in `to-sqlite3` by hand.
+
+```clojure
+(let-do [db (Result.unsafe-from-success (SQLite3.open "db"))]
+ (ignore
+ (SQLite3.with-prepared [stmt &db "INSERT INTO mytable VALUES (?1, ?2);"]
+ (do
+ (for [i 0 100]
+ (ignore (SQLite3.exec-prepared &stmt (SQLite3.params i @"row"))))
+ (Result.Success ()))))
+ (SQLite3.close db))
+```
+
+The body must evaluate to a `Result` (like [`with-transaction`](#usage)):
+`with-prepared` returns the prepare error if the statement can't be prepared,
+otherwise the `Result` the body produced.
+
For more information, check out [the
documentation](https://veitheller.de/sqlite3)!
diff --git a/sqlite3.carp b/sqlite3.carp
index 7d62f4c..84abd7f 100644
--- a/sqlite3.carp
+++ b/sqlite3.carp
@@ -304,7 +304,48 @@ or DELETE.")
(list 'SQLite3.rollback db))
(list 'Result.Error '__wtx_cerr))
(list 'Result.Success '_)
- (list 'Result.Success '__wtx_val))))))))
+ (list 'Result.Success '__wtx_val)))))))
+
+ (doc with-prepared "prepares `sql` on the database `db`, binds the resulting
+statement to `stmt`, evaluates `body`, and finalizes the statement on every
+exit path — including when `body` short-circuits.
+
+Like the body of [with-transaction](#with-transaction), `body` must evaluate to
+a `Result`. The whole form returns the prepare error if the statement can’t be
+prepared, otherwise the `Result` that `body` produced.
+
+```
+(SQLite3.with-prepared [stmt &db \"INSERT INTO t VALUES (?1, ?2)\"]
+ (do
+ (for [i 0 100]
+ (ignore (SQLite3.exec-prepared &stmt (SQLite3.params i @\"row\"))))
+ (Result.Success ())))
+```")
+ (defmacro with-prepared [binding body]
+ (let [stmt (car binding)
+ db (cadr binding)
+ sql (car (cddr binding))]
+ (list 'match
+ (list 'SQLite3.prepare db sql)
+ (list 'Result.Error '__wp_err)
+ (list 'Result.Error '__wp_err)
+ (list 'Result.Success stmt)
+ (list 'let-do
+ (array '__wp_res body)
+ (list 'SQLite3.finalize-stmt stmt)
+ '__wp_res))))
+
+ (doc params "wraps each argument in `to-sqlite3` and returns a reference to
+the resulting parameter array, ready to hand to [query](#query) or
+[exec-prepared](#exec-prepared).
+
+`(SQLite3.params id @\"name\")` expands to
+`&[(to-sqlite3 id) (to-sqlite3 @\"name\")]`, removing the per-argument
+`to-sqlite3` ceremony. Values that are already a `SQLite3.Type` — such as
+`(SQLite3.Type.Null)` or a `Blob` — pass straight through, so they can be mixed
+in with primitives.")
+ (defmacro params [:rest args]
+ (list 'ref (collect-into (map (fn [a] (list 'to-sqlite3 a)) args) array))))
(definterface to-sqlite3 (Fn [a] SQLite3.Type))
@@ -331,3 +372,12 @@ or DELETE.")
(defmodule String
(defn to-sqlite3 [s] (SQLite3.Type.Text s))
(implements to-sqlite3 String.to-sqlite3))
+
+(defmodule SQLite3
+ (defmodule Type
+ (doc to-sqlite3 "is the identity on values that are already a
+`SQLite3.Type`, so pre-built `Null` and `Blob` values flow through
+[params](#params) next to primitives.")
+ (sig to-sqlite3 (Fn [SQLite3.Type] SQLite3.Type))
+ (defn to-sqlite3 [t] t)
+ (implements to-sqlite3 SQLite3.Type.to-sqlite3)))
diff --git a/test/memory.carp b/test/memory.carp
index b1eba47..4b35c25 100644
--- a/test/memory.carp
+++ b/test/memory.carp
@@ -46,6 +46,23 @@
(ignore (exec-prepared &stmt &[(to-sqlite3 1)]))
(finalize-stmt stmt))
(Result.Error _) ())
+ ; with-prepared must finalize on the success path...
+ (ignore
+ (with-prepared
+ [stmt &db "INSERT INTO t VALUES (?1, ?2, ?3);"]
+ (do
+ (for [i 0 20]
+ (ignore
+ (exec-prepared &stmt
+ (params i
+ @"with-prepared text"
+ (SQLite3.Type.Blob [5b 6b 0b 7b])))))
+ (Result.Success ()))))
+ ; ...and when the body short-circuits with an error.
+ (ignore
+ (with-prepared
+ [stmt &db "INSERT INTO t VALUES (?1, ?2, ?3);"]
+ (the (Result () String) (Result.Error @"body short-circuited"))))
(close db))))
(deftest test
diff --git a/test/sqlite3.carp b/test/sqlite3.carp
index 105e129..0352466 100644
--- a/test/sqlite3.carp
+++ b/test/sqlite3.carp
@@ -434,4 +434,78 @@
(SQLite3.finalize-stmt stmt)
(let-do [r (SQLite3.last-insert-rowid &db)] (SQLite3.close db) r))
(Result.Error _) (do (SQLite3.close db) 0l)))
- "last-insert-rowid works with prepared inserts"))
+ "last-insert-rowid works with prepared inserts")
+
+ (assert-equal test
+ &(Result.Success [[(SQLite3.Type.Integer 7l) (SQLite3.Type.Text @"carp")]])
+ &(let [db (open-memory)]
+ (let-do [r (do
+ (ignore
+ (SQLite3.query &db "CREATE TABLE t (i INT, s TEXT);" &[]))
+ (ignore
+ (SQLite3.query &db
+ "INSERT INTO t VALUES (?1, ?2);"
+ (SQLite3.params 7 @"carp")))
+ (SQLite3.query &db "SELECT * FROM t;" &[]))]
+ (SQLite3.close db)
+ r))
+ "params builds a working parameter array for query")
+
+ (assert-equal test
+ &(Result.Success [[(SQLite3.Type.Integer 3l)]])
+ &(let [db (open-memory)]
+ (let-do [r (do
+ (ignore (SQLite3.query &db "CREATE TABLE t (i INT);" &[]))
+ (ignore
+ (SQLite3.with-prepared [stmt &db "INSERT INTO t VALUES (?1);"]
+ (do
+ (ignore
+ (SQLite3.exec-prepared &stmt
+ (SQLite3.params 1)))
+ (ignore
+ (SQLite3.exec-prepared &stmt
+ (SQLite3.params 2)))
+ (ignore
+ (SQLite3.exec-prepared &stmt
+ (SQLite3.params 3)))
+ (Result.Success ()))))
+ (SQLite3.query &db "SELECT COUNT(*) FROM t;" &[]))]
+ (SQLite3.close db)
+ r))
+ "with-prepared runs a reusable insert and finalizes")
+
+ (assert-true test
+ (let [db (open-memory)]
+ (let-do [r (SQLite3.with-prepared [stmt &db "NOT VALID SQL"]
+ (Result.Success ()))]
+ (SQLite3.close db)
+ (Result.error? &r)))
+ "with-prepared surfaces the prepare error for invalid SQL")
+
+ (assert-equal test
+ &(Result.Success [[(SQLite3.Type.Integer 2l)]])
+ &(let [db (open-memory)]
+ (let-do [r (do
+ (ignore (SQLite3.query &db "CREATE TABLE t (i INT);" &[]))
+ (ignore (SQLite3.query &db "INSERT INTO t VALUES (2);" &[]))
+ (SQLite3.with-prepared
+ [stmt &db "SELECT * FROM t WHERE i = ?1;"]
+ (SQLite3.exec-prepared &stmt (SQLite3.params 2))))]
+ (SQLite3.close db)
+ r))
+ "with-prepared returns the body Result without extra nesting")
+
+ (assert-equal test
+ &(Result.Success [[(SQLite3.Type.Null) (SQLite3.Type.Blob [1b 2b])]])
+ &(let [db (open-memory)]
+ (let-do [r (do
+ (ignore (SQLite3.query &db "CREATE TABLE t (a, b);" &[]))
+ (ignore
+ (SQLite3.query &db
+ "INSERT INTO t VALUES (?1, ?2);"
+ (SQLite3.params (SQLite3.Type.Null)
+ (SQLite3.Type.Blob [1b 2b]))))
+ (SQLite3.query &db "SELECT * FROM t;" &[]))]
+ (SQLite3.close db)
+ r))
+ "params passes pre-built Type values (Null, Blob) through unchanged"))
From 30e7099ddb84a2c7086ef5d1536af9ba0a63201a Mon Sep 17 00:00:00 2001
From: "carpentry-heartbeat[bot]"
Date: Sat, 4 Jul 2026 19:50:36 +0200
Subject: [PATCH 2/2] Complete the prepared-statement docs
PR #11 added the prepared-statement API (Stmt, prepare, exec-prepared,
finalize-stmt, reset-stmt, with-prepared, params, and Type.to-sqlite3)
but did not regenerate the HTML docs, so docs/ still described only the
pre-existing surface. Regenerate them, and while here:
- make the README example self-contained by creating the table it
inserts into, so it is copy-pasteable on its own;
- note in both the README and the with-prepared doc string that a failed
prepare and an error returned by the body are both Result.Error String
and so cannot be told apart by type (matching with-transaction).
---
README.md | 5 +-
docs/SQLite3.Type.html | 22 +++++
docs/SQLite3.html | 179 ++++++++++++++++++++++++++++++++++++++---
sqlite3.carp | 4 +-
4 files changed, 198 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index eb01686..ddd3be8 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ to wrap each value in `to-sqlite3` by hand.
```clojure
(let-do [db (Result.unsafe-from-success (SQLite3.open "db"))]
+ (ignore (SQLite3.query &db "CREATE TABLE mytable (i INT, s TEXT);" &[]))
(ignore
(SQLite3.with-prepared [stmt &db "INSERT INTO mytable VALUES (?1, ?2);"]
(do
@@ -52,7 +53,9 @@ to wrap each value in `to-sqlite3` by hand.
The body must evaluate to a `Result` (like [`with-transaction`](#usage)):
`with-prepared` returns the prepare error if the statement can't be prepared,
-otherwise the `Result` the body produced.
+otherwise the `Result` the body produced. A failed prepare and an error the
+body returns both surface as `Result.Error String`, so the caller can't tell
+them apart by type — the same tradeoff as `with-transaction`.
For more information, check out [the
documentation](https://veitheller.de/sqlite3)!
diff --git a/docs/SQLite3.Type.html b/docs/SQLite3.Type.html
index a1ef1a3..8abd8ef 100644
--- a/docs/SQLite3.Type.html
+++ b/docs/SQLite3.Type.html
@@ -298,6 +298,28 @@
+
+
+
+ to-sqlite3
+
+
+
+ defn
+
+
+ (Fn [SQLite3.Type] SQLite3.Type)
+
+
+ (to-sqlite3 t)
+
+
+
is the identity on values that are already a
+SQLite3.Type, so pre-built Null and Blob values flow through
+params next to primitives.
+
+
+
+
+
+
+
+ exec-prepared
+
+
+
+ defn
+
+
+ (Fn [(Ref Stmt a), (Ref (Array SQLite3.Type) b)] (Result (Array (Array SQLite3.Type)) String))
+
+
+ (exec-prepared stmt p)
+
+
+
executes a prepared statement with the given parameters.
+Automatically resets the statement afterward for reuse.
+
+
+
+
+
+
+ finalize-stmt
+
+
+
+ external
+
+
+ (Fn [Stmt] ())
+
+
+
+
+
+
releases a prepared statement’s resources. Must not be
+used afterward.
+
+
+
+
+
+
+ params
+
+
+
+ macro
+
+
+ Macro
+
+
+ (params :rest args)
+
+
+
wraps each argument in to-sqlite3 and returns a reference to
+the resulting parameter array, ready to hand to query or
+exec-prepared.
+
(SQLite3.params id @"name") expands to
+&[(to-sqlite3 id) (to-sqlite3 @"name")], removing the per-argument
+to-sqlite3 ceremony. Values that are already a SQLite3.Type — such as
+(SQLite3.Type.Null) or a Blob — pass straight through, so they can be mixed
+in with primitives.
+
+
+
+
+
+
+ prepare
+
+
+
+ defn
+
+
+ (Fn [(Ref SQLite a), (Ref String b)] (Result Stmt String))
+
+
+ (prepare db sql)
+
+
+
prepares a SQL statement for repeated execution via
+exec-prepared. Release with finalize-stmt
+when done.
+
+
+
+
+
+
+ reset-stmt
+
+
+
+ external
+
+
+ (Fn [(Ref Stmt a)] ())
+
+
+
+
+
+
manually resets a prepared statement and clears its bindings.
+Called automatically by exec-prepared.
+
+
+
+
+
+
+ with-prepared
+
+
+
+ macro
+
+
+ Macro
+
+
+ (with-prepared binding body)
+
+
+
prepares sql on the database db, binds the resulting
+statement to stmt, evaluates body, and finalizes the statement on every
+exit path — including when body short-circuits.
+
Like the body of with-transaction, body must evaluate to
+a Result. The whole form returns the prepare error if the statement can’t be
+prepared, otherwise the Result that body produced. A failed prepare and an
+error the body returns are both Result.Error String, so a caller can’t tell
+them apart by type.
+
(SQLite3.with-prepared [stmt &db "INSERT INTO t VALUES (?1, ?2)"]
+ (do
+ (for [i 0 100]
+ (ignore (SQLite3.exec-prepared &stmt (SQLite3.params i @"row"))))
+ (Result.Success ())))
+
+
+
+
diff --git a/sqlite3.carp b/sqlite3.carp
index 84abd7f..701c959 100644
--- a/sqlite3.carp
+++ b/sqlite3.carp
@@ -312,7 +312,9 @@ exit path — including when `body` short-circuits.
Like the body of [with-transaction](#with-transaction), `body` must evaluate to
a `Result`. The whole form returns the prepare error if the statement can’t be
-prepared, otherwise the `Result` that `body` produced.
+prepared, otherwise the `Result` that `body` produced. A failed prepare and an
+error the body returns are both `Result.Error String`, so a caller can’t tell
+them apart by type.
```
(SQLite3.with-prepared [stmt &db \"INSERT INTO t VALUES (?1, ?2)\"]