Skip to content

sql(sqlite): report affected row count for INSERT/UPDATE/DELETE with subquery SELECT - #30814

Closed
robobun wants to merge 1 commit into
mainfrom
farm/7cabb575/sqlite-insert-select-count
Closed

robobun wants to merge 1 commit into
mainfrom
farm/7cabb575/sqlite-insert-select-count

Conversation

@robobun

@robobun robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Fixes #30811.

Repro

import { SQL } from "bun";
const sql = new SQL(":memory:");
await sql`CREATE TABLE company (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`;
await sql`INSERT INTO company (name) VALUES (${"ACME"})`;
await sql`INSERT INTO company (name) VALUES (${"FOO"})`;

const result = await sql`INSERT INTO company (name) SELECT name || ${" 2"} FROM company`;
console.log(result.count, result.lastInsertRowid);
// Before: 0 null
// After:  2 4

Same shape fails for UPDATE ... WHERE id IN (SELECT ...) and DELETE ... WHERE id IN (SELECT ...) without a RETURNING clause.

Cause

parseSQLQuery in src/js/internal/sql/sqlite.ts walks the SQL token-by-token in reverse and flips canReturnRows = true the first time it sees SELECT, PRAGMA, WITH, or EXPLAIN. The SQLite adapter then uses canReturnRows to pick between two branches:

  • true → db.prepare(sql).all(...) — results come from the prepared statement's row array.
  • false → db.run(sql, ...) — results come from changes.changes / changes.lastInsertRowid.

For INSERT ... SELECT without RETURNING, the mid-query SELECT flipped the flag, so the handle ran stmt.all() which returns an empty array — sqlResult.count became result.length === 0 and lastInsertRowid / affectedRows were never populated.

Fix

Drop SELECT/PRAGMA/WITH/EXPLAIN from the mid-loop whitespace case. They still flip canReturnRows in the post-loop block when they are the leading statement keyword (so SELECT * FROM t, PRAGMA ..., WITH cte AS (...) SELECT ..., and EXPLAIN ... still return rows via stmt.all()). RETURNING keeps flipping canReturnRows everywhere, so INSERT ... SELECT ... RETURNING * still works.

Verification

Four tests added in test/js/sql/sqlite-sql.test.ts under SQLite-specific features:

  • INSERT ... SELECT without RETURNING — count = rows inserted, lastInsertRowid set, rows actually in the table.
  • INSERT ... SELECT ... RETURNING — count = rows inserted, result contains the returned rows.
  • UPDATE ... WHERE id IN (SELECT ...) without RETURNING — count = rows updated.
  • DELETE ... WHERE id IN (SELECT ...) without RETURNING — count = rows deleted.

Gate:

  • With src/ reverted, the first new test fails: expect(result.count).toBe(2) receives 0.
  • With the fix applied, all 4 new tests pass alongside the existing INSERT with RETURNING, UPDATE with affected rows, DELETE with affected rows, and INSERT containing literal 'RETURNING' tests.
  • Full sqlite-sql.test.ts: 232/235 pass. The three failures (handles exotic but valid SQL patterns, handles special characters in database paths, properly finalizes prepared statements) are pre-existing debug-build timeouts and reproduce on unmodified main.

@coderabbitai

coderabbitai Bot commented May 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Fixes SQLite statement classification for INSERT...SELECT and WITH-driven mutations by preprocessing comment removal, introducing a sawDML flag, and deferring row-return logic. Reformats conditional compilation attributes across sources for readability.

Changes

SQL Parser RETURNING handling

Layer / File(s) Summary
SQL parser changes
src/js/internal/sql/sqlite.ts
Adds stripSQLComments() to strip SQL comments while preserving quoted/identifier regions. Introduces sawDML flag to track INSERT/UPDATE/DELETE/REPLACE presence. Updates RETURNING to unconditionally set canReturnRows = true mid-query. Defers leading-statement row-return logic to post-loop handler, where leading WITH sets canReturnRows only when !sawDML.
SQLite regression tests
test/regression/issue/30811.test.ts
Verifies INSERT...SELECT and WITH-driven mutations (without RETURNING) report correct affected row counts and lastInsertRowid. Tests that leading block/line comments do not hide statement keywords. Validates that comment-like sequences inside string literals are preserved.

Conditional compilation attribute reformatting

Layer / File(s) Summary
Crash, errno, perf attrs
src/crash_handler/lib.rs, src/errno/lib.rs, src/perf/tracy.rs
Reformatted multi-target #[cfg(any(...))] and #[cfg(not(any(...)))] attributes for signal-stack setup, errno test assertions, and tracy dlsym fallback into multi-line form without changing predicates.
CLI link-section cfg_attr reformat
src/runtime/cli/Arguments.rs, src/runtime/cli/run_command.rs, src/runtime/cli/upgrade_command.rs
Reformatted cfg_attr attributes controlling .rodata.startup and .text.unlikely link-section placement in CLI helpers into multi-line form.
Spawn, webview, jsc_hooks refactors
src/runtime/jsc_hooks.rs, src/runtime/webview/ChromeProcess.rs, src/spawn/process.rs, src/spawn_sys/spawn_process.rs
Refactored jsc_hooks tmpname/tmpdir calls with explicit buffer slicing; reshaped spawn/process no_orphans platform gating with explicit fallback for unsupported POSIX targets; reformatted ChromeProcess and spawn_sys conditional attributes.

Possibly related PRs

  • oven-sh/bun#30720: Adds Fs::FileSystem::tmpname() helper that is called in src/runtime/jsc_hooks.rs by this PR's refactored resolve_embedded_file_to_buf.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing affected row count reporting for INSERT/UPDATE/DELETE statements with subquery SELECTs, which is the primary objective of this PR.
Description check ✅ Passed The description provides comprehensive coverage of the issue, root cause, fix approach, and verification details. It follows the template structure with clear sections on repro, cause, fix, and verification.
Linked Issues check ✅ Passed The PR fully addresses #30811 by fixing the parseSQLQuery logic to correctly report affected row counts for INSERT/UPDATE/DELETE with subquery SELECTs while preserving row-returning behavior for leading SELECT/PRAGMA/WITH/EXPLAIN and RETURNING clauses.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the SQLite statement classification issue. The formatting changes to cfg attributes across multiple files are necessary refactoring to support the core fix without introducing unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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

@robobun

robobun commented May 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:43 AM PT - May 22nd, 2026

✅ @robobun, your commit 9aeb6beb70e62d069872dde52ad75eba133428ea passed in Build #56831! 🎉


🧪   To try this PR locally:

bunx bun-pr 30814

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

bun-30814 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(sqlite): correctly detect row-returning statements #30815 - Also fixes Bun.SQL SQLite returns count 0 for INSERT ... SELECT without RETURNING #30811 by modifying parseSQLQuery in src/js/internal/sql/sqlite.ts to guard canReturnRows for subquery SELECT tokens

🤖 Generated with Claude Code

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/sql/sqlite.ts`:
- Around line 142-148: The leading WITH handling still sets canReturnRows = true
unconditionally, causing CTE-based INSERT/UPDATE/DELETE without RETURNING to be
treated as row-returning; update the post-loop logic that sets canReturnRows
(the code referencing canReturnRows and the leading "WITH" branch) to inspect
the statement following the WITH: only set canReturnRows to true if the
following top-level statement is a SELECT or any DML that includes a RETURNING
clause; for DML (INSERT/UPDATE/DELETE) without RETURNING leave canReturnRows
false so the code uses sqlite3_changes() instead of stmt.all().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7544d818-68ff-4162-bccb-c129d9bb5e85

📥 Commits

Reviewing files that changed from the base of the PR and between 314d044 and d12296c.

📒 Files selected for processing (12)
  • src/crash_handler/lib.rs
  • src/errno/lib.rs
  • src/js/internal/sql/sqlite.ts
  • src/perf/tracy.rs
  • src/runtime/cli/Arguments.rs
  • src/runtime/cli/run_command.rs
  • src/runtime/cli/upgrade_command.rs
  • src/runtime/jsc_hooks.rs
  • src/runtime/webview/ChromeProcess.rs
  • src/spawn/process.rs
  • src/spawn_sys/spawn_process.rs
  • test/js/sql/sqlite-sql.test.ts

Comment thread src/js/internal/sql/sqlite.ts
Comment thread src/js/internal/sql/sqlite.ts
Comment thread src/js/internal/sql/sqlite.ts

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/sql/sqlite.ts`:
- Around line 65-107: stripSQLComments currently only recognizes single and
double quotes; update the quoted handling in stripSQLComments to also support
backticks and bracket identifiers: extend the quoted variable type to include
"`" and "[" (e.g., false | "'" | '"' | "`" | "["), treat encountering "`" like
other quote characters (push into quoted state and support doubled-character
escape for backticks the same way as "'" and "\""), and treat "[" as an opening
bracket that must close on "]" (no doubled-bracket escaping). Update the
closing/escape logic inside the quoted branch to compute the closingChar =
quoted === "[" ? "]" : quoted and only apply the doubled-character skip when
closingChar equals quoted (i.e., for "'", '"' and "`"); ensure you still append
characters to out and handle index increments exactly as in the existing logic
so comments and tokens remain correctly separated.

In `@test/js/sql/sqlite-sql.test.ts`:
- Around line 1369-1371: The test uses a bound parameter (`const lit = await
sql<{ quoted: string }[]>`SELECT ${"hello -- world"} AS quoted``) so
stripSQLComments() never sees the `--`; change the test to use an actual inline
SQL literal (e.g. embed the text directly in the template or use sql.unsafe(...)
so the `--` appears in the SQL string) to exercise the quoted-literal parser
path, and add an additional case that uses a `/* ... */` literal to verify
block-comment handling; update references in the test to use the `lit` variable
and keep assertions on `lit[0].quoted` unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c1363283-9043-4cb3-acb4-223708269d35

📥 Commits

Reviewing files that changed from the base of the PR and between d12296c and 19f80f1.

📒 Files selected for processing (2)
  • src/js/internal/sql/sqlite.ts
  • test/js/sql/sqlite-sql.test.ts

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/sql/sqlite.ts`:
- Around line 53-69: The reverse-token walk in parseSQLQuery that sets quoted
mode only for single and double quotes must also recognize backtick (`) and
bracket ([) quoting so identifiers like `[ RETURNING ]` or `` `RETURNING` `` are
preserved; update the logic in the reverse scan (the part that toggles
`quoted`/`quoteChar` and skips tokens) to treat '`' as an opening quote that
closes on '`' and '[' as an opening quote that closes on ']', mirroring
stripSQLComments behavior, and ensure tokenization does not expose inner words
like RETURNING; add a regression unit test such as `INSERT INTO t([ RETURNING ])
VALUES (1)` to cover this path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 65ce7d1f-88d2-4a75-b803-7005b6d3e1e2

📥 Commits

Reviewing files that changed from the base of the PR and between 19f80f1 and 67e5505.

📒 Files selected for processing (2)
  • src/js/internal/sql/sqlite.ts
  • test/js/sql/sqlite-sql.test.ts

Comment thread src/js/internal/sql/sqlite.ts

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/internal/sql/sqlite.ts:291-301 — Nit: the new sawDML gate introduces a narrow regression for WITH ... SELECT queries that reference a bracket- or backtick-quoted identifier containing a space-bounded INSERT/UPDATE/DELETE keyword — e.g. WITH cte AS (SELECT [last update time] FROM t) SELECT * FROM cte. The reverse-walk tokenizer's quote handling at line 239 only recognizes '/" (not [ or `), so UPDATE inside [last update time] is tokenized as a standalone keyword and sets sawDML = true; the post-loop case "WITH": if (!sawDML) then leaves canReturnRows = false and the SELECT routes through db.run(), silently returning zero rows. Pre-PR this query returned rows correctly (both the mid-loop SELECT case and the unconditional post-loop WITH would have flipped canReturnRows). Distinct from the stripSQLComments bracket issue flagged elsewhere — this is the reverse-walk's own quote handling, and fixing one does not fix the other.

    Extended reasoning...

    What this is

    Commit f2d1def added a sawDML flag to parseSQLQuery so that a leading WITH followed by INSERT/UPDATE/DELETE routes through db.run(). The flag is set whenever the reverse token walk sees a whitespace-bounded INSERT/UPDATE/DELETE token anywhere in the query (sqlite.ts:158-181), and the post-loop case "WITH" now only flips canReturnRows when !sawDML (sqlite.ts:291-301).

    The reverse walk's quote-skipping logic in the default arm at sqlite.ts:239 only recognizes ' and " as quote delimiters. SQLite also accepts [...] (MS Access/SQL Server compat) and `...` (MySQL compat) as identifier quotes, and the tokenizer treats those characters as ordinary token text. So a bracket- or backtick-quoted identifier whose contents contain a DML keyword bounded by spaces on both sides — e.g. [last update time], [insert date here], `my delete log` — gets tokenized as separate words, and the inner UPDATE/INSERT/DELETE matches the mid-loop case arm and sets sawDML = true.

    Step-by-step trace

    Query: WITH cte AS (SELECT [last update time] FROM t) SELECT * FROM cte

    1. stripSQLComments(query.toUpperCase()) → unchanged (no -- or /*); .trim() → unchanged. Leading token will be WITH.
    2. Reverse walk from the end. The default arm at line 239 checks only char === '"' || char === "'"; [ and ] fall through to token = char + token.
    3. Tokens encountered (right-to-left, at each whitespace boundary): CTE → default; FROM → default; * → default; SELECT → default (no longer flips canReturnRows after this PR); T) → default; FROM → default; TIME] → default; UPDATE → matches case "UPDATE": → sawDML = true; [LAST → default; (SELECT → default; AS → default; CTE → default.
    4. Loop terminates at i < 0 with token = "WITH".
    5. Post-loop case "WITH": if (!sawDML) canReturnRows = true — sawDML is true, so canReturnRows stays false.
    6. SQLiteQueryHandle.run() checks parsedInfo.canReturnRows === false → takes db.run(sql, ...) → returns { changes: 0, lastInsertRowid: 0 } for a SELECT → sqlResult is an empty SQLResultArray with count = 0. The rows are silently dropped.

    For comparison, the same query with a double-quoted identifier ("last update time") does not trigger this: the default arm enters the quoted state on ", suppresses token = char + token for the inner characters, and the inner spaces produce empty tokens that hit the whitespace-switch default — UPDATE is never seen as a standalone token.

    Why this is a regression

    Before this PR, two safety nets existed for this query:

    • The mid-loop whitespace switch had case "SELECT": which set canReturnRows = true for the trailing SELECT * FROM cte token.
    • The post-loop case "WITH": unconditionally set canReturnRows = true.

    Commit 783a287 removed the first; commit f2d1def gated the second on !sawDML. The reverse walk's lack of [/` handling is pre-existing, but it was previously harmless for WITH ... SELECT because one of those two paths always flipped canReturnRows. Now neither does, so this specific shape regresses from "returns rows" to "returns empty".

    Why nothing else catches it

    • stripSQLComments doesn't help: there are no comment delimiters in the query, so it passes through verbatim.
    • The new WITH ... SELECT still returns rows assertion in the test suite (sqlite-sql.test.ts:1303) uses unquoted column names, so it doesn't exercise bracket identifiers.
    • This is distinct from the CodeRabbit-flagged stripSQLComments bracket issue (which is about --//* inside [...] being mis-stripped). That bug is in a different function with a different failure mode (truncated query → RETURNING lost). Fixing stripSQLComments to skip [...] does not change the reverse-walk tokenizer at line 239, so both need the same fix applied independently.

    Impact

    A row-returning WITH ... SELECT silently returns an empty result instead of the rows — no error is thrown. The trigger is admittedly contrived (requires a bracket/backtick identifier containing a space-bounded DML keyword inside a WITH ... SELECT; [update_log] or [updated] would not trigger it), but column names like [Last Update Time] or [Insert Date] are plausible in schemas migrated from SQL Server or Access, which is exactly the population that uses [...] quoting in SQLite.

    Suggested fix

    Extend the reverse-walk's default arm to also treat ` and [/] as quote delimiters (same change CodeRabbit suggested for stripSQLComments, applied here too):

    default: {
      if (char === '"' || char === "'" || char === "`") {
        if (quoted === char) quoted = false;
        else if (!quoted) quoted = char;
        continue;
      }
      if (char === "]") { if (!quoted) quoted = "["; continue; }
      if (char === "[") { if (quoted === "[") quoted = false; continue; }
      if (!quoted) token = char + token;
    }

    (with quoted's type widened to false | "'" | '"' | "" | "["). Alternatively, since the pre-PR behavior was correct for this case, restoring an unconditional canReturnRows = truesomewhere on theWITH ... ... SELECTpath would also work, but fixing the tokenizer's quote handling is the more principled fix and also hardens thesawDML` heuristic against other false positives.

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
src/js/internal/sql/sqlite.ts (1)

142-142: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Quote backtick and bracket identifiers in the reverse scan too.

stripSQLComments() now preserves `...` / [...], but Lines 142 and 248-259 still only enter quoted mode for ' and ". A valid identifier like [ RETURNING ] or ` RETURNING ` is therefore exposed as bare RETURNING, which flips canReturnRows = true and routes non-RETURNING DML back through stmt.all() instead of db.run(). Please mirror the backtick/bracket handling here and add a regression with a whitespace-delimited keyword inside the quoted identifier.

Suggested fix
-  let quoted: false | "'" | '"' = false;
+  let quoted: false | "'" | '"' | "`" | "]" = false;
@@
       default: {
         // skip quoted commands
-        if (char === '"' || char === "'") {
-          if (quoted === char) {
-            quoted = false;
-          } else {
-            quoted = char;
-          }
+        if (quoted) {
+          if ((quoted === "]" && char === "[") || quoted === char) {
+            quoted = false;
+          }
+          continue;
+        }
+        if (char === '"' || char === "'" || char === "`" || char === "]") {
+          quoted = char === "]" ? "]" : char;
           continue;
         }
-        if (!quoted) {
-          token = char + token;
-        }
+        token = char + token;
       }

Also applies to: 248-259

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/internal/sql/sqlite.ts` at line 142, stripSQLComments now preserves
backtick (`...`) and bracket ([...]) identifiers, but the reverse scan logic
(variable quoted declared as let quoted: false | "'" | '"' = false and the
quoted-mode branches in the reverse-scan loop around the code that sets
canReturnRows) only recognizes single-quote and double-quote, so identifiers
like `\` RETURNING \`` or `[ RETURNING ]` are treated as unquoted; update the
reverse-scan logic in the same function (where quoted is declared and later in
the reverse-scan loop) to also enter/exit quoted mode for backticks and bracket
pairs (treat ` as a quote like '"' and treat '[' as opening and ']' as closing),
ensure escaping/paired behavior mirrors the forward pass, and add a regression
test that uses a whitespace-delimited keyword inside a quoted identifier (e.g.,
"` RETURNING `" or "[ RETURNING ]") to assert canReturnRows stays false and that
DML without an actual RETURNING uses db.run().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/js/internal/sql/sqlite.ts`:
- Line 142: stripSQLComments now preserves backtick (`...`) and bracket ([...])
identifiers, but the reverse scan logic (variable quoted declared as let quoted:
false | "'" | '"' = false and the quoted-mode branches in the reverse-scan loop
around the code that sets canReturnRows) only recognizes single-quote and
double-quote, so identifiers like `\` RETURNING \`` or `[ RETURNING ]` are
treated as unquoted; update the reverse-scan logic in the same function (where
quoted is declared and later in the reverse-scan loop) to also enter/exit quoted
mode for backticks and bracket pairs (treat ` as a quote like '"' and treat '['
as opening and ']' as closing), ensure escaping/paired behavior mirrors the
forward pass, and add a regression test that uses a whitespace-delimited keyword
inside a quoted identifier (e.g., "` RETURNING `" or "[ RETURNING ]") to assert
canReturnRows stays false and that DML without an actual RETURNING uses
db.run().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d0232149-f186-45f2-b88c-ce7f0caf9b2b

📥 Commits

Reviewing files that changed from the base of the PR and between 67e5505 and 1b2bc34.

📒 Files selected for processing (2)
  • src/js/internal/sql/sqlite.ts
  • test/js/sql/sqlite-sql.test.ts

Comment thread src/js/internal/sql/sqlite.ts Outdated
@robobun
robobun force-pushed the farm/7cabb575/sqlite-insert-select-count branch from e0c2f57 to 550a4be Compare May 15, 2026 17:21
Comment on lines +198 to +201
// `i` points at the whitespace immediately AFTER `REPLACE`
// in source order; peek forward past any further whitespace
// for an `INTO` token to disambiguate.
let peek = i + "REPLACE".length + 1;

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.

🟡 Nit (comment only — code is correct): this says i points at the whitespace "immediately AFTER REPLACE in source order", but the reverse walk fires this case when i is at the whitespace immediately before REPLACE in source order — i+1..i+7 hold REPLACE itself, which is exactly why peek = i + "REPLACE".length + 1 lands on the first char after it. The arithmetic is right; only the comment is inverted, which could mislead a future maintainer into "fixing" the +8 offset.

Extended reasoning...

What this is

The new case "REPLACE": arm in the mid-loop whitespace switch carries a comment explaining the forward-peek arithmetic:

i points at the whitespace immediately AFTER REPLACE in source order; peek forward past any further whitespace for an INTO token to disambiguate.

This description of i is backwards. The code that follows it (peek = i + "REPLACE".length + 1) is correct; only the comment text is wrong.

Why the comment is wrong

parseSQLQuery walks the (uppercased, comment-stripped) query right-to-left:

for (let i = text_len - 1; i >= 0; i--) {
  const char = text[i];
  switch (char) {
    case " ": case "\n": ... {   // whitespace → flush `token`
      switch (token) {
        case "REPLACE": { ... }

The default arm builds token by prepending: token = char + token. So when the whitespace case fires with token === "REPLACE", the seven characters R E P L A C E were accumulated from indices i+1, i+2, …, i+7, and text[i] is the whitespace character to the left of the R. In left-to-right source order, that whitespace is immediately before REPLACE, not after it.

The comment explicitly says "in source order", which rules out a "after, in reverse-walk order" reading — it is unambiguously inverted.

Step-by-step proof

Take text = "X REPLACE INTO":

index 0 1 2 3 4 5 6 7 8 9 10 11 12 13
char X ␠ R E P L A C E ␠ I N T O

Reverse walk:

  1. i=13..10 accumulate token="INTO"; i=9 (space) → flush, lastToken="INTO", token="".
  2. i=8..2 accumulate token="REPLACE".
  3. i=1 (space) → whitespace case fires with token === "REPLACE".

At this point i = 1, which is the space before REPLACE in source order. Then:

peek = i + "REPLACE".length + 1 = 1 + 7 + 1 = 9

text[9] is the space after the trailing E; the inner while skips it to peek = 10, and text[10..13] === "INTO" → sawDML = true. ✅

If i were actually "the whitespace immediately AFTER REPLACE" as the comment claims (i.e. i = 9), then peek = 9 + 7 + 1 = 17, which is past the end of the string — the INTO check would never match. The arithmetic only works because i is before REPLACE.

Why nothing else catches it

This is purely a documentation error. The runtime behaviour is correct and is covered by the new REPLACE as scalar function inside WITH ... SELECT and WITH ... REPLACE INTO regression tests in test/regression/issue/30811.test.ts. No test can catch a misleading comment.

Impact

No runtime impact. The risk is maintainer confusion: someone reading "i is after REPLACE" and then seeing peek = i + 8 would reasonably conclude the offset should be i + 1 (one past the trailing whitespace) and "fix" it, breaking the REPLACE INTO disambiguation.

Suggested fix

Reword the comment to match the code, e.g.:

// `i` points at the whitespace immediately BEFORE `REPLACE` in
// source order (`i+1..i+7` is `REPLACE` itself); peek forward from
// `i+8` past any further whitespace for an `INTO` token to
// disambiguate.

On the refutations

Both refutations flag this as a duplicate of the other (bug_001 ↔ bug_005), not as a false positive — they agree the comment is wrong. The two reports have been merged into this single comment, so there is no duplication concern.

@robobun

robobun commented May 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI infra noise only. Diff is green on every lane that ran my test file:

  • The fix passes on Linux x64 debug/ASAN, release, all musl and aarch64 lanes, freebsd, android, windows.
  • Build 54905: alpine-3-dot-23-x64-baseline-test-bun hit exit 2 → Buildkite retried → passed.
  • Same build: darwin-14-aarch64-test-bun marked "Expired" — Buildkite agent/step timeout, not a test failure.
  • Earlier build: windows-x64-baseline-build-rust hit exit 101 (Rust compiler crash) on a test-only commit touching zero Rust files.

All three are infra/agent hiccups on Darwin-aarch64, Alpine, and Windows lanes that my JS-only diff cannot cause. The umbrella buildkite/bun status reflects the "Expired" Darwin lane but every other individual job is green.

Ready for a maintainer to merge.

…subquery SELECT

`parseSQLQuery` in the SQLite adapter walked the SQL token-by-token in
reverse and flipped `canReturnRows = true` whenever it saw `SELECT`,
`PRAGMA`, `WITH`, or `EXPLAIN` — regardless of whether the keyword was
the leading statement token or a subquery inside a DML statement.

For `INSERT ... SELECT` and related shapes (`UPDATE ... WHERE id IN
(SELECT ...)`, `WITH ... INSERT/UPDATE/DELETE/REPLACE`, `/* … */ SELECT
...`) without a `RETURNING` clause, the handle then routed through
`stmt.all()`, which returned an empty array — so `result.count` came
back as `0` and `lastInsertRowid` / `affectedRows` as `null`, even
though SQLite actually mutated rows (and `changes()` reported the
correct number).

The fix rewrites the classifier:

- `SELECT` / `PRAGMA` / `WITH` / `EXPLAIN` only flip `canReturnRows`
  when they appear as the leading statement keyword (handled in the
  post-loop block), not when seen mid-query as part of a subquery.
  `RETURNING` keeps flipping it everywhere.
- A new `stripSQLComments` pre-pass removes `/* … */` and `-- …` runs
  so a query tagged with a leading comment (sqlcommenter / APM tagging)
  still has its leading statement keyword visible to the walker. The
  stripper preserves quoted regions for all four SQLite identifier
  styles (`'`, `"`, \`, `[...]`).
- A `sawDML` flag tracks INSERT/UPDATE/DELETE/REPLACE tokens across
  the walk so a leading `WITH` only flips `canReturnRows` when the
  CTE-prefixed statement is actually a SELECT.
- `REPLACE` is overloaded in SQLite (statement keyword vs. the
  `replace(X,Y,Z)` scalar function), so the REPLACE arm peeks forward
  for an `INTO` token to disambiguate — otherwise
  `SELECT REPLACE (name, 'a', 'b')` (with whitespace before `(`) would
  falsely flip `sawDML`.

Fixes #30811
@robobun
robobun force-pushed the farm/7cabb575/sqlite-insert-select-count branch from 550a4be to 9aeb6be Compare May 22, 2026 07:45
// `WITH cte AS (...) INSERT/UPDATE/DELETE ...` (does not, unless
// RETURNING was seen). Only RETURNING flips `canReturnRows` mid-loop.
let sawDML = false;
let quoted: false | "'" | '"' = false;

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.

🟡 Nit: CodeRabbit comment 3248701495 ("handle backtick/bracket quoting in the reverse token walk") is marked ✅ addressed in 1e32b07..1b2bc34, but only stripSQLComments was extended — the reverse walk's quoted state here is still false | "'" | '"' and the default arm only checks '/". Combined with the new sawDML gate, a bracket/backtick identifier with internal whitespace like [ DELETE ] inside a WITH … SELECT now flushes a bare DELETE token, sets sawDML, and suppresses canReturnRows (pre-PR the mid-loop SELECT case covered this). Vanishingly rare trigger, just flagging since the resolved comment implies this was fixed when it wasn't.

Extended reasoning...

What this is

CodeRabbit inline comment 3248701495 asked for the reverse token walk in parseSQLQuery to recognise `...` and [...] identifiers (mirroring the change made to stripSQLComments), and the timeline marks it "✅ Addressed in commits 1e32b07 to 1b2bc34". However in the final code:

  • stripSQLComments (sqlite.ts:69) declares quoted: false | "'" | '"' | "\" | "["` — it was extended.
  • The reverse walk (sqlite.ts:142) still declares let quoted: false | "'" | '"' = false;, and the default arm at ~288–297 only checks char === '"' || char === "'". Backtick and bracket characters fall through to token = char + token like any other char, and the outer whitespace case has no quoted guard.

So the resolved comment's claimed fix was applied to one function but not the other.

Why it matters now (and didn't before this PR)

On main, the mid-loop whitespace switch had case "SELECT": canReturnRows = true. This PR removes that case (the #30811 fix) and instead uses the new sawDML flag to decide whether a leading WITH returns rows: case "WITH": if (!sawDML) canReturnRows = true. That makes the reverse walk's tokenisation of identifier contents load-bearing in a way it wasn't before — a spurious DELETE/INSERT/UPDATE token now actively suppresses row-returning behaviour for WITH … SELECT.

Step-by-step trace

Query: WITH cte AS (SELECT [ DELETE ] AS col FROM t) SELECT * FROM cte (SQLite accepts [ DELETE ] as an identifier; brackets may contain spaces).

After stripSQLComments(...).toUpperCase().trim() the brackets are preserved verbatim (the new prepass treats [...] as quoted). The reverse walk:

  1. ] → default arm; not '/", !quoted → token = "]".
  2. space → whitespace case flushes token = "]" via the default arm; token = "".
  3. E,T,E,L,E,D → default arm accumulates token = "DELETE".
  4. space → whitespace case flushes token = "DELETE" → matches the new mid-loop case "DELETE": → sawDML = true.
  5. Walk continues through [, (SELECT, AS, CTE; the trailing-SELECT and embedded-SELECT tokens hit the default arm (deliberately — the Bun.SQL SQLite returns count 0 for INSERT ... SELECT without RETURNING #30811 fix).
  6. Loop exits with token = "WITH".
  7. Post-loop case "WITH": if (!sawDML) canReturnRows = true — sawDML is true, so canReturnRows stays false.
  8. SQLiteQueryHandle.run() takes the db.run() branch → SELECT executes but rows are discarded; result is an empty SQLResultArray with count = 0.

Pre-PR, step 5's mid-loop SELECT case would have set canReturnRows = true regardless, so this query returned its rows. So this is technically a regression on valid SQLite, introduced by the interaction of the new sawDML mechanism with the pre-existing reverse-walk gap.

The same applies to ` UPDATE `, [ INSERT ], or [ RETURNING ] (the original CodeRabbit example) — any DML keyword padded with whitespace inside a backtick/bracket identifier inside a WITH … SELECT.

On the refutation

The refutation argues this is implausible because the trigger requires an identifier whose actual name is e.g. ' DELETE ' — a DML keyword padded with literal spaces — which no human, ORM, or query builder produces. That's fair, and is exactly why this is a nit, not a normal-severity bug. The reason it's still worth a comment rather than dropping entirely:

  1. The PR timeline marks CodeRabbit 3248701495 as "✅ Addressed" when it demonstrably wasn't (only stripSQLComments got the backtick/bracket handling). Worth knowing whether the reverse-walk half of that fix was intentionally deferred or accidentally dropped between 1e32b07 and 1b2bc34.
  2. It IS a behavioural regression on valid SQL (however pathological) — pre-PR the embedded SELECT masked this gap; the new sawDML mechanism exposes it.

This is not a merge blocker; parseSQLQuery is an acknowledged heuristic and the trigger has effectively zero real-world surface area.

Suggested fix

Mirror stripSQLComments's delimiter set in the reverse walk (CodeRabbit's suggested diff is already correct for a right-to-left scan — enter quoted mode on ] and exit on [):

let quoted: false | "'" | '"' | "\`" | "]" = false;
...
default: {
  if (quoted) {
    if ((quoted === "]" && char === "[") || quoted === char) quoted = false;
    continue;
  }
  if (char === '"' || char === "'" || char === "\`" || char === "]") {
    quoted = char;
    continue;
  }
  token = char + token;
}

Or simply note in the resolved comment that the reverse-walk half is intentionally left as-is given the trigger's rarity.

🔬 also observed by coderabbitai

Comment on lines +1 to +12
// Regression tests for oven-sh/bun#30811.
//
// `Bun.SQL` SQLite's `parseSQLQuery` classifier reverse-walked tokens and
// flipped `canReturnRows = true` whenever it saw a `SELECT` / `PRAGMA` /
// `WITH` / `EXPLAIN` token — so `INSERT … SELECT` (without `RETURNING`),
// `WITH … INSERT/UPDATE/DELETE/REPLACE`, and queries whose leading token
// is hidden by a `/* … */` or `-- …` comment all routed through the
// `stmt.all()` branch and reported `count: 0` / `lastInsertRowid: null`
// for mutations that actually affected rows.
import { SQL } from "bun";
import { describe, expect, test } from "bun:test";

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.

🟡 Nit: per CLAUDE.md / test/CLAUDE.md, test/regression/issue/${issueNumber}.test.ts is reserved for true regressions (worked in a prior release, then broke) — bugs that were never correct go in the module's existing test file. The PR's own Cause section describes parseSQLQuery's original design (not a later breakage), and the description even says "Four tests added in test/js/sql/sqlite-sql.test.ts". Suggest moving these into test/js/sql/sqlite-sql.test.ts alongside the existing INSERT/UPDATE/DELETE row-count tests.

Extended reasoning...

What the convention says

Both root CLAUDE.md:66 and test/CLAUDE.md:153 state verbatim:

test/regression/issue/${issueNumber}.test.ts is only for bugs that have a GitHub issue number and are true regressions (worked in a previous release, then broke). An issue number alone does not qualify — if it was never correct, put the test in the module's existing test file instead.

So having a GitHub issue (#30811) is necessary but not sufficient for placing a test under test/regression/issue/ — the bug must also have been a regression in the strict sense (a previously-working release broke).

Why this PR doesn't appear to qualify

The PR's own Cause section describes the original implementation of parseSQLQuery:

parseSQLQuery in src/js/internal/sql/sqlite.ts walks the SQL token-by-token in reverse and flips canReturnRows = true the first time it sees SELECT, PRAGMA, WITH, or EXPLAIN.

There's no reference to a prior commit or release that handled INSERT … SELECT correctly and then broke — the reverse-walk classifier appears to have had this gap from the start. That makes #30811 a never-worked bug rather than a true regression, which per the convention belongs in the module's own test file.

Description / diff mismatch

The PR description's Verification section explicitly says:

Four tests added in test/js/sql/sqlite-sql.test.ts under SQLite-specific features

…and references running them "alongside the existing INSERT with RETURNING, UPDATE with affected rows, DELETE with affected rows" tests in that file. But the actual diff creates test/regression/issue/30811.test.ts instead. So the author seemingly targeted the convention-correct location originally and the diff drifted (or the description was never updated). Either way, the description and diff disagree on where the tests live.

Step-by-step

  1. test/CLAUDE.md:153 reserves test/regression/issue/ for true regressions and explicitly says "an issue number alone does not qualify".
  2. PR Cause section describes parseSQLQuery's original token-walk design; nothing indicates it ever worked.
  3. → Bun.SQL SQLite returns count 0 for INSERT ... SELECT without RETURNING #30811 is a never-correct bug, not a regression.
  4. → Convention says: put the tests "in the module's existing test file instead" — here, test/js/sql/sqlite-sql.test.ts.
  5. PR description already names test/js/sql/sqlite-sql.test.ts as the target, confirming that's the natural home.
  6. Diff creates test/regression/issue/30811.test.ts → convention violation + description/diff mismatch.

Impact

Zero runtime impact — this is test-file placement only. Filed as a nit: it's an explicit, documented repo convention (in two CLAUDE.md files) and the PR description itself contradicts the diff, but it doesn't affect correctness or CI.

Suggested fix

Move the four describe("#30811: …") tests into test/js/sql/sqlite-sql.test.ts (e.g. under the SQLite-specific features block referenced in the description, near the existing INSERT with RETURNING / UPDATE with affected rows / DELETE with affected rows tests), and drop test/regression/issue/30811.test.ts. If #30811 actually does document a specific prior release where INSERT … SELECT reported the correct count, then the current location is fine and the PR description's Cause/Verification sections should be updated instead.

Comment on lines +214 to +226
peek + 4 <= text_len &&
text[peek] === "I" &&
text[peek + 1] === "N" &&
text[peek + 2] === "T" &&
text[peek + 3] === "O" &&
(peek + 4 === text_len ||
text[peek + 4] === " " ||
text[peek + 4] === "\t" ||
text[peek + 4] === "\n" ||
text[peek + 4] === "\r" ||
text[peek + 4] === "\f" ||
text[peek + 4] === "\v")
) {

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.

🟡 Nit: the INTO boundary check at text[peek + 4] only accepts whitespace or end-of-string, but SQLite also accepts a quoted identifier immediately after INTO with no space — REPLACE INTO"dst", REPLACE INTO`dst` , and REPLACE INTO[dst] are all valid. In those forms text[peek+4] is " / ` / [, the check fails, sawDML stays false, and WITH cte AS (...) REPLACE INTO"dst" SELECT ... still routes through stmt.all() → count = 0. Not a regression (pre-PR behaved the same), just a small completeness gap in the new peek — adding " / ` / [ to the peek + 4 boundary set would close it.

Extended reasoning...

What this is

The new mid-loop case "REPLACE": arm peeks forward from the whitespace before REPLACE, skips further whitespace, then checks for the four characters I N T O followed by a token boundary at text[peek + 4]. The boundary set is peek + 4 === text_len or one of the six whitespace characters ( , \t, \n, \r, \f, \v).

SQLite's tokenizer, however, also starts a new token when it sees an identifier-quote character — ", `, or [ — immediately after a keyword, with no intervening whitespace. So all of these are valid SQLite and parse as REPLACE + INTO + quoted-identifier:

REPLACE INTO"dst" SELECT ...
REPLACE INTO`dst` SELECT ...
REPLACE INTO[dst] SELECT ...

When INTO is written that way, text[peek + 4] is " / ` / [, which is not in the whitespace boundary set, so the INTO check fails and sawDML is not set.

Step-by-step trace

Query: WITH cte AS (SELECT id, name FROM src) REPLACE INTO"dst" SELECT id, name FROM cte

After stripSQLComments(...).toUpperCase().trim() the string is unchanged (no comments). The reverse walk:

  1. Walking right-to-left, the default arm's quote handling skips the contents of "DST" (the " chars toggle quoted on/off, and the D/S/T between them are dropped because !quoted is false). The token accumulated immediately to the left of the first " is INTO, which flushes via the default arm at the next space.
  2. Next the loop accumulates REPLACE (indices i+1..i+7) and at i = the space between ) and R it enters case "REPLACE":.
  3. peek = i + 8 lands on the I of INTO. The whitespace-skip while is a no-op (there is exactly one space, already consumed by the +1 in the offset). text[peek..peek+3] === "INTO" ✅.
  4. text[peek + 4] is " — not whitespace, and peek + 4 !== text_len. The boundary check fails → sawDML stays false.
  5. None of the remaining tokens (SRC), FROM, NAME, ID,, (SELECT, AS, CTE) match INSERT/UPDATE/DELETE, so sawDML is never set.
  6. The loop exits with token = "WITH". Post-loop case "WITH": if (!sawDML) canReturnRows = true fires.
  7. SQLiteQueryHandle.run() takes the stmt.all() branch → [] → result.count = 0, lastInsertRowid unset — exactly the Bun.SQL SQLite returns count 0 for INSERT ... SELECT without RETURNING #30811 symptom for a shape this PR's REPLACE handling is meant to cover.

The same trace applies with ` or [ in place of ".

Why nothing else catches it

stripSQLComments preserves quoted identifiers verbatim (including the " / ` / [ delimiters), so the boundary character reaches the reverse walk unchanged. The reverse walk's own quote handling correctly skips the contents of "dst", but that only affects token accumulation — the forward INTO peek is a separate character-by-character check against text, and its boundary set is hard-coded to whitespace + EOS. The new WITH ... REPLACE INTO regression test in test/regression/issue/30811.test.ts writes REPLACE INTO dst with a space and an unquoted identifier, so it doesn't exercise this shape.

Not a regression

Before this PR, the mid-loop SELECT (inside the CTE body) and/or the leading WITH would have flipped canReturnRows = true anyway, so WITH ... REPLACE INTO"dst" SELECT ... already misrouted to stmt.all() on main. This is purely a completeness gap in the brand-new REPLACE-INTO peek added in this PR.

Scope / impact

Very narrow trigger: requires (1) a leading WITH, (2) REPLACE INTO (rather than INSERT/UPDATE/DELETE, which don't use the peek), (3) a quoted table name, (4) no space between INTO and the opening quote, and (5) no RETURNING. Writing INTO"tbl" with no space is unusual hand-written style and not something ORMs typically emit, so this is filed as a nit rather than a blocker. parseSQLQuery is an explicitly heuristic classifier with other known edge cases.

Suggested fix

Add the three identifier-quote openers to the peek + 4 boundary set:

(peek + 4 === text_len ||
  text[peek + 4] === " "  ||
  text[peek + 4] === "\t" ||
  text[peek + 4] === "\n" ||
  text[peek + 4] === "\r" ||
  text[peek + 4] === "\f" ||
  text[peek + 4] === "\v" ||
  text[peek + 4] === "\"" ||
  text[peek + 4] === "`"  ||
  text[peek + 4] === "[")

(INTO cannot be followed by ( or ' in the REPLACE INTO grammar, so those aren't needed.)

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #33583, which replaces the row-returning heuristic with the prepared statement's column count instead of extending the tokenizer. The extra shapes covered by this PR's tests (WITH ... UPDATE/DELETE/REPLACE INTO, subquery UPDATE/DELETE, comment-prefixed writes, comment markers inside literals, REPLACE as a function) are now covered in test/js/sql/sqlite-sql-row-detection.test.ts over there.

@robobun robobun closed this Aug 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.

Bun.SQL SQLite returns count 0 for INSERT ... SELECT without RETURNING

1 participant