feat(sql): add prepared statements primitive#444
Draft
SevInf wants to merge 7 commits into
Draft
Conversation
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).
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
@prisma-next/mongo-runtime
@prisma-next/family-mongo
@prisma-next/sql-runtime
@prisma-next/family-sql
@prisma-next/extension-arktype-json
@prisma-next/middleware-telemetry
@prisma-next/mongo
@prisma-next/extension-paradedb
@prisma-next/extension-pgvector
@prisma-next/postgres
@prisma-next/sql-orm-client
@prisma-next/sqlite
@prisma-next/target-mongo
@prisma-next/adapter-mongo
@prisma-next/driver-mongo
@prisma-next/contract
@prisma-next/utils
@prisma-next/config
@prisma-next/errors
@prisma-next/framework-components
@prisma-next/operations
@prisma-next/ts-render
@prisma-next/contract-authoring
@prisma-next/ids
@prisma-next/psl-parser
@prisma-next/psl-printer
@prisma-next/cli
@prisma-next/emitter
@prisma-next/migration-tools
prisma-next
@prisma-next/vite-plugin-contract-emit
@prisma-next/mongo-codec
@prisma-next/mongo-contract
@prisma-next/mongo-value
@prisma-next/mongo-contract-psl
@prisma-next/mongo-contract-ts
@prisma-next/mongo-emitter
@prisma-next/mongo-schema-ir
@prisma-next/mongo-query-ast
@prisma-next/mongo-orm
@prisma-next/mongo-query-builder
@prisma-next/mongo-lowering
@prisma-next/mongo-wire
@prisma-next/sql-contract
@prisma-next/sql-errors
@prisma-next/sql-operations
@prisma-next/sql-schema-ir
@prisma-next/sql-contract-psl
@prisma-next/sql-contract-ts
@prisma-next/sql-contract-emitter
@prisma-next/sql-lane-query-builder
@prisma-next/sql-relational-core
@prisma-next/sql-builder
@prisma-next/target-postgres
@prisma-next/target-sqlite
@prisma-next/adapter-postgres
@prisma-next/adapter-sqlite
@prisma-next/driver-postgres
@prisma-next/driver-sqlite
commit: |
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 (PostgresparsedStatementskeeps one named plan per Client). The samePreparedStatementis reusable across runtime, connection, and transaction scopes — produced once, redirected at execute time.Change map
RuntimeQueryablegainsexecutePrepared;preparelowers once and freezes; transaction and connection scopes route through their ownSqlQueryable.pg-cursorsubclass that overrides onlysubmitto send Parse with a name and Bind referencing the named statement.The story
The runtime gains
prepare(declaration, callback). The callback receives bind-siteparamsproxies and returns a plan via.build(). The runtime invokes thebeforeCompilemiddleware chain on the AST exactly once, lowers via the adapter, and freezes{sql, ast, meta, slots}onto aPreparedStatement. No driver I/O.Driver SPI gets
executePrepared({ sql, params, handle }). The driver never sees thePreparedStatementobject — only the lowered SQL, encoded params, and an opaque getter/setter slot. The runtime persists the slot's value in a per-runtimeWeakMap, so the handle is GC'd alongside the user'sPreparedStatementreference.Postgres reuses pg's per-Client
parsedStatementscache. A driver-scoped allocator mintspn_<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.Streaming uses a
pg-cursorsubclass.NamedCursorextendsCursorand overrides onlysubmit— Parse withname, Bind referencing the named statement, then the inherited portal/PortalSuspended/Sync lifecycle handles batched streaming unchanged. The execute path through the driver shares onerunQuery(name?)helper with the ad-hocexecute()path.Stale-handle errors retry once with a fresh name. SQLSTATE
26000(deallocated name, e.g. afterDEALLOCATE ALL) and0A000("cached plan must not change result type" after DDL) are both healed by minting a newpn_<n>and retrying. The same retry layer wraps both the streaming and buffered paths so the behaviour is identical regardless ofcursor.disabled.PreparedStatement.execute(target, params)always names its scope. Thetargetis aRuntimeQueryable(Runtime, RuntimeConnection, RuntimeTransaction, or TransactionContext). Each scope implementsexecutePreparedand routes through theSqlQueryableit 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 aPreparedStatement<Params, Row>. Lowers exactly once; no driver I/O. ThrowsRUNTIME.PREPARE_UNUSED_PARAMwhen a declared param is unreferenced by the callback's plan.Adds
SqlQueryable.executePrepared({ sql, params, handle })to the driver SPI. The runtime never hands thePreparedStatementto the driver. The handle slot is opaque to the runtime, populated lazily on first execute.relational-coreAdds Postgres-backed streaming for named prepared statements.
NamedCursorextendspg-cursorand overrides onlysubmitso Parse carries a name (and is skipped on subsequent submits whenconnection.parsedStatementsalready 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.withStaleHandleRetry,isStalePreparedStatementError)DEALLOCATE ALLbetween executes (26000), column-type swap withSELECT *(0A000)Adds
preparedStatements: falsedriver opt-out. Skips server-side prepare entirely; routes through the same cursor/buffered path as ad-hoc execute. Lowering reuse on thePreparedStatementis the only win retained, intended for transaction-mode poolers.PreparedStatement.execute(target, params)requires an explicit target. EachRuntimeQueryablescope (Runtime, RuntimeConnection, RuntimeTransaction, TransactionContext) implementsexecutePreparedand routes through its ownSqlQueryable. The prepared statement is pure data — no runtime captured at construction.RuntimeQueryable,wrapTransaction,withTransaction'stxContext), prepared-statement.tswithTransaction(...)sees the tx's uncommitted writes; the same PS run against the runtime after commit sees the committed row.Compatibility / migration / risk
ps.execute(target, params)is new — the previous M2 surfaceps.execute(params)was never released, so there is no migration burden for external callers. Two example queries underexamples/and existing E2E tests were updated.@types/pg-cursorinternals dependency.NamedCursorreads/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'sConnection(set byClient, notConnection). The cast is local and one field deep.Follow-ups / open questions
insert({...})andupdate({...})acceptTInput, notExpression. A prepared INSERT that takesparams.emailfrom the prepare callback doesn't typecheck today. Out of scope for this PR; tracked separately.Map<number, StatementSync>is not landed in this PR.Non-goals / intentionally out of scope
PreparedStatementreference plus the connection.dispose()/DEALLOCATE. Server-side state is bounded by(distinct PreparedStatements) × (connections that have executed each one)and self-heals on connection recycle.PREPAREat pool init. First execute per connection pays the Parse cost.