diff --git a/docs/api/sqlite.md b/docs/api/sqlite.md index ddc405fb02571d..04ab1bbf4d63d6 100644 --- a/docs/api/sqlite.md +++ b/docs/api/sqlite.md @@ -62,7 +62,7 @@ const db = new Database("mydb.sqlite", { create: true }); You can also use an import attribute to load a database. ```ts -import db from "./mydb.sqlite" with {"type": "sqlite"}; +import db from "./mydb.sqlite" with { "type": "sqlite" }; console.log(db.query("select * from users LIMIT 1").get()); ``` @@ -74,16 +74,39 @@ import { Database } from "bun:sqlite"; const db = new Database("./mydb.sqlite"); ``` -### `.close()` +### `.close(throwOnError: boolean = false)` -To close a database: +To close a database connection, but allow existing queries to finish, call `.close(false)`: ```ts const db = new Database(); -db.close(); +// ... do stuff +db.close(false); ``` -Note: `close()` is called automatically when the database is garbage collected. It is safe to call multiple times but has no effect after the first. +To close the database and throw an error if there are any pending queries, call `.close(true)`: + +```ts +const db = new Database(); +// ... do stuff +db.close(true); +``` + +Note: `close(false)` is called automatically when the database is garbage collected. It is safe to call multiple times but has no effect after the first. + +### `using` statement + +You can use the `using` statement to ensure that a database connection is closed when the `using` block is exited. + +```ts +import { Database } from "bun:sqlite"; + +{ + using db = new Database("mydb.sqlite"); + using query = db.query("select 'Hello world' as message;"); + console.log(query.get()); // => { message: "Hello world" } +} +``` ### `.serialize()` @@ -128,6 +151,8 @@ db.exec("PRAGMA journal_mode = WAL;"); {% details summary="What is WAL mode" %} In WAL mode, writes to the database are written directly to a separate file called the "WAL file" (write-ahead log). This file will be later integrated into the main database file. Think of it as a buffer for pending writes. Refer to the [SQLite docs](https://www.sqlite.org/wal.html) for a more detailed overview. + +On macOS, WAL files may be persistent by default. This is not a bug, it is how macOS configured the system version of SQLite. {% /details %} ## Statements @@ -387,6 +412,25 @@ db.loadExtension("myext"); {% /details %} +### .fileControl(cmd: number, value: any) + +To use the advanced `sqlite3_file_control` API, call `.fileControl(cmd, value)` on your `Database` instance. + +```ts +import { Database, constants } from "bun:sqlite"; + +const db = new Database(); +// Ensure WAL mode is NOT persistent +// this prevents wal files from lingering after the database is closed +db.fileControl(constants.SQLITE_FCNTL_PERSIST_WAL, 0); +``` + +`value` can be: + +- `number` +- `TypedArray` +- `undefined` or `null` + ## Reference ```ts diff --git a/packages/bun-types/sqlite.d.ts b/packages/bun-types/sqlite.d.ts index f0ef907e82a473..1f2fd7ff5db1bd 100644 --- a/packages/bun-types/sqlite.d.ts +++ b/packages/bun-types/sqlite.d.ts @@ -24,7 +24,7 @@ * | `null` | `NULL` | */ declare module "bun:sqlite" { - export class Database { + export class Database implements Disposable { /** * Open or create a SQLite3 database * @@ -257,7 +257,20 @@ declare module "bun:sqlite" { * * Internally, this calls `sqlite3_close_v2`. */ - close(): void; + close( + /** + * If `true`, then the database will throw an error if it is in use + * @default false + * + * When true, this calls `sqlite3_close` instead of `sqlite3_close_v2`. + * + * Learn more about this in the [sqlite3 documentation](https://www.sqlite.org/c3ref/close.html). + * + * Bun will automatically call close by default when the database instance is garbage collected. + * In The future, Bun may default `throwOnError` to be true but for backwards compatibility, it is false by default. + */ + throwOnError?: boolean, + ): void; /** * The filename passed when `new Database()` was called @@ -304,6 +317,8 @@ declare module "bun:sqlite" { */ static setCustomSQLite(path: string): boolean; + [Symbol.dispose](): void; + /** * Creates a function that always runs inside a transaction. When the * function is invoked, it will begin a new transaction. When the function @@ -427,6 +442,17 @@ declare module "bun:sqlite" { * ``` */ static deserialize(serialized: NodeJS.TypedArray | ArrayBufferLike, isReadOnly?: boolean): Database; + + /** + * See `sqlite3_file_control` for more information. + * @link https://www.sqlite.org/c3ref/file_control.html + */ + fileControl(op: number, arg?: ArrayBufferView | number): number; + /** + * See `sqlite3_file_control` for more information. + * @link https://www.sqlite.org/c3ref/file_control.html + */ + fileControl(zDbName: string, op: number, arg?: ArrayBufferView | number): number; } /** @@ -455,7 +481,7 @@ declare module "bun:sqlite" { * // => undefined * ``` */ - export class Statement { + export class Statement implements Disposable { /** * Creates a new prepared statement from native code. * @@ -633,6 +659,11 @@ declare module "bun:sqlite" { */ finalize(): void; + /** + * Calls {@link finalize} if it wasn't already called. + */ + [Symbol.dispose](): void; + /** * Return the expanded SQL string for the prepared statement. * @@ -766,6 +797,187 @@ declare module "bun:sqlite" { * @constant 0x04 */ SQLITE_PREPARE_NO_VTAB: number; + + /** + * @constant 1 + */ + SQLITE_FCNTL_LOCKSTATE: number; + /** + * @constant 2 + */ + SQLITE_FCNTL_GET_LOCKPROXYFILE: number; + /** + * @constant 3 + */ + SQLITE_FCNTL_SET_LOCKPROXYFILE: number; + /** + * @constant 4 + */ + SQLITE_FCNTL_LAST_ERRNO: number; + /** + * @constant 5 + */ + SQLITE_FCNTL_SIZE_HINT: number; + /** + * @constant 6 + */ + SQLITE_FCNTL_CHUNK_SIZE: number; + /** + * @constant 7 + */ + SQLITE_FCNTL_FILE_POINTER: number; + /** + * @constant 8 + */ + SQLITE_FCNTL_SYNC_OMITTED: number; + /** + * @constant 9 + */ + SQLITE_FCNTL_WIN32_AV_RETRY: number; + /** + * @constant 10 + * + * Control whether or not the WAL is persisted + * Some versions of macOS configure WAL to be persistent by default. + * + * You can change this with code like the below: + * ```ts + * import { Database } from "bun:sqlite"; + * + * const db = Database.open("mydb.sqlite"); + * db.fileControl(constants.SQLITE_FCNTL_PERSIST_WAL, 0); + * // enable WAL + * db.exec("PRAGMA journal_mode = WAL"); + * // .. do some work + * db.close(); + * ``` + * + */ + SQLITE_FCNTL_PERSIST_WAL: number; + /** + * @constant 11 + */ + SQLITE_FCNTL_OVERWRITE: number; + /** + * @constant 12 + */ + SQLITE_FCNTL_VFSNAME: number; + /** + * @constant 13 + */ + SQLITE_FCNTL_POWERSAFE_OVERWRITE: number; + /** + * @constant 14 + */ + SQLITE_FCNTL_PRAGMA: number; + /** + * @constant 15 + */ + SQLITE_FCNTL_BUSYHANDLER: number; + /** + * @constant 16 + */ + SQLITE_FCNTL_TEMPFILENAME: number; + /** + * @constant 18 + */ + SQLITE_FCNTL_MMAP_SIZE: number; + /** + * @constant 19 + */ + SQLITE_FCNTL_TRACE: number; + /** + * @constant 20 + */ + SQLITE_FCNTL_HAS_MOVED: number; + /** + * @constant 21 + */ + SQLITE_FCNTL_SYNC: number; + /** + * @constant 22 + */ + SQLITE_FCNTL_COMMIT_PHASETWO: number; + /** + * @constant 23 + */ + SQLITE_FCNTL_WIN32_SET_HANDLE: number; + /** + * @constant 24 + */ + SQLITE_FCNTL_WAL_BLOCK: number; + /** + * @constant 25 + */ + SQLITE_FCNTL_ZIPVFS: number; + /** + * @constant 26 + */ + SQLITE_FCNTL_RBU: number; + /** + * @constant 27 + */ + SQLITE_FCNTL_VFS_POINTER: number; + /** + * @constant 28 + */ + SQLITE_FCNTL_JOURNAL_POINTER: number; + /** + * @constant 29 + */ + SQLITE_FCNTL_WIN32_GET_HANDLE: number; + /** + * @constant 30 + */ + SQLITE_FCNTL_PDB: number; + /** + * @constant 31 + */ + SQLITE_FCNTL_BEGIN_ATOMIC_WRITE: number; + /** + * @constant 32 + */ + SQLITE_FCNTL_COMMIT_ATOMIC_WRITE: number; + /** + * @constant 33 + */ + SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE: number; + /** + * @constant 34 + */ + SQLITE_FCNTL_LOCK_TIMEOUT: number; + /** + * @constant 35 + */ + SQLITE_FCNTL_DATA_VERSION: number; + /** + * @constant 36 + */ + SQLITE_FCNTL_SIZE_LIMIT: number; + /** + * @constant 37 + */ + SQLITE_FCNTL_CKPT_DONE: number; + /** + * @constant 38 + */ + SQLITE_FCNTL_RESERVE_BYTES: number; + /** + * @constant 39 + */ + SQLITE_FCNTL_CKPT_START: number; + /** + * @constant 40 + */ + SQLITE_FCNTL_EXTERNAL_READER: number; + /** + * @constant 41 + */ + SQLITE_FCNTL_CKSM_FILE: number; + /** + * @constant 42 + */ + SQLITE_FCNTL_RESET_CACHE: number; }; /** diff --git a/src/bun.js/bindings/sqlite/JSSQLStatement.cpp b/src/bun.js/bindings/sqlite/JSSQLStatement.cpp index c0b17460235c21..4fb9f9d950bacf 100644 --- a/src/bun.js/bindings/sqlite/JSSQLStatement.cpp +++ b/src/bun.js/bindings/sqlite/JSSQLStatement.cpp @@ -1,5 +1,10 @@ + #include "root.h" +#include "JavaScriptCore/ExceptionScope.h" +#include "JavaScriptCore/JSArrayBufferView.h" +#include "JavaScriptCore/JSType.h" + #include "JSSQLStatement.h" #include #include @@ -200,7 +205,7 @@ extern "C" void Bun__closeAllSQLiteDatabasesForTermination() for (auto& db : dbs) { if (db->db) - sqlite3_close_v2(db->db); + sqlite3_close(db->db); } } @@ -335,6 +340,47 @@ class JSSQLStatement : public JSC::JSDestructibleObject { void finishCreation(JSC::VM& vm); }; +static JSValue toJS(JSC::VM& vm, JSC::JSGlobalObject* globalObject, sqlite3_stmt* stmt, int i) +{ + switch (sqlite3_column_type(stmt, i)) { + case SQLITE_INTEGER: { + // https://github.com/oven-sh/bun/issues/1536 + return jsNumberFromSQLite(stmt, i); + } + case SQLITE_FLOAT: { + return jsDoubleNumber(sqlite3_column_double(stmt, i)); + } + // > Note that the SQLITE_TEXT constant was also used in SQLite version + // > 2 for a completely different meaning. Software that links against + // > both SQLite version 2 and SQLite version 3 should use SQLITE3_TEXT, + // > not SQLITE_TEXT. + case SQLITE3_TEXT: { + size_t len = sqlite3_column_bytes(stmt, i); + const unsigned char* text = len > 0 ? sqlite3_column_text(stmt, i) : nullptr; + if (UNLIKELY(text == nullptr || len == 0)) { + return jsEmptyString(vm); + } + + return len < 64 ? jsString(vm, WTF::String::fromUTF8({ text, len })) : JSC::JSValue::decode(Bun__encoding__toStringUTF8(text, len, globalObject)); + } + case SQLITE_BLOB: { + size_t len = sqlite3_column_bytes(stmt, i); + const void* blob = len > 0 ? sqlite3_column_blob(stmt, i) : nullptr; + if (LIKELY(len > 0 && blob != nullptr)) { + JSC::JSUint8Array* array = JSC::JSUint8Array::createUninitialized(globalObject, globalObject->m_typedArrayUint8.get(globalObject), len); + memcpy(array->vector(), blob, len); + return array; + } + + return JSC::JSUint8Array::create(globalObject, globalObject->m_typedArrayUint8.get(globalObject), 0); + } + default: { + break; + } + } + + return jsNull(); +} extern "C" { static JSC_DECLARE_JIT_OPERATION_WITHOUT_WTF_INTERNAL(jsSQLStatementExecuteStatementFunctionGetWithoutTypeChecking, JSC::EncodedJSValue, (JSC::JSGlobalObject * lexicalGlobalObject, JSSQLStatement* castedThis)); } @@ -435,7 +481,7 @@ static void initializeColumnNames(JSC::JSGlobalObject* lexicalGlobalObject, JSSQ // see https://github.com/oven-sh/bun/issues/987 // also see https://github.com/oven-sh/bun/issues/1646 auto& globalObject = *lexicalGlobalObject; - PropertyOffset offset; + auto columnNames = castedThis->columnNames.get(); bool anyHoles = false; for (int i = 0; i < count; i++) { @@ -456,6 +502,7 @@ static void initializeColumnNames(JSC::JSGlobalObject* lexicalGlobalObject, JSSQ } if (LIKELY(!anyHoles)) { + PropertyOffset offset; Structure* structure = globalObject.structureCache().emptyObjectStructureForPrototype(&globalObject, globalObject.objectPrototype(), columnNames->size()); vm.writeBarrier(castedThis, structure); @@ -1226,6 +1273,8 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje openFlags = flags.toInt32(lexicalGlobalObject); } + JSValue finalizationTarget = callFrame->argument(2); + sqlite3* db = nullptr; int statusCode = sqlite3_open_v2(path.utf8().data(), &db, openFlags, nullptr); @@ -1244,10 +1293,20 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje if (status != SQLITE_OK) { // TODO: log a warning here that defensive mode is unsupported. } - auto count = databases().size(); + auto index = databases().size(); sqlite3_extended_result_codes(db, 1); databases().append(new VersionSqlite3(db)); - RELEASE_AND_RETURN(scope, JSValue::encode(jsNumber(count))); + if (finalizationTarget.isObject()) { + vm.heap.addFinalizer(finalizationTarget.getObject(), [index](JSC::JSCell* ptr) -> void { + auto* db = databases()[index]; + if (!db->db) { + return; + } + sqlite3_close_v2(db->db); + databases()[index]->db = nullptr; + }); + } + RELEASE_AND_RETURN(scope, JSValue::encode(jsNumber(index))); } JSC_DEFINE_HOST_FUNCTION(jsSQLStatementCloseStatementFunction, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) @@ -1270,6 +1329,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementCloseStatementFunction, (JSC::JSGlobalObj } JSValue dbNumber = callFrame->argument(0); + JSValue throwOnError = callFrame->argument(1); if (!dbNumber.isNumber()) { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected number"_s)); return JSValue::encode(jsUndefined()); @@ -1282,13 +1342,17 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementCloseStatementFunction, (JSC::JSGlobalObj return JSValue::encode(jsUndefined()); } + bool shouldThrowOnError = (throwOnError.isEmpty() || throwOnError.isUndefined()) ? false : throwOnError.toBoolean(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + sqlite3* db = databases()[dbIndex]->db; // no-op if already closed if (!db) { return JSValue::encode(jsUndefined()); } - int statusCode = sqlite3_close_v2(db); + // sqlite3_close_v2 is used for automatic GC cleanup + int statusCode = shouldThrowOnError ? sqlite3_close(db) : sqlite3_close_v2(db); if (statusCode != SQLITE_OK) { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, WTF::String::fromUTF8(sqlite3_errstr(statusCode)))); return JSValue::encode(jsUndefined()); @@ -1298,6 +1362,91 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementCloseStatementFunction, (JSC::JSGlobalObj return JSValue::encode(jsUndefined()); } +JSC_DEFINE_HOST_FUNCTION(jsSQLStatementFcntlFunction, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + JSC::VM& vm = lexicalGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue thisValue = callFrame->thisValue(); + JSSQLStatementConstructor* thisObject = jsDynamicCast(thisValue.getObject()); + if (UNLIKELY(!thisObject)) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected SQLStatement"_s)); + return JSValue::encode(jsUndefined()); + } + + if (callFrame->argumentCount() < 2) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected 2 arguments"_s)); + return JSValue::encode(jsUndefined()); + } + + JSValue dbNumber = callFrame->argument(0); + JSValue databaseFileName = callFrame->argument(1); + JSValue opNumber = callFrame->argument(2); + JSValue resultValue = callFrame->argument(3); + + if (!dbNumber.isNumber() || !opNumber.isNumber()) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected number"_s)); + return JSValue::encode(jsUndefined()); + } + + int dbIndex = dbNumber.toInt32(lexicalGlobalObject); + int op = opNumber.toInt32(lexicalGlobalObject); + + if (dbIndex < 0 || dbIndex >= databases().size()) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid database handle"_s)); + return JSValue::encode(jsUndefined()); + } + + sqlite3* db = databases()[dbIndex]->db; + // no-op if already closed + if (!db) { + return JSValue::encode(jsUndefined()); + } + + CString fileNameStr; + + if (databaseFileName.isString()) { + fileNameStr = databaseFileName.toWTFString(lexicalGlobalObject).utf8(); + RETURN_IF_EXCEPTION(scope, {}); + } + + int resultInt = -1; + void* resultPtr = nullptr; + if (resultValue.isObject()) { + if (auto* view = jsDynamicCast(resultValue.getObject())) { + if (view->isDetached()) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "TypedArray is detached"_s)); + return JSValue::encode(jsUndefined()); + } + + resultPtr = view->vector(); + if (resultPtr == nullptr) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected buffer"_s)); + return JSValue::encode(jsUndefined()); + } + } + } else if (resultValue.isNumber()) { + resultInt = resultValue.toInt32(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); + + resultPtr = &resultInt; + } else if (resultValue.isNull()) { + + } else { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected result to be a number, null or a TypedArray"_s)); + return {}; + } + + int statusCode = sqlite3_file_control(db, fileNameStr.isNull() ? nullptr : fileNameStr.data(), op, resultPtr); + + if (statusCode == SQLITE_ERROR) { + throwException(lexicalGlobalObject, scope, createSQLiteError(lexicalGlobalObject, db)); + return JSValue::encode(jsUndefined()); + } + + return JSValue::encode(jsNumber(statusCode)); +} + /* Hash table for constructor */ static const HashTableValue JSSQLStatementConstructorTableValues[] = { { "open"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementOpenStatementFunction, 2 } }, @@ -1309,6 +1458,7 @@ static const HashTableValue JSSQLStatementConstructorTableValues[] = { { "setCustomSQLite"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementSetCustomSQLite, 1 } }, { "serialize"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementSerialize, 1 } }, { "deserialize"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementDeserialize, 2 } }, + { "fcntl"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementFcntlFunction, 2 } }, }; const ClassInfo JSSQLStatementConstructor::s_info = { "SQLStatement"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSSQLStatementConstructor) }; @@ -1346,53 +1496,7 @@ static inline JSC::JSValue constructResultObject(JSC::JSGlobalObject* lexicalGlo result = JSC::constructEmptyObject(vm, structure); for (unsigned int i = 0; i < count; i++) { - JSValue value; - - // Loop 1. Fill the rowBuffer with values from SQLite - switch (sqlite3_column_type(stmt, i)) { - case SQLITE_INTEGER: { - // https://github.com/oven-sh/bun/issues/1536 - value = jsNumberFromSQLite(stmt, i); - break; - } - case SQLITE_FLOAT: { - value = jsNumber(sqlite3_column_double(stmt, i)); - break; - } - // > Note that the SQLITE_TEXT constant was also used in SQLite version - // > 2 for a completely different meaning. Software that links against - // > both SQLite version 2 and SQLite version 3 should use SQLITE3_TEXT, - // > not SQLITE_TEXT. - case SQLITE3_TEXT: { - size_t len = sqlite3_column_bytes(stmt, i); - const unsigned char* text = len > 0 ? sqlite3_column_text(stmt, i) : nullptr; - - if (len > 64) { - value = JSC::JSValue::decode(Bun__encoding__toStringUTF8(text, len, lexicalGlobalObject)); - break; - } else { - value = jsString(vm, WTF::String::fromUTF8({ text, len })); - break; - } - } - case SQLITE_BLOB: { - size_t len = sqlite3_column_bytes(stmt, i); - const void* blob = len > 0 ? sqlite3_column_blob(stmt, i) : nullptr; - JSC::JSUint8Array* array = JSC::JSUint8Array::createUninitialized(lexicalGlobalObject, lexicalGlobalObject->m_typedArrayUint8.get(lexicalGlobalObject), len); - - if (LIKELY(blob && len)) - memcpy(array->vector(), blob, len); - - value = array; - break; - } - default: { - value = jsNull(); - break; - } - } - - result->putDirectOffset(vm, i, value); + result->putDirectOffset(vm, i, toJS(vm, lexicalGlobalObject, stmt, i)); } } else { @@ -1403,50 +1507,8 @@ static inline JSC::JSValue constructResultObject(JSC::JSGlobalObject* lexicalGlo } for (int i = 0; i < count; i++) { - auto name = columnNames[i]; - - switch (sqlite3_column_type(stmt, i)) { - case SQLITE_INTEGER: { - // https://github.com/oven-sh/bun/issues/1536 - result->putDirect(vm, name, jsNumberFromSQLite(stmt, i), 0); - break; - } - case SQLITE_FLOAT: { - result->putDirect(vm, name, jsDoubleNumber(sqlite3_column_double(stmt, i)), 0); - break; - } - // > Note that the SQLITE_TEXT constant was also used in SQLite version - // > 2 for a completely different meaning. Software that links against - // > both SQLite version 2 and SQLite version 3 should use SQLITE3_TEXT, - // > not SQLITE_TEXT. - case SQLITE3_TEXT: { - size_t len = sqlite3_column_bytes(stmt, i); - const unsigned char* text = len > 0 ? sqlite3_column_text(stmt, i) : nullptr; - - if (len > 64) { - result->putDirect(vm, name, JSC::JSValue::decode(Bun__encoding__toStringUTF8(text, len, lexicalGlobalObject)), 0); - continue; - } - - result->putDirect(vm, name, jsString(vm, WTF::String::fromUTF8({ text, len })), 0); - break; - } - case SQLITE_BLOB: { - size_t len = sqlite3_column_bytes(stmt, i); - const void* blob = len > 0 ? sqlite3_column_blob(stmt, i) : nullptr; - JSC::JSUint8Array* array = JSC::JSUint8Array::createUninitialized(lexicalGlobalObject, lexicalGlobalObject->m_typedArrayUint8.get(lexicalGlobalObject), len); - - if (LIKELY(blob && len)) - memcpy(array->vector(), blob, len); - - result->putDirect(vm, name, array, 0); - break; - } - default: { - result->putDirect(vm, name, jsNull(), 0); - break; - } - } + const auto& name = columnNames[i]; + result->putDirect(vm, name, toJS(vm, lexicalGlobalObject, stmt, i), 0); } } @@ -1457,53 +1519,15 @@ static inline JSC::JSArray* constructResultRow(JSC::JSGlobalObject* lexicalGloba { int count = castedThis->columnNames->size(); auto& vm = lexicalGlobalObject->vm(); + auto throwScope = DECLARE_THROW_SCOPE(vm); JSC::JSArray* result = JSArray::create(vm, lexicalGlobalObject->arrayStructureForIndexingTypeDuringAllocation(ArrayWithContiguous), count); auto* stmt = castedThis->stmt; for (int i = 0; i < count; i++) { - - switch (sqlite3_column_type(stmt, i)) { - case SQLITE_INTEGER: { - // https://github.com/oven-sh/bun/issues/1536 - result->putDirectIndex(lexicalGlobalObject, i, jsNumberFromSQLite(stmt, i)); - break; - } - case SQLITE_FLOAT: { - result->putDirectIndex(lexicalGlobalObject, i, jsDoubleNumber(sqlite3_column_double(stmt, i))); - break; - } - // > Note that the SQLITE_TEXT constant was also used in SQLite version - // > 2 for a completely different meaning. Software that links against - // > both SQLite version 2 and SQLite version 3 should use SQLITE3_TEXT, - // > not SQLITE_TEXT. - case SQLITE_TEXT: { - size_t len = sqlite3_column_bytes(stmt, i); - const unsigned char* text = len > 0 ? sqlite3_column_text(stmt, i) : nullptr; - if (UNLIKELY(text == nullptr || len == 0)) { - result->putDirectIndex(lexicalGlobalObject, i, jsEmptyString(vm)); - continue; - } - result->putDirectIndex(lexicalGlobalObject, i, len < 64 ? jsString(vm, WTF::String::fromUTF8({ text, len })) : JSC::JSValue::decode(Bun__encoding__toStringUTF8(text, len, lexicalGlobalObject))); - break; - } - case SQLITE_BLOB: { - size_t len = sqlite3_column_bytes(stmt, i); - const void* blob = len > 0 ? sqlite3_column_blob(stmt, i) : nullptr; - if (LIKELY(len > 0 && blob != nullptr)) { - JSC::JSUint8Array* array = JSC::JSUint8Array::createUninitialized(lexicalGlobalObject, lexicalGlobalObject->m_typedArrayUint8.get(lexicalGlobalObject), len); - memcpy(array->vector(), blob, len); - result->putDirectIndex(lexicalGlobalObject, i, array); - } else { - result->putDirectIndex(lexicalGlobalObject, i, JSC::JSUint8Array::create(lexicalGlobalObject, lexicalGlobalObject->m_typedArrayUint8.get(lexicalGlobalObject), 0)); - } - break; - } - default: { - result->putDirectIndex(lexicalGlobalObject, i, jsNull()); - break; - } - } + JSValue value = toJS(vm, lexicalGlobalObject, stmt, i); + RETURN_IF_EXCEPTION(throwScope, nullptr); + result->putDirectIndex(lexicalGlobalObject, i, value); } return result; diff --git a/src/bun.js/bindings/sqlite/lazy_sqlite3.h b/src/bun.js/bindings/sqlite/lazy_sqlite3.h index 06c0a83e5551f8..00a1c292d07d7d 100644 --- a/src/bun.js/bindings/sqlite/lazy_sqlite3.h +++ b/src/bun.js/bindings/sqlite/lazy_sqlite3.h @@ -20,6 +20,8 @@ typedef int (*lazy_sqlite3_bind_parameter_index_type)(sqlite3_stmt*, const char* typedef int (*lazy_sqlite3_changes_type)(sqlite3*); typedef int (*lazy_sqlite3_clear_bindings_type)(sqlite3_stmt*); typedef int (*lazy_sqlite3_close_v2_type)(sqlite3*); +typedef int (*lazy_sqlite3_close_type)(sqlite3*); +typedef int (*lazy_sqlite3_file_control_type)(sqlite3*, const char* zDbName, int op, void* pArg); typedef int (*lazy_sqlite3_extended_result_codes_type)(sqlite3*, int onoff); typedef const void* (*lazy_sqlite3_column_blob_type)(sqlite3_stmt*, int iCol); typedef double (*lazy_sqlite3_column_double_type)(sqlite3_stmt*, int iCol); @@ -100,6 +102,8 @@ static lazy_sqlite3_bind_text16_type lazy_sqlite3_bind_text16; static lazy_sqlite3_changes_type lazy_sqlite3_changes; static lazy_sqlite3_clear_bindings_type lazy_sqlite3_clear_bindings; static lazy_sqlite3_close_v2_type lazy_sqlite3_close_v2; +static lazy_sqlite3_close_type lazy_sqlite3_close; +static lazy_sqlite3_file_control_type lazy_sqlite3_file_control; static lazy_sqlite3_column_blob_type lazy_sqlite3_column_blob; static lazy_sqlite3_column_bytes_type lazy_sqlite3_column_bytes; static lazy_sqlite3_column_bytes16_type lazy_sqlite3_column_bytes16; @@ -147,6 +151,8 @@ static lazy_sqlite3_memory_used_type lazy_sqlite3_memory_used; #define sqlite3_changes lazy_sqlite3_changes #define sqlite3_clear_bindings lazy_sqlite3_clear_bindings #define sqlite3_close_v2 lazy_sqlite3_close_v2 +#define sqlite3_close lazy_sqlite3_close +#define sqlite3_file_control lazy_sqlite3_file_control #define sqlite3_column_blob lazy_sqlite3_column_blob #define sqlite3_column_bytes lazy_sqlite3_column_bytes #define sqlite3_column_count lazy_sqlite3_column_count @@ -226,6 +232,8 @@ static int lazyLoadSQLite() lazy_sqlite3_changes = (lazy_sqlite3_changes_type)dlsym(sqlite3_handle, "sqlite3_changes"); lazy_sqlite3_clear_bindings = (lazy_sqlite3_clear_bindings_type)dlsym(sqlite3_handle, "sqlite3_clear_bindings"); lazy_sqlite3_close_v2 = (lazy_sqlite3_close_v2_type)dlsym(sqlite3_handle, "sqlite3_close_v2"); + lazy_sqlite3_close = (lazy_sqlite3_close_type)dlsym(sqlite3_handle, "sqlite3_close"); + lazy_sqlite3_file_control = (lazy_sqlite3_file_control_type)dlsym(sqlite3_handle, "sqlite3_file_control"); lazy_sqlite3_column_blob = (lazy_sqlite3_column_blob_type)dlsym(sqlite3_handle, "sqlite3_column_blob"); lazy_sqlite3_column_bytes = (lazy_sqlite3_column_bytes_type)dlsym(sqlite3_handle, "sqlite3_column_bytes"); lazy_sqlite3_column_count = (lazy_sqlite3_column_count_type)dlsym(sqlite3_handle, "sqlite3_column_count"); diff --git a/src/js/bun/sqlite.ts b/src/js/bun/sqlite.ts index a01b55c6b8164a..6744c569fea41b 100644 --- a/src/js/bun/sqlite.ts +++ b/src/js/bun/sqlite.ts @@ -31,6 +31,48 @@ const constants = { SQLITE_PREPARE_PERSISTENT: 0x01, SQLITE_PREPARE_NORMALIZE: 0x02, SQLITE_PREPARE_NO_VTAB: 0x04, + + SQLITE_FCNTL_LOCKSTATE: 1, + SQLITE_FCNTL_GET_LOCKPROXYFILE: 2, + SQLITE_FCNTL_SET_LOCKPROXYFILE: 3, + SQLITE_FCNTL_LAST_ERRNO: 4, + SQLITE_FCNTL_SIZE_HINT: 5, + SQLITE_FCNTL_CHUNK_SIZE: 6, + SQLITE_FCNTL_FILE_POINTER: 7, + SQLITE_FCNTL_SYNC_OMITTED: 8, + SQLITE_FCNTL_WIN32_AV_RETRY: 9, + SQLITE_FCNTL_PERSIST_WAL: 10, + SQLITE_FCNTL_OVERWRITE: 11, + SQLITE_FCNTL_VFSNAME: 12, + SQLITE_FCNTL_POWERSAFE_OVERWRITE: 13, + SQLITE_FCNTL_PRAGMA: 14, + SQLITE_FCNTL_BUSYHANDLER: 15, + SQLITE_FCNTL_TEMPFILENAME: 16, + SQLITE_FCNTL_MMAP_SIZE: 18, + SQLITE_FCNTL_TRACE: 19, + SQLITE_FCNTL_HAS_MOVED: 20, + SQLITE_FCNTL_SYNC: 21, + SQLITE_FCNTL_COMMIT_PHASETWO: 22, + SQLITE_FCNTL_WIN32_SET_HANDLE: 23, + SQLITE_FCNTL_WAL_BLOCK: 24, + SQLITE_FCNTL_ZIPVFS: 25, + SQLITE_FCNTL_RBU: 26, + SQLITE_FCNTL_VFS_POINTER: 27, + SQLITE_FCNTL_JOURNAL_POINTER: 28, + SQLITE_FCNTL_WIN32_GET_HANDLE: 29, + SQLITE_FCNTL_PDB: 30, + SQLITE_FCNTL_BEGIN_ATOMIC_WRITE: 31, + SQLITE_FCNTL_COMMIT_ATOMIC_WRITE: 32, + SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE: 33, + SQLITE_FCNTL_LOCK_TIMEOUT: 34, + SQLITE_FCNTL_DATA_VERSION: 35, + SQLITE_FCNTL_SIZE_LIMIT: 36, + SQLITE_FCNTL_CKPT_DONE: 37, + SQLITE_FCNTL_RESERVE_BYTES: 38, + SQLITE_FCNTL_CKPT_START: 39, + SQLITE_FCNTL_EXTERNAL_READER: 40, + SQLITE_FCNTL_CKSM_FILE: 41, + SQLITE_FCNTL_RESET_CACHE: 42, }; var SQL; @@ -161,6 +203,12 @@ class Statement { this.isFinalized = true; return this.#raw.finalize(...args); } + + [Symbol.dispose]() { + if (!this.isFinalized) { + this.finalize(); + } + } } var cachedCount = Symbol.for("Bun.Database.cache.count"); @@ -213,7 +261,7 @@ class Database { SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor"); } - this.#handle = SQL.open(anonymous ? ":memory:" : filename, flags); + this.#handle = SQL.open(anonymous ? ":memory:" : filename, flags, this); this.filename = filename; } @@ -222,7 +270,7 @@ class Database { #cachedQueriesLengths = []; #cachedQueriesValues = []; filename; - + #hasClosed = false; get handle() { return this.#handle; } @@ -255,6 +303,12 @@ class Database { return new Database(serialized, isReadOnly ? constants.SQLITE_OPEN_READONLY : 0); } + [Symbol.dispose]() { + if (!this.#hasClosed) { + this.close(true); + } + } + static setCustomSQLite(path) { if (!SQL) { SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor"); @@ -263,13 +317,24 @@ class Database { return SQL.setCustomSQLite(path); } - close() { + fileControl(cmd, arg) { + const handle = this.#handle; + + if (arguments.length <= 2) { + return SQL.fcntl(handle, null, arguments[0], arguments[1]); + } + + return SQL.fcntl(handle, ...arguments); + } + + close(throwOnError = false) { this.clearQueryCache(); - return SQL.close(this.#handle); + this.#hasClosed = true; + return SQL.close(this.#handle, throwOnError); } clearQueryCache() { for (let item of this.#cachedQueriesValues) { - item.finalize(); + item?.finalize?.(); } this.#cachedQueriesKeys.length = 0; this.#cachedQueriesValues.length = 0; diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index 4578acb730e8a0..f75c2f03153c6d 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -1,8 +1,8 @@ import { expect, it, describe } from "bun:test"; import { Database, constants, SQLiteError } from "bun:sqlite"; -import { existsSync, fstat, realpathSync, rmSync, writeFileSync } from "fs"; +import { existsSync, fstat, readdirSync, realpathSync, rmSync, writeFileSync } from "fs"; import { spawnSync } from "bun"; -import { bunExe } from "harness"; +import { bunExe, isWindows, tempDirWithFiles } from "harness"; import { tmpdir } from "os"; import path from "path"; @@ -777,3 +777,107 @@ it.skipIf( expect(db.prepare("SELECT SQRT(0.25)").all()).toEqual([{ "SQRT(0.25)": 0.5 }]); expect(db.prepare("SELECT TAN(0.25)").all()).toEqual([{ "TAN(0.25)": 0.25534192122103627 }]); }); + +it("should close with WAL enabled", () => { + const dir = tempDirWithFiles("sqlite-wal-test", { "empty.txt": "" }); + const file = path.join(dir, "my.db"); + const db = new Database(file); + db.exec("PRAGMA journal_mode = WAL"); + db.fileControl(constants.SQLITE_FCNTL_PERSIST_WAL, 0); + db.exec("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"); + db.exec("INSERT INTO foo (name) VALUES ('foo')"); + expect(db.query("SELECT * FROM foo").all()).toEqual([{ id: 1, name: "foo" }]); + db.exec("PRAGMA wal_checkpoint(truncate)"); + db.close(); + expect(readdirSync(dir).sort()).toEqual(["empty.txt", "my.db"]); +}); + +it("close(true) should throw an error if the database is in use", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"); + db.exec("INSERT INTO foo (name) VALUES ('foo')"); + const prepared = db.prepare("SELECT * FROM foo"); + expect(() => db.close(true)).toThrow("database is locked"); + prepared.finalize(); + expect(() => db.close(true)).not.toThrow(); +}); + +it("close() should NOT throw an error if the database is in use", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"); + db.exec("INSERT INTO foo (name) VALUES ('foo')"); + const prepared = db.prepare("SELECT * FROM foo"); + expect(() => db.close()).not.toThrow("database is locked"); +}); + +it("should dispose AND throw an error if the database is in use", () => { + expect(() => { + { + using db = new Database(":memory:"); + db.exec("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"); + db.exec("INSERT INTO foo (name) VALUES ('foo')"); + var prepared = db.prepare("SELECT * FROM foo"); + } + }).toThrow("database is locked"); +}); + +it("should dispose", () => { + expect(() => { + { + using db = new Database(":memory:"); + db.exec("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"); + db.exec("INSERT INTO foo (name) VALUES ('foo')"); + } + }).not.toThrow(); +}); + +it("can continue to use existing statements after database has been GC'd", async () => { + var called = false; + var registry = new FinalizationRegistry(() => { + called = true; + }); + function leakTheStatement() { + const db = new Database(":memory:"); + db.exec("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"); + db.exec("INSERT INTO foo (name) VALUES ('foo')"); + const prepared = db.prepare("SELECT * FROM foo"); + registry.register(db); + return prepared; + } + + const stmt = leakTheStatement(); + Bun.gc(true); + await Bun.sleep(1); + Bun.gc(true); + expect(stmt.all()).toEqual([{ id: 1, name: "foo" }]); + stmt.finalize(); + expect(() => stmt.all()).toThrow(); + if (!isWindows) { + // on Windows, FinalizationRegistry is more flaky than on POSIX. + expect(called).toBe(true); + } +}); + +it("statements should be disposable", () => { + { + using db = new Database("mydb.sqlite"); + using query = db.query("select 'Hello world' as message;"); + console.log(query.get()); // => { message: "Hello world" } + } +}); + +it("query should work if the cached statement was finalized", () => { + { + using db = new Database("mydb.sqlite"); + { + using query = db.query("select 'Hello world' as message;"); + var prevQuery = query; + query.get(); + } + { + using query = db.query("select 'Hello world' as message;"); + expect(() => query.get()).not.toThrow(); + } + expect(() => prevQuery.get()).toThrow(); + } +});