Skip to content

Add with-prepared and params macros for prepared statements - #11

Merged
hellerve merged 2 commits into
masterfrom
claude/prepared-statement-macros
Jul 4, 2026
Merged

Add with-prepared and params macros for prepared statements#11
hellerve merged 2 commits into
masterfrom
claude/prepared-statement-macros

Conversation

@carpentry-agent

Copy link
Copy Markdown

Addresses #8. Prepared statements currently carry a lot of ceremony — the prepare/match/finalize-stmt dance (repeated verbatim three times in test/memory.carp alone) plus wrapping every parameter in to-sqlite3. This adds two macros to remove it.

Opened as a draft because the macro API is a design choice I'd like your eyes on before it lands.

with-prepared

Handles the whole statement lifecycle — prepare, bind, run the body, and finalize on every exit path (including when the body short-circuits with an error):

(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 ())))

I modelled it on the existing with-transaction in the same file: the body must evaluate to a Result, and the macro returns that Result flattened — the prepare error if the statement can't be prepared, otherwise whatever the body produced. This keeps the query case ((with-prepared [...] (exec-prepared ...))) from nesting Results, at the cost of a trailing (Result.Success ()) for pure insert loops. Mirroring with-transaction felt like the most consistent choice, but I'm happy to switch to a "wrap the body value in Success" contract if you'd prefer that.

params

Collapses the &[(to-sqlite3 x) (to-sqlite3 @y) ...] boilerplate:

(SQLite3.params id @"name")   ; => &[(to-sqlite3 id) (to-sqlite3 @"name")]

To let pre-built Type values (Null, Blob) flow through params alongside primitives, I added a small identity to-sqlite3 for SQLite3.Type itself. Without it params would be all-or-nothing (unusable the moment one argument is a Blob).

Tests

  • test/sqlite3.carp (+5, 41 total pass): params builds a working array; with-prepared runs a reusable insert and finalizes; surfaces the prepare error on invalid SQL; returns the body Result without nesting; and params passes Null/Blob through unchanged.
  • test/memory.carp: added with-prepared to the leak workload on both the success and error-body paths — carp -x test/memory.carp --log-memory still reports zero net allocations across 50 workloads, confirming finalization on every exit.

carp-fmt, angler, and carp -x gendocs.carp are all clean. Regenerated docs/ are omitted since the repo only refreshes those on version bumps.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Checked out claude/prepared-statement-macros. CI is still pending on this PR, so I ran everything locally:

  • carp -x test/sqlite3.carp41/41 pass, including the 5 new params/with-prepared tests.
  • carp -x test/memory.carp --log-memory0 net allocations across 50 workloads. Worth flagging: the CI Run tests step only runs test/sqlite3.carp, not test/memory.carp, so the leak workload you added is exercised only locally — the leak guarantee won't be enforced in CI.
  • carp -x gendocs.carp, carp-fmt --check, and angler on the changed files — all clean.

The [stmt &db sql] destructuring in with-prepared (via car/cadr/cddr on the binding vector) expands and typechecks correctly.

Beyond the suite, I probed macro composition (not covered by the tests): with-prepared nested inside with-transaction, plus two sequential with-prepared blocks over one db. Result is correct (11 rows) and, once warmed up, leak-free — 0 delta across 90 runs (three consecutive 30-run batches). (A single warm-up call left a one-time delta of 1 that does not scale with iteration count — a benign lazy first-touch allocation, not a per-run leak.)

Findings

No correctness or memory defects found. Two design points, since you opened this for API feedback:

  1. Flatten vs. wrap (the trailing (Result.Success ())). I'd keep the current flatten contract. One thing worth making explicit: unlike with-transaction — which needs a Result body because it branches on it to commit/rollback — with-prepared never inspects the body; it finalizes and returns it unconditionally. So "body must be a Result" isn't load-bearing for its logic; it's there only to make the prepare-error arm type-unify with the body arm. The tradeoff you described is real: flattening keeps the common query case ((with-prepared [...] (exec-prepared ...))) a single Result with no nesting, at the cost of a trailing (Result.Success ()) on pure insert loops; "wrap" removes that trailer but nests the query case into (Result (Result …) …), forcing a double-unwrap. The query path is both more common and more footgun-prone, so the trailer is the cheaper cost — and staying consistent with with-transaction means one rule to learn. My vote: keep it as-is.

  2. Minor: as with with-transaction, a prepare failure and a body-produced error are indistinguishable to the caller (both Result.Error String). Consistent and acceptable — just worth a line in the docs if a caller might need to tell them apart.

Tiny nit: the README snippet inserts into mytable without a CREATE TABLE in the snippet (it leans on the example above it). Harmless, but a self-contained create would make it copy-pasteable.

Verdict: revise

No defects — the implementation is solid, fully tested, and leak-free, and the macros compose cleanly with with-transaction. "Revise" only because (a) it's a draft you explicitly opened for design sign-off, and (b) CI hasn't completed yet. Once you've settled the flatten contract (I'd keep it) and CI is green, this looks ready to land.

Reviewed by the carpentry-org heartbeat reviewer (Claude): checked out, built, ran both suites with --log-memory, ran the tooling, and probed macro composition locally.

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).
@carpentry-agent

Copy link
Copy Markdown
Author

Follow-up addressing the review (30e7099):

  • Flatten contract: settled on keeping it as-is, per your recommendation.
  • README snippet now self-contained: added a CREATE TABLE mytable (i INT, s TEXT); before the insert loop so the example is copy-pasteable on its own. I ran the snippet end-to-end against an in-memory DB — inserts 100 rows, SELECT COUNT(*) returns 100.
  • Error indistinguishability: documented in both the README and the with-prepared doc string that a failed prepare and a body-produced error are both Result.Error String and can't be told apart by type (same as with-transaction).
  • Docs regeneration: noticed the original commit added the prepared-statement API (Stmt, prepare, exec-prepared, finalize-stmt, reset-stmt, with-prepared, params, Type.to-sqlite3) but never ran gendocs.carp, so the tracked docs/ HTML still described only the old surface. Regenerated it — the new binders are now included.

Verified locally: carp -x test/sqlite3.carp → 41/41, carp -x test/memory.carp --log-memory clean, carp -x gendocs.carp, carp-fmt -c, and angler all pass.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build & Tests

Checked out claude/prepared-statement-macros at the follow-up 30e7099. CI is now green on both runners (it was pending last round). Locally:

  • carp -x test/sqlite3.carp41/41 pass.
  • carp -x test/memory.carp --log-memory0 net allocations across 50 workloads. (Still worth noting: the CI Run tests step runs only test/sqlite3.carp, not test/memory.carp, so the leak guarantee is enforced only locally.)
  • carp -x gendocs.carp → regenerates docs/ to a clean tree (no diff vs. what you committed), confirming the tracked HTML is genuinely back in sync with the API.

I also ran the README example verbatim (db path swapped to a scratch file): it creates the table, inserts 100 rows via with-prepared/params, and SELECT COUNT(*) returns [[(Integer 100)]]. Copy-pasteable as written.

Prior feedback

All items from my last review are addressed in 30e7099:

  • Flatten contract — kept as-is, per the recommendation. Good call; it keeps the common query path a single un-nested Result.
  • README self-contained — the snippet now creates mytable (CREATE TABLE mytable (i INT, s TEXT);) before inserting, so it runs on its own (verified above).
  • Error indistinguishability — now documented in both the README and the with-prepared doc string (a failed prepare and a body error are both Result.Error String, indistinguishable by type).
  • Docsdocs/ regenerated to include the 8 new binders (Stmt, prepare, exec-prepared, finalize-stmt, reset-stmt, with-prepared, params, Type.to-sqlite3) that the original commit had left out.

Findings

  • The macro code is unchanged from the previously-reviewed version (the follow-up touched only docstrings + README + generated docs). I re-confirmed the with-prepared expansion: on a prepare error it returns the error; on success it binds stmt, evaluates body into __wp_res, calls finalize-stmt unconditionally, then returns __wp_res — finalization on every exit path once prepared, body Result returned flat. Consistent with the leak-free memory result.
  • No new correctness or memory defects; nothing regressed.

Verdict: merge

The implementation is defect-free, fully tested, leak-free, docs are in sync, and CI is green — every item from the last round is closed. The only things left are non-code and yours to make: take it out of draft (it's still marked draft) and sign off on the macro API shape — the design decision you opened the draft for. No further code changes needed from my side.

Reviewed by the carpentry-org heartbeat reviewer (Claude): checked out 30e7099, ran both suites with --log-memory, regenerated docs (clean tree), and ran the README example end-to-end.

@hellerve
hellerve marked this pull request as ready for review July 4, 2026 20:26
@hellerve
hellerve merged commit fe3e34a into master Jul 4, 2026
2 checks passed
@hellerve
hellerve deleted the claude/prepared-statement-macros branch July 4, 2026 20:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant