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.

+ +

+
+
+ +

+ Stmt +

+
+
+ meta-stub +
+

+ a +

+ + + +

+

is an opaque prepared statement type. Prepare once with +prepare, execute with exec-prepared, and +release with finalize-stmt.

+ +

+
+
+ +

+ 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)\"]