Skip to content

feat(sql): add prepared statements primitive#444

Draft
SevInf wants to merge 7 commits into
mainfrom
prepared-statements-impl
Draft

feat(sql): add prepared statements primitive#444
SevInf wants to merge 7 commits into
mainfrom
prepared-statements-impl

Conversation

@SevInf
Copy link
Copy Markdown
Contributor

@SevInf SevInf commented May 8, 2026

closes TML-2381 and TML-2382

Intent

Add a first-class prepared-statement primitive to the SQL DSL that opts users into both kinds of execution reuse: lowering reuse (the AST → SQL pass runs once per prepare) and server-side parse reuse (Postgres parsedStatements keeps one named plan per Client). The same PreparedStatement is reusable across runtime, connection, and transaction scopes — produced once, redirected at execute time.

Change map

The story

  1. The runtime gains prepare(declaration, callback). The callback receives bind-site params proxies and returns a plan via .build(). The runtime invokes the beforeCompile middleware chain on the AST exactly once, lowers via the adapter, and freezes {sql, ast, meta, slots} onto a PreparedStatement. No driver I/O.

  2. Driver SPI gets executePrepared({ sql, params, handle }). The driver never sees the PreparedStatement object — only the lowered SQL, encoded params, and an opaque getter/setter slot. The runtime persists the slot's value in a per-runtime WeakMap, so the handle is GC'd alongside the user's PreparedStatement reference.

  3. Postgres reuses pg's per-Client parsedStatements cache. A driver-scoped allocator mints pn_<n> names. First execute writes Parse with the name; subsequent executes on the same Client skip Parse. A different Client (a fresh pool acquisition) auto-Parses on first use.

  4. Streaming uses a pg-cursor subclass. NamedCursor extends Cursor and overrides only submit — Parse with name, Bind referencing the named statement, then the inherited portal/PortalSuspended/Sync lifecycle handles batched streaming unchanged. The execute path through the driver shares one runQuery(name?) helper with the ad-hoc execute() path.

  5. Stale-handle errors retry once with a fresh name. SQLSTATE 26000 (deallocated name, e.g. after DEALLOCATE ALL) and 0A000 ("cached plan must not change result type" after DDL) are both healed by minting a new pn_<n> and retrying. The same retry layer wraps both the streaming and buffered paths so the behaviour is identical regardless of cursor.disabled.

  6. PreparedStatement.execute(target, params) always names its scope. The target is a RuntimeQueryable (Runtime, RuntimeConnection, RuntimeTransaction, or TransactionContext). Each scope implements executePrepared and routes through the SqlQueryable it is backed by. The same prepared statement is reusable across scopes — prepare once, run against the runtime for one request and against an active transaction for another. There is no implicit binding back to the runtime that produced it.

Behavior changes & evidence

  • Adds runtime.prepare(declaration, callback) returning a PreparedStatement<Params, Row>. Lowers exactly once; no driver I/O. Throws RUNTIME.PREPARE_UNUSED_PARAM when a declared param is unreferenced by the callback's plan.

  • Adds SqlQueryable.executePrepared({ sql, params, handle }) to the driver SPI. The runtime never hands the PreparedStatement to the driver. The handle slot is opaque to the runtime, populated lazily on first execute.

  • Adds Postgres-backed streaming for named prepared statements. NamedCursor extends pg-cursor and overrides only submit so Parse carries a name (and is skipped on subsequent submits when connection.parsedStatements already records it). Bind references the named statement; PortalSuspended/Close/Sync are inherited.

  • Adds stale-handle retry on SQLSTATE 26000 and 0A000. A fresh pn_<n> is minted and the execute is retried exactly once. Any other SQLSTATE is re-surfaced unchanged. If a row was already yielded (mid-stream error), the retry is suppressed to avoid duplicate rows.

  • Adds preparedStatements: false driver opt-out. Skips server-side prepare entirely; routes through the same cursor/buffered path as ad-hoc execute. Lowering reuse on the PreparedStatement is the only win retained, intended for transaction-mode poolers.

  • PreparedStatement.execute(target, params) requires an explicit target. Each RuntimeQueryable scope (Runtime, RuntimeConnection, RuntimeTransaction, TransactionContext) implements executePrepared and routes through its own SqlQueryable. The prepared statement is pure data — no runtime captured at construction.

    • Why: a single PS reused across many transactions over time is the common workload; an implicit "the runtime that made me" binding silently couples scope and forces an awkward second API for the transaction case.
    • Implementation: sql-runtime.ts (RuntimeQueryable, wrapTransaction, withTransaction's txContext), prepared-statement.ts
    • Tests: runtime.prepared.test.ts — a single PS executed inside withTransaction(...) sees the tx's uncommitted writes; the same PS run against the runtime after commit sees the committed row.

Compatibility / migration / risk

  • API shape. ps.execute(target, params) is new — the previous M2 surface ps.execute(params) was never released, so there is no migration burden for external callers. Two example queries under examples/ and existing E2E tests were updated.
  • @types/pg-cursor internals dependency. NamedCursor reads/writes pg-cursor's runtime-only fields (_portal, _conf, _result, _ifNoData, _rowDescription). These have been stable since 2014 and are consumed by pg-cursor's own handlers, so the implicit contract is not new — but a future pg-cursor major could break it. The pg version is pinned via the workspace catalog.
  • connection.parsedStatements. Not declared on @types/pg's Connection (set by Client, not Connection). The cast is local and one field deep.
  • Stale-handle scope. Retry covers 26000 and 0A000 only. A pg error of any other class is re-surfaced unchanged, including a recurrence of 26000/0A000 on the retry attempt.

Follow-ups / open questions

  • Prepared INSERT/UPDATE with parameterised values. The SQL builder's insert({...}) and update({...}) accept TInput, not Expression. A prepared INSERT that takes params.email from the prepare callback doesn't typecheck today. Out of scope for this PR; tracked separately.
  • SQLite driver streaming named statements. The SQLite SPI scaffold is in place (M2) but server-side reuse via per-connection Map<number, StatementSync> is not landed in this PR.

Non-goals / intentionally out of scope

  • Global shape cache / cross-process state. Lifetime is the user's PreparedStatement reference plus the connection.
  • Explicit dispose() / DEALLOCATE. Server-side state is bounded by (distinct PreparedStatements) × (connections that have executed each one) and self-heals on connection recycle.
  • List/array parameter types. The framework has no list codecs yet.
  • Pre-warming PREPARE at pool init. First execute per connection pays the Parse cost.
  • Observability / tracing for prepared executions.

SevInf added 5 commits May 8, 2026 12:06
Land M2 of the prepared-statements project: runtime.prepare() primitive,
db.prepare() facade method on Postgres/SQLite clients, PreparedStatement
type, and the executePrepared driver SPI extension. Both drivers route
executePrepared through a one-shot parameterized-query path; M3/M4 will
swap in real server-side preparation behind the same SPI.

PreparedParamRef is a first-class AST node and LoweredStatement.params
is a LoweredParam discriminated union ({kind:literal,value} |
{kind:bind,name}). Renderers tag bind-site positions structurally,
replacing an earlier marker-in-value approach. CodecTypesBase moved to
relational-core/expression so prepare-time and DSL-time codec typing
share one source of truth, with CodecValue extracted from CodecExpression
for reuse in ParamsFromDeclaration.

executeAgainstQueryable and executePreparedAgainstQueryable share a
single streamExec helper; lifecycle (codec ctx, validatePlan,
verifyMarker, runWithMiddleware, iterator drive, telemetry) is no longer
duplicated.
Drop `driverHandle` from the public PreparedStatement interface and stop
storing it on PreparedStatementImpl. The runtime now keeps an opaque
WeakMap<PreparedStatement, unknown> and builds the driver-facing slot
wrapper inline at execute time, so the slot factory no longer leaks onto
the impl class. Entries GC with the user's reference to the statement.

Other cleanups in this pass:
- runtime.prepare now goes through `this.runBeforeCompile(userPlan)`
  instead of constructing a synthetic {ast, meta} draft, so a meta
  rewrite from the chain flows through to internals.meta.
- LoweredParam JSDoc spells out that the same `name` may legitimately
  appear in multiple bind slots (SQLite walks collectParamRefs without
  dedupe; resolution-by-name handles it).
- Lift the fixture-only `lit(...)` helper to @prisma-next/test-utils as
  litParams/bindParams (subpath ./lowered-params), and migrate the
  adapter-postgres + adapter-sqlite tests to import it.
- Tests that asserted on ps.driverHandle now verify the same invariants
  via spying on req.handle.get() across two executes.
…retry (TML-2382)

M3 of the SQL prepared-statements project. Replace the M2 fallback in
PostgresQueryable.executePrepared with a real server-side PREPARE+EXECUTE
path and add the preparedStatements: false escape hatch.

- Driver-scoped HandleAllocator mints pn_<n> identifiers; threading it
  through ConnectionOptions means a single PreparedStatement reused
  across one driver's Connections and Transactions sees one handle.
- runPrepared either falls through to client.query(sql, params) when
  preparedStatements is disabled, or sends client.query({ name, text,
  values }) and lets pg's per-Client parsedStatements cache de-dupe
  the wire-level Parse on repeat executes.
- Stale-handle retry catches SQLSTATE 26000 (DEALLOCATE/DISCARD ALL)
  and 0A000 ("cached plan must not change result type" after DDL).
  pg's parsedStatements still records the old name as parsed, so the
  retry must mint a fresh name to force pg to re-Parse on the same
  Client.
- Test mocks for the intercept and marker tests gain an executePrepared
  stub so they satisfy the SqlDriver interface that landed in M2; this
  unblocks the typecheck gate for the driver work.

Tests cover the unit-level call shape (mock pg.Client) and exercise the
real-PG end-to-end path (createDevDatabase) for handle reuse on the same
connection, cross-Client re-Parse, both retry SQLSTATEs, in-transaction
reuse, and the preparedStatements: false anonymous fallback. Verified
via pg_prepared_statements rather than peeking at pg internals.
… subclass (TML-2382)

Replace the buffered named-query path with a streaming NamedCursor that
subclasses pg-cursor and overrides only `submit` — the wire-level Parse
now carries a name (and is skipped on subsequent executes when pg's
per-Connection parsedStatements already records the name) and Bind
references the named statement. The rest of pg-cursor's lifecycle —
portal, batched Execute, PortalSuspended fan-out, error recovery via
Sync, deferred Close — is inherited unchanged.

Unify the prepared and ad-hoc execution paths through a shared runQuery
helper that takes an optional name. cursor.disabled now also opts out
of cursor mode for prepared executes, falling back to pg's buffered
named-query path. The 26000 / 0A000 retry layer wraps both paths via
withStaleHandleRetry, so a fresh handle is minted regardless of whether
streaming or buffered was in use.
…2381)

Move execution scope to a required first argument:

  ps.execute(runtime, params)
  ps.execute(tx, params)
  ps.execute(connection, params)

The PreparedStatement is now pure data: sql, ast, meta, slots. It no
longer closes over the runtime that produced it. RuntimeQueryable gains
executePrepared alongside execute; every scope (Runtime,
RuntimeConnection, RuntimeTransaction, TransactionContext) closes over
its own SqlQueryable and routes prepared executions through the
existing executePreparedAgainstQueryable helper. TransactionContext
applies the same invalidation guard to executePrepared as it already
does to execute, so a row stream produced inside a tx that has since
ended rejects with RUNTIME.TRANSACTION_CLOSED.

The same PreparedStatement now redirects across scopes — prepare once
at startup; run against the runtime for one request and against an
active transaction for another. Inside a tx, ps.execute(tx, params)
sees the transaction's uncommitted writes; a rollback discards them.

ADR 210 updated with the new signature and a section explaining the
explicit-target design choice. Two new e2e tests in
runtime.prepared.test.ts exercise ps.execute(tx, params) inside a
db.transaction callback, including the case where commit makes the
inserted row visible to a subsequent ps.execute(runtime, params).
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 5e770859-e6fe-4941-b3cd-c171b0a9e6c3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch prepared-statements-impl

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 8, 2026

Open in StackBlitz

@prisma-next/mongo-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-runtime@444

@prisma-next/family-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-mongo@444

@prisma-next/sql-runtime

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-runtime@444

@prisma-next/family-sql

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/family-sql@444

@prisma-next/extension-arktype-json

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-arktype-json@444

@prisma-next/middleware-telemetry

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/middleware-telemetry@444

@prisma-next/mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo@444

@prisma-next/extension-paradedb

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-paradedb@444

@prisma-next/extension-pgvector

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/extension-pgvector@444

@prisma-next/postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/postgres@444

@prisma-next/sql-orm-client

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-orm-client@444

@prisma-next/sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sqlite@444

@prisma-next/target-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-mongo@444

@prisma-next/adapter-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-mongo@444

@prisma-next/driver-mongo

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-mongo@444

@prisma-next/contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract@444

@prisma-next/utils

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/utils@444

@prisma-next/config

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/config@444

@prisma-next/errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/errors@444

@prisma-next/framework-components

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/framework-components@444

@prisma-next/operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/operations@444

@prisma-next/ts-render

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ts-render@444

@prisma-next/contract-authoring

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/contract-authoring@444

@prisma-next/ids

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/ids@444

@prisma-next/psl-parser

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-parser@444

@prisma-next/psl-printer

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/psl-printer@444

@prisma-next/cli

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/cli@444

@prisma-next/emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/emitter@444

@prisma-next/migration-tools

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/migration-tools@444

prisma-next

npm i https://pkg.pr.new/prisma/prisma-next@444

@prisma-next/vite-plugin-contract-emit

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/vite-plugin-contract-emit@444

@prisma-next/mongo-codec

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-codec@444

@prisma-next/mongo-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract@444

@prisma-next/mongo-value

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-value@444

@prisma-next/mongo-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-psl@444

@prisma-next/mongo-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-contract-ts@444

@prisma-next/mongo-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-emitter@444

@prisma-next/mongo-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-schema-ir@444

@prisma-next/mongo-query-ast

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-ast@444

@prisma-next/mongo-orm

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-orm@444

@prisma-next/mongo-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-query-builder@444

@prisma-next/mongo-lowering

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-lowering@444

@prisma-next/mongo-wire

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/mongo-wire@444

@prisma-next/sql-contract

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract@444

@prisma-next/sql-errors

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-errors@444

@prisma-next/sql-operations

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-operations@444

@prisma-next/sql-schema-ir

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-schema-ir@444

@prisma-next/sql-contract-psl

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-psl@444

@prisma-next/sql-contract-ts

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-ts@444

@prisma-next/sql-contract-emitter

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-contract-emitter@444

@prisma-next/sql-lane-query-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-lane-query-builder@444

@prisma-next/sql-relational-core

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-relational-core@444

@prisma-next/sql-builder

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/sql-builder@444

@prisma-next/target-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-postgres@444

@prisma-next/target-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/target-sqlite@444

@prisma-next/adapter-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-postgres@444

@prisma-next/adapter-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/adapter-sqlite@444

@prisma-next/driver-postgres

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-postgres@444

@prisma-next/driver-sqlite

npm i https://pkg.pr.new/prisma/prisma-next/@prisma-next/driver-sqlite@444

commit: 2730f11

SevInf added 2 commits May 8, 2026 21:31
…rface and freeze the impl

Promote `sql`, `ast`, `meta`, `slots` from private impl-only fields to readonly
public fields on the `PreparedStatement<Params, Row>` interface, and call
`Object.freeze(this)` in `PreparedStatementImpl` so consumers cannot mutate
them. The `driverHandle` continues to live in the runtime's per-runtime
`WeakMap` and is not part of the public surface.
Widen `WithPagination` so `limit()` / `offset()` accept either a literal
integer or any expression bound to a codec carrying the `numeric` trait.
The expression form is what enables prepared-statement bind sites at the
pagination position, so a single PreparedStatement can paginate with
`take` / `skip` parameters.

`SelectAst.limit` / `offset` widen to `number | AnyExpression`. The AST
walks (rewrite, collectColumnRefs, collectParamRefs) descend into the
expression form when present. Both renderers (Postgres + SQLite) route
through `renderExpr` for expression-form limit/offset; literal numbers
keep rendering as before. The budgets middleware treats expression-form
limit as bounded-but-unknown, falling back to the table-estimate cap.

E2E tests on Postgres and SQLite prepare a statement with `take` / `skip`
bind sites in `LIMIT` / `OFFSET`, paginate the seeded users in 2-row
pages, and re-bind with a wider `take` to prove per-execute resolution.
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