Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/runtime/sql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1138,7 +1138,7 @@ try {
// SQLite-specific error
console.log(error.code); // SQLite error code (e.g., "SQLITE_CONSTRAINT")
console.log(error.errno); // SQLite error number
console.log(error.byteOffset); // Byte offset in SQL statement (if available)
console.log(error.byteOffset); // UTF-8 byte offset in the query text (-1 or undefined if not known)
} else if (error instanceof SQL.SQLError) {
// Generic SQL error (base class)
console.log(error.message);
Expand Down
8 changes: 8 additions & 0 deletions packages/bun-types/sql.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ declare module "bun" {
class SQLiteError extends SQLError {
public readonly code: string;
public readonly errno: number;
/**
* The UTF-8 byte offset of the token that the error refers to, counted
* from the start of the query text. `-1` if the error refers to no
* token, or if the offset is not known.
*
* Not set on errors that Bun raises without SQLite, such as
* `ERR_SQLITE_CONNECTION_CLOSED`.
*/
public readonly byteOffset?: number | undefined;

constructor(message: string, options: { code: string; errno: number; byteOffset?: number | undefined });
Expand Down
9 changes: 7 additions & 2 deletions packages/bun-types/sqlite.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1293,9 +1293,14 @@ declare module "bun:sqlite" {
code?: string;

/**
* The UTF-8 byte offset of the sqlite3 query that failed, if known
* The UTF-8 byte offset of the token that the error refers to, counted
* from the start of the SQL string that was passed. `-1` if the error
* refers to no token, or if the offset is not known.
*
* This corresponds to `sqlite3_error_offset`.
* {@link Database.run} counts from the start of the whole string, also
* when the statement that failed is not the first one.
*
* The offset comes from `sqlite3_error_offset`.
*
* @since v1.0.21
*/
Expand Down
19 changes: 13 additions & 6 deletions src/jsc/bindings/sqlite/JSSQLStatement.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -376,11 +376,14 @@ JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnDeclaredTypes);
JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetSafeIntegers);
JSC_DECLARE_CUSTOM_SETTER(jsSqlStatementSetSafeIntegers);

static JSValue createSQLiteError(JSC::JSGlobalObject* globalObject, sqlite3* db)
static JSValue createSQLiteError(JSC::JSGlobalObject* globalObject, sqlite3* db, int64_t byteOffsetBase = 0)
{
auto& vm = JSC::getVM(globalObject);
int code = sqlite3_extended_errcode(db);
int byteOffset = sqlite3_error_offset(db);
int64_t byteOffset = sqlite3_error_offset(db);
// SQLite counts the offset from the pointer that sqlite3_prepare_v3 received.
if (byteOffset >= 0)
byteOffset += byteOffsetBase;
Comment on lines +383 to +386

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.

🟣 pre-existing, not blocking: Callers of db.run()/db.exec() can receive a byteOffset that points past the statement that actually failed. At src/jsc/bindings/sqlite/JSSQLStatement.cpp:385-387 the helper adds byteOffsetBase to whatever sqlite3_error_offset returns, but SQLite leaves a stale non-negative offset in place when a prepare fails outside the parser: sqlite3ErrorWithMsg at sqlite3.c:148663 ("database schema is locked") and sqlite3.c:148681 ("statement too long") never reset errByteOffset. The base branch reported the stale value k; the PR reports k plus the start of a later statement. Fix: only add the base when the error came from the parser of this prepare, e.g. clear or snapshot the offset before sqlite3_prepare_v3 at JSSQLStatement.cpp:1567 and add the base only when it changed.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A prior statement on the same connection leaves errByteOffset >= 0 (any parse error, or a step-time re-prepare failure in statement N of the same run()). The next prepare in the loop at src/jsc/bindings/sqlite/JSSQLStatement.cpp:1567 fails outside the parser: sqlite3Prepare's "database schema is locked" path (sqlite3.c:148660-148665, shared-cache) or the SQLITE_TOOBIG path (sqlite3.c:148680-148683) go through sqlite3ErrorWithMsg, which sets errCode and pErr but never touches errByteOffset; sqlite3ErrorClear (sqlite3.c:36534-36539) runs only on success at sqlite3.c:148728. createSQLiteError at JSSQLStatement.cpp:385-387 sees byteOffset >= 0 and adds sqlStringHead - sqlStringStart. On the base branch the user got the stale k unchanged; now they get k + base, an offset the new JSDoc at packages/bun-types/sqlite.d.ts:1296-1301 tells them is the token position in the whole string. The dismissal argued "no worse" without reading these two non-resetting paths. Remedy: add the base only when the offset was produced by this prepare call.

Verification: sqlite3ErrorWithMsg (36582-36596) sets errCode and pErr and never touches errByteOffset. New code at src/jsc/bindings/sqlite/JSSQLStatement.cpp:385-387 adds the loop base from line 1570 to that stale value. In the realistic trigger the failing prepare is the FIRST statement of the run() (base = 0), and the PR reports the identical stale value the base branch reports.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is real, and it exists without this PR. I measured it on 1.4.3: after db.run("SELECT 1 nope nope") (offset 14), a text longer than SQLITE_LIMIT_SQL_LENGTH throws SQLITE_TOOBIG "statement too long" with byteOffset 14 through run, exec and query. A SQLITE_BUSY during the schema load gives -1, so the two paths you name are the ones that keep the old value.

This PR does not cause it and does not fix it. It changes the stale number only when that failure happens behind blanks or behind an earlier statement: 14 becomes 14 plus the base.

I did not take the snapshot before sqlite3_prepare_v3. It adds a sqlite3_error_offset call, with its mutex, to every statement of every successful run. "Changed" also cannot tell a new offset from an old one with the same value. The fix belongs in createSQLiteError for all callers, and its test must not need 1 GB of text. I handed that off as separate work. The Notes of the PR body list it under "Not changed".


const char* msg = sqlite3_errmsg(db);
// Error messages can echo identifiers/values from the query, which SQLite does
Expand Down Expand Up @@ -1526,8 +1529,9 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l
auto utf8 = Bun::UTF8View::tryCreate(lexicalGlobalObject, scope, sqlString);
RETURN_IF_EXCEPTION(scope, {});

const char* sqlStringHead = utf8->span().data();
const char* end = utf8->span().data() + utf8->span().size();
const char* const sqlStringStart = utf8->span().data();
const char* sqlStringHead = sqlStringStart;
const char* end = sqlStringStart + utf8->span().size();

bool didSetBindings = false;
bool didExecuteAny = false;
Expand Down Expand Up @@ -1561,8 +1565,10 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l

rc = sqlite3_prepare_v3(db, sqlStringHead, end - sqlStringHead, 0, &sql.stmt, &tail);

if (rc != SQLITE_OK)
break;
if (rc != SQLITE_OK) {
throwException(lexicalGlobalObject, scope, createSQLiteError(lexicalGlobalObject, db, sqlStringHead - sqlStringStart));
return {};
}

if (!sql.stmt) {
// Empty statement. Stop if sqlite3_prepare_v3 made no progress
Expand Down Expand Up @@ -1608,6 +1614,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l
}

if (rc != SQLITE_OK && rc != SQLITE_DONE) [[unlikely]] {
// sqlite3_step failed. sqlStringHead is past that statement, so it is no base for the offset.
throwException(lexicalGlobalObject, scope, createSQLiteError(lexicalGlobalObject, db));
return {};
}
Expand Down
67 changes: 67 additions & 0 deletions test/js/bun/sqlite/sqlite.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,73 @@ it("syntax error sets the byteOffset", () => {
}
});

// The system SQLite of macOS before 13 has no sqlite3_error_offset. Bun reports -1 there.
const hasErrorOffset = !(isMacOS && !isMacOSVersionAtLeast(13));

// sqlite3_error_offset counts from the pointer that sqlite3_prepare_v3 received. run() and exec() prepare each
// statement from where the one before it ended, after the blanks and `;` that bun skips, so the offset of the
// statement in the text has to be added.
it.each(["run", "exec"])("%s() counts byteOffset from the start of the SQL text", method => {
const byteOffsetOf = (sql, how = method) => {
using db = new Database(":memory:");
db.run("CREATE TABLE users (id, name)");
try {
db[how](sql);
} catch (e) {
return e.byteOffset;
}
return "no error";
};
// The UTF-8 byte index of the token that the error names.
const at = (sql, token) => (hasErrorOffset ? Buffer.from(sql).indexOf(token) : -1);

// The first statement of the text fails. prepare() compiles it from the start of the text and agrees.
const first = [
["SELECT nmae FROM users", "nmae"],
["\n SELECT nmae FROM users", "nmae"],
[" !", "!"],
[";;; SELECT nmae FROM users", "nmae"],
["-- comment\nSELECT nmae FROM users", "nmae"],
["/* comment */ SELECT nmae FROM users", "nmae"],
["\n\t SELECT 'abc", "'abc"],
];
expect(first.map(([sql]) => ({ sql, byteOffset: byteOffsetOf(sql), prepare: byteOffsetOf(sql, "prepare") }))).toEqual(
first.map(([sql, token]) => ({ sql, byteOffset: at(sql, token), prepare: at(sql, token) })),
);

// A later statement fails. The offset counts the statements, blanks, comments and non-ASCII text before it.
const later = [
["DELETE FROM users; SELECT nmae FROM users", "nmae"],
["DELETE FROM users; DELETE FROM users; SELECT nmae FROM users", "nmae"],
["DELETE FROM users; -- comment\n SELECT nmae FROM users", "nmae"],
["SELECT 'é😀'; SELECT nmae FROM users", "nmae"],
["SELECT ';'; SELECT nmae FROM users", "nmae"],
["CREATE TRIGGER t AFTER INSERT ON users BEGIN DELETE FROM users; END; SELECT nmae FROM users", "nmae"],
["SELECT 1; SELECT * FROM users!!", "!!"],
["SELECT 1; !!", "!!"],
["SELECT 1; SELECT 'abc", "'abc"],
// SQLite rejects a vertical tab. Bun skips it in front of a statement.
["\vSELECT nmae FROM users", "nmae"],
];
expect(later.map(([sql]) => ({ sql, byteOffset: byteOffsetOf(sql) }))).toEqual(
later.map(([sql, token]) => ({ sql, byteOffset: at(sql, token) })),
);

// An error that names no token has no offset, wherever its statement is. The last one fails in sqlite3_step.
const noToken = [
"SELECT 1;\n\n SELECT * FROM nope",
"SELECT 1; SELECT (",
"\n INSERT INTO users VALUES (1, 2, 3)",
"SELECT 1;\n SELECT abs(-9223372036854775808)",
];
expect(noToken.map(sql => ({ sql, byteOffset: byteOffsetOf(sql) }))).toEqual(
noToken.map(sql => ({ sql, byteOffset: -1 })),
);

// An error that does not come from SQLite has no byteOffset property.
expect(byteOffsetOf(" ; ")).toBeUndefined();
});

it("Missing DB throws SQLITE_CANTOPEN", () => {
try {
new Database("./definitely/not/found");
Expand Down
49 changes: 48 additions & 1 deletion test/js/sql/sqlite-sql.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { randomUUIDv7, SQL } from "bun";
import { Database } from "bun:sqlite";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
import { isDebug, tempDir } from "harness";
import { isDebug, isMacOS, isMacOSVersionAtLeast, tempDir } from "harness";
import { existsSync } from "node:fs";
import { rm, stat } from "node:fs/promises";
import { join } from "node:path";
Expand Down Expand Up @@ -2063,6 +2063,53 @@ describe("Error handling", () => {
expect((err as Error).message).toContain("FOREIGN KEY");
}
});

test("byteOffset counts from the start of the query text", async () => {
await sql`CREATE TABLE offsets (id, name)`;
// The system SQLite of macOS before 13 has no sqlite3_error_offset. Bun reports -1 there.
const hasErrorOffset = !(isMacOS && !isMacOSVersionAtLeast(13));
const at = (text: string, token: string) => (hasErrorOffset ? Buffer.from(text).indexOf(token) : -1);
const byteOffsetOf = async (query: PromiseLike<unknown>) => {
try {
await query;
} catch (error: any) {
return error.byteOffset;
}
return "no error";
};

// The adapter sends text with no SELECT, PRAGMA, WITH, EXPLAIN or RETURNING token to Database.run, which
// prepares one statement at a time.
const indented = "\n DELETE FROM offsets WHERE nmae = 1";
const second = "DELETE FROM offsets WHERE name = 'é'; DELETE FROM offsets WHERE nmae = 1";
// Text with such a token goes to Database.prepare, which compiles the first statement from the start.
const select = "\n SELECT nmae FROM offsets";
using dir = tempDir("sqlite-sql-offsets", { "statements.sql": second });

expect({
tagged: await byteOffsetOf(sql`
DELETE FROM offsets WHERE nmae = 1`),
unsafe: await byteOffsetOf(sql.unsafe(second)),
file: await byteOffsetOf(sql.file(join(String(dir), "statements.sql"))),
transaction: await byteOffsetOf(sql.begin(tx => tx.unsafe(second))),
select: await byteOffsetOf(sql.unsafe(select)),
}).toEqual({
tagged: at(indented, "nmae"),
unsafe: at(second, "nmae"),
file: at(second, "nmae"),
transaction: at(second, "nmae"),
select: at(select, "nmae"),
});

// An error that Bun raises without SQLite has no byteOffset property.
const closed = new SQL("sqlite://:memory:");
await closed.close();
const error = await closed`SELECT 1`.catch(e => e);
expect({ code: error.code, hasByteOffset: "byteOffset" in error }).toEqual({
code: "ERR_SQLITE_CONNECTION_CLOSED",
hasByteOffset: false,
});
});
});

describe("Connection management", () => {
Expand Down
Loading