diff --git a/docs/runtime/sqlite.mdx b/docs/runtime/sqlite.mdx index 1358af68caa7..c530516878f2 100644 --- a/docs/runtime/sqlite.mdx +++ b/docs/runtime/sqlite.mdx @@ -714,7 +714,7 @@ class Statement { columnNames: string[]; // the column names of the result set columnTypes: string[]; // types based on actual values in first row (call .get()/.all() first) - declaredTypes: (string | null)[]; // types from CREATE TABLE schema (call .get()/.all() first) + declaredTypes: (string | null)[]; // types from CREATE TABLE schema paramsCount: number; // the number of parameters expected by the statement native: any; // the native object representing the statement diff --git a/packages/bun-types/sqlite.d.ts b/packages/bun-types/sqlite.d.ts index ff0065132750..9ed677ee8814 100644 --- a/packages/bun-types/sqlite.d.ts +++ b/packages/bun-types/sqlite.d.ts @@ -829,25 +829,22 @@ declare module "bun:sqlite" { * - The exact type string declared in the `CREATE TABLE` statement * - `null` for columns without declared types, such as expressions and computed columns * - * The statement must be executed at least once before accessing this - * property. Available for both read-only and read-write statements. + * Available immediately after `prepare()`; the statement does not need to + * be executed first. Available for both read-only and read-write statements. * * @example * ```ts * // For table columns: * const stmt = db.prepare("SELECT id, name, weight FROM products"); - * stmt.get(); * console.log(stmt.declaredTypes); * // => ["INTEGER", "TEXT", "REAL"] * * // For expressions (no declared types): * const exprStmt = db.prepare("SELECT length('bun') AS str_length"); - * exprStmt.get(); * console.log(exprStmt.declaredTypes); * // => [null] * ``` * - * @throws Error if statement hasn't been executed * @since Bun v1.2.13 */ readonly declaredTypes: Array; diff --git a/scripts/handle-crash-patterns.ts b/scripts/handle-crash-patterns.ts index 81c3ed8f6b41..fed3b7c31638 100644 --- a/scripts/handle-crash-patterns.ts +++ b/scripts/handle-crash-patterns.ts @@ -35,7 +35,7 @@ else if ( closeAction = { reason: "not_planned", comment: `Duplicate of #4290. -better-sqlite3 is not supported yet in Bun due to missing V8 C++ APIs. For now, you can try [bun:sqlite](https://bun.com/docs/api/sqlite) for an almost drop-in replacement.`, +Bun cannot load the better-sqlite3 native addon because it uses V8 C++ APIs that Bun does not implement. In current Bun releases \`require("better-sqlite3")\` resolves to a built-in module backed by [bun:sqlite](https://bun.com/docs/api/sqlite); please upgrade Bun and use \`require("better-sqlite3")\` directly instead of loading the \`.node\` file.`, }; } diff --git a/src/codegen/internal-module-registry-scanner.ts b/src/codegen/internal-module-registry-scanner.ts index 851c88114c2c..8508279b1cfa 100644 --- a/src/codegen/internal-module-registry-scanner.ts +++ b/src/codegen/internal-module-registry-scanner.ts @@ -15,7 +15,7 @@ export function createInternalModuleRegistry(basedir: string) { for (let i = 0; i < moduleList.length; i++) { const prefix = moduleList[i].startsWith("node/") ? "node:" - : moduleList[i].startsWith("bun:") + : moduleList[i].startsWith("bun/") ? "bun:" : moduleList[i].startsWith("internal/") ? "internal/" @@ -64,7 +64,7 @@ export function createInternalModuleRegistry(basedir: string) { const requireTransformer = (specifier: string, from: string) => { const directMatch = internalRegistry.get(specifier); - if (directMatch) return codegenRequireId(`${directMatch}/*${specifier}*/`); + if (directMatch !== undefined) return codegenRequireId(`${directMatch}/*${specifier}*/`); const relativeMatch = resolveSyncOrNull(specifier, path.join(basedir, path.dirname(from))) ?? resolveSyncOrNull(specifier, basedir); diff --git a/src/install/default-trusted-dependencies.txt b/src/install/default-trusted-dependencies.txt index f8a85ba83a30..3cc8a02bc5fe 100644 --- a/src/install/default-trusted-dependencies.txt +++ b/src/install/default-trusted-dependencies.txt @@ -114,7 +114,6 @@ azure-functions-core-tools azure-streamanalytics-cicd backport bcrypt -better-sqlite3 bigint-buffer blake-hash bs-platform diff --git a/src/js/bun/sqlite.ts b/src/js/bun/sqlite.ts index 416d67327921..0aa12e44ee50 100644 --- a/src/js/bun/sqlite.ts +++ b/src/js/bun/sqlite.ts @@ -110,6 +110,7 @@ interface CppSQLStatement { paramsCount: number; columnTypes: string[]; declaredTypes: (string | null)[]; + readonly: boolean; safeIntegers: boolean; } @@ -614,7 +615,7 @@ class Database implements SqliteTypes.Database { exclusive: { value: wrapTransaction(fn, db, controller.exclusive), }, - database: { value: this, enumerable: true }, + database: { value: self ?? this, enumerable: true }, }; defineProperties(properties.default.value, properties); diff --git a/src/js/thirdparty/better-sqlite3.ts b/src/js/thirdparty/better-sqlite3.ts new file mode 100644 index 000000000000..9847c8502774 --- /dev/null +++ b/src/js/thirdparty/better-sqlite3.ts @@ -0,0 +1,435 @@ +// Hardcoded module "better-sqlite3": the real package is a V8-API addon Bun cannot dlopen, so wrap bun:sqlite. API: https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md +const { Database: BunDatabase, SQLiteError } = require("bun:sqlite"); +const { existsSync } = require("node:fs"); +const { dirname, resolve } = require("node:path"); +const { throwNotImplemented } = require("internal/shared"); + +const nodejsUtilInspectCustom = Symbol.for("nodejs.util.inspect.custom"); +const notImplementedExtra = "This module is backed by bun:sqlite; see https://bun.com/docs/api/sqlite"; +let inspect; + +function getBooleanOption(options, key) { + let value = false; + if (key in options && typeof (value = options[key]) !== "boolean") { + throw new TypeError(`Expected the "${key}" option to be a boolean`); + } + return value; +} + +// better-sqlite3 Statement: .raw()/.pluck()/.expand()/.bind() are chainable mode setters, not one-shot getters. +class Statement { + #stmt; + #db; + #source; + #log; + #names; + #raw = false; + #pluck = false; + #expand = false; + #bound: any[] | null = null; + + constructor(stmt, database, source, trace) { + this.#stmt = stmt; + this.#db = database; + this.#source = source; + this.#log = trace; + } + + #trace() { + if (this.#log !== null) this.#log(this.#stmt.toString()); + } + + #requireReader() { + if (this.#stmt.native.columnsCount === 0) { + throw new TypeError("This statement does not return data. Use run() instead"); + } + } + + get database() { + return this.#db; + } + get source() { + return this.#source; + } + get reader() { + return this.#stmt.native.columnsCount > 0; + } + get readonly() { + return this.#stmt.native.readonly; + } + get busy() { + return false; + } + + #params(args: any[]) { + if (this.#bound !== null) { + if (args.length > 0) { + throw new TypeError("This statement already has bound parameters"); + } + return this.#bound; + } + return args; + } + + #mapRow(row) { + if (this.#pluck) return row[0]; + if (this.#expand) return this.#expandRow(row); + return row; + } + + run(...args) { + const params = this.#params(args); + try { + return this.#stmt.run.$apply(this.#stmt, params); + } finally { + this.#trace(); + } + } + + get(...args) { + this.#requireReader(); + const params = this.#params(args); + try { + if (this.#raw || this.#pluck || this.#expand) { + const rows = this.#stmt.values.$apply(this.#stmt, params); + return rows.length > 0 ? this.#mapRow(rows[0]) : undefined; + } + const row = this.#stmt.get.$apply(this.#stmt, params); + return row === null ? undefined : row; + } finally { + this.#trace(); + } + } + + all(...args) { + this.#requireReader(); + const params = this.#params(args); + try { + if (this.#raw || this.#pluck || this.#expand) { + const rows = this.#stmt.values.$apply(this.#stmt, params); + if (this.#raw) return rows; + const out = $newArrayWithSize(rows.length); + for (let i = 0; i < rows.length; i++) out[i] = this.#mapRow(rows[i]); + return out; + } + return this.#stmt.all.$apply(this.#stmt, params); + } finally { + this.#trace(); + } + } + + iterate(...args) { + this.#requireReader(); + return this.#iterate(this.#params(args)); + } + + *#iterate(params) { + if (this.#raw || this.#pluck || this.#expand) { + let rows; + try { + rows = this.#stmt.values.$apply(this.#stmt, params); + } finally { + this.#trace(); + } + for (let i = 0; i < rows.length; i++) yield this.#mapRow(rows[i]); + return; + } + const iter = this.#stmt.iterate.$apply(this.#stmt, params); + let first; + try { + first = iter.next(); + } finally { + this.#trace(); + } + if (!first.done) { + yield first.value; + yield* iter; + } + } + + #expandRow(row) { + // sqlite3_column_table_name isn't exposed; group everything under "$" (better-sqlite3's no-table bucket). + const names = (this.#names ??= this.#stmt.columnNames); + const obj = { $: {} }; + for (let i = 0; i < names.length; i++) obj.$[names[i]] = row[i]; + return obj; + } + + bind(...args) { + if (this.#bound !== null) { + throw new TypeError("The bind() method can only be invoked once per statement object"); + } + for (let i = 0; i < args.length; i++) { + if (args[i] instanceof Uint8Array) args[i] = Buffer.from(args[i]); + } + this.#bound = args; + return this; + } + + pluck(toggle) { + this.#requireReader(); + this.#pluck = toggle === undefined ? true : !!toggle; + if (this.#pluck) this.#raw = this.#expand = false; + return this; + } + + raw(toggle) { + this.#requireReader(); + this.#raw = toggle === undefined ? true : !!toggle; + if (this.#raw) this.#pluck = this.#expand = false; + return this; + } + + expand(toggle) { + this.#requireReader(); + this.#expand = toggle === undefined ? true : !!toggle; + if (this.#expand) this.#pluck = this.#raw = false; + return this; + } + + safeIntegers(toggle) { + this.#stmt.safeIntegers(toggle === undefined ? true : !!toggle); + return this; + } + + columns() { + this.#requireReader(); + const names = this.#stmt.columnNames; + const declared = this.#stmt.declaredTypes; + const out = $newArrayWithSize(names.length); + for (let i = 0; i < names.length; i++) { + out[i] = { + name: names[i], + column: null, + table: null, + database: null, + type: declared[i] ?? null, + }; + } + return out; + } + + [Symbol.iterator]() { + return this.iterate(); + } +} + +function Database(filenameGiven, options) { + if (new.target == null) { + return new Database(filenameGiven, options); + } + + let buffer; + if (Buffer.isBuffer(filenameGiven)) { + buffer = filenameGiven; + filenameGiven = ":memory:"; + } + if (filenameGiven == null) filenameGiven = ""; + if (options == null) options = {}; + + if (typeof filenameGiven !== "string") throw new TypeError("Expected first argument to be a string"); + if (typeof options !== "object") throw new TypeError("Expected second argument to be an options object"); + if ("readOnly" in options) throw new TypeError('Misspelled option "readOnly" should be "readonly"'); + if ("memory" in options) + throw new TypeError('Option "memory" was removed in v7.0.0 (use ":memory:" filename instead)'); + + const filename = filenameGiven.trim(); + const anonymous = filename === "" || filename === ":memory:"; + const readonly = getBooleanOption(options, "readonly"); + const fileMustExist = getBooleanOption(options, "fileMustExist"); + const timeout = "timeout" in options ? options.timeout : 5000; + const verbose = "verbose" in options ? options.verbose : null; + // nativeBinding is accepted and ignored: there is no native addon to load. + const nativeBinding = "nativeBinding" in options ? options.nativeBinding : null; + + if (readonly && anonymous && !buffer) throw new TypeError("In-memory/temporary databases cannot be readonly"); + if (!Number.isInteger(timeout) || timeout < 0) + throw new TypeError('Expected the "timeout" option to be a positive integer'); + if (timeout > 0x7fffffff) throw new RangeError('Option "timeout" cannot be greater than 2147483647'); + if (verbose != null && typeof verbose !== "function") + throw new TypeError('Expected the "verbose" option to be a function'); + if (nativeBinding != null && typeof nativeBinding !== "string" && typeof nativeBinding !== "object") + throw new TypeError('Expected the "nativeBinding" option to be a string or addon object'); + + if (!anonymous && !existsSync(dirname(resolve(filename)))) { + throw new TypeError("Cannot open database because the directory does not exist"); + } + + const openOptions = readonly + ? { readonly: true, strict: true } + : anonymous || !fileMustExist + ? { create: true, strict: true } + : { readwrite: true, strict: true }; + + const db = buffer + ? new BunDatabase(buffer, { readonly, strict: true }) + : new BunDatabase(anonymous ? ":memory:" : filename, openOptions); + + if (timeout > 0) db.run(`PRAGMA busy_timeout = ${timeout}`); + + let isOpen = true; + let defaultSafeIntegers = false; + const statements = new Set(); + const statementFinalizer = new FinalizationRegistry(ref => statements.delete(ref)); + const self = this; + const trace = + verbose == null + ? null + : function trace(sql) { + try { + verbose.$call(self, sql); + } catch { + // A throwing logger must not mask the primary result or the underlying error. + } + }; + + Object.defineProperties(this, { + name: { value: filenameGiven, enumerable: true }, + readonly: { value: readonly, enumerable: true }, + memory: { value: anonymous, enumerable: true }, + open: { get: () => isOpen, enumerable: true }, + inTransaction: { get: () => isOpen && db.inTransaction, enumerable: true }, + }); + + this.prepare = function prepare(source) { + if (typeof source !== "string") throw new TypeError("Expected first argument to be a string"); + const stmt = db.prepare(source, undefined, 0); + if (defaultSafeIntegers) stmt.safeIntegers(true); + const ref = new WeakRef(stmt); + statements.add(ref); + statementFinalizer.register(stmt, ref); + return new Statement(stmt, this, source, trace); + }; + + this.exec = function exec(source) { + if (typeof source !== "string") throw new TypeError("Expected first argument to be a string"); + if (trace !== null) trace(source); + if (source.length > 0) { + try { + db.run(source); + } catch (e) { + // sqlite3_exec() is a no-op for whitespace/comment-only input; bun:sqlite's run() throws instead. + if ((e as Error)?.message !== "Query contained no valid SQL statement; likely empty query.") throw e; + } + } + return this; + }; + + this.close = function close() { + if (isOpen) { + isOpen = false; + for (const ref of statements) ref.deref()?.finalize(); + statements.clear(); + db.close(); + } + return this; + }; + + this.pragma = function pragma(source, opts) { + if (opts == null) opts = {}; + if (typeof source !== "string") throw new TypeError("Expected first argument to be a string"); + if (typeof opts !== "object") throw new TypeError("Expected second argument to be an options object"); + const simple = getBooleanOption(opts, "simple"); + const sql = `PRAGMA ${source}`; + if (trace !== null) trace(sql); + const stmt = db.prepare(sql, undefined, 0); + if (defaultSafeIntegers) stmt.safeIntegers(true); + try { + if (simple) { + const rows = stmt.values(); + return rows.length > 0 ? rows[0][0] : undefined; + } + return stmt.all(); + } finally { + stmt.finalize(); + } + }; + + // bun:sqlite's transaction() already returns a function with .deferred/.immediate/.exclusive. + this.transaction = function transaction(fn) { + return db.transaction(fn, self); + }; + + this.serialize = function serialize(opts) { + const attached = opts && typeof opts === "object" ? opts.attached || "main" : "main"; + return db.serialize(attached); + }; + + this.loadExtension = function loadExtension(path, entryPoint) { + db.loadExtension(path, entryPoint); + return this; + }; + + this.defaultSafeIntegers = function (toggle) { + defaultSafeIntegers = toggle === undefined ? true : !!toggle; + return this; + }; + + this.unsafeMode = function unsafeMode(toggle) { + if (toggle === undefined ? true : !!toggle) { + throwNotImplemented("better-sqlite3 Database#unsafeMode(true)", 4290, notImplementedExtra); + } + return this; + }; + + this.backup = function backup() { + throwNotImplemented("better-sqlite3 Database#backup()", 4290, notImplementedExtra); + }; + this.function = function defineFunction() { + throwNotImplemented("better-sqlite3 Database#function()", 4290, notImplementedExtra); + }; + this.aggregate = function aggregate() { + throwNotImplemented("better-sqlite3 Database#aggregate()", 4290, notImplementedExtra); + }; + this.table = function table() { + throwNotImplemented("better-sqlite3 Database#table()", 4290, notImplementedExtra); + }; + + this[nodejsUtilInspectCustom] = function (depth, opts) { + inspect ??= require("node:util").inspect; + return `Database ${inspect( + { + name: filenameGiven, + open: isOpen, + inTransaction: isOpen && db.inTransaction, + readonly, + memory: anonymous, + }, + opts, + )}`; + }; +} + +class SqliteErrorClass extends Error { + code; + constructor(message, code) { + if (typeof code !== "string") { + throw new TypeError("Expected second argument to be a string"); + } + super("" + message); + this.code = code; + Error.captureStackTrace(this, SqliteError); + } + get name() { + return "SqliteError"; + } +} + +function SqliteError(message, code) { + return new SqliteErrorClass(message, code); +} +SqliteError.prototype = SqliteErrorClass.prototype; +// Let `err instanceof SqliteError` match bun:sqlite's `SQLiteError` (capital L) too. +Object.defineProperty(SqliteError, Symbol.hasInstance, { + value(instance) { + return ( + instance != null && + typeof instance === "object" && + (instance.name === "SqliteError" || SQLiteError[Symbol.hasInstance](instance)) + ); + }, +}); + +Database.SqliteError = SqliteError; + +export default Database; diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 31bfe950540a..86fe2834be2a 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -510,7 +510,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionDlopen, (JSC::JSGlobalObject * globalOb // Handle known yet-to-be-working in Bun { static constexpr ASCIILiteral better_sqlite3_node = "better_sqlite3.node"_s; - static constexpr ASCIILiteral better_sqlite3_message = "'better-sqlite3' is not yet supported in Bun.\nTrack the status in https://github.com/oven-sh/bun/issues/4290\nIn the meantime, you could try bun:sqlite which has a similar API."_s; + static constexpr ASCIILiteral better_sqlite3_message = "Bun cannot load the 'better-sqlite3' native addon because it uses V8 APIs that Bun does not implement (https://github.com/oven-sh/bun/issues/4290).\nBun ships a drop-in 'better-sqlite3' module backed by bun:sqlite; use require('better-sqlite3') instead of loading the .node file directly."_s; if (filename.endsWith(better_sqlite3_node)) { return throwError(globalObject, scope, ErrorCode::ERR_DLOPEN_FAILED, better_sqlite3_message); diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 83b78490b14c..547a3a26bee1 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -344,6 +344,7 @@ JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetHasMultipleStatements); JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnTypes); JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnDeclaredTypes); +JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetReadonly); JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetSafeIntegers); JSC_DECLARE_CUSTOM_SETTER(jsSqlStatementSetSafeIntegers); @@ -666,6 +667,7 @@ static const HashTableValue JSSQLStatementPrototypeTableValues[] = { { "paramsCount"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetParamCount, 0 } }, { "columnTypes"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetColumnTypes, 0 } }, { "declaredTypes"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetColumnDeclaredTypes, 0 } }, + { "readonly"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetReadonly, 0 } }, { "safeIntegers"_s, static_cast(JSC::PropertyAttribute::CustomAccessor), NoIntrinsic, { HashTableValue::GetterSetterType, jsSqlStatementGetSafeIntegers, jsSqlStatementSetSafeIntegers } }, }; @@ -2652,6 +2654,17 @@ JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetColumnCount, (JSGlobalObject * lexical RELEASE_AND_RETURN(scope, JSValue::encode(JSC::jsNumber(sqlite3_column_count(castedThis->stmt)))); } +JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetReadonly, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName attributeName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + JSSQLStatement* castedThis = dynamicDowncast(JSValue::decode(thisValue)); + auto scope = DECLARE_THROW_SCOPE(vm); + CHECK_THIS + CHECK_PREPARED + + RELEASE_AND_RETURN(scope, JSValue::encode(JSC::jsBoolean(sqlite3_stmt_readonly(castedThis->stmt) != 0))); +} + JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetParamCount, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName attributeName)) { auto& vm = JSC::getVM(lexicalGlobalObject); @@ -2756,12 +2769,6 @@ JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetColumnDeclaredTypes, (JSGlobalObject * CHECK_THIS CHECK_PREPARED - // Ensure the statement has been executed at least once - if (!castedThis->hasExecuted) { - throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Statement must be executed before accessing declaredTypes"_s)); - return {}; - } - int count = sqlite3_column_count(castedThis->stmt); JSC::JSArray* array = JSC::constructEmptyArray(lexicalGlobalObject, static_cast(nullptr), count); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/resolve_builtins/HardcodedModule.rs b/src/resolve_builtins/HardcodedModule.rs index 74e98600f720..cadb0b2f214c 100644 --- a/src/resolve_builtins/HardcodedModule.rs +++ b/src/resolve_builtins/HardcodedModule.rs @@ -129,6 +129,8 @@ pub enum HardcodedModule { NodeFetch, #[strum(serialize = "@vercel/fetch")] VercelFetch, + #[strum(serialize = "better-sqlite3")] + BetterSqlite3, #[strum(serialize = "utf-8-validate")] Utf8Validate, #[strum(serialize = "node:v8")] @@ -294,6 +296,7 @@ bun_core::comptime_string_map! { b"undici" => HardcodedModule::Undici, b"ws" => HardcodedModule::Ws, b"@vercel/fetch" => HardcodedModule::VercelFetch, + b"better-sqlite3" => HardcodedModule::BetterSqlite3, b"utf-8-validate" => HardcodedModule::Utf8Validate, b"abort-controller" => HardcodedModule::AbortController, }; @@ -741,6 +744,7 @@ const BUN_EXTRA_ALIAS_KVS: &[AliasKv] = &[ // // Thirdparty packages we override entry!("@vercel/fetch"), + entry!("better-sqlite3"), entry!("isomorphic-fetch"), entry!("node-fetch"), entry!("undici"), diff --git a/test/js/bun/sqlite/column-types.test.js b/test/js/bun/sqlite/column-types.test.js index 1d93a845ad71..4d813d118f18 100644 --- a/test/js/bun/sqlite/column-types.test.js +++ b/test/js/bun/sqlite/column-types.test.js @@ -224,7 +224,7 @@ describe("SQLite Statement column types", () => { expect(stmt.declaredTypes).toEqual(["INTEGER", "ANY"]); }); - it("throws an error when accessing columnTypes before statement execution", () => { + it("columnTypes and declaredTypes are available before statement execution", () => { const db = new Database(":memory:"); db.run(`CREATE TABLE test (id INTEGER, name TEXT)`); @@ -234,10 +234,8 @@ describe("SQLite Statement column types", () => { // Accessing columnTypes before executing is fine (implicitly executes the statement) expect(stmt.columnTypes).toBeArray(); - // Accessing declaredTypes before executing should throw - expect(() => { - stmt.declaredTypes; - }).toThrow("Statement must be executed before accessing declaredTypes"); + // declaredTypes reads schema metadata via sqlite3_column_decltype, which does not require a stepped row + expect(db.prepare("SELECT * FROM test").declaredTypes).toEqual(["INTEGER", "TEXT"]); }); it("throws an error when accessing columnTypes on non-read-only statements", () => { diff --git a/test/js/first_party/better-sqlite3/better-sqlite3.test.ts b/test/js/first_party/better-sqlite3/better-sqlite3.test.ts new file mode 100644 index 000000000000..97b09b8dc1a9 --- /dev/null +++ b/test/js/first_party/better-sqlite3/better-sqlite3.test.ts @@ -0,0 +1,383 @@ +// better-sqlite3 is a V8-API native addon; Bun overrides `require("better-sqlite3")` +// with a shim backed by bun:sqlite. https://github.com/oven-sh/bun/issues/14997 +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +// @ts-expect-error no types for the builtin override +import Database from "better-sqlite3"; + +const { SqliteError } = Database; + +describe("better-sqlite3 shim", () => { + test("default export is a Database constructor", () => { + expect(typeof Database).toBe("function"); + expect(typeof SqliteError).toBe("function"); + expect(require.resolve("better-sqlite3")).toBe("better-sqlite3"); + }); + + test("basic CRUD", () => { + const db = new Database(":memory:"); + expect(db.open).toBe(true); + expect(db.readonly).toBe(false); + expect(db.memory).toBe(true); + expect(db.name).toBe(":memory:"); + + db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"); + + const insert = db.prepare("INSERT INTO users (name) VALUES (?)"); + const r = insert.run("alice"); + expect(r.changes).toBe(1); + expect(r.lastInsertRowid).toBe(1); + insert.run("bob"); + + const sel = db.prepare("SELECT * FROM users WHERE id = ?"); + expect(sel.get(1)).toEqual({ id: 1, name: "alice" }); + expect(sel.get(999)).toBeUndefined(); + + expect(db.prepare("SELECT * FROM users").all()).toEqual([ + { id: 1, name: "alice" }, + { id: 2, name: "bob" }, + ]); + + db.close(); + expect(db.open).toBe(false); + }); + + test("is callable without new", () => { + // @ts-expect-error + const db = Database(":memory:"); + expect(db.open).toBe(true); + db.close(); + }); + + test(".raw() / .pluck() / .bind() are chainable mode setters", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (a INTEGER, b TEXT)"); + db.prepare("INSERT INTO t VALUES (?, ?)").run(1, "x"); + db.prepare("INSERT INTO t VALUES (?, ?)").run(2, "y"); + + expect(db.prepare("SELECT * FROM t").raw().all()).toEqual([ + [1, "x"], + [2, "y"], + ]); + expect(db.prepare("SELECT * FROM t").raw(false).all()).toEqual([ + { a: 1, b: "x" }, + { a: 2, b: "y" }, + ]); + expect(db.prepare("SELECT * FROM t").raw().get()).toEqual([1, "x"]); + + expect(db.prepare("SELECT b FROM t").pluck().all()).toEqual(["x", "y"]); + expect(db.prepare("SELECT b FROM t").pluck().get()).toBe("x"); + + const bound = db.prepare("SELECT * FROM t WHERE a = ?").bind(2); + expect(bound.all()).toEqual([{ a: 2, b: "y" }]); + expect(bound.get()).toEqual({ a: 2, b: "y" }); + expect(() => bound.all(1)).toThrow("already has bound parameters"); + expect(() => bound.iterate(1)).toThrow("already has bound parameters"); + expect(() => bound.bind(1)).toThrow("only be invoked once"); + + // .bind() snapshots TypedArray contents (SQLITE_TRANSIENT semantics) + db.exec("CREATE TABLE blobs (b BLOB)"); + const buf = Buffer.from([1, 2, 3]); + const ins = db.prepare("INSERT INTO blobs VALUES (?)").bind(buf); + buf.fill(0); + ins.run(); + expect([...db.prepare("SELECT b FROM blobs").pluck().get()]).toEqual([1, 2, 3]); + + db.close(); + }); + + test(".pragma()", () => { + const db = new Database(":memory:"); + expect(db.pragma("journal_mode", { simple: true })).toBe("memory"); + const full = db.pragma("journal_mode"); + expect(full).toEqual([{ journal_mode: "memory" }]); + db.close(); + }); + + test(".transaction() has .deferred/.immediate/.exclusive", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (a INTEGER)"); + const insert = db.prepare("INSERT INTO t VALUES (?)"); + expect(db.inTransaction).toBe(false); + + const tx = db.transaction((values: number[]) => { + expect(db.inTransaction).toBe(true); + for (const v of values) insert.run(v); + }); + expect(typeof tx.deferred).toBe("function"); + expect(typeof tx.immediate).toBe("function"); + expect(typeof tx.exclusive).toBe("function"); + + tx.deferred([1, 2, 3]); + expect(db.inTransaction).toBe(false); + expect(db.prepare("SELECT a FROM t").pluck().all()).toEqual([1, 2, 3]); + + expect(() => + db.transaction(() => { + insert.run(4); + throw new Error("rollback"); + })(), + ).toThrow("rollback"); + expect(db.prepare("SELECT a FROM t").pluck().all()).toEqual([1, 2, 3]); + + db.close(); + }); + + test(".columns(), .reader and .readonly", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (a INTEGER, b TEXT)"); + const sel = db.prepare("SELECT a, b FROM t"); + expect(sel.columns()).toEqual([ + { name: "a", column: null, table: null, database: null, type: "INTEGER" }, + { name: "b", column: null, table: null, database: null, type: "TEXT" }, + ]); + expect(sel.reader).toBe(true); + expect(sel.readonly).toBe(true); + const ins = db.prepare("INSERT INTO t VALUES (1, 'x')"); + expect(ins.reader).toBe(false); + expect(ins.readonly).toBe(false); + db.close(); + }); + + test(".iterate()", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (a INTEGER)"); + for (let i = 1; i <= 3; i++) db.prepare("INSERT INTO t VALUES (?)").run(i); + + const seen: number[] = []; + for (const row of db.prepare("SELECT a FROM t").iterate()) seen.push(row.a); + expect(seen).toEqual([1, 2, 3]); + + const raw = [...db.prepare("SELECT a FROM t").raw().iterate()]; + expect(raw).toEqual([[1], [2], [3]]); + db.close(); + }); + + test(".raw().iterate() preserves positional values with duplicate column names", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE a (id INTEGER); CREATE TABLE b (id INTEGER)"); + db.exec("INSERT INTO a VALUES (1); INSERT INTO b VALUES (2)"); + const stmt = db.prepare("SELECT a.id, b.id FROM a JOIN b"); + expect(stmt.raw().all()).toEqual([[1, 2]]); + expect([...stmt.raw().iterate()]).toEqual([[1, 2]]); + expect(db.prepare("SELECT a.id, b.id FROM a JOIN b").pluck().get()).toBe(1); + db.close(); + }); + + test(".expand() groups columns under $", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (a INTEGER, b TEXT)"); + db.prepare("INSERT INTO t VALUES (?, ?)").run(1, "x"); + expect(db.prepare("SELECT * FROM t").expand().get()).toEqual({ $: { a: 1, b: "x" } }); + expect(db.prepare("SELECT * FROM t").expand().all()).toEqual([{ $: { a: 1, b: "x" } }]); + db.close(); + }); + + test("Symbol.iterator on a Statement", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (a INTEGER)"); + db.exec("INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)"); + const rows = [...db.prepare("SELECT a FROM t")]; + expect(rows).toEqual([{ a: 1 }, { a: 2 }]); + db.close(); + }); + + test(".serialize() round-trips through new Database(Buffer)", () => { + const src = new Database(":memory:"); + src.exec("CREATE TABLE t (x INTEGER)"); + src.prepare("INSERT INTO t VALUES (?)").run(7); + const buf = src.serialize(); + src.close(); + expect(Buffer.isBuffer(buf)).toBe(true); + + const dst = new Database(buf); + expect(dst.name).toBe(":memory:"); + expect(dst.memory).toBe(true); + expect(dst.prepare("SELECT x FROM t").pluck().get()).toBe(7); + dst.close(); + }); + + test(".safeIntegers() and .defaultSafeIntegers()", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (x INTEGER)"); + db.prepare("INSERT INTO t VALUES (?)").run(42); + + expect(db.prepare("SELECT x FROM t").pluck().get()).toBe(42); + expect(db.prepare("SELECT x FROM t").safeIntegers(true).pluck().get()).toBe(42n); + + db.defaultSafeIntegers(true); + expect(db.prepare("SELECT x FROM t").pluck().get()).toBe(42n); + expect(typeof db.pragma("cache_size", { simple: true })).toBe("bigint"); + expect(db.prepare("SELECT x FROM t").safeIntegers(false).pluck().get()).toBe(42); + db.close(); + }); + + test(".exec() is a no-op for empty or comment-only input", () => { + const db = new Database(":memory:"); + expect(db.exec("")).toBe(db); + expect(db.exec(" ")).toBe(db); + expect(db.exec("-- placeholder migration\n")).toBe(db); + expect(() => db.exec("NOT VALID SQL")).toThrow(SqliteError); + db.close(); + }); + + test("named parameters bind without the @/$/: prefix", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (name TEXT)"); + db.prepare("INSERT INTO t VALUES (@name)").run({ name: "x" }); + db.prepare("INSERT INTO t VALUES ($name)").run({ name: "y" }); + db.prepare("INSERT INTO t VALUES (:name)").run({ name: "z" }); + expect(db.prepare("SELECT name FROM t").pluck().all()).toEqual(["x", "y", "z"]); + expect(() => db.prepare("INSERT INTO t VALUES (@name)").run({ wrong: "a" })).toThrow('Missing parameter "name"'); + db.close(); + }); + + test("transaction().database is the shim Database", () => { + const db = new Database(":memory:"); + const tx = db.transaction(() => {}); + expect(tx.database).toBe(db); + expect(tx.deferred.database).toBe(db); + db.close(); + }); + + test("verbose option receives the expanded SQL", () => { + const seen: string[] = []; + const db = new Database(":memory:", { verbose: (sql: string) => seen.push(sql) }); + db.exec("CREATE TABLE t (x INTEGER UNIQUE)"); + db.prepare("INSERT INTO t VALUES (?)").run(1); + db.prepare("SELECT x FROM t WHERE x = ?").all(1); + [...db.prepare("SELECT x FROM t WHERE x = ?").iterate(1)]; + db.pragma("journal_mode"); + expect(() => db.prepare("INSERT INTO t VALUES (?)").run(1)).toThrow(SqliteError); + // A throwing logger must not mask the primary result. + const db2 = new Database(":memory:", { + verbose: () => { + throw new Error("sink down"); + }, + }); + db2.exec("CREATE TABLE t (x INTEGER)"); + expect(db2.prepare("INSERT INTO t VALUES (?)").run(5)).toEqual({ changes: 1, lastInsertRowid: 1 }); + db2.close(); + expect(seen).toEqual([ + "CREATE TABLE t (x INTEGER UNIQUE)", + "INSERT INTO t VALUES (1)", + "SELECT x FROM t WHERE x = 1", + "SELECT x FROM t WHERE x = 1", + "PRAGMA journal_mode", + "INSERT INTO t VALUES (1)", + ]); + db.close(); + }); + + test("read methods refuse statements that do not return data", () => { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (x INTEGER)"); + db.exec("INSERT INTO t VALUES (1), (2), (3)"); + const del = db.prepare("DELETE FROM t"); + expect(() => del.all()).toThrow("This statement does not return data. Use run() instead"); + expect(() => del.get()).toThrow("This statement does not return data. Use run() instead"); + expect(() => del.iterate()).toThrow("This statement does not return data. Use run() instead"); + expect(() => del.pluck()).toThrow("This statement does not return data. Use run() instead"); + expect(() => del.raw()).toThrow("This statement does not return data. Use run() instead"); + expect(() => del.expand()).toThrow("This statement does not return data. Use run() instead"); + expect(() => del.columns()).toThrow("This statement does not return data. Use run() instead"); + expect(db.prepare("SELECT count(*) FROM t").pluck().get()).toBe(3); + db.close(); + }); + + test("SqliteError", () => { + const err = SqliteError("oops", "SQLITE_TEST"); + expect(err.name).toBe("SqliteError"); + expect(err.code).toBe("SQLITE_TEST"); + expect(err.message).toBe("oops"); + expect(err instanceof SqliteError).toBe(true); + expect(err instanceof Error).toBe(true); + + const db = new Database(":memory:"); + let thrown: any; + try { + db.exec("NOT VALID SQL"); + } catch (e) { + thrown = e; + } + expect(thrown instanceof SqliteError).toBe(true); + db.close(); + }); + + test("constructor option validation matches better-sqlite3", () => { + // @ts-expect-error + expect(() => new Database(123)).toThrow("Expected first argument to be a string"); + expect(() => new Database(":memory:", { readOnly: true } as any)).toThrow('Misspelled option "readOnly"'); + expect(() => new Database(":memory:", { readonly: true })).toThrow("cannot be readonly"); + expect(() => new Database(":memory:", { timeout: -1 } as any)).toThrow("positive integer"); + }); + + test("opens a file and applies fileMustExist", () => { + using dir = tempDir("better-sqlite3", {}); + const path = `${dir}/data.db`; + + expect(() => new Database(path, { fileMustExist: true })).toThrow(SqliteError); + + const db = new Database(path); + db.exec("CREATE TABLE t (x INTEGER)"); + db.prepare("INSERT INTO t VALUES (?)").run(42); + db.close(); + + const db2 = new Database(path, { fileMustExist: true }); + expect(db2.prepare("SELECT x FROM t").pluck().get()).toBe(42); + db2.close(); + }); + + test("unimplemented methods throw ERR_NOT_IMPLEMENTED", () => { + const db = new Database(":memory:"); + for (const m of ["function", "aggregate", "table", "backup", "unsafeMode"]) { + let code: string | undefined; + try { + (db as any)[m](); + } catch (e: any) { + code = e.code; + } + expect(code).toBe("ERR_NOT_IMPLEMENTED"); + } + expect(db.unsafeMode(false)).toBe(db); + db.close(); + }); +}); + +// https://github.com/oven-sh/bun/issues/14997 +// drizzle-kit imports better-sqlite3 and calls prepare().bind().all(), +// prepare().raw().all(), and transaction()[behavior](). The native addon +// cannot load under Bun, so this must be served by the shim. +test("issue #14997: drizzle-orm's better-sqlite3 driver call shapes work", async () => { + using dir = tempDir("issue-14997", { + "index.js": ` + const Database = require("better-sqlite3"); + const db = new Database("db.sqlite"); + db.exec("CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash TEXT, created_at INTEGER)"); + db.prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)").run("abc", 1); + const a = db.prepare("SELECT * FROM __drizzle_migrations WHERE hash = ?").bind("abc").all(); + const b = db.prepare("SELECT hash, created_at FROM __drizzle_migrations").raw(true).all(); + const tx = db.transaction(() => { + db.prepare("INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?)").run("def", 2); + }); + tx.deferred(); + const c = db.prepare("SELECT hash FROM __drizzle_migrations ORDER BY id").pluck().all(); + console.log(JSON.stringify({ a, b, c })); + db.close(); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + a: [{ id: 1, hash: "abc", created_at: 1 }], + b: [["abc", 1]], + c: ["abc", "def"], + }); + expect(exitCode).toBe(0); +});