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
1 change: 1 addition & 0 deletions src/js/bun/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ interface CppSQLStatement {
columns: string[];
columnsCount: number;
paramsCount: number;
remainingSQL: string;
columnTypes: string[];
declaredTypes: (string | null)[];
safeIntegers: boolean;
Expand Down
42 changes: 33 additions & 9 deletions src/js/internal/sql/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,24 +241,48 @@ class SQLiteQueryHandle implements BaseQueryHandle<BunSQLiteModule.Database> {
// For SELECT queries, we need to use a prepared statement
// For other queries, we can check if there are multiple statements and use db.run() if so
if (parsedInfo.canReturnRows) {
// SELECT queries must use prepared statements for results
const stmt = db.prepare(sql);
// A prepared statement only compiles the first statement of the string,
// so loop over every statement to avoid silently dropping the rest.
// Bindings apply to the first statement, matching db.run(). The first
// statement that returns rows provides the result set.
let result: unknown[] | undefined;
let remaining = sql;
let isFirst = true;

while (remaining) {
const stmt = db.prepare(remaining);
const next = stmt.native.remainingSQL;
const bindValues = isFirst ? values : [];

try {
if (result === undefined && stmt.native.columnsCount > 0) {
if (mode === SQLQueryResultMode.values) {
result = stmt.values.$apply(stmt, bindValues);
} else if (mode === SQLQueryResultMode.raw) {
result = stmt.raw.$apply(stmt, bindValues);
} else {
result = stmt.all.$apply(stmt, bindValues);
}
} else {
stmt.run.$apply(stmt, bindValues);
}
} finally {
stmt.finalize();
}

isFirst = false;
remaining = next;
}
Comment thread
robobun marked this conversation as resolved.

if (mode === SQLQueryResultMode.values) {
result = stmt.values.$apply(stmt, values);
} else if (mode === SQLQueryResultMode.raw) {
result = stmt.raw.$apply(stmt, values);
} else {
result = stmt.all.$apply(stmt, values);
if (result === undefined) {
result = [];
}

const sqlResult = $isArray(result) ? new SQLResultArray(result) : new SQLResultArray([result]);

sqlResult.command = commandToString(command, parsedInfo.lastToken);
sqlResult.count = $isArray(result) ? result.length : 1;

stmt.finalize();
query.resolve(sqlResult);
} else {
// For INSERT/UPDATE/DELETE/CREATE etc., use db.run() which handles multiple statements natively
Expand Down
52 changes: 51 additions & 1 deletion src/jsc/bindings/sqlite/JSSQLStatement.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnNames);
JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnCount);
JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetParamCount);
JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetHasMultipleStatements);
JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetRemainingSQL);

JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnTypes);
JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnDeclaredTypes);
Expand Down Expand Up @@ -496,6 +497,10 @@ class JSSQLStatement : public JSC::JSDestructibleObject {
mutable JSC::WriteBarrier<JSC::JSObject> userPrototype;
size_t extraMemorySize = 0;
SQLiteBindingsMap m_bindingNames = { 0, false };
// The SQL text that follows this statement within a multi-statement string,
// starting at the next statement that can be prepared (empty when only
// whitespace or comments remain). Lets callers execute the whole string.
WTF::String remainingSQL;
bool hasExecuted : 1 = false;
bool useBigInt64 : 1 = false;

Expand Down Expand Up @@ -639,6 +644,7 @@ static const HashTableValue JSSQLStatementPrototypeTableValues[] = {
{ "columns"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetColumnNames, 0 } },
{ "columnsCount"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetColumnCount, 0 } },
{ "paramsCount"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetParamCount, 0 } },
{ "remainingSQL"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetRemainingSQL, 0 } },
{ "columnTypes"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetColumnTypes, 0 } },
{ "declaredTypes"_s, static_cast<unsigned>(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetColumnDeclaredTypes, 0 } },
{ "safeIntegers"_s, static_cast<unsigned>(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetSafeIntegers, jsSqlStatementSetSafeIntegers } },
Expand Down Expand Up @@ -1662,8 +1668,12 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementPrepareStatementFunction, (JSC::JSGlobalO
// but that should be okay.
int64_t currentMemoryUsage = sqlite_malloc_amount;

const char* sqlStart = reinterpret_cast<const char*>(utf8.span().data());
const char* sqlEnd = sqlStart + utf8.span().size();

int rc = SQLITE_OK;
rc = sqlite3_prepare_v3(db, reinterpret_cast<const char*>(utf8.span().data()), utf8.span().size(), flags, &statement, nullptr);
const char* tail = nullptr;
rc = sqlite3_prepare_v3(db, sqlStart, utf8.span().size(), flags, &statement, &tail);

if (rc != SQLITE_OK) {
throwException(lexicalGlobalObject, scope, createSQLiteError(lexicalGlobalObject, db));
Expand All @@ -1675,6 +1685,32 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementPrepareStatementFunction, (JSC::JSGlobalO
JSSQLStatement* sqlStatement = JSSQLStatement::create(
static_cast<Zig::GlobalObject*>(lexicalGlobalObject), statement, versionDB, memoryChange);

// Find where the next preparable statement begins so callers can execute a
// multi-statement string one statement at a time. Scan past chunks that
// contain no statement (trailing whitespace or comments) so the remainder
// always starts at real SQL, and stays empty when nothing is left.
const char* cursor = tail;
while (cursor && cursor < sqlEnd) {
sqlite3_stmt* nextStatement = nullptr;
const char* nextTail = nullptr;
int nextRc = sqlite3_prepare_v3(db, cursor, sqlEnd - cursor, 0, &nextStatement, &nextTail);
if (nextRc != SQLITE_OK) {
// Leave the malformed remainder in place; the caller re-prepares it
// and surfaces the error at the right point in the batch.
sqlStatement->remainingSQL = WTF::String::fromUTF8ReplacingInvalidSequences({ reinterpret_cast<const unsigned char*>(cursor), static_cast<size_t>(sqlEnd - cursor) });
break;
}
if (nextStatement) {
sqlite3_finalize(nextStatement);
sqlStatement->remainingSQL = WTF::String::fromUTF8ReplacingInvalidSequences({ reinterpret_cast<const unsigned char*>(cursor), static_cast<size_t>(sqlEnd - cursor) });
break;
}
if (!nextTail || nextTail <= cursor) {
break;
}
cursor = nextTail;
}

if (internalFlagsValue.isInt32()) {
const int32_t internalFlags = internalFlagsValue.asInt32();
sqlStatement->m_bindingNames.trimLeadingPrefix = (internalFlags & kStrictFlag) != 0;
Expand Down Expand Up @@ -2653,6 +2689,20 @@ JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetParamCount, (JSGlobalObject * lexicalG
RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::jsNumber(sqlite3_bind_parameter_count(castedThis->stmt))));
}

JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetRemainingSQL, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName attributeName))
{
auto& vm = JSC::getVM(lexicalGlobalObject);
JSSQLStatement* castedThis = dynamicDowncast<JSSQLStatement>(JSValue::decode(thisValue));
auto scope = DECLARE_THROW_SCOPE(vm);
CHECK_THIS

if (castedThis->remainingSQL.isEmpty()) {
RELEASE_AND_RETURN(scope, JSValue::encode(jsEmptyString(vm)));
}

RELEASE_AND_RETURN(scope, JSValue::encode(jsString(vm, castedThis->remainingSQL)));
}

JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetColumnTypes, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName attributeName))
{
auto& vm = JSC::getVM(lexicalGlobalObject);
Expand Down
83 changes: 82 additions & 1 deletion test/js/sql/sqlite-sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,87 @@ describe("Query Execution", () => {
expect(result2).toHaveLength(1);
expect(result2[0].id).toBe(2);
});

test("runs every statement when the first one returns rows", async () => {
await sql.unsafe(`CREATE TABLE batch_rows (v INTEGER)`);
await sql`INSERT INTO batch_rows VALUES (1), (2), (3)`;

// The first statement returns rows, the rest must still execute.
const result = await sql.unsafe("SELECT count(*) AS before FROM batch_rows; DELETE FROM batch_rows");
expect(result).toEqual([{ before: 3 }]);

const left = await sql`SELECT count(*) AS n FROM batch_rows`;
expect(left[0].n).toBe(0);
});

test("returns the first row-returning statement when a write comes first", async () => {
await sql.unsafe(`CREATE TABLE batch_reverse (v INTEGER)`);
await sql`INSERT INTO batch_reverse VALUES (1), (2), (3)`;

const result = await sql.unsafe("DELETE FROM batch_reverse WHERE v = 1; SELECT count(*) AS n FROM batch_reverse");
expect(result).toEqual([{ n: 2 }]);

const left = await sql`SELECT count(*) AS n FROM batch_reverse`;
expect(left[0].n).toBe(2);
});

test("executes a RETURNING statement exactly once in a batch", async () => {
await sql.unsafe(`CREATE TABLE batch_returning (v INTEGER)`);

const result = await sql.unsafe(
"INSERT INTO batch_returning VALUES (99) RETURNING v; DELETE FROM batch_returning WHERE v = 99",
);
expect(result).toEqual([{ v: 99 }]);

const left = await sql`SELECT count(*) AS n FROM batch_returning`;
expect(left[0].n).toBe(0);
});

test("binds parameters to the first statement of a batch", async () => {
await sql.unsafe(`CREATE TABLE batch_params (v INTEGER)`);
await sql`INSERT INTO batch_params VALUES (1), (2), (3)`;

const result = await sql.unsafe("SELECT v FROM batch_params WHERE v > ?; DELETE FROM batch_params WHERE v = 1", [
1,
]);
expect(result).toEqual([{ v: 2 }, { v: 3 }]);

const left = await sql`SELECT count(*) AS n FROM batch_params`;
expect(left[0].n).toBe(2);
});

test("runs later statements and ignores a trailing comment", async () => {
await sql.unsafe(`CREATE TABLE batch_comment (v INTEGER)`);
await sql`INSERT INTO batch_comment VALUES (1), (2)`;

// Row-returning first statement, a write, then a trailing comment.
const result = await sql.unsafe(
"SELECT v FROM batch_comment ORDER BY v; DELETE FROM batch_comment; -- trailing comment",
);
expect(result).toEqual([{ v: 1 }, { v: 2 }]);

const left = await sql`SELECT count(*) AS n FROM batch_comment`;
expect(left[0].n).toBe(0);
});

test("surfaces a syntax error in a later statement after earlier ones run", async () => {
await sql.unsafe(`CREATE TABLE batch_err (v INTEGER)`);

let caught: unknown;
try {
// A row-returning first statement used to hide the invalid tail entirely.
await sql.unsafe("INSERT INTO batch_err VALUES (1) RETURNING v; this is not valid sql");
} catch (e) {
caught = e;
}
expect((caught as any)?.name).toBe("SQLiteError");
expect((caught as any)?.code).toBe("SQLITE_ERROR");
expect((caught as Error)?.message).toMatch(/syntax/i);

// The valid statement before the error still executed.
const rows = await sql`SELECT count(*) AS n FROM batch_err`;
expect(rows[0].n).toBe(1);
});
});

describe("Parameterized Queries", () => {
Expand Down Expand Up @@ -2014,7 +2095,7 @@ describe("Memory and resource management", () => {

await sql`CREATE TABLE stmt_test (id INTEGER PRIMARY KEY, value TEXT)`;

const iterations = 10000;
const iterations = 2000;

for (let i = 0; i < iterations; i++) {
await sql`INSERT INTO stmt_test (id, value) VALUES (${i}, ${"test" + i})`;
Expand Down
Loading