diff --git a/docs/runtime/sqlite.mdx b/docs/runtime/sqlite.mdx index 1358af68caa7..b2358457a445 100644 --- a/docs/runtime/sqlite.mdx +++ b/docs/runtime/sqlite.mdx @@ -486,45 +486,45 @@ const results = query.all("hello", "goodbye"); SQLite supports signed 64-bit integers, but JavaScript only supports signed 52-bit integers or arbitrary-precision integers with `bigint`. -`bigint` input is supported everywhere, but by default `bun:sqlite` returns integers as `number` types. If you need to handle integers larger than 2^53, set the `safeIntegers` option to `true` when creating a `Database` instance. This also validates that `bigint` values passed to `bun:sqlite` do not exceed 64 bits. +`bigint` input is supported everywhere, but by default `bun:sqlite` returns integers as `number` types. If you need to handle integers larger than 2^53, set the `safeIntegers` option to `true` when creating a `Database` instance. -### `safeIntegers: true` - -When `safeIntegers` is `true`, `bun:sqlite` returns integers as `bigint` types: +Regardless of the `safeIntegers` setting, `bun:sqlite` throws a `RangeError` if a `bigint` value in a bound parameter does not fit in a signed 64-bit integer: -```ts db.ts icon="/icons/typescript.svg" highlight={3} +```ts db.ts icon="/icons/typescript.svg" import { Database } from "bun:sqlite"; -const db = new Database(":memory:", { safeIntegers: true }); -const query = db.query(`SELECT ${BigInt(Number.MAX_SAFE_INTEGER) + 102n} as max_int`); -const result = query.get(); +const db = new Database(":memory:"); +db.run("CREATE TABLE test (id INTEGER PRIMARY KEY, value INTEGER)"); -console.log(result.max_int); +const query = db.query("INSERT INTO test (value) VALUES ($value)"); + +try { + query.run({ $value: BigInt(Number.MAX_SAFE_INTEGER) ** 2n }); +} catch (e) { + console.log(e.message); +} ``` ```txt -9007199254741093n +BigInt value '81129638414606663681390495662081' is out of range ``` -When `safeIntegers` is `true`, `bun:sqlite` throws an error if a `bigint` value in a bound parameter exceeds 64 bits: +### `safeIntegers: true` + +When `safeIntegers` is `true`, `bun:sqlite` returns integers as `bigint` types: ```ts db.ts icon="/icons/typescript.svg" highlight={3} import { Database } from "bun:sqlite"; const db = new Database(":memory:", { safeIntegers: true }); -db.run("CREATE TABLE test (id INTEGER PRIMARY KEY, value INTEGER)"); - -const query = db.query("INSERT INTO test (value) VALUES ($value)"); +const query = db.query(`SELECT ${BigInt(Number.MAX_SAFE_INTEGER) + 102n} as max_int`); +const result = query.get(); -try { - query.run({ $value: BigInt(Number.MAX_SAFE_INTEGER) ** 2n }); -} catch (e) { - console.log(e.message); -} +console.log(result.max_int); ``` ```txt -BigInt value '81129638414606663681390495662081' is out of range +9007199254741093n ``` ### `safeIntegers: false` (default) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 83b78490b14c..c7231615e7ec 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -506,6 +506,7 @@ class JSSQLStatement : public JSC::JSDestructibleObject { bool need_update() { return version_db->version.load() != version; } void update_version() { version = version_db->version.load(); } + void updateColumnNamesIfNeeded(JSC::JSGlobalObject*); ~JSSQLStatement(); @@ -851,13 +852,22 @@ static void initializeColumnNames(JSC::JSGlobalObject* lexicalGlobalObject, JSSQ castedThis->_prototype.set(vm, castedThis, object); } +void JSSQLStatement::updateColumnNamesIfNeeded(JSC::JSGlobalObject* lexicalGlobalObject) +{ + // sqlite3_step() may transparently re-prepare under a changed result shape. + const bool reprepared = sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_REPREPARE, 1) > 0; + if (!hasExecuted || reprepared || need_update()) { + initializeColumnNames(lexicalGlobalObject, this); + } +} + void JSSQLStatement::destroy(JSC::JSCell* cell) { JSSQLStatement* thisObject = static_cast(cell); thisObject->~JSSQLStatement(); } -static inline bool rebindValue(JSC::JSGlobalObject* lexicalGlobalObject, sqlite3* db, sqlite3_stmt* stmt, int i, JSC::JSValue value, JSC::ThrowScope& scope, bool isSafeInteger) +static inline bool rebindValue(JSC::JSGlobalObject* lexicalGlobalObject, sqlite3* db, sqlite3_stmt* stmt, int i, JSC::JSValue value, JSC::ThrowScope& scope) { auto throwSQLiteError = [&]() -> void { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, WTF::String::fromUTF8(sqlite3_errmsg(db)))); @@ -902,30 +912,24 @@ static inline bool rebindValue(JSC::JSGlobalObject* lexicalGlobalObject, sqlite3 } if (roped->is8Bit() && roped->containsOnlyASCII()) { - CHECK_BIND(sqlite3_bind_text(stmt, i, reinterpret_cast(roped->span8().data()), roped->length(), SQLITE_TRANSIENT)); - } else if (!roped->is8Bit()) { - CHECK_BIND(sqlite3_bind_text16(stmt, i, roped->span16().data(), roped->length() * 2, SQLITE_TRANSIENT)); + CHECK_BIND(sqlite3_bind_text64(stmt, i, reinterpret_cast(roped->span8().data()), roped->length(), SQLITE_TRANSIENT, SQLITE_UTF8)); } else { + // Not sqlite3_bind_text16: SQLite's UTF-16 decoder stores lone surrogates as ill-formed UTF-8. auto utf8 = roped->utf8(); - CHECK_BIND(sqlite3_bind_text(stmt, i, utf8.data(), utf8.length(), SQLITE_TRANSIENT)); + CHECK_BIND(sqlite3_bind_text64(stmt, i, utf8.data(), utf8.length(), SQLITE_TRANSIENT, SQLITE_UTF8)); } } else if (value.isHeapBigInt()) [[unlikely]] { - if (!isSafeInteger) { + JSBigInt* bigInt = value.asHeapBigInt(); + const auto min = JSBigInt::compare(bigInt, std::numeric_limits::min()); + const auto max = JSBigInt::compare(bigInt, std::numeric_limits::max()); + if ((min == JSBigInt::ComparisonResult::GreaterThan || min == JSBigInt::ComparisonResult::Equal) && (max == JSBigInt::ComparisonResult::LessThan || max == JSBigInt::ComparisonResult::Equal)) [[likely]] { CHECK_BIND(sqlite3_bind_int64(stmt, i, JSBigInt::toBigInt64(value))); } else { - JSBigInt* bigInt = value.asHeapBigInt(); - const auto min = JSBigInt::compare(bigInt, std::numeric_limits::min()); - const auto max = JSBigInt::compare(bigInt, std::numeric_limits::max()); - if ((min == JSBigInt::ComparisonResult::GreaterThan || min == JSBigInt::ComparisonResult::Equal) && (max == JSBigInt::ComparisonResult::LessThan || max == JSBigInt::ComparisonResult::Equal)) [[likely]] { - CHECK_BIND(sqlite3_bind_int64(stmt, i, JSBigInt::toBigInt64(value))); - } else { - throwRangeError(lexicalGlobalObject, scope, makeString("BigInt value '"_s, bigInt->toString(lexicalGlobalObject, 10), "' is out of range"_s)); - sqlite3_clear_bindings(stmt); - return false; - } + throwRangeError(lexicalGlobalObject, scope, makeString("BigInt value '"_s, bigInt->toString(lexicalGlobalObject, 10), "' is out of range"_s)); + sqlite3_clear_bindings(stmt); + return false; } - } else if (JSC::JSArrayBufferView* buffer = dynamicDowncast(value)) { CHECK_BIND(sqlite3_bind_blob(stmt, i, buffer->vector(), buffer->byteLength(), SQLITE_TRANSIENT)); } else { @@ -937,7 +941,7 @@ static inline bool rebindValue(JSC::JSGlobalObject* lexicalGlobalObject, sqlite3 #undef CHECK_BIND } -static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindingsMap& bindings, JSC::JSObject* target, JSC::ThrowScope& scope, sqlite3* db, sqlite3_stmt* stmt, bool safeIntegers, JSSQLStatement* statement) +static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindingsMap& bindings, JSC::JSObject* target, JSC::ThrowScope& scope, sqlite3* db, sqlite3_stmt* stmt, JSSQLStatement* statement) { int count = 0; @@ -1015,7 +1019,7 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin } RETURN_IF_EXCEPTION(scope, {}); - if (!rebindValue(globalObject, db, stmt, i + 1, value, scope, safeIntegers)) { + if (!rebindValue(globalObject, db, stmt, i + 1, value, scope)) { return {}; } @@ -1042,7 +1046,7 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin RETURN_IF_EXCEPTION(scope, {}); - if (!rebindValue(globalObject, db, stmt, i + 1, value, scope, safeIntegers)) { + if (!rebindValue(globalObject, db, stmt, i + 1, value, scope)) { return {}; } @@ -1070,7 +1074,7 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin RETURN_IF_EXCEPTION(scope, {}); - if (!rebindValue(globalObject, db, stmt, i + 1, value, scope, safeIntegers)) { + if (!rebindValue(globalObject, db, stmt, i + 1, value, scope)) { return {}; } @@ -1105,7 +1109,7 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin if (!statementStillAlive()) return {}; - if (!rebindValue(globalObject, db, stmt, i + 1, value, scope, safeIntegers)) { + if (!rebindValue(globalObject, db, stmt, i + 1, value, scope)) { return {}; } @@ -1117,7 +1121,7 @@ static JSC::JSValue rebindObject(JSC::JSGlobalObject* globalObject, SQLiteBindin return jsNumber(count); } -static JSC::JSValue rebindStatement(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue values, JSC::ThrowScope& scope, sqlite3* db, sqlite3_stmt* stmt, SQLiteBindingsMap& bindings, bool safeIntegers, JSSQLStatement* statement) +static JSC::JSValue rebindStatement(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue values, JSC::ThrowScope& scope, sqlite3* db, sqlite3_stmt* stmt, SQLiteBindingsMap& bindings, JSSQLStatement* statement) { sqlite3_clear_bindings(stmt); JSC::JSArray* array = dynamicDowncast(values); @@ -1125,7 +1129,7 @@ static JSC::JSValue rebindStatement(JSC::JSGlobalObject* lexicalGlobalObject, JS if (!array) { if (JSC::JSObject* object = values.getObject()) { - auto res = rebindObject(lexicalGlobalObject, bindings, object, scope, db, stmt, safeIntegers, statement); + auto res = rebindObject(lexicalGlobalObject, bindings, object, scope, db, stmt, statement); RETURN_IF_EXCEPTION(scope, {}); return res; } @@ -1161,7 +1165,7 @@ static JSC::JSValue rebindStatement(JSC::JSGlobalObject* lexicalGlobalObject, JS if (!value) value = JSC::jsUndefined(); } - if (!rebindValue(lexicalGlobalObject, db, stmt, i + 1, value, scope, safeIntegers)) { + if (!rebindValue(lexicalGlobalObject, db, stmt, i + 1, value, scope)) { return {}; } RETURN_IF_EXCEPTION(scope, {}); @@ -1534,7 +1538,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l int count = sqlite3_bind_parameter_count(sql.stmt); SQLiteBindingsMap bindings { static_cast(count > -1 ? count : 0), strict }; - JSC::JSValue reb = rebindStatement(lexicalGlobalObject, bindingsAliveScope.value(), scope, db, sql.stmt, bindings, safeIntegers, nullptr); + JSC::JSValue reb = rebindStatement(lexicalGlobalObject, bindingsAliveScope.value(), scope, db, sql.stmt, bindings, nullptr); RETURN_IF_EXCEPTION(scope, {}); if (versionDB->db != db) [[unlikely]] { @@ -2173,10 +2177,8 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionIterate, (JSC::JS castedThis->version_db->version++; } - if (!castedThis->hasExecuted || castedThis->need_update()) { - initializeColumnNames(lexicalGlobalObject, castedThis); - RETURN_IF_EXCEPTION(scope, {}); - } + castedThis->updateColumnNamesIfNeeded(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); JSValue result = jsNull(); if (status == SQLITE_ROW) { @@ -2225,10 +2227,8 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionAll, (JSC::JSGlob castedThis->version_db->version++; } - if (!castedThis->hasExecuted || castedThis->need_update()) { - initializeColumnNames(lexicalGlobalObject, castedThis); - RETURN_IF_EXCEPTION(scope, {}); - } + castedThis->updateColumnNamesIfNeeded(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); int columnCount = sqlite3_column_count(stmt); JSValue result = jsUndefined(); @@ -2318,10 +2318,8 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionGet, (JSC::JSGlob castedThis->version_db->version++; } - if (!castedThis->hasExecuted || castedThis->need_update()) { - initializeColumnNames(lexicalGlobalObject, castedThis); - RETURN_IF_EXCEPTION(scope, {}); - } + castedThis->updateColumnNamesIfNeeded(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); JSValue result = jsNull(); if (status == SQLITE_ROW) { @@ -2374,14 +2372,11 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionRows, (JSC::JSGlo castedThis->version_db->version++; } - if (!castedThis->hasExecuted || castedThis->need_update()) { - initializeColumnNames(lexicalGlobalObject, castedThis); - - if (scope.exception()) [[unlikely]] { - // Don't forget to reset before releasing the exception. - sqlite3_reset(stmt); - RELEASE_AND_RETURN(scope, {}); - } + castedThis->updateColumnNamesIfNeeded(lexicalGlobalObject); + if (scope.exception()) [[unlikely]] { + // Don't forget to reset before releasing the exception. + sqlite3_reset(stmt); + RELEASE_AND_RETURN(scope, {}); } size_t columnCount = sqlite3_column_count(stmt); @@ -2463,12 +2458,10 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionRawRows, (JSC::JS castedThis->version_db->version++; } - if (!castedThis->hasExecuted || castedThis->need_update()) { - initializeColumnNames(lexicalGlobalObject, castedThis); - if (scope.exception()) [[unlikely]] { - sqlite3_reset(stmt); - RELEASE_AND_RETURN(scope, {}); - } + castedThis->updateColumnNamesIfNeeded(lexicalGlobalObject); + if (scope.exception()) [[unlikely]] { + sqlite3_reset(stmt); + RELEASE_AND_RETURN(scope, {}); } size_t columnCount = sqlite3_column_count(stmt); @@ -2564,12 +2557,10 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionRun, (JSC::JSGlob castedThis->version_db->version++; } - if (!castedThis->hasExecuted || castedThis->need_update()) { - initializeColumnNames(lexicalGlobalObject, castedThis); - if (scope.exception()) [[unlikely]] { - sqlite3_reset(stmt); - RELEASE_AND_RETURN(scope, {}); - } + castedThis->updateColumnNamesIfNeeded(lexicalGlobalObject); + if (scope.exception()) [[unlikely]] { + sqlite3_reset(stmt); + RELEASE_AND_RETURN(scope, {}); } while (status == SQLITE_ROW) { @@ -2671,8 +2662,6 @@ JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetColumnTypes, (JSGlobalObject * lexical CHECK_THIS CHECK_PREPARED - int count = sqlite3_column_count(castedThis->stmt); - // We need to reset and step the statement to get fresh types, // but only do this for read-only statements to avoid side effects bool isReadOnly = sqlite3_stmt_readonly(castedThis->stmt) != 0; @@ -2695,6 +2684,9 @@ JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetColumnTypes, (JSGlobalObject * lexical // Step once to get to the first row (safe for read-only statements) int stepStatus = sqlite3_step(castedThis->stmt); + // After the step: sqlite3_step() can re-prepare under a changed column count. + int count = sqlite3_column_count(castedThis->stmt); + // If we got a row, get types from it if (stepStatus == SQLITE_ROW) { for (int i = 0; i < count; i++) { @@ -2873,7 +2865,7 @@ JSC::JSValue JSSQLStatement::rebind(JSC::JSGlobalObject* lexicalGlobalObject, JS auto scope = DECLARE_THROW_SCOPE(vm); auto* stmt = this->stmt; - auto val = rebindStatement(lexicalGlobalObject, values, scope, this->version_db->db, stmt, this->m_bindingNames, this->useBigInt64, this); + auto val = rebindStatement(lexicalGlobalObject, values, scope, this->version_db->db, stmt, this->m_bindingNames, this); RETURN_IF_EXCEPTION(scope, {}); // A getter invoked while binding can finalize this statement; the callers diff --git a/src/jsc/bindings/sqlite/lazy_sqlite3.h b/src/jsc/bindings/sqlite/lazy_sqlite3.h index 1c3c02dd2750..0502758aae92 100644 --- a/src/jsc/bindings/sqlite/lazy_sqlite3.h +++ b/src/jsc/bindings/sqlite/lazy_sqlite3.h @@ -24,8 +24,6 @@ typedef int (*lazy_sqlite3_bind_double_type)(sqlite3_stmt*, int, double); typedef int (*lazy_sqlite3_bind_int_type)(sqlite3_stmt*, int, int); typedef int (*lazy_sqlite3_bind_int64_type)(sqlite3_stmt*, int, sqlite3_int64); typedef int (*lazy_sqlite3_bind_null_type)(sqlite3_stmt*, int); -typedef int (*lazy_sqlite3_bind_text_type)(sqlite3_stmt*, int, const char*, int, void (*)(void*)); -typedef int (*lazy_sqlite3_bind_text16_type)(sqlite3_stmt*, int, const void*, int, void (*)(void*)); typedef int (*lazy_sqlite3_bind_text64_type)(sqlite3_stmt*, int, const char*, sqlite3_uint64, void (*)(void*), unsigned char encoding); typedef int (*lazy_sqlite3_bind_parameter_count_type)(sqlite3_stmt*); typedef int (*lazy_sqlite3_bind_parameter_index_type)(sqlite3_stmt*, const char* zName); @@ -135,8 +133,6 @@ inline lazy_sqlite3_bind_int64_type lazy_sqlite3_bind_int64; inline lazy_sqlite3_bind_null_type lazy_sqlite3_bind_null; inline lazy_sqlite3_bind_parameter_count_type lazy_sqlite3_bind_parameter_count; inline lazy_sqlite3_bind_parameter_index_type lazy_sqlite3_bind_parameter_index; -inline lazy_sqlite3_bind_text_type lazy_sqlite3_bind_text; -inline lazy_sqlite3_bind_text16_type lazy_sqlite3_bind_text16; inline lazy_sqlite3_bind_text64_type lazy_sqlite3_bind_text64; inline lazy_sqlite3_changes_type lazy_sqlite3_changes; inline lazy_sqlite3_changes64_type lazy_sqlite3_changes64; @@ -235,8 +231,6 @@ inline lazy_sqlite3changeset_apply_type lazy_sqlite3changeset_apply; #define sqlite3_bind_null lazy_sqlite3_bind_null #define sqlite3_bind_parameter_count lazy_sqlite3_bind_parameter_count #define sqlite3_bind_parameter_index lazy_sqlite3_bind_parameter_index -#define sqlite3_bind_text lazy_sqlite3_bind_text -#define sqlite3_bind_text16 lazy_sqlite3_bind_text16 #define sqlite3_bind_text64 lazy_sqlite3_bind_text64 #define sqlite3_changes lazy_sqlite3_changes #define sqlite3_changes64 lazy_sqlite3_changes64 @@ -375,8 +369,6 @@ inline int lazyLoadSQLite() lazy_sqlite3_bind_null = (lazy_sqlite3_bind_null_type)dlsym(sqlite3_handle, "sqlite3_bind_null"); lazy_sqlite3_bind_parameter_count = (lazy_sqlite3_bind_parameter_count_type)dlsym(sqlite3_handle, "sqlite3_bind_parameter_count"); lazy_sqlite3_bind_parameter_index = (lazy_sqlite3_bind_parameter_index_type)dlsym(sqlite3_handle, "sqlite3_bind_parameter_index"); - lazy_sqlite3_bind_text = (lazy_sqlite3_bind_text_type)dlsym(sqlite3_handle, "sqlite3_bind_text"); - lazy_sqlite3_bind_text16 = (lazy_sqlite3_bind_text16_type)dlsym(sqlite3_handle, "sqlite3_bind_text16"); lazy_sqlite3_bind_text64 = (lazy_sqlite3_bind_text64_type)dlsym(sqlite3_handle, "sqlite3_bind_text64"); lazy_sqlite3_changes = (lazy_sqlite3_changes_type)dlsym(sqlite3_handle, "sqlite3_changes"); lazy_sqlite3_changes64 = (lazy_sqlite3_changes64_type)dlsym(sqlite3_handle, "sqlite3_changes64"); diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index 555794e65156..1e1ed2fe30aa 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -108,8 +108,43 @@ describe("safeIntegers", () => { const query = db.query("INSERT INTO test (value) VALUES ($value)"); expect(() => query.run({ $value: BigInt(Number.MAX_SAFE_INTEGER) ** 2n })).toThrow(RangeError); + // safeIntegers only controls how integers are returned. An out-of-range + // BigInt parameter is always rejected instead of being wrapped modulo 2^64. query.safeIntegers(false); - expect(() => query.run({ $value: BigInt(Number.MAX_SAFE_INTEGER) ** 2n })).not.toThrow(RangeError); + expect(() => query.run({ $value: BigInt(Number.MAX_SAFE_INTEGER) ** 2n })).toThrow(RangeError); + }); + + it("rejects BigInt parameters outside the signed 64-bit range in both modes", () => { + const outOfRange = [ + 2n ** 63n, // INT64_MAX + 1 + -(2n ** 63n) - 1n, // INT64_MIN - 1 + 2n ** 64n + 5n, // previously wrapped to 5 + -(2n ** 64n) - 5n, // previously wrapped to -5 + ]; + for (const safeIntegers of [false, true]) { + using db = new Database(":memory:", { safeIntegers }); + const q = db.query("SELECT ? AS v"); + for (const value of outOfRange) { + expect(() => q.get(value)).toThrow(RangeError); + expect(() => q.get(value)).toThrow(`BigInt value '${value}' is out of range`); + } + } + }); + + it("binds BigInt values at the signed 64-bit boundaries exactly in both modes", () => { + const inRange = [ + [0n, "0"], + [2n ** 62n, "4611686018427387904"], + [2n ** 63n - 1n, "9223372036854775807"], // INT64_MAX + [-(2n ** 63n), "-9223372036854775808"], // INT64_MIN + ]; + for (const safeIntegers of [false, true]) { + using db = new Database(":memory:", { safeIntegers }); + for (const [value, text] of inRange) { + // CAST(... AS TEXT) reads the stored value back without Number precision loss. + expect(db.query("SELECT CAST(? AS TEXT) AS t").get(value)).toEqual({ t: text }); + } + } }); }); @@ -2076,15 +2111,14 @@ it("decodes non-UTF-8 column names leniently instead of dropping the column", () db.close(); }); -it("expands bound non-UTF-8 values in Statement#toString instead of returning an empty string", () => { +it("expands bound values in Statement#toString instead of returning an empty string", () => { const db = new Database(":memory:"); const stmt = db.prepare("SELECT ? AS x"); - // A lone surrogate binds via sqlite3_bind_text16 and is stored by SQLite as - // invalid UTF-8. sqlite3_expanded_sql() then returns those bytes, which the - // strict decoder turned into a null string -> the whole toString() became "". + // A lone surrogate is sanitized to a single U+FFFD before it reaches SQLite, + // so the expanded SQL contains exactly one replacement character, never "". stmt.get("\uD800"); - expect(String(stmt)).toBe("SELECT '\uFFFD\uFFFD\uFFFD' AS x"); + expect(String(stmt)).toBe("SELECT '\uFFFD' AS x"); // Valid values still round-trip. stmt.get("ok"); @@ -2124,6 +2158,167 @@ it("decodes declared types leniently and accepts single-character declared types db.close(); }); +describe("string parameters are encoded as well-formed UTF-8", () => { + // A lone surrogate must become U+FFFD, exactly like TextEncoder. SQLite's own + // UTF-16 decoder instead pairs a lone high surrogate with the following code + // unit (consuming it) and stores trailing ones as ill-formed 3-byte sequences. + const cases = [ + ["lone high surrogate followed by a non-surrogate", "a\uD800b"], + ["trailing lone high surrogate", "a\uD800"], + ["lone low surrogate followed by a non-surrogate", "a\uDC00b"], + ["trailing lone low surrogate", "a\uDC00"], + ["only a lone high surrogate", "\uD800"], + ["well-formed surrogate pair", "a\uD83D\uDE00b"], + ["Latin-1 non-ASCII", "caf\u00E9"], + ["BMP non-Latin-1", "\u65E5\u672C\u8A9E"], + ]; + const expectedHex = s => Buffer.from(new TextEncoder().encode(s)).toString("hex").toUpperCase(); + + it.each(cases)("%s, positional parameter", (_desc, input) => { + using db = new Database(":memory:"); + expect(db.query("SELECT hex(CAST(? AS BLOB)) AS h, ? AS v").get(input, input)).toEqual({ + h: expectedHex(input), + v: input.toWellFormed(), + }); + }); + + it.each(cases)("%s, named parameter", (_desc, input) => { + using db = new Database(":memory:"); + expect(db.query("SELECT hex(CAST($x AS BLOB)) AS h, $x AS v").get({ $x: input })).toEqual({ + h: expectedHex(input), + v: input.toWellFormed(), + }); + }); + + it.each(cases)("%s, db.run() exec path", (_desc, input) => { + using db = new Database(":memory:"); + db.run("CREATE TABLE t (s TEXT)"); + db.run("INSERT INTO t VALUES (?)", [input]); + expect(db.query("SELECT hex(CAST(s AS BLOB)) AS h, s AS v FROM t").get()).toEqual({ + h: expectedHex(input), + v: input.toWellFormed(), + }); + }); + + it("never writes bytes the database cannot round-trip as UTF-8", () => { + using db = new Database(":memory:"); + // A lone high surrogate followed by "b" must NOT become U+10062 (the + // surrogate paired with the "b"), and a trailing one must NOT become the + // ill-formed CESU-8 bytes ED A0 80. + const h = s => db.query("SELECT hex(CAST(? AS BLOB)) AS h").get(s).h; + expect(h("a\uD800b")).toBe("61EFBFBD62"); + expect(h("a\uD800")).toBe("61EFBFBD"); + }); +}); + +describe("prepared statements refresh cached column names after a schema change", () => { + // SQLite transparently re-prepares a statement when the schema changes. The + // cached column names and result object shape must be rebuilt to match. + it("ALTER TABLE ADD COLUMN issued through db.run()", () => { + using db = new Database(":memory:"); + db.run("CREATE TABLE t (a INT)"); + db.run("INSERT INTO t VALUES (1)"); + const q = db.query("SELECT * FROM t"); + expect(q.get()).toEqual({ a: 1 }); + + db.run("ALTER TABLE t ADD COLUMN b INT DEFAULT 42"); + expect(q.get()).toEqual({ a: 1, b: 42 }); + expect(q.all()).toEqual([{ a: 1, b: 42 }]); + expect(q.values()).toEqual([[1, 42]]); + expect(q.columnNames).toEqual(["a", "b"]); + }); + + it("DROP + CREATE with a renamed column does not mis-key the row", () => { + using db = new Database(":memory:"); + db.run("CREATE TABLE t (a INT)"); + db.run("INSERT INTO t VALUES (111)"); + const q = db.query("SELECT * FROM t"); + expect(q.get()).toEqual({ a: 111 }); + + // Same column count, different name. The new column's value must not be + // returned under the old column's name. + db.run("DROP TABLE t"); + db.run("CREATE TABLE t (zzz TEXT)"); + db.run("INSERT INTO t VALUES ('boom')"); + expect(q.get()).toEqual({ zzz: "boom" }); + expect(q.columnNames).toEqual(["zzz"]); + }); + + it("ALTER TABLE issued through a prepared statement", () => { + using db = new Database(":memory:"); + db.run("CREATE TABLE t (a INT)"); + db.run("INSERT INTO t VALUES (1)"); + const q = db.query("SELECT * FROM t"); + expect(q.get()).toEqual({ a: 1 }); + + using alter = db.prepare("ALTER TABLE t ADD COLUMN b INT DEFAULT 7"); + alter.run(); + expect(q.get()).toEqual({ a: 1, b: 7 }); + }); + + it("schema change made by a second connection to the same file", () => { + const file = tmpbase + `sqlite-reprepare-${Date.now()}-${(Math.random() * 1e9) | 0}.db`; + using a = new Database(file, { create: true }); + a.run("CREATE TABLE t (a INT)"); + a.run("INSERT INTO t VALUES (1)"); + const q = a.query("SELECT * FROM t"); + expect(q.get()).toEqual({ a: 1 }); + + { + using b = new Database(file); + b.run("ALTER TABLE t ADD COLUMN c TEXT DEFAULT 'x'"); + } + expect(q.get()).toEqual({ a: 1, c: "x" }); + }); + + it("schema change made by another process", async () => { + // https://github.com/oven-sh/bun/issues/1332 + const file = tmpbase + `sqlite-xproc-${Date.now()}-${(Math.random() * 1e9) | 0}.db`; + using db = new Database(file, { create: true }); + db.run("PRAGMA journal_mode = wal"); + db.run("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, greeting TEXT)"); + db.run("INSERT INTO foo (greeting) VALUES (?)", ["Welcome to bun!"]); + const q = db.query("SELECT * FROM foo"); + expect(q.get()).toEqual({ id: 1, greeting: "Welcome to bun!" }); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Database } = require("bun:sqlite");` + + `const d = new Database(${JSON.stringify(file)});` + + `d.run("ALTER TABLE foo RENAME COLUMN greeting TO greeting2");` + + `d.close();`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + + expect(q.get()).toEqual({ id: 1, greeting2: "Welcome to bun!" }); + }); + + it("columnTypes reflects the new result shape after a schema change", () => { + using db = new Database(":memory:"); + db.run("CREATE TABLE t (a INT)"); + db.run("INSERT INTO t VALUES (1)"); + const q = db.query("SELECT * FROM t"); + expect(q.columnTypes).toEqual(["INTEGER"]); + + // More columns: the array must grow, not stay truncated at the old count. + db.run("ALTER TABLE t ADD COLUMN b TEXT DEFAULT 'x'"); + expect(q.columnTypes).toEqual(["INTEGER", "TEXT"]); + + // Fewer columns: the array must shrink, not be padded with spurious "NULL" + // entries read from out-of-range column indexes. + db.run("DROP TABLE t"); + db.run("CREATE TABLE t (z TEXT)"); + db.run("INSERT INTO t VALUES ('y')"); + expect(q.columnTypes).toEqual(["TEXT"]); + }); +}); + // The process-global SQLite database registry is shared by every Worker // thread. Concurrent opens, prepares, serialize/deserialize, and closes from // several Workers must not corrupt the registry while its backing storage diff --git a/test/js/sql/sqlite-sql.test.ts b/test/js/sql/sqlite-sql.test.ts index 1a34b8264d61..0c570cb912df 100644 --- a/test/js/sql/sqlite-sql.test.ts +++ b/test/js/sql/sqlite-sql.test.ts @@ -5143,30 +5143,10 @@ describe("Unicode & Encoding Fuzzing Tests", () => { const result = await sql`SELECT text_data, description FROM unicode_fuzz WHERE id = ${i}`; expect(result).toHaveLength(1); - // SQLite's actual behavior with problematic Unicode: - // - Lone surrogates (\uD800, \uDFFF) bind via sqlite3_bind_text16 and are - // stored by SQLite as invalid UTF-8 (WTF-8, e.g. ED A0 80). On read-back - // those invalid bytes decode leniently to U+FFFD, matching node:sqlite. - // - BOM inverse (\uFFFE) is dropped by SQLite itself (stored as 0 bytes), - // so it reads back as an empty string. - // - Null characters are preserved (not truncated). - const lenientlyReplaced: Record = { - // 3 invalid bytes -> 3 replacement characters (maximal-subpart replacement) - "High surrogate (invalid alone)": "\uFFFD\uFFFD\uFFFD", - "Low surrogate (invalid alone)": "\uFFFD\uFFFD\uFFFD", - }; - const droppedBySqlite = ["Byte order mark inverse"]; - - if (desc in lenientlyReplaced) { - // Invalid UTF-8 bytes decode leniently to U+FFFD rather than dropping the field. - expect(result[0].text_data).toBe(lenientlyReplaced[desc]); - } else if (droppedBySqlite.includes(desc)) { - // SQLite stores nothing for these, so they come back empty. - expect(result[0].text_data).toBe(""); - } else { - // All other characters should be preserved exactly, including null bytes - expect(result[0].text_data).toBe(text); - } + // Strings are encoded to well-formed UTF-8 before binding (lone surrogates + // become U+FFFD, like TextEncoder), so everything else round-trips exactly, + // including embedded null bytes and the noncharacter U+FFFE. + expect(result[0].text_data).toBe(text.toWellFormed()); expect(result[0].description).toBe(desc); }