Skip to content

sql(sqlite): run every statement when a multi-statement query returns rows - #33582

Closed
robobun wants to merge 5 commits into
mainfrom
farm/dd819eeb/sqlite-multi-statement-rows
Closed

robobun wants to merge 5 commits into
mainfrom
farm/dd819eeb/sqlite-multi-statement-rows

Conversation

@robobun

@robobun robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.SQL's SQLite adapter silently dropped every statement after the first when the first statement returned rows. A multi-statement string like select count(*) from t; delete from t returned the count and never ran the DELETE, losing writes with no error.

const sql = new Bun.SQL(":memory:");
await sql`create table t(v)`;
await sql`insert into t values (1),(2),(3)`;

const r = await sql.unsafe("select count(*) as before from t; delete from t");
// r => [{ before: 3 }]
(await sql`select count(*) n from t`)[0].n; // => 3, expected 0: the DELETE never ran

This depends on statement order and is easy to hit with sql.file() running a migration or seed .sql file that starts with a SELECT version check. The PostgreSQL adapter runs the whole multi-statement text, so code ported to sqlite:// silently stops writing.

Cause

SQLiteQueryHandle.run() has two branches. Non-row queries go through db.run(), which executes every statement. Row-returning queries (SELECT/PRAGMA/WITH/EXPLAIN/RETURNING) went through db.prepare(sql), and sqlite3_prepare_v3 compiles only the first statement of the string, leaving the tail unexecuted and unreported.

Fix

The native prepare now captures sqlite3_prepare_v3's leftover-tail pointer and exposes the remaining SQL on the statement (remainingSQL), scanning past trailing whitespace and comments so the remainder always starts at a real statement and is empty when nothing is left. The adapter walks every statement in the string, executing each exactly once. Bindings apply to the first statement, matching db.run(). The first statement that returns rows provides the result set, so the example above still returns [{ before: 3 }] while the DELETE now runs.

Verification

New tests in test/js/sql/sqlite-sql.test.ts cover: row-returning first statement followed by a write, a write followed by a row-returning statement, RETURNING executing exactly once (no double-insert), parameter binding across a batch, trailing comments, and a syntax error in a later statement surfacing after earlier ones run. All fail on the current release build and pass with this change.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d9d1899e-0d87-45af-ab16-13cf2bfd2a59

📥 Commits

Reviewing files that changed from the base of the PR and between 332f744 and d09a2d0.

📒 Files selected for processing (4)
  • src/js/bun/sqlite.ts
  • src/js/internal/sql/sqlite.ts
  • src/jsc/bindings/sqlite/JSSQLStatement.cpp
  • test/js/sql/sqlite-sql.test.ts

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

@github-actions github-actions Bot added the claude label Jul 7, 2026
@robobun

robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:27 PM PT - Jul 8th, 2026

❌ @robobun, your commit d09a2d0 has 1 failures in Build #70549 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33582

That installs a local version of the PR into your bun-33582 executable, so you can run:

bun-33582 --bun

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun.SQL SQLite returns count 0 for INSERT ... SELECT without RETURNING #30811 - Bun.SQL SQLite returns count 0 for INSERT ... SELECT without RETURNING

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #30811

🤖 Generated with Claude Code

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, but I don't think this PR fixes #30811. That issue is about INSERT ... SELECT (a single statement) reporting count: 0, which is a row-count reporting bug on a different code path. I verified it's still present with this change:

const r = await sql.unsafe("insert into dst select v from src");
// r.count === 0   (the rows are inserted, but the affected-row count is still 0)

That looks like what #30814 ("report affected row count for INSERT/UPDATE/DELETE with subquery SELECT") is targeting. This PR is scoped to the separate bug where statements after the first are silently dropped when the first statement returns rows, so I'm leaving the auto-close link off.

Comment thread src/js/internal/sql/sqlite.ts
Comment thread test/js/sql/sqlite-sql.test.ts Outdated
Comment thread test/js/sql/sqlite-sql.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for addressing the nits — no correctness issues found, but the multi-statement execution semantics (bindings only to the first statement, first row-returning statement wins) and the new C++ scan-ahead in prepare are design choices worth a human sign-off.

Extended reasoning...

Overview

This PR fixes Bun.SQL's SQLite adapter silently dropping every statement after the first when the first statement returns rows. It touches four files: adds a remainingSQL getter and a scan-ahead loop in JSSQLStatement.cpp (C++ JSC binding), rewrites the row-returning branch of SQLiteQueryHandle.run() in src/js/internal/sql/sqlite.ts into a statement-by-statement loop, adds the remainingSQL field to the CppSQLStatement TS interface, and adds six new tests plus shrinks two slow existing tests in sqlite-sql.test.ts.

I previously left three inline nits (perf of the tail-copying loop, per-test timeouts vs. shrinking workload, weak error assertion in the syntax-error test); the author addressed the latter two in a2ca5e3 and acknowledged the first as a follow-up. The bug-hunting system found no issues on the current revision.

Security risks

None identified. The change only affects how a multi-statement string that the caller already passed to sql.unsafe() / sql.file() is executed — it doesn't widen the input surface. The scan-ahead sqlite3_prepare_v3 operates within the bounds of the already-UTF-8-encoded buffer (sqlEnd - cursor), and the probe statement is finalized before the loop exits.

Level of scrutiny

Medium-high. This is a behavioral change to SQL execution, not a mechanical fix. It encodes design decisions that a maintainer should confirm:

  • Bindings apply only to the first statement (matching db.run()). A later statement with ? placeholders will get [] and either error or bind NULLs — reasonable, but a choice.
  • The first row-returning statement provides the result set; subsequent row-returning statements execute via .run() and their rows are discarded. Postgres's simple-query protocol returns the last result set, so this is a deliberate divergence.
  • New native surface: remainingSQL is now readable on every prepared statement via the public stmt.native accessor in bun:sqlite, and every db.prepare() call now does at least one extra sqlite3_prepare_v3 probe when there's a non-empty tail.

The C++ additions look memory-safe (WTF::String owns its buffer, the probe stmt is finalized on the only path that creates it, the cursor loop is bounded by sqlEnd with a nextTail <= cursor progress guard), but native binding changes in this repo generally get human eyes.

Other factors

Test coverage is solid — six new tests covering both statement orderings, RETURNING execute-once, parameter binding, trailing comments, and syntax-error surfacing with specific error-class assertions. The two shrunk tests were pre-existing debug+ASAN timeouts on main, and the shrink follows repo test guidelines. Given the design-decision surface and native-code changes, I'm deferring rather than auto-approving.

@robobun

robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

The diff is ready (rebased onto 332f744). test/js/sql/sqlite-sql.test.ts passes on every lane across all three CI runs; the remaining red is unrelated flake:

  • build 69561: hot.test.ts, update_interactive_install.test.ts, napi.test.ts, node-net-server.test.ts, net-mongodb-pattern-leak.test.ts (x3 lanes), bun-install-proxy.test.ts, postgres-multi-statement-fields.test.ts (postgres container, one lane), vite-build.test.ts
  • build 69767: bun-install.test.ts, complex-workspace.test.ts
  • build 70549 (post-rebase): terminal-platform-gaps.test.ts, bun-install-registry.test.ts, complex-workspace.test.ts, postgres-binary-array-bounds.test.ts, express/res.send.test.ts, napi.test.ts (x2 lanes), s3.test.ts, bun-run-dir.test.ts, webview-chrome.test.ts, issue/26030.test.ts (x2 lanes), no-orphans.test.ts

None of these touch the sqlite adapter, JSSQLStatement.cpp, or bun:sqlite. The debug+ASAN test file passes 237/237 locally. The bot review found no correctness issues and all three review threads are resolved. Ready for a maintainer to look over the behavioral choices (bindings to the first statement, first row-returning statement provides the result, new remainingSQL getter) and merge.

robobun and others added 5 commits July 8, 2026 16:36
… rows

The SQLite adapter routed row-returning queries through db.prepare(),
which only compiles the first statement of a multi-statement string and
silently discards the rest. A string whose first statement returned rows
(e.g. "select count(*) from t; delete from t") returned the rows but
never ran the following statements, losing writes with no error.

Expose the leftover tail from the native prepare so the adapter can walk
every statement in the string, executing each exactly once. Bindings
apply to the first statement, matching db.run(). The first statement
that returns rows provides the result set.
'properly finalizes prepared statements' (10k awaited inserts) and
'handles exotic but valid SQL patterns' (a 50M-char identifier) take
~13s each under the debug+ASAN build, exceeding the 5s default. Both
were already timing out under debug on main before this PR and pass in
under 100ms on release builds. Matches the explicit-timeout convention
in neighboring SQL tests (sql.test.ts, local-sql.test.ts,
postgres-tls-ctx-leak.test.ts).
Drop the 60s timeouts added in 335036e and shrink the workload
instead (test/CLAUDE.md): 10000 awaited inserts becomes 2000 and the
50M-char identifier becomes 5M via Buffer.alloc(..., 'a').toString().
Both now finish in ~2s under debug+ASAN without a per-test timeout.

Also tighten the 'surfaces a syntax error' test to assert the caught
error is a SQLiteError with code SQLITE_ERROR and a 'syntax' message,
so an unrelated throw would not pass.
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-08, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant